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 createState() => _TabShellState(); } class _TabShellState extends ConsumerState { static const double _compactHeaderThreshold = 34; bool _showCompactHeader = false; int get _index => widget.navigationShell.currentIndex; @override void initState() { super.initState(); ref.listenManual(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 _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( 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( 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; }