vehicle_list_page.dart 12 KB

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