vehicle_list_page.dart 18 KB

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