vehicle_list_page.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import 'package:easy_refresh/easy_refresh.dart';
  6. import '../../core/theme/app_colors.dart';
  7. import '../../core/theme/app_colors_extension.dart';
  8. import '../../core/auth/role_provider.dart';
  9. import '../shell/nav_bar_config.dart';
  10. import '../../core/utils/date_utils.dart' as du;
  11. import '../../shared/widgets/empty_state.dart';
  12. import '../../shared/widgets/skeleton_list_card.dart';
  13. import '../../shared/widgets/filter_bar.dart';
  14. import '../../core/i18n/app_localizations.dart';
  15. import 'vehicle_list_controller.dart';
  16. import 'vehicle_model.dart';
  17. final _scopeProvider = StateProvider<String>((ref) => 'my');
  18. class VehicleListPage extends ConsumerStatefulWidget {
  19. const VehicleListPage({super.key});
  20. @override
  21. ConsumerState<VehicleListPage> createState() => _VehicleListPageState();
  22. }
  23. class _VehicleListPageState extends ConsumerState<VehicleListPage>
  24. with TickerProviderStateMixin {
  25. List<String> _getTabLabels(AppLocalizations l10n) => [
  26. l10n.get('all'),
  27. l10n.get('draft'),
  28. l10n.get('pending'),
  29. l10n.get('approved'),
  30. l10n.get('rejected'),
  31. l10n.get('revoked'),
  32. l10n.get('returned'),
  33. ];
  34. static const _tabKeys = [
  35. '',
  36. 'draft',
  37. 'pending',
  38. 'approved',
  39. 'rejected',
  40. 'withdrawn',
  41. 'returned',
  42. ];
  43. late final TabController _tabCtrl;
  44. @override
  45. void initState() {
  46. super.initState();
  47. _tabCtrl = TabController(length: _tabKeys.length, vsync: this);
  48. _tabCtrl.addListener(() {
  49. if (!_tabCtrl.indexIsChanging) {
  50. ref.read(vehicleStatusFilterProvider.notifier).state =
  51. _tabKeys[_tabCtrl.index];
  52. }
  53. });
  54. }
  55. @override
  56. void dispose() {
  57. _tabCtrl.dispose();
  58. super.dispose();
  59. }
  60. @override
  61. Widget build(BuildContext context) {
  62. final status = ref.watch(vehicleStatusFilterProvider);
  63. final dateStart = ref.watch(vehicleDateStartProvider);
  64. final dateEnd = ref.watch(vehicleDateEndProvider);
  65. final purposeFilter = ref.watch(vehiclePurposeFilterProvider);
  66. final l10n = AppLocalizations.of(context);
  67. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  68. final isManager = ref.watch(isManagerProvider);
  69. // Sync TabController with external filter changes
  70. final targetIdx = _tabKeys.indexOf(status);
  71. if (targetIdx >= 0 &&
  72. _tabCtrl.index != targetIdx &&
  73. !_tabCtrl.indexIsChanging) {
  74. WidgetsBinding.instance.addPostFrameCallback((_) {
  75. if (mounted) _tabCtrl.animateTo(targetIdx);
  76. });
  77. }
  78. final filterGroups = [
  79. FilterGroup(
  80. title: '日期范围',
  81. type: FilterGroupType.dateRange,
  82. sections: [
  83. FilterSection(
  84. label: '起始日期',
  85. type: FilterSectionType.dateRange,
  86. startDate: dateStart,
  87. endDate: dateEnd,
  88. onStartChanged: (v) =>
  89. ref.read(vehicleDateStartProvider.notifier).state = v,
  90. onEndChanged: (v) =>
  91. ref.read(vehicleDateEndProvider.notifier).state = v,
  92. ),
  93. FilterSection(
  94. label: '结束日期',
  95. type: FilterSectionType.dateRange,
  96. startDate: dateStart,
  97. endDate: dateEnd,
  98. onStartChanged: (v) =>
  99. ref.read(vehicleDateStartProvider.notifier).state = v,
  100. onEndChanged: (v) =>
  101. ref.read(vehicleDateEndProvider.notifier).state = v,
  102. ),
  103. ],
  104. ),
  105. FilterGroup(
  106. title: '其它',
  107. type: FilterGroupType.other,
  108. sections: [
  109. FilterSection(
  110. label: '用车目的',
  111. type: FilterSectionType.singleSelect,
  112. options: const [
  113. FilterOption(value: 'reception', label: '客户接待'),
  114. FilterOption(value: 'business', label: '商务出行'),
  115. FilterOption(value: 'official', label: '公务'),
  116. ],
  117. selectedValue: purposeFilter,
  118. onChanged: (v) =>
  119. ref.read(vehiclePurposeFilterProvider.notifier).state = v,
  120. ),
  121. ],
  122. ),
  123. ];
  124. final hasFilter = FilterBar.hasActiveFilter(filterGroups);
  125. void onFilterReset() {
  126. ref.read(vehicleDateStartProvider.notifier).state = null;
  127. ref.read(vehicleDateEndProvider.notifier).state = null;
  128. ref.read(vehiclePurposeFilterProvider.notifier).state = null;
  129. }
  130. ref
  131. .read(navBarConfigProvider.notifier)
  132. .update(
  133. NavBarConfig(
  134. title: l10n.get('vehicleList'),
  135. showBack: true,
  136. showRight: true,
  137. rightWidget: GestureDetector(
  138. onTap: () => FilterBar.show(
  139. context,
  140. groups: filterGroups,
  141. onReset: onFilterReset,
  142. onConfirm: () {},
  143. ),
  144. child: Stack(
  145. children: [
  146. Icon(
  147. TDIcons.filter,
  148. color: hasFilter ? colors.primary : colors.textPrimary,
  149. ),
  150. if (hasFilter)
  151. Positioned(
  152. right: -2,
  153. top: -2,
  154. child: Container(
  155. width: 6,
  156. height: 6,
  157. decoration: BoxDecoration(
  158. color: colors.danger,
  159. shape: BoxShape.circle,
  160. ),
  161. ),
  162. ),
  163. ],
  164. ),
  165. ),
  166. onBack: () => context.pop(),
  167. ),
  168. );
  169. return Column(
  170. children: [
  171. if (isManager)
  172. _buildScopeChip(colors),
  173. Container(
  174. color: colors.bgCard,
  175. padding: const EdgeInsets.symmetric(horizontal: 8),
  176. child: TDTabBar(
  177. tabs: _getTabLabels(l10n).map((l) => TDTab(text: l)).toList(),
  178. controller: _tabCtrl,
  179. isScrollable: true,
  180. labelColor: colors.primary,
  181. unselectedLabelColor: colors.textSecondary,
  182. outlineType: TDTabBarOutlineType.filled,
  183. showIndicator: true,
  184. indicatorColor: colors.primary,
  185. indicatorHeight: 3,
  186. dividerHeight: 0,
  187. labelPadding: const EdgeInsets.symmetric(horizontal: 12),
  188. onTap: (index) {
  189. ref.read(vehicleStatusFilterProvider.notifier).state =
  190. _tabKeys[index];
  191. },
  192. ),
  193. ),
  194. Expanded(
  195. child: Container(
  196. color: colors.bgPage,
  197. child: TabBarView(
  198. controller: _tabCtrl,
  199. children: List.generate(_tabKeys.length, (tabIdx) {
  200. return _buildTabContent(tabIdx);
  201. }),
  202. ),
  203. ),
  204. ),
  205. ],
  206. );
  207. }
  208. Widget _buildScopeChip(AppColorsExtension colors) {
  209. final scope = ref.watch(_scopeProvider);
  210. return Padding(
  211. padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
  212. child: Row(
  213. children: [
  214. GestureDetector(
  215. onTap: () => ref.read(_scopeProvider.notifier).state = 'my',
  216. child: Container(
  217. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
  218. decoration: BoxDecoration(
  219. color: scope == 'my' ? colors.primary : colors.bgPage,
  220. borderRadius: BorderRadius.circular(16),
  221. ),
  222. child: Text(
  223. '我的发起',
  224. style: TextStyle(
  225. fontSize: 13,
  226. color: scope == 'my' ? Colors.white : colors.textSecondary,
  227. ),
  228. ),
  229. ),
  230. ),
  231. const SizedBox(width: 8),
  232. GestureDetector(
  233. onTap: () => ref.read(_scopeProvider.notifier).state = 'sub',
  234. child: Container(
  235. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
  236. decoration: BoxDecoration(
  237. color: scope == 'sub' ? colors.primary : colors.bgPage,
  238. borderRadius: BorderRadius.circular(16),
  239. ),
  240. child: Text(
  241. '下属审批',
  242. style: TextStyle(
  243. fontSize: 13,
  244. color: scope == 'sub' ? Colors.white : colors.textSecondary,
  245. ),
  246. ),
  247. ),
  248. ),
  249. ],
  250. ),
  251. );
  252. }
  253. Widget _buildTabContent(int tabIdx) {
  254. return _VehicleTabContent(statusKey: _tabKeys[tabIdx]);
  255. }
  256. }
  257. class _VehicleTabContent extends ConsumerWidget {
  258. final String statusKey;
  259. const _VehicleTabContent({required this.statusKey});
  260. @override
  261. Widget build(BuildContext context, WidgetRef ref) {
  262. final itemsAsync = ref.watch(vehicleListProvider(statusKey));
  263. final scope = ref.watch(_scopeProvider);
  264. if (itemsAsync.isLoading && !itemsAsync.hasValue) {
  265. return SkeletonLoadingList(
  266. cardBuilder: () => const SkeletonVehicleCard(),
  267. );
  268. }
  269. return EasyRefresh(
  270. header: TDRefreshHeader(),
  271. onRefresh: () async {
  272. ref.read(vehicleRefreshProvider.notifier).state++;
  273. },
  274. child: _buildContent(itemsAsync, context, ref, scope),
  275. );
  276. }
  277. Widget _buildContent(
  278. AsyncValue<List<VehicleModel>> itemsAsync,
  279. BuildContext context,
  280. WidgetRef ref,
  281. String scope,
  282. ) {
  283. final l10n = AppLocalizations.of(context);
  284. final isSub = scope == 'sub';
  285. if (itemsAsync.isReloading) {
  286. final oldItems = itemsAsync.valueOrNull ?? [];
  287. if (oldItems.isEmpty) {
  288. return ListView(
  289. children: [
  290. const SizedBox(height: 120),
  291. EmptyState(message: l10n.get('noVehicles')),
  292. ],
  293. );
  294. }
  295. return ListView.builder(
  296. padding: const EdgeInsets.all(16),
  297. itemCount: oldItems.length,
  298. itemBuilder: (_, i) {
  299. final card = _buildVehicleListItem(context, oldItems[i], isSub: isSub);
  300. if (isSub && oldItems[i].status == 'pending') {
  301. return Padding(
  302. padding: const EdgeInsets.only(bottom: 16),
  303. child: _buildSwipeApprove(card, oldItems[i].id),
  304. );
  305. }
  306. return Padding(
  307. padding: const EdgeInsets.only(bottom: 16),
  308. child: card,
  309. );
  310. },
  311. );
  312. }
  313. if (itemsAsync.hasError) {
  314. return ListView(
  315. children: [
  316. const SizedBox(height: 120),
  317. EmptyState(message: l10n.get('loadFailed')),
  318. ],
  319. );
  320. }
  321. final items = itemsAsync.requireValue;
  322. if (items.isEmpty) {
  323. return ListView(
  324. children: [
  325. const SizedBox(height: 120),
  326. EmptyState(message: l10n.get('noVehicles')),
  327. ],
  328. );
  329. }
  330. return ListView.builder(
  331. padding: const EdgeInsets.all(16),
  332. itemCount: items.length,
  333. itemBuilder: (_, i) {
  334. final card = _buildVehicleListItem(context, items[i], isSub: isSub);
  335. if (isSub && items[i].status == 'pending') {
  336. return Padding(
  337. padding: const EdgeInsets.only(bottom: 16),
  338. child: _buildSwipeApprove(card, items[i].id),
  339. );
  340. }
  341. return Padding(
  342. padding: const EdgeInsets.only(bottom: 16),
  343. child: card,
  344. );
  345. },
  346. );
  347. }
  348. Widget _buildVehicleListItem(BuildContext context, VehicleModel item, {bool isSub = false}) {
  349. final l10n = AppLocalizations.of(context);
  350. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  351. Color bg, fg;
  352. String label;
  353. switch (item.status) {
  354. case 'pending':
  355. bg = colors.warningBg;
  356. fg = colors.warning;
  357. label = l10n.get('pending');
  358. case 'approved':
  359. bg = colors.successBg;
  360. fg = colors.success;
  361. label = l10n.get('approved');
  362. case 'rejected':
  363. bg = colors.dangerBg;
  364. fg = colors.danger;
  365. label = l10n.get('rejected');
  366. case 'draft':
  367. bg = colors.bgPage;
  368. fg = colors.statusGray;
  369. label = l10n.get('draft');
  370. case 'revoked':
  371. bg = colors.revokedBg;
  372. fg = colors.revokedText;
  373. label = l10n.get('revoked');
  374. case 'returned':
  375. bg = const Color(0xFFEDF2FC);
  376. fg = const Color(0xFF5A8CDB);
  377. label = l10n.get('returned');
  378. default:
  379. bg = colors.bgPage;
  380. fg = colors.statusGray;
  381. label = item.status;
  382. }
  383. return GestureDetector(
  384. onTap: () => context.push('/vehicle/detail/${item.id}'),
  385. child: Container(
  386. padding: const EdgeInsets.all(12),
  387. decoration: BoxDecoration(
  388. color: colors.bgCard,
  389. borderRadius: BorderRadius.circular(8),
  390. ),
  391. child: Column(
  392. children: [
  393. // R1: 车牌号 + 状态标签
  394. Row(
  395. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  396. children: [
  397. Text(
  398. item.licensePlate.isNotEmpty ? item.licensePlate : '未指定车辆',
  399. style: TextStyle(
  400. fontSize: AppFontSizes.subtitle,
  401. fontWeight: FontWeight.w700,
  402. color: colors.textPrimary,
  403. ),
  404. ),
  405. Container(
  406. padding: const EdgeInsets.symmetric(
  407. horizontal: 8,
  408. vertical: 2,
  409. ),
  410. decoration: BoxDecoration(
  411. color: bg,
  412. borderRadius: BorderRadius.circular(4),
  413. ),
  414. child: Text(
  415. label,
  416. style: TextStyle(
  417. fontSize: AppFontSizes.caption,
  418. fontWeight: FontWeight.w500,
  419. color: fg,
  420. ),
  421. ),
  422. ),
  423. ],
  424. ),
  425. if (isSub) ...[
  426. const SizedBox(height: 4),
  427. Align(
  428. alignment: Alignment.centerLeft,
  429. child: Text(
  430. '申请人: ${item.applicantName} · ${item.deptName}',
  431. style: TextStyle(
  432. fontSize: AppFontSizes.caption,
  433. color: colors.textPlaceholder,
  434. ),
  435. ),
  436. ),
  437. ],
  438. const SizedBox(height: 6),
  439. // R2: 申请单号 + 用途标签
  440. Row(
  441. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  442. children: [
  443. Text(
  444. item.applicationNo,
  445. style: TextStyle(
  446. fontSize: AppFontSizes.caption,
  447. color: colors.textSecondary,
  448. ),
  449. ),
  450. Container(
  451. padding: const EdgeInsets.symmetric(
  452. horizontal: 6,
  453. vertical: 1,
  454. ),
  455. decoration: BoxDecoration(
  456. color: colors.primaryLight,
  457. borderRadius: BorderRadius.circular(3),
  458. ),
  459. child: Text(
  460. item.purpose.isNotEmpty ? item.purpose : '公务',
  461. style: TextStyle(fontSize: 10, color: colors.primary),
  462. ),
  463. ),
  464. ],
  465. ),
  466. const SizedBox(height: 6),
  467. // R3: 路线 + 时间
  468. Row(
  469. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  470. children: [
  471. Flexible(
  472. child: Text(
  473. '${item.origin.isNotEmpty ? item.origin : '未知'} → ${item.destination.isNotEmpty ? item.destination : '未知'}',
  474. style: TextStyle(fontSize: 13, color: colors.textSecondary),
  475. overflow: TextOverflow.ellipsis,
  476. ),
  477. ),
  478. const SizedBox(width: 8),
  479. Text(
  480. '${du.DateUtils.formatMonthDay(item.startTime)} ${du.DateUtils.formatTime(item.startTime)}',
  481. style: TextStyle(
  482. fontSize: AppFontSizes.caption,
  483. color: colors.textPlaceholder,
  484. ),
  485. ),
  486. ],
  487. ),
  488. ],
  489. ),
  490. ),
  491. );
  492. }
  493. Widget _buildSwipeApprove(Widget card, String itemId) {
  494. return Builder(
  495. builder: (ctx) {
  496. final screenWidth = MediaQuery.of(ctx).size.width;
  497. return TDSwipeCell(
  498. groupTag: 'vehicle_approve',
  499. right: TDSwipeCellPanel(
  500. extentRatio: 100 / screenWidth,
  501. children: [
  502. TDSwipeCellAction(
  503. label: '',
  504. backgroundColor: Colors.transparent,
  505. builder: (_) => Container(
  506. margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
  507. decoration: BoxDecoration(
  508. color: Colors.green,
  509. borderRadius: BorderRadius.circular(8),
  510. ),
  511. alignment: Alignment.center,
  512. padding: const EdgeInsets.symmetric(horizontal: 12),
  513. child: const Text(
  514. '一键同意',
  515. style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600),
  516. ),
  517. ),
  518. onPressed: (_) async {
  519. final confirmed = await showDialog<bool>(
  520. context: ctx,
  521. builder: (dCtx) => TDAlertDialog(
  522. title: '确认审批',
  523. content: '确认同意该用车申请?',
  524. leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.of(dCtx).pop(false)),
  525. rightBtn: TDDialogButtonOptions(title: '确认', action: () => Navigator.of(dCtx).pop(true)),
  526. ),
  527. );
  528. if (confirmed == true) {
  529. // TODO: 接入实际审批 API
  530. if (ctx.mounted) {
  531. TDToast.showSuccess('已审批通过', context: ctx);
  532. }
  533. }
  534. },
  535. ),
  536. ],
  537. ),
  538. cell: card,
  539. );
  540. },
  541. );
  542. }
  543. }