vehicle_list_page.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  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 '../../core/utils/date_utils.dart' as du;
  10. import '../../shared/widgets/empty_state.dart';
  11. import '../../shared/widgets/app_skeletons.dart';
  12. import '../../shared/widgets/list_footer.dart';
  13. import '../../shared/widgets/status_tag.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('withdrawn'),
  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. bool _firstBuild = true;
  45. bool _invalidateDone = false;
  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. WidgetsBinding.instance.addPostFrameCallback((_) {
  57. ref.invalidate(vehicleListProvider(''));
  58. _invalidateDone = true;
  59. setState(() {});
  60. });
  61. }
  62. @override
  63. void dispose() {
  64. _tabCtrl.dispose();
  65. super.dispose();
  66. }
  67. @override
  68. Widget build(BuildContext context) {
  69. if (_invalidateDone) {
  70. ref.listen(vehicleListProvider(''), (prev, next) {
  71. if (_firstBuild && !next.isLoading && !next.isReloading) {
  72. setState(() => _firstBuild = false);
  73. WidgetsBinding.instance.addPostFrameCallback((_) {
  74. if (mounted) setState(() {});
  75. });
  76. }
  77. });
  78. }
  79. final status = ref.watch(vehicleStatusFilterProvider);
  80. final l10n = AppLocalizations.of(context);
  81. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  82. final isManager = ref.watch(isManagerProvider);
  83. // Sync TabController with external filter changes
  84. final targetIdx = _tabKeys.indexOf(status);
  85. if (targetIdx >= 0 &&
  86. _tabCtrl.index != targetIdx &&
  87. !_tabCtrl.indexIsChanging) {
  88. WidgetsBinding.instance.addPostFrameCallback((_) {
  89. if (mounted) _tabCtrl.animateTo(targetIdx);
  90. });
  91. }
  92. return Column(
  93. children: [
  94. if (isManager)
  95. Container(
  96. color: colors.bgCard,
  97. padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
  98. child: _buildScopeChip(colors),
  99. ),
  100. Container(
  101. color: colors.bgCard,
  102. padding: EdgeInsets.zero,
  103. child: TDSearchBar(
  104. placeHolder: l10n.get('searchVehicle'),
  105. needCancel: true,
  106. style: TDSearchStyle.round,
  107. ),
  108. ),
  109. Container(
  110. color: colors.bgCard,
  111. padding: const EdgeInsets.symmetric(horizontal: 8),
  112. child: TDTabBar(
  113. tabs: _getTabLabels(l10n).map((l) => TDTab(text: l)).toList(),
  114. controller: _tabCtrl,
  115. isScrollable: true,
  116. labelColor: colors.primary,
  117. unselectedLabelColor: colors.textSecondary,
  118. outlineType: TDTabBarOutlineType.filled,
  119. showIndicator: true,
  120. indicatorColor: colors.primary,
  121. indicatorHeight: 3,
  122. dividerHeight: 0,
  123. labelPadding: const EdgeInsets.symmetric(horizontal: 12),
  124. onTap: (index) {
  125. ref.read(vehicleStatusFilterProvider.notifier).state =
  126. _tabKeys[index];
  127. },
  128. ),
  129. ),
  130. Expanded(
  131. child: Container(
  132. color: colors.bgPage,
  133. child: _firstBuild
  134. ? const Center(child: SkeletonLoadingList())
  135. : TabBarView(
  136. controller: _tabCtrl,
  137. children: List.generate(_tabKeys.length, (tabIdx) {
  138. return _buildTabContent(tabIdx);
  139. }),
  140. ),
  141. ),
  142. ),
  143. ],
  144. );
  145. }
  146. Widget _buildScopeChip(AppColorsExtension colors) {
  147. final scope = ref.watch(_scopeProvider);
  148. final l10n = AppLocalizations.of(context);
  149. return Row(
  150. children: [
  151. GestureDetector(
  152. onTap: () => ref.read(_scopeProvider.notifier).state = 'my',
  153. child: Container(
  154. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
  155. decoration: BoxDecoration(
  156. color: scope == 'my' ? colors.primary : colors.bgPage,
  157. borderRadius: BorderRadius.circular(16),
  158. border: scope == 'my' ? null : Border.all(color: colors.border),
  159. ),
  160. child: Text(
  161. l10n.get('scopeMyApplications'),
  162. style: TextStyle(
  163. fontSize: 13,
  164. color: scope == 'my' ? colors.bgCard : colors.textSecondary,
  165. ),
  166. ),
  167. ),
  168. ),
  169. const SizedBox(width: 8),
  170. GestureDetector(
  171. onTap: () => ref.read(_scopeProvider.notifier).state = 'sub',
  172. child: Container(
  173. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
  174. decoration: BoxDecoration(
  175. color: scope == 'sub' ? colors.primary : colors.bgPage,
  176. borderRadius: BorderRadius.circular(16),
  177. border: scope == 'sub' ? null : Border.all(color: colors.border),
  178. ),
  179. child: Text(
  180. l10n.get('scopeSubordinates'),
  181. style: TextStyle(
  182. fontSize: 13,
  183. color: scope == 'sub' ? colors.bgCard : colors.textSecondary,
  184. ),
  185. ),
  186. ),
  187. ),
  188. ],
  189. );
  190. }
  191. Widget _buildTabContent(int tabIdx) {
  192. return _VehicleTabContent(statusKey: _tabKeys[tabIdx]);
  193. }
  194. }
  195. class _VehicleTabContent extends ConsumerWidget {
  196. final String statusKey;
  197. const _VehicleTabContent({required this.statusKey});
  198. @override
  199. Widget build(BuildContext context, WidgetRef ref) {
  200. final itemsAsync = ref.watch(vehicleListProvider(statusKey));
  201. final scope = ref.watch(_scopeProvider);
  202. if (itemsAsync.isLoading && !itemsAsync.hasValue) {
  203. return SkeletonLoadingList(
  204. cardBuilder: () => const SkeletonVehicleCard(),
  205. );
  206. }
  207. return EasyRefresh(
  208. header: TDRefreshHeader(),
  209. onRefresh: () async {
  210. ref.read(vehicleRefreshProvider.notifier).state++;
  211. },
  212. child: _buildContent(itemsAsync, context, ref, scope),
  213. );
  214. }
  215. Widget _buildContent(
  216. AsyncValue<List<VehicleModel>> itemsAsync,
  217. BuildContext context,
  218. WidgetRef ref,
  219. String scope,
  220. ) {
  221. final l10n = AppLocalizations.of(context);
  222. final isSub = scope == 'sub';
  223. if (itemsAsync.isReloading) {
  224. final oldItems = itemsAsync.valueOrNull ?? [];
  225. if (oldItems.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noVehicles'))]);
  226. return ListView.builder(padding: const EdgeInsets.all(16), itemCount: oldItems.length, itemBuilder: (_, i) {
  227. final card = _buildVehicleListItem(context, oldItems[i], isSub: isSub);
  228. if (isSub && oldItems[i].status == 'pending') return Padding(padding: const EdgeInsets.only(bottom: 16), child: _buildSwipeApprove(card, oldItems[i].id));
  229. return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
  230. });
  231. }
  232. if (itemsAsync.hasError) {
  233. return ListView(
  234. children: [
  235. const SizedBox(height: 120),
  236. EmptyState(message: l10n.get('loadFailed')),
  237. ],
  238. );
  239. }
  240. final items = itemsAsync.requireValue;
  241. if (items.isEmpty) {
  242. return ListView(
  243. children: [
  244. const SizedBox(height: 120),
  245. EmptyState(message: l10n.get('noVehicles')),
  246. ],
  247. );
  248. }
  249. return ListView.builder(
  250. padding: const EdgeInsets.all(16),
  251. itemCount: items.length + 1,
  252. itemBuilder: (_, i) {
  253. if (i == items.length) return ListFooter(itemCount: items.length);
  254. final card = _buildVehicleListItem(context, items[i], isSub: isSub);
  255. if (isSub && items[i].status == 'pending') {
  256. return Padding(
  257. padding: const EdgeInsets.only(bottom: 16),
  258. child: _buildSwipeApprove(card, items[i].id),
  259. );
  260. }
  261. return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
  262. },
  263. );
  264. }
  265. Widget _buildVehicleListItem(
  266. BuildContext context,
  267. VehicleModel item, {
  268. bool isSub = false,
  269. }) {
  270. final l10n = AppLocalizations.of(context);
  271. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  272. return GestureDetector(
  273. onTap: () => context.push('/vehicle/detail/${item.id}'),
  274. child: Container(
  275. padding: const EdgeInsets.all(12),
  276. decoration: BoxDecoration(
  277. color: colors.bgCard,
  278. borderRadius: BorderRadius.circular(8),
  279. ),
  280. child: Column(
  281. children: [
  282. // R1: 车牌号 + 状态标签
  283. Row(
  284. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  285. children: [
  286. Text(
  287. item.licensePlate.isNotEmpty ? item.licensePlate : '未指定车辆',
  288. style: TextStyle(
  289. fontSize: AppFontSizes.subtitle,
  290. fontWeight: FontWeight.w700,
  291. color: colors.textPrimary,
  292. ),
  293. ),
  294. StatusTag.fromStatus(item.status, l10n),
  295. ],
  296. ),
  297. if (isSub) ...[
  298. const SizedBox(height: 4),
  299. Align(
  300. alignment: Alignment.centerLeft,
  301. child: Text(
  302. '申请人: ${item.applicantName} · ${item.deptName}',
  303. style: TextStyle(
  304. fontSize: AppFontSizes.caption,
  305. color: colors.textPlaceholder,
  306. ),
  307. ),
  308. ),
  309. ],
  310. const SizedBox(height: 6),
  311. // R2: 申请单号 + 用途标签
  312. Row(
  313. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  314. children: [
  315. Text(
  316. item.applicationNo,
  317. style: TextStyle(
  318. fontSize: AppFontSizes.caption,
  319. color: colors.textSecondary,
  320. ),
  321. ),
  322. Container(
  323. padding: const EdgeInsets.symmetric(
  324. horizontal: 6,
  325. vertical: 1,
  326. ),
  327. decoration: BoxDecoration(
  328. color: colors.primaryLight,
  329. borderRadius: BorderRadius.circular(3),
  330. ),
  331. child: Text(
  332. item.purpose.isNotEmpty ? item.purpose : '公务',
  333. style: TextStyle(fontSize: 10, color: colors.primary),
  334. ),
  335. ),
  336. ],
  337. ),
  338. const SizedBox(height: 6),
  339. // R3: 路线 + 时间
  340. Row(
  341. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  342. children: [
  343. Flexible(
  344. child: Text(
  345. '${item.origin.isNotEmpty ? item.origin : '未知'} → ${item.destination.isNotEmpty ? item.destination : '未知'}',
  346. style: TextStyle(fontSize: 13, color: colors.textSecondary),
  347. overflow: TextOverflow.ellipsis,
  348. ),
  349. ),
  350. const SizedBox(width: 8),
  351. Text(
  352. '${du.DateUtils.formatMonthDay(item.startTime)} ${du.DateUtils.formatTime(item.startTime)}',
  353. style: TextStyle(
  354. fontSize: AppFontSizes.caption,
  355. color: colors.textPlaceholder,
  356. ),
  357. ),
  358. ],
  359. ),
  360. ],
  361. ),
  362. ),
  363. );
  364. }
  365. Widget _buildSwipeApprove(Widget card, String itemId) {
  366. return Builder(
  367. builder: (ctx) {
  368. final screenWidth = MediaQuery.of(ctx).size.width;
  369. return TDSwipeCell(
  370. groupTag: 'vehicle_approve',
  371. right: TDSwipeCellPanel(
  372. extentRatio: 100 / screenWidth,
  373. children: [
  374. TDSwipeCellAction(
  375. label: '',
  376. backgroundColor: Colors.transparent,
  377. builder: (_) => Container(
  378. margin: const EdgeInsets.symmetric(
  379. horizontal: 4,
  380. vertical: 8,
  381. ),
  382. decoration: BoxDecoration(
  383. color: Colors.green,
  384. borderRadius: BorderRadius.circular(8),
  385. ),
  386. alignment: Alignment.center,
  387. padding: const EdgeInsets.symmetric(horizontal: 12),
  388. child: const Text(
  389. '一键同意',
  390. style: TextStyle(
  391. color: Colors.white,
  392. fontSize: 14,
  393. fontWeight: FontWeight.w600,
  394. ),
  395. ),
  396. ),
  397. onPressed: (_) async {
  398. final confirmed = await showDialog<bool>(
  399. context: ctx,
  400. builder: (dCtx) => TDAlertDialog(
  401. title: '确认审批',
  402. content: '确认同意该用车申请?',
  403. leftBtn: TDDialogButtonOptions(
  404. title: '取消',
  405. action: () => Navigator.of(dCtx).pop(false),
  406. ),
  407. rightBtn: TDDialogButtonOptions(
  408. title: '确认',
  409. action: () => Navigator.of(dCtx).pop(true),
  410. ),
  411. ),
  412. );
  413. if (confirmed == true) {
  414. // TODO: 接入实际审批 API
  415. if (ctx.mounted) {
  416. TDToast.showSuccess('已审批通过', context: ctx);
  417. }
  418. }
  419. },
  420. ),
  421. ],
  422. ),
  423. cell: card,
  424. );
  425. },
  426. );
  427. }
  428. }