vehicle_list_page.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  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 '../shell/nav_bar_config.dart';
  9. import '../../core/utils/date_utils.dart' as du;
  10. import '../../shared/widgets/empty_state.dart';
  11. import '../../shared/widgets/skeleton_list_card.dart';
  12. import '../../shared/widgets/filter_bar.dart';
  13. import '../../core/i18n/app_localizations.dart';
  14. import 'vehicle_list_controller.dart';
  15. import 'vehicle_model.dart';
  16. class VehicleListPage extends ConsumerStatefulWidget {
  17. const VehicleListPage({super.key});
  18. @override
  19. ConsumerState<VehicleListPage> createState() => _VehicleListPageState();
  20. }
  21. class _VehicleListPageState extends ConsumerState<VehicleListPage>
  22. with TickerProviderStateMixin {
  23. static const _tabLabels = ['全部', '草稿', '审批中', '已通过', '已拒绝', '已还车'];
  24. static const _tabKeys = [
  25. '',
  26. 'draft',
  27. 'pending',
  28. 'approved',
  29. 'rejected',
  30. 'returned',
  31. ];
  32. late final TabController _tabCtrl;
  33. @override
  34. void initState() {
  35. super.initState();
  36. _tabCtrl = TabController(length: _tabLabels.length, vsync: this);
  37. _tabCtrl.addListener(() {
  38. if (!_tabCtrl.indexIsChanging) {
  39. ref.read(vehicleStatusFilterProvider.notifier).state =
  40. _tabKeys[_tabCtrl.index];
  41. }
  42. });
  43. }
  44. @override
  45. void dispose() {
  46. _tabCtrl.dispose();
  47. super.dispose();
  48. }
  49. @override
  50. Widget build(BuildContext context) {
  51. final status = ref.watch(vehicleStatusFilterProvider);
  52. final dateStart = ref.watch(vehicleDateStartProvider);
  53. final dateEnd = ref.watch(vehicleDateEndProvider);
  54. final purposeFilter = ref.watch(vehiclePurposeFilterProvider);
  55. final l10n = AppLocalizations.of(context);
  56. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  57. // Sync TabController with external filter changes
  58. final targetIdx = _tabKeys.indexOf(status);
  59. if (targetIdx >= 0 &&
  60. _tabCtrl.index != targetIdx &&
  61. !_tabCtrl.indexIsChanging) {
  62. WidgetsBinding.instance.addPostFrameCallback((_) {
  63. if (mounted) _tabCtrl.animateTo(targetIdx);
  64. });
  65. }
  66. final filterGroups = [
  67. FilterGroup(title: '日期范围', type: FilterGroupType.dateRange, sections: [
  68. FilterSection(
  69. label: '起始日期',
  70. type: FilterSectionType.dateRange,
  71. startDate: dateStart,
  72. endDate: dateEnd,
  73. onStartChanged: (v) =>
  74. ref.read(vehicleDateStartProvider.notifier).state = v,
  75. onEndChanged: (v) =>
  76. ref.read(vehicleDateEndProvider.notifier).state = v,
  77. ),
  78. FilterSection(
  79. label: '结束日期',
  80. type: FilterSectionType.dateRange,
  81. startDate: dateStart,
  82. endDate: dateEnd,
  83. onStartChanged: (v) =>
  84. ref.read(vehicleDateStartProvider.notifier).state = v,
  85. onEndChanged: (v) =>
  86. ref.read(vehicleDateEndProvider.notifier).state = v,
  87. ),
  88. ]),
  89. FilterGroup(title: '其它', type: FilterGroupType.other, sections: [
  90. FilterSection(
  91. label: '用车目的',
  92. type: FilterSectionType.singleSelect,
  93. options: const [
  94. FilterOption(value: 'reception', label: '客户接待'),
  95. FilterOption(value: 'business', label: '商务出行'),
  96. FilterOption(value: 'official', label: '公务'),
  97. ],
  98. selectedValue: purposeFilter,
  99. onChanged: (v) =>
  100. ref.read(vehiclePurposeFilterProvider.notifier).state = v,
  101. ),
  102. ]),
  103. ];
  104. final hasFilter = FilterBar.hasActiveFilter(filterGroups);
  105. final onFilterReset = () {
  106. ref.read(vehicleDateStartProvider.notifier).state = null;
  107. ref.read(vehicleDateEndProvider.notifier).state = null;
  108. ref.read(vehiclePurposeFilterProvider.notifier).state = null;
  109. };
  110. ref
  111. .read(navBarConfigProvider.notifier)
  112. .update(
  113. NavBarConfig(
  114. title: l10n.get('vehicleList'),
  115. showBack: true,
  116. showRight: true,
  117. rightWidget: GestureDetector(
  118. onTap: () => FilterBar.show(
  119. context,
  120. groups: filterGroups,
  121. onReset: onFilterReset,
  122. onConfirm: () {},
  123. ),
  124. child: Stack(
  125. children: [
  126. Icon(TDIcons.filter, color: hasFilter ? colors.primary : colors.textPrimary),
  127. if (hasFilter)
  128. Positioned(
  129. right: -2,
  130. top: -2,
  131. child: Container(
  132. width: 6,
  133. height: 6,
  134. decoration: BoxDecoration(
  135. color: colors.danger,
  136. shape: BoxShape.circle,
  137. ),
  138. ),
  139. ),
  140. ],
  141. ),
  142. ),
  143. onBack: () => context.pop(),
  144. ),
  145. );
  146. return Column(
  147. children: [
  148. Container(
  149. color: colors.bgCard,
  150. padding: const EdgeInsets.symmetric(horizontal: 8),
  151. child: TDTabBar(
  152. tabs: _tabLabels.map((l) => TDTab(text: l)).toList(),
  153. controller: _tabCtrl,
  154. isScrollable: true,
  155. labelColor: colors.primary,
  156. unselectedLabelColor: colors.textSecondary,
  157. outlineType: TDTabBarOutlineType.filled,
  158. showIndicator: true,
  159. indicatorColor: colors.primary,
  160. indicatorHeight: 3,
  161. dividerHeight: 0,
  162. labelPadding: const EdgeInsets.symmetric(horizontal: 12),
  163. onTap: (index) {
  164. ref.invalidate(vehicleListProvider);
  165. ref.read(vehicleStatusFilterProvider.notifier).state =
  166. _tabKeys[index];
  167. },
  168. ),
  169. ),
  170. Expanded(
  171. child: Container(
  172. color: colors.bgPage,
  173. child: TabBarView(
  174. controller: _tabCtrl,
  175. children: List.generate(_tabKeys.length, (tabIdx) {
  176. return _buildTabContent(tabIdx);
  177. }),
  178. ),
  179. ),
  180. ),
  181. ],
  182. );
  183. }
  184. Widget _buildTabContent(int tabIdx) {
  185. return _VehicleTabContent(statusKey: _tabKeys[tabIdx]);
  186. }
  187. }
  188. class _VehicleTabContent extends ConsumerWidget {
  189. final String statusKey;
  190. const _VehicleTabContent({required this.statusKey});
  191. @override
  192. Widget build(BuildContext context, WidgetRef ref) {
  193. final itemsAsync = ref.watch(vehicleListProvider(statusKey));
  194. if (itemsAsync.isLoading && !itemsAsync.hasValue) {
  195. return SkeletonLoadingList(
  196. cardBuilder: () => const SkeletonVehicleCard(),
  197. );
  198. }
  199. return EasyRefresh(
  200. header: TDRefreshHeader(),
  201. onRefresh: () async {
  202. ref.read(vehicleRefreshProvider.notifier).state++;
  203. },
  204. child: _buildContent(itemsAsync, context, ref),
  205. );
  206. }
  207. Widget _buildContent(
  208. AsyncValue<List<VehicleModel>> itemsAsync,
  209. BuildContext context,
  210. WidgetRef ref,
  211. ) {
  212. if (itemsAsync.isReloading) {
  213. final oldItems = itemsAsync.valueOrNull ?? [];
  214. if (oldItems.isEmpty) {
  215. return SkeletonLoadingList(
  216. cardBuilder: () => const SkeletonVehicleCard(),
  217. );
  218. }
  219. return ListView.builder(
  220. padding: const EdgeInsets.all(16),
  221. itemCount: oldItems.length,
  222. itemBuilder: (_, i) => Padding(
  223. padding: const EdgeInsets.only(bottom: 16),
  224. child: _buildVehicleListItem(context, oldItems[i]),
  225. ),
  226. );
  227. }
  228. if (itemsAsync.hasError) {
  229. return ListView(
  230. children: const [
  231. SizedBox(height: 120),
  232. EmptyState(message: '加载失败'),
  233. ],
  234. );
  235. }
  236. final items = itemsAsync.requireValue;
  237. if (items.isEmpty) {
  238. return ListView(
  239. children: const [
  240. SizedBox(height: 120),
  241. EmptyState(message: '暂无用车记录'),
  242. ],
  243. );
  244. }
  245. return ListView.builder(
  246. padding: const EdgeInsets.all(16),
  247. itemCount: items.length,
  248. itemBuilder: (_, i) => Padding(
  249. padding: const EdgeInsets.only(bottom: 16),
  250. child: _buildVehicleListItem(context, items[i]),
  251. ),
  252. );
  253. }
  254. Widget _buildVehicleListItem(BuildContext context, VehicleModel item) {
  255. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  256. Color bg, fg;
  257. String label;
  258. switch (item.status) {
  259. case 'pending':
  260. bg = colors.warningBg;
  261. fg = colors.warning;
  262. label = '审批中';
  263. case 'approved':
  264. bg = colors.successBg;
  265. fg = colors.success;
  266. label = '已通过';
  267. case 'rejected':
  268. bg = colors.dangerBg;
  269. fg = colors.danger;
  270. label = '已拒绝';
  271. case 'draft':
  272. bg = colors.bgPage;
  273. fg = colors.statusGray;
  274. label = '草稿';
  275. case 'revoked':
  276. bg = colors.revokedBg;
  277. fg = colors.revokedText;
  278. label = '已撤回';
  279. case 'returned':
  280. bg = const Color(0xFFEDF2FC);
  281. fg = const Color(0xFF5A8CDB);
  282. label = '已还车';
  283. default:
  284. bg = colors.bgPage;
  285. fg = colors.statusGray;
  286. label = item.status;
  287. }
  288. return GestureDetector(
  289. onTap: () => context.push('/vehicle/detail/${item.id}'),
  290. child: Container(
  291. padding: const EdgeInsets.all(12),
  292. decoration: BoxDecoration(
  293. color: colors.bgCard,
  294. borderRadius: BorderRadius.circular(8),
  295. ),
  296. child: Column(
  297. children: [
  298. // R1: 车牌号 + 状态标签
  299. Row(
  300. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  301. children: [
  302. Text(
  303. item.licensePlate.isNotEmpty ? item.licensePlate : '未指定车辆',
  304. style: TextStyle(
  305. fontSize: AppFontSizes.subtitle,
  306. fontWeight: FontWeight.w700,
  307. color: colors.textPrimary,
  308. ),
  309. ),
  310. Container(
  311. padding: const EdgeInsets.symmetric(
  312. horizontal: 8,
  313. vertical: 2,
  314. ),
  315. decoration: BoxDecoration(
  316. color: bg,
  317. borderRadius: BorderRadius.circular(4),
  318. ),
  319. child: Text(
  320. label,
  321. style: TextStyle(
  322. fontSize: AppFontSizes.caption,
  323. fontWeight: FontWeight.w500,
  324. color: fg,
  325. ),
  326. ),
  327. ),
  328. ],
  329. ),
  330. const SizedBox(height: 6),
  331. // R2: 申请单号 + 用途标签
  332. Row(
  333. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  334. children: [
  335. Text(
  336. item.applicationNo,
  337. style: TextStyle(
  338. fontSize: AppFontSizes.caption,
  339. color: colors.textSecondary,
  340. ),
  341. ),
  342. Container(
  343. padding: const EdgeInsets.symmetric(
  344. horizontal: 6,
  345. vertical: 1,
  346. ),
  347. decoration: BoxDecoration(
  348. color: colors.primaryLight,
  349. borderRadius: BorderRadius.circular(3),
  350. ),
  351. child: Text(
  352. item.purpose.isNotEmpty ? item.purpose : '公务',
  353. style: TextStyle(
  354. fontSize: 10,
  355. color: colors.primary,
  356. ),
  357. ),
  358. ),
  359. ],
  360. ),
  361. const SizedBox(height: 6),
  362. // R3: 路线 + 时间
  363. Row(
  364. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  365. children: [
  366. Flexible(
  367. child: Text(
  368. '${item.origin.isNotEmpty ? item.origin : '未知'} → ${item.destination.isNotEmpty ? item.destination : '未知'}',
  369. style: TextStyle(
  370. fontSize: 13,
  371. color: colors.textSecondary,
  372. ),
  373. overflow: TextOverflow.ellipsis,
  374. ),
  375. ),
  376. const SizedBox(width: 8),
  377. Text(
  378. '${du.DateUtils.formatMonthDay(item.startTime)} ${du.DateUtils.formatTime(item.startTime)}',
  379. style: TextStyle(
  380. fontSize: AppFontSizes.caption,
  381. color: colors.textPlaceholder,
  382. ),
  383. ),
  384. ],
  385. ),
  386. ],
  387. ),
  388. ),
  389. );
  390. }
  391. }