397 lines
13 KiB
Dart
397 lines
13 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
|
||
import '../../features/mall/application/mall_tab_config.dart';
|
||
import '../../features/web/application/mall_web_host_controller.dart';
|
||
import '../../features/web/presentation/mall_web_host.dart';
|
||
import '../theme/app_icons.dart';
|
||
import '../widgets/adaptive.dart';
|
||
import 'app_router.dart';
|
||
|
||
class TabShell extends ConsumerStatefulWidget {
|
||
const TabShell({
|
||
super.key,
|
||
required this.navigationShell,
|
||
required this.location,
|
||
});
|
||
|
||
final StatefulNavigationShell navigationShell;
|
||
final String location;
|
||
|
||
@override
|
||
ConsumerState<TabShell> createState() => _TabShellState();
|
||
}
|
||
|
||
class _TabShellState extends ConsumerState<TabShell> {
|
||
static const double _compactHeaderThreshold = 34;
|
||
|
||
bool _showCompactHeader = false;
|
||
|
||
int get _index => widget.navigationShell.currentIndex;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
ref.listenManual<MallTabConfig>(mallTabConfigProvider, (_, next) {
|
||
if (!next.isVisible) return;
|
||
final uri = next.attributedUri(
|
||
clickId: '',
|
||
isDark: Theme.of(context).brightness == Brightness.dark,
|
||
);
|
||
if (uri != null) MallWebHostController.instance.preload(uri.toString());
|
||
});
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant TabShell oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (oldWidget.location != widget.location && _showCompactHeader) {
|
||
_showCompactHeader = false;
|
||
}
|
||
}
|
||
|
||
Future<void> _openMall(MallTabConfig config) async {
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
final actual = await ref
|
||
.read(mallTabConfigProvider.notifier)
|
||
.ensureLoaded();
|
||
if (!actual.isVisible || !mounted) return;
|
||
final clickId = await ref
|
||
.read(mallTabConfigProvider.notifier)
|
||
.recordClick();
|
||
final uri = actual.attributedUri(clickId: clickId ?? '', isDark: isDark);
|
||
if (uri == null) return;
|
||
if (actual.opensInApp) {
|
||
MallWebHostController.instance.open(uri.toString());
|
||
return;
|
||
}
|
||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
final mall = ref.watch(mallTabConfigProvider);
|
||
final header = _headerForPath(widget.location);
|
||
final canPop = GoRouter.of(context).canPop();
|
||
|
||
return Scaffold(
|
||
backgroundColor: cs.background,
|
||
resizeToAvoidBottomInset: false,
|
||
body: Stack(
|
||
children: [
|
||
PopScope(
|
||
canPop: !MallWebHostController.instance.state.value.visible,
|
||
onPopInvokedWithResult: (didPop, _) {
|
||
if (!didPop &&
|
||
MallWebHostController.instance.state.value.visible) {
|
||
MallWebHostController.instance.handleBack();
|
||
}
|
||
},
|
||
child: Scaffold(
|
||
backgroundColor: cs.background,
|
||
resizeToAvoidBottomInset: false,
|
||
appBar: AppBar(
|
||
backgroundColor: cs.background,
|
||
surfaceTintColor: Colors.transparent,
|
||
elevation: 0,
|
||
scrolledUnderElevation: 0,
|
||
centerTitle: true,
|
||
toolbarHeight: 32,
|
||
leading: canPop
|
||
? IconButton(
|
||
onPressed: () => context.pop(),
|
||
icon: const Icon(Icons.chevron_left, size: 20),
|
||
)
|
||
: null,
|
||
title: AnimatedOpacity(
|
||
opacity: _showCompactHeader ? 1 : 0,
|
||
duration: const Duration(milliseconds: 160),
|
||
curve: Curves.easeOut,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
header.title,
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.foreground,
|
||
height: 1.1,
|
||
letterSpacing: 0,
|
||
),
|
||
),
|
||
if (header.subtitle != null) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
header.subtitle!,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w400,
|
||
color: cs.mutedForeground,
|
||
height: 1.1,
|
||
letterSpacing: 0,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
body: ColoredBox(
|
||
color: cs.background,
|
||
child: NotificationListener<ScrollNotification>(
|
||
onNotification: _handleScrollNotification,
|
||
child: widget.navigationShell,
|
||
),
|
||
),
|
||
bottomNavigationBar: SafeArea(
|
||
top: false,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
color: cs.background,
|
||
border: Border(
|
||
top: BorderSide(color: cs.border, width: 1),
|
||
),
|
||
),
|
||
child: SizedBox(
|
||
height: 62,
|
||
// 平板:tab 行限宽 600 居中(demo .nav .tabs)
|
||
child: Center(
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(
|
||
maxWidth: Adaptive.tabsMaxWidth,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
for (var i = 0; i < _labels.length; i++)
|
||
Expanded(
|
||
child: _TabButton(
|
||
icon: i == _index
|
||
? _filledIcons[i]
|
||
: _outlineIcons[i],
|
||
label: _labels[i],
|
||
selected: i == _index,
|
||
onTap: () =>
|
||
widget.navigationShell.goBranch(
|
||
i,
|
||
initialLocation: i == _index,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (mall.isVisible)
|
||
Positioned(
|
||
bottom: 39,
|
||
left: 0,
|
||
right: 0,
|
||
child: Center(
|
||
child: _MallDockButton(
|
||
title: mall.title,
|
||
badge: mall.badgeText,
|
||
onTap: () => _openMall(mall),
|
||
),
|
||
),
|
||
),
|
||
ValueListenableBuilder<MallWebHostState>(
|
||
valueListenable: MallWebHostController.instance.state,
|
||
builder: (context, state, _) => Positioned.fill(
|
||
child: Offstage(
|
||
offstage: !state.visible,
|
||
child: MallWebHost(
|
||
onExit: MallWebHostController.instance.dismiss,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (!kReleaseMode)
|
||
Positioned(
|
||
right: 16,
|
||
bottom: 88,
|
||
child: FilledButton(
|
||
onPressed: () => context.push(AppRoutes.fancyDioInspector),
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: cs.primary,
|
||
foregroundColor: cs.primaryForeground,
|
||
minimumSize: const Size(60, 60),
|
||
shape: const CircleBorder(),
|
||
padding: EdgeInsets.zero,
|
||
),
|
||
child: const Text('请求'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
bool _handleScrollNotification(ScrollNotification notification) {
|
||
if (notification.metrics.axis != Axis.vertical) {
|
||
return false;
|
||
}
|
||
final next = notification.metrics.pixels > _compactHeaderThreshold;
|
||
if (next != _showCompactHeader && mounted) {
|
||
setState(() => _showCompactHeader = next);
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
class _MallDockButton extends StatelessWidget {
|
||
const _MallDockButton({
|
||
required this.title,
|
||
required this.badge,
|
||
required this.onTap,
|
||
});
|
||
final String title;
|
||
final String badge;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Semantics(
|
||
button: true,
|
||
label: title,
|
||
child: Material(
|
||
color: cs.primary,
|
||
borderRadius: BorderRadius.circular(24),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(24),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.shopping_bag_outlined,
|
||
size: 18,
|
||
color: cs.primaryForeground,
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
title,
|
||
style: TextStyle(
|
||
color: cs.primaryForeground,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
if (badge.isNotEmpty) ...[
|
||
const SizedBox(width: 6),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 5,
|
||
vertical: 1,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: cs.background,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Text(
|
||
badge,
|
||
style: TextStyle(fontSize: 10, color: cs.foreground),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _TabButton extends StatelessWidget {
|
||
const _TabButton({
|
||
required this.icon,
|
||
required this.label,
|
||
required this.selected,
|
||
required this.onTap,
|
||
});
|
||
|
||
final IconData icon;
|
||
final String label;
|
||
final bool selected;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
final color = selected ? cs.foreground : cs.mutedForeground;
|
||
return InkWell(
|
||
onTap: onTap,
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(icon, size: 22, color: color),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||
color: color,
|
||
letterSpacing: 0,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
const _filledIcons = [
|
||
AppIcons.assetsFill,
|
||
AppIcons.marketFill,
|
||
AppIcons.newsFill,
|
||
AppIcons.meFill,
|
||
];
|
||
|
||
const _outlineIcons = [
|
||
AppIcons.assetsLine,
|
||
AppIcons.marketLine,
|
||
AppIcons.newsLine,
|
||
AppIcons.meLine,
|
||
];
|
||
|
||
const _labels = ['资产', '行情', '资讯', '我的'];
|
||
|
||
_ShellHeader _headerForPath(String path) {
|
||
return switch (path) {
|
||
'/market' => const _ShellHeader('行情', ''),
|
||
'/news' => const _ShellHeader('资讯', ''),
|
||
'/profile' => const _ShellHeader('我的'),
|
||
_ => const _ShellHeader('金值', ''),
|
||
};
|
||
}
|
||
|
||
class _ShellHeader {
|
||
const _ShellHeader(this.title, [this.subtitle]);
|
||
|
||
final String title;
|
||
final String? subtitle;
|
||
}
|