app_scaffold.dart 10 KB

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