Files
jinzhi/lib/common/router/tab_shell.dart
T
2026-08-24 19:07:31 +08:00

397 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}