app_scaffold.dart 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import '../../core/theme/app_colors.dart';
  5. import 'package:go_router/go_router.dart';
  6. import 'package:tdesign_flutter/tdesign_flutter.dart';
  7. import '../../core/i18n/app_localizations.dart';
  8. import '../../core/theme/app_colors_extension.dart';
  9. import 'nav_bar_config.dart';
  10. /// 应用级 Scaffold:NavBar + body + 可选 BottomTabBar
  11. class AppScaffold extends ConsumerWidget {
  12. final Widget body;
  13. final bool showTabBar;
  14. final bool resizeToAvoidBottomInset;
  15. final NavBarConfig? navConfig;
  16. const AppScaffold({
  17. super.key,
  18. required this.body,
  19. this.showTabBar = false,
  20. this.resizeToAvoidBottomInset = true,
  21. this.navConfig,
  22. });
  23. bool _isRootTab(String location) {
  24. final path = location.split('?').first;
  25. return path == '/' || path == '/messages' || path == '/profile';
  26. }
  27. String? _titleForRoute(String location, AppLocalizations l10n) {
  28. final path = location.split('?').first;
  29. if (path == '/') return l10n.get('appName');
  30. if (path == '/messages') return l10n.get('tabMessages');
  31. if (path == '/profile') return l10n.get('tabProfile');
  32. if (path.startsWith('/expense/list')) return l10n.get('expenseList');
  33. if (path.startsWith('/expense/import-apply')) return l10n.get('importExpenseApply');
  34. if (path.startsWith('/expense/detail')) return l10n.get('expenseDetail');
  35. if (path.startsWith('/expense/create') || path.startsWith('/expense/edit')) return l10n.get('expenseApply');
  36. if (path.startsWith('/expense-apply/list')) return l10n.get('expenseApplyList');
  37. if (path.startsWith('/expense-apply/detail')) return l10n.get('expenseApplyDetail');
  38. if (path.startsWith('/expense-apply/create')) return l10n.get('expenseApplyRequest');
  39. if (path.startsWith('/overtime/list')) return l10n.get('overtimeList');
  40. if (path.startsWith('/overtime/detail')) return l10n.get('overtimeDetail');
  41. if (path.startsWith('/overtime/create')) return l10n.get('overtimeRequest');
  42. if (path.startsWith('/vehicle/list')) return l10n.get('vehicleList');
  43. if (path.startsWith('/vehicle/detail')) return l10n.get('vehicleDetail');
  44. if (path.startsWith('/vehicle/create')) return l10n.get('vehicleRequest');
  45. if (path.startsWith('/outing-log/list')) return l10n.get('outingLogList');
  46. if (path.startsWith('/outing-log/detail')) return l10n.get('outingLogDetail');
  47. if (path.startsWith('/outing-log/create')) return l10n.get('outingLogCreate');
  48. if (path.startsWith('/announcement/list')) return l10n.get('announcementList');
  49. if (path.startsWith('/announcement/detail')) return l10n.get('announcementDetail');
  50. if (path.startsWith('/announcement/create')) return l10n.get('announcementCreate');
  51. if (path.startsWith('/report/expense-detail')) return l10n.get('reportExpenseDetail');
  52. if (path.startsWith('/report/expense-apply-detail')) return l10n.get('reportExpenseApplyDetail');
  53. if (path.startsWith('/report/overtime')) return l10n.get('reportOvertimeDetail');
  54. if (path.startsWith('/report/vehicle')) return l10n.get('reportVehicleDetail');
  55. if (path.startsWith('/report/outing-log')) return l10n.get('reportOutingLogDetail');
  56. if (path.startsWith('/admin/permissions')) return l10n.get('permissionManagement');
  57. return null;
  58. }
  59. NavBarConfig _rootConfig(String location, AppLocalizations l10n) {
  60. final path = location.split('?').first;
  61. if (path == '/messages') return NavBarConfig(title: l10n.get('tabMessages'), showBack: true, leadingIcon: Icons.close);
  62. if (path == '/') return NavBarConfig(title: l10n.get('appName'), showBack: true, leadingIcon: Icons.close);
  63. if (path == '/profile') return NavBarConfig(title: l10n.get('tabProfile'), showBack: true, leadingIcon: Icons.close);
  64. return NavBarConfig.home;
  65. }
  66. NavBarConfig _resolve(NavBarConfig cfg, String location, AppLocalizations l10n) {
  67. final routeTitle = _titleForRoute(location, l10n);
  68. if (routeTitle == null) return cfg;
  69. return NavBarConfig(
  70. title: routeTitle,
  71. showBack: cfg.showBack,
  72. showRight: cfg.showRight,
  73. rightWidget: cfg.rightWidget,
  74. onBack: cfg.onBack,
  75. leadingIcon: cfg.leadingIcon,
  76. );
  77. }
  78. @override
  79. Widget build(BuildContext context, WidgetRef ref) {
  80. SystemChrome.setSystemUIOverlayStyle(
  81. const SystemUiOverlayStyle(statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark),
  82. );
  83. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  84. final l10n = AppLocalizations.of(context);
  85. final location = GoRouterState.of(context).uri.toString();
  86. final config = navConfig != null
  87. ? _resolve(navConfig!, location, l10n)
  88. : _rootConfig(location, l10n);
  89. // 页面可通过 pageBackProvider 覆盖返回按钮行为(如 create 页面的 _doPop)
  90. final pageBack = ref.watch(pageBackProvider);
  91. return Scaffold(
  92. resizeToAvoidBottomInset: resizeToAvoidBottomInset,
  93. backgroundColor: colors.bgPage,
  94. body: Column(
  95. crossAxisAlignment: CrossAxisAlignment.stretch,
  96. children: [
  97. _NavBarView(
  98. config: config,
  99. location: location,
  100. pageBack: pageBack,
  101. onBack: () {
  102. if (_isRootTab(location)) {
  103. SystemNavigator.pop();
  104. } else {
  105. final router = GoRouter.of(context);
  106. if (router.canPop()) {
  107. router.pop();
  108. } else {
  109. SystemNavigator.pop();
  110. }
  111. }
  112. },
  113. ),
  114. Expanded(child: body),
  115. if (showTabBar)
  116. Container(color: colors.bgCard, child: _AppTabBar(location: location)),
  117. ],
  118. ),
  119. );
  120. }
  121. }
  122. class _NavBarView extends StatelessWidget {
  123. final NavBarConfig config;
  124. final String location;
  125. final VoidCallback? pageBack;
  126. final VoidCallback onBack;
  127. const _NavBarView({required this.config, required this.location, this.pageBack, required this.onBack});
  128. @override
  129. Widget build(BuildContext context) {
  130. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  131. List<TDNavBarItem>? leftItems;
  132. if (config.showBack) {
  133. final icon = config.leadingIcon ?? TDIcons.chevron_left;
  134. // config.onBack(路由级声明)优先于 pageBack(页面级 Provider),
  135. // 防止 Provider 残留值泄漏到不相关的页面
  136. final backAction = config.onBack ?? pageBack ?? onBack;
  137. leftItems = [TDNavBarItem(icon: icon, iconSize: 22, iconColor: colors.textPrimary, action: backAction)];
  138. }
  139. List<TDNavBarItem>? rightItems;
  140. if (config.showRight && config.rightWidget != null) {
  141. rightItems = [TDNavBarItem(iconWidget: config.rightWidget, iconSize: 22)];
  142. }
  143. return TDNavBar(
  144. title: config.title,
  145. titleColor: colors.textPrimary,
  146. titleFontWeight: FontWeight.w600,
  147. titleFont: Font(size: AppFontSizes.title.toInt(), lineHeight: 26),
  148. backgroundColor: colors.bgCard,
  149. height: 56,
  150. centerTitle: true,
  151. useDefaultBack: false,
  152. screenAdaptation: true,
  153. leftBarItems: leftItems,
  154. rightBarItems: rightItems,
  155. );
  156. }
  157. }
  158. class _AppTabBar extends StatelessWidget {
  159. final String location;
  160. const _AppTabBar({required this.location});
  161. static int tabIndex(String location) {
  162. if (location.startsWith('/messages')) return 0;
  163. if (location == '/' || (!location.startsWith('/messages') && !location.startsWith('/profile'))) return 1;
  164. return 2;
  165. }
  166. @override
  167. Widget build(BuildContext context) {
  168. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  169. final l10n = AppLocalizations.of(context);
  170. return LayoutBuilder(builder: (ctx, constraints) {
  171. if (constraints.maxWidth <= 0) return const SizedBox.shrink();
  172. final bottomPadding = MediaQuery.of(ctx).padding.bottom;
  173. return Padding(
  174. padding: EdgeInsets.only(top: 0, bottom: 8 + bottomPadding),
  175. child: TDBottomTabBar(
  176. TDBottomTabBarBasicType.iconText, useSafeArea: false,
  177. componentType: TDBottomTabBarComponentType.label,
  178. outlineType: TDBottomTabBarOutlineType.filled,
  179. backgroundColor: colors.bgCard, dividerColor: Colors.transparent,
  180. selectedBgColor: colors.primaryLight, unselectedBgColor: Colors.transparent,
  181. currentIndex: tabIndex(location),
  182. navigationTabs: [
  183. TDBottomTabBarTabConfig(
  184. tabText: l10n.get('tabMessages'),
  185. selectedIcon: Icon(Icons.notifications, size: 22, color: colors.primary),
  186. unselectedIcon: Icon(Icons.notifications_outlined, size: 22, color: colors.textSecondary),
  187. selectTabTextStyle: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: colors.primary),
  188. unselectTabTextStyle: TextStyle(fontSize: 10, color: colors.textSecondary),
  189. onTap: () => context.go('/messages'),
  190. ),
  191. TDBottomTabBarTabConfig(
  192. tabText: l10n.get('tabWorkbench'),
  193. selectedIcon: Icon(Icons.dashboard, size: 22, color: colors.primary),
  194. unselectedIcon: Icon(Icons.dashboard_outlined, size: 22, color: colors.textSecondary),
  195. selectTabTextStyle: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: colors.primary),
  196. unselectTabTextStyle: TextStyle(fontSize: 10, color: colors.textSecondary),
  197. onTap: () => context.go('/'),
  198. ),
  199. TDBottomTabBarTabConfig(
  200. tabText: l10n.get('tabProfile'),
  201. selectedIcon: Icon(Icons.person, size: 22, color: colors.primary),
  202. unselectedIcon: Icon(Icons.person_outline, size: 22, color: colors.textSecondary),
  203. selectTabTextStyle: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: colors.primary),
  204. unselectTabTextStyle: TextStyle(fontSize: 10, color: colors.textSecondary),
  205. onTap: () => context.go('/profile'),
  206. ),
  207. ],
  208. ),
  209. );
  210. });
  211. }
  212. }