app_scaffold.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  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 == '/') {
  30. return l10n.get('appName');
  31. }
  32. if (path == '/messages') {
  33. return l10n.get('tabMessages');
  34. }
  35. if (path == '/profile') {
  36. return l10n.get('tabProfile');
  37. }
  38. if (path.startsWith('/expense/list')) {
  39. return l10n.get('expenseList');
  40. }
  41. if (path.startsWith('/expense/import-apply')) {
  42. return l10n.get('importExpenseApply');
  43. }
  44. if (path.startsWith('/expense/detail')) {
  45. return l10n.get('expenseDetail');
  46. }
  47. if (path.startsWith('/expense/edit')) {
  48. return l10n.get('editExpense');
  49. }
  50. if (path.startsWith('/expense/create')) {
  51. return l10n.get('expenseApply');
  52. }
  53. if (path.startsWith('/expense-apply/list')) {
  54. return l10n.get('expenseApplyList');
  55. }
  56. if (path.startsWith('/expense-apply/detail')) {
  57. return l10n.get('expenseApplyDetail');
  58. }
  59. if (path.startsWith('/expense-apply/edit')) {
  60. return l10n.get('editApply');
  61. }
  62. if (path.startsWith('/expense-apply/create')) {
  63. return l10n.get('expenseApplyRequest');
  64. }
  65. if (path.startsWith('/overtime-apply/list')) {
  66. return l10n.get('overtimeList');
  67. }
  68. if (path.startsWith('/overtime-apply/detail')) {
  69. return l10n.get('overtimeDetail');
  70. }
  71. if (path.startsWith('/overtime-apply/create')) {
  72. return l10n.get('overtimeRequest');
  73. }
  74. if (path.startsWith('/overtime-apply/edit')) {
  75. return l10n.get('editOvertime');
  76. }
  77. if (path.startsWith('/vehicle-apply/list')) {
  78. return l10n.get('vehicleList');
  79. }
  80. if (path.startsWith('/vehicle-apply/detail')) {
  81. return l10n.get('vehicleDetail');
  82. }
  83. if (path.startsWith('/vehicle-apply/create')) {
  84. return l10n.get('vehicleRequest');
  85. }
  86. if (path.startsWith('/vehicle-apply/edit')) {
  87. return l10n.get('editVehicle');
  88. }
  89. if (path.startsWith('/outing-log/list')) {
  90. return l10n.get('outingLogList');
  91. }
  92. if (path.startsWith('/outing-log/detail')) {
  93. return l10n.get('outingLogDetail');
  94. }
  95. if (path.startsWith('/outing-log/create')) {
  96. return l10n.get('outingLogCreate');
  97. }
  98. if (path.startsWith('/announcement/list')) {
  99. return l10n.get('announcementList');
  100. }
  101. if (path.startsWith('/announcement/detail')) {
  102. return l10n.get('announcementDetail');
  103. }
  104. if (path.startsWith('/announcement/create')) {
  105. return l10n.get('announcementCreate');
  106. }
  107. if (path.startsWith('/report/expense-detail')) {
  108. return l10n.get('reportExpenseDetail');
  109. }
  110. if (path.startsWith('/report/expense-apply-detail')) {
  111. return l10n.get('reportExpenseApplyDetail');
  112. }
  113. if (path.startsWith('/report/overtime-apply')) {
  114. return l10n.get('reportOvertimeDetail');
  115. }
  116. if (path.startsWith('/report/vehicle-apply')) {
  117. return l10n.get('reportVehicleDetail');
  118. }
  119. if (path.startsWith('/report/outing-log')) {
  120. return l10n.get('reportOutingLogDetail');
  121. }
  122. if (path.startsWith('/admin/permissions')) {
  123. return l10n.get('permissionManagement');
  124. }
  125. return null;
  126. }
  127. NavBarConfig _rootConfig(String location, AppLocalizations l10n) {
  128. final path = location.split('?').first;
  129. if (path == '/messages') {
  130. return NavBarConfig(
  131. title: l10n.get('tabMessages'),
  132. showBack: true,
  133. leadingIcon: Icons.close,
  134. );
  135. }
  136. if (path == '/') {
  137. return NavBarConfig(
  138. title: l10n.get('appName'),
  139. showBack: true,
  140. leadingIcon: Icons.close,
  141. );
  142. }
  143. if (path == '/profile') {
  144. return NavBarConfig(
  145. title: l10n.get('tabProfile'),
  146. showBack: true,
  147. leadingIcon: Icons.close,
  148. );
  149. }
  150. return NavBarConfig.home;
  151. }
  152. NavBarConfig _resolve(
  153. NavBarConfig cfg,
  154. String location,
  155. AppLocalizations l10n,
  156. ) {
  157. final routeTitle = _titleForRoute(location, l10n);
  158. if (routeTitle == null) return cfg;
  159. return NavBarConfig(
  160. title: routeTitle,
  161. showBack: cfg.showBack,
  162. showRight: cfg.showRight,
  163. rightWidget: cfg.rightWidget,
  164. onBack: cfg.onBack,
  165. leadingIcon: cfg.leadingIcon,
  166. );
  167. }
  168. @override
  169. Widget build(BuildContext context, WidgetRef ref) {
  170. SystemChrome.setSystemUIOverlayStyle(
  171. const SystemUiOverlayStyle(
  172. statusBarColor: Colors.transparent,
  173. statusBarIconBrightness: Brightness.dark,
  174. ),
  175. );
  176. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  177. final l10n = AppLocalizations.of(context);
  178. final location = GoRouterState.of(context).uri.toString();
  179. final config = navConfig != null
  180. ? _resolve(navConfig!, location, l10n)
  181. : _rootConfig(location, l10n);
  182. // 页面可通过 pageBackProvider 覆盖返回按钮行为(如 create 页面的 _doPop)
  183. final pageBack = ref.watch(pageBackProvider);
  184. return Scaffold(
  185. resizeToAvoidBottomInset: resizeToAvoidBottomInset,
  186. backgroundColor: colors.bgPage,
  187. body: Column(
  188. crossAxisAlignment: CrossAxisAlignment.stretch,
  189. children: [
  190. _NavBarView(
  191. config: config,
  192. location: location,
  193. pageBack: pageBack,
  194. onBack: () {
  195. if (_isRootTab(location)) {
  196. SystemNavigator.pop();
  197. } else {
  198. final router = GoRouter.of(context);
  199. if (router.canPop()) {
  200. router.pop();
  201. } else {
  202. SystemNavigator.pop();
  203. }
  204. }
  205. },
  206. ),
  207. Expanded(child: body),
  208. if (showTabBar)
  209. Container(
  210. color: colors.bgCard,
  211. child: _AppTabBar(location: location),
  212. ),
  213. ],
  214. ),
  215. );
  216. }
  217. }
  218. class _NavBarView extends StatelessWidget {
  219. final NavBarConfig config;
  220. final String location;
  221. final VoidCallback? pageBack;
  222. final VoidCallback onBack;
  223. const _NavBarView({
  224. required this.config,
  225. required this.location,
  226. this.pageBack,
  227. required this.onBack,
  228. });
  229. @override
  230. Widget build(BuildContext context) {
  231. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  232. List<TDNavBarItem>? leftItems;
  233. if (config.showBack) {
  234. final icon = config.leadingIcon ?? TDIcons.chevron_left;
  235. // config.onBack(路由级声明)优先于 pageBack(页面级 Provider),
  236. // 防止 Provider 残留值泄漏到不相关的页面
  237. final backAction = config.onBack ?? pageBack ?? onBack;
  238. leftItems = [
  239. TDNavBarItem(
  240. icon: icon,
  241. iconSize: 22,
  242. iconColor: colors.textPrimary,
  243. action: backAction,
  244. ),
  245. ];
  246. }
  247. List<TDNavBarItem>? rightItems;
  248. if (config.showRight && config.rightWidget != null) {
  249. rightItems = [TDNavBarItem(iconWidget: config.rightWidget, iconSize: 22)];
  250. }
  251. return TDNavBar(
  252. title: config.title,
  253. titleColor: colors.textPrimary,
  254. titleFontWeight: FontWeight.w600,
  255. titleFont: Font(size: AppFontSizes.title.toInt(), lineHeight: 26),
  256. backgroundColor: colors.bgCard,
  257. height: 56,
  258. centerTitle: true,
  259. useDefaultBack: false,
  260. screenAdaptation: true,
  261. leftBarItems: leftItems,
  262. rightBarItems: rightItems,
  263. );
  264. }
  265. }
  266. class _AppTabBar extends StatelessWidget {
  267. final String location;
  268. const _AppTabBar({required this.location});
  269. static int tabIndex(String location) {
  270. if (location.startsWith('/messages')) {
  271. return 0;
  272. }
  273. if (location == '/' ||
  274. (!location.startsWith('/messages') &&
  275. !location.startsWith('/profile'))) {
  276. return 1;
  277. }
  278. return 2;
  279. }
  280. @override
  281. Widget build(BuildContext context) {
  282. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  283. final l10n = AppLocalizations.of(context);
  284. return LayoutBuilder(
  285. builder: (ctx, constraints) {
  286. if (constraints.maxWidth <= 0) return const SizedBox.shrink();
  287. final bottomPadding = MediaQuery.of(ctx).padding.bottom;
  288. return Padding(
  289. padding: EdgeInsets.only(top: 0, bottom: 8 + bottomPadding),
  290. child: TDBottomTabBar(
  291. TDBottomTabBarBasicType.iconText,
  292. useSafeArea: false,
  293. componentType: TDBottomTabBarComponentType.label,
  294. outlineType: TDBottomTabBarOutlineType.filled,
  295. backgroundColor: colors.bgCard,
  296. dividerColor: Colors.transparent,
  297. selectedBgColor: colors.primaryLight,
  298. unselectedBgColor: Colors.transparent,
  299. currentIndex: tabIndex(location),
  300. navigationTabs: [
  301. TDBottomTabBarTabConfig(
  302. tabText: l10n.get('tabMessages'),
  303. selectedIcon: Icon(
  304. Icons.notifications,
  305. size: 22,
  306. color: colors.primary,
  307. ),
  308. unselectedIcon: Icon(
  309. Icons.notifications_outlined,
  310. size: 22,
  311. color: colors.textSecondary,
  312. ),
  313. selectTabTextStyle: TextStyle(
  314. fontSize: 10,
  315. fontWeight: FontWeight.w600,
  316. color: colors.primary,
  317. ),
  318. unselectTabTextStyle: TextStyle(
  319. fontSize: 10,
  320. color: colors.textSecondary,
  321. ),
  322. onTap: () => context.go('/messages'),
  323. ),
  324. TDBottomTabBarTabConfig(
  325. tabText: l10n.get('tabWorkbench'),
  326. selectedIcon: Icon(
  327. Icons.dashboard,
  328. size: 22,
  329. color: colors.primary,
  330. ),
  331. unselectedIcon: Icon(
  332. Icons.dashboard_outlined,
  333. size: 22,
  334. color: colors.textSecondary,
  335. ),
  336. selectTabTextStyle: TextStyle(
  337. fontSize: 10,
  338. fontWeight: FontWeight.w600,
  339. color: colors.primary,
  340. ),
  341. unselectTabTextStyle: TextStyle(
  342. fontSize: 10,
  343. color: colors.textSecondary,
  344. ),
  345. onTap: () => context.go('/'),
  346. ),
  347. TDBottomTabBarTabConfig(
  348. tabText: l10n.get('tabProfile'),
  349. selectedIcon: Icon(
  350. Icons.person,
  351. size: 22,
  352. color: colors.primary,
  353. ),
  354. unselectedIcon: Icon(
  355. Icons.person_outline,
  356. size: 22,
  357. color: colors.textSecondary,
  358. ),
  359. selectTabTextStyle: TextStyle(
  360. fontSize: 10,
  361. fontWeight: FontWeight.w600,
  362. color: colors.primary,
  363. ),
  364. unselectTabTextStyle: TextStyle(
  365. fontSize: 10,
  366. color: colors.textSecondary,
  367. ),
  368. onTap: () => context.go('/profile'),
  369. ),
  370. ],
  371. ),
  372. );
  373. },
  374. );
  375. }
  376. }