vehicle_list_page.dart 18 KB

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