vehicle_list_page.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  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/i18n/app_localizations.dart';
  7. import '../../core/theme/app_colors_extension.dart';
  8. import '../../core/utils/date_utils.dart' as du;
  9. import '../../shared/widgets/app_skeletons.dart';
  10. import '../../shared/widgets/date_range_picker.dart';
  11. import '../../shared/widgets/empty_state.dart';
  12. import '../../shared/widgets/list_card.dart';
  13. import '../../shared/widgets/list_footer.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. String _keyword = '';
  23. final _startDateCtrl = TextEditingController();
  24. final _endDateCtrl = TextEditingController();
  25. late final EasyRefreshController _refreshCtrl;
  26. bool _firstBuild = true;
  27. bool _invalidateDone = false;
  28. bool _filterLoading = false;
  29. final _scrollCtrl = ScrollController();
  30. bool _showBackToTop = false;
  31. @override
  32. void initState() {
  33. super.initState();
  34. final now = DateTime.now();
  35. _startDateCtrl.text =
  36. '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
  37. _endDateCtrl.text =
  38. '${now.year}-${now.month.toString().padLeft(2, '0')}-${DateTime(now.year, now.month + 1, 0).day.toString().padLeft(2, '0')}';
  39. _refreshCtrl = EasyRefreshController();
  40. _scrollCtrl.addListener(() {
  41. final show = _scrollCtrl.hasClients && _scrollCtrl.offset > 300;
  42. if (show != _showBackToTop && mounted) {
  43. setState(() => _showBackToTop = show);
  44. }
  45. });
  46. WidgetsBinding.instance.addPostFrameCallback((_) {
  47. ref.read(vehicleDateStartProvider.notifier).state = DateTime(now.year, now.month, 1);
  48. ref.read(vehicleDateEndProvider.notifier).state = DateTime(
  49. now.year,
  50. now.month,
  51. DateTime(now.year, now.month + 1, 0).day,
  52. );
  53. ref.invalidate(vehicleListProvider(''));
  54. _invalidateDone = true;
  55. setState(() {});
  56. });
  57. }
  58. @override
  59. void dispose() {
  60. _startDateCtrl.dispose();
  61. _endDateCtrl.dispose();
  62. _refreshCtrl.dispose();
  63. _scrollCtrl.dispose();
  64. super.dispose();
  65. }
  66. void _applyFilter() {
  67. FocusManager.instance.primaryFocus?.unfocus();
  68. setState(() => _filterLoading = true);
  69. _doRefresh();
  70. }
  71. Future<void> _doRefresh() async {
  72. ref.read(vehicleKeywordProvider.notifier).state = _keyword;
  73. ref.read(vehicleDateStartProvider.notifier).state =
  74. _startDateCtrl.text.isNotEmpty ? DateTime.tryParse(_startDateCtrl.text) : null;
  75. ref.read(vehicleDateEndProvider.notifier).state =
  76. _endDateCtrl.text.isNotEmpty ? DateTime.tryParse(_endDateCtrl.text) : null;
  77. ref.read(vehicleRefreshProvider.notifier).state++;
  78. }
  79. @override
  80. Widget build(BuildContext context) {
  81. final l10n = AppLocalizations.of(context);
  82. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  83. final tdTheme = TDTheme.of(context);
  84. if (_invalidateDone) {
  85. ref.listen(vehicleListProvider(''), (prev, next) {
  86. if (_firstBuild && !next.isLoading && !next.isReloading) {
  87. setState(() => _firstBuild = false);
  88. WidgetsBinding.instance.addPostFrameCallback((_) {
  89. if (mounted) setState(() {});
  90. });
  91. }
  92. if (_filterLoading && !next.isReloading && !next.isLoading) {
  93. setState(() => _filterLoading = false);
  94. }
  95. });
  96. }
  97. return Stack(
  98. children: [
  99. Column(
  100. children: [
  101. Container(
  102. decoration: BoxDecoration(
  103. color: colors.bgCard,
  104. border: Border(bottom: BorderSide(color: tdTheme.componentStrokeColor)),
  105. ),
  106. child: Column(
  107. mainAxisSize: MainAxisSize.min,
  108. children: [
  109. Padding(
  110. padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  111. child: DateRangePicker(
  112. startCtrl: _startDateCtrl,
  113. endCtrl: _endDateCtrl,
  114. onChanged: () {
  115. setState(() {});
  116. _applyFilter();
  117. },
  118. ),
  119. ),
  120. const SizedBox(height: 8),
  121. Padding(
  122. padding: const EdgeInsets.fromLTRB(0, 0, 12, 8),
  123. child: Row(
  124. children: [
  125. Expanded(
  126. child: TDSearchBar(
  127. placeHolder: l10n.get('searchVehicle'),
  128. style: TDSearchStyle.round,
  129. onTextChanged: (String text) {
  130. setState(() => _keyword = text);
  131. if (text.isEmpty) _applyFilter();
  132. },
  133. onSubmitted: (_) => _applyFilter(),
  134. ),
  135. ),
  136. GestureDetector(
  137. onTap: () {
  138. final dir = ref.read(vehicleSortDirProvider);
  139. ref.read(vehicleSortDirProvider.notifier).state =
  140. dir == 'ASC' ? 'DESC' : 'ASC';
  141. _applyFilter();
  142. },
  143. child: Container(
  144. width: 40,
  145. height: 40,
  146. decoration: BoxDecoration(
  147. color: colors.primary,
  148. borderRadius: BorderRadius.circular(20),
  149. ),
  150. child: Center(
  151. child: Icon(
  152. ref.watch(vehicleSortDirProvider) == 'ASC'
  153. ? Icons.arrow_upward
  154. : Icons.arrow_downward,
  155. color: Colors.white,
  156. size: 20,
  157. ),
  158. ),
  159. ),
  160. ),
  161. ],
  162. ),
  163. ),
  164. ],
  165. ),
  166. ),
  167. Expanded(
  168. child: Container(
  169. color: colors.bgPage,
  170. child: Stack(
  171. children: [
  172. _firstBuild
  173. ? const Center(child: SkeletonLoadingList())
  174. : _VehicleListContent(
  175. refreshCtrl: _refreshCtrl,
  176. onRefresh: _doRefresh,
  177. scrollCtrl: _scrollCtrl,
  178. ),
  179. if (_filterLoading) ...[
  180. Container(color: colors.bgPage.withValues(alpha: 0.6)),
  181. const Center(
  182. child: TDLoading(size: TDLoadingSize.medium, icon: TDLoadingIcon.activity),
  183. ),
  184. ],
  185. ],
  186. ),
  187. ),
  188. ),
  189. ],
  190. ),
  191. AnimatedPositioned(
  192. duration: const Duration(milliseconds: 200),
  193. bottom: _showBackToTop ? 80 : -60,
  194. left: 0,
  195. right: 0,
  196. child: Center(
  197. child: GestureDetector(
  198. onTap: () {
  199. if (_scrollCtrl.hasClients) _scrollCtrl.jumpTo(0);
  200. },
  201. child: AnimatedOpacity(
  202. duration: const Duration(milliseconds: 200),
  203. opacity: _showBackToTop ? 1.0 : 0.0,
  204. child: Container(
  205. width: 36,
  206. height: 36,
  207. decoration: BoxDecoration(
  208. color: colors.primary400,
  209. shape: BoxShape.circle,
  210. boxShadow: [
  211. BoxShadow(
  212. color: Colors.black.withValues(alpha: 0.15),
  213. blurRadius: 6,
  214. offset: const Offset(0, 2),
  215. ),
  216. ],
  217. ),
  218. child: const Icon(Icons.keyboard_arrow_up, color: Colors.white, size: 22),
  219. ),
  220. ),
  221. ),
  222. ),
  223. ),
  224. Positioned(
  225. right: 16,
  226. bottom: 16,
  227. child: FloatingActionButton(
  228. onPressed: () => context.push('/report/vehicle-report'),
  229. backgroundColor: colors.primary,
  230. shape: const CircleBorder(),
  231. child: const Icon(Icons.bar_chart, color: Colors.white),
  232. ),
  233. ),
  234. ],
  235. );
  236. }
  237. }
  238. class _VehicleListContent extends ConsumerWidget {
  239. final EasyRefreshController refreshCtrl;
  240. final Future<void> Function() onRefresh;
  241. final ScrollController scrollCtrl;
  242. const _VehicleListContent({required this.refreshCtrl, required this.onRefresh, required this.scrollCtrl});
  243. @override
  244. Widget build(BuildContext context, WidgetRef ref) {
  245. final itemsAsync = ref.watch(vehicleListProvider(''));
  246. if (itemsAsync.isLoading && !itemsAsync.hasValue) {
  247. return const Center(child: SkeletonLoadingList());
  248. }
  249. return EasyRefresh(
  250. controller: refreshCtrl,
  251. header: TDRefreshHeader(),
  252. onRefresh: onRefresh,
  253. child: _buildContent(itemsAsync, context, ref),
  254. );
  255. }
  256. Widget _buildContent(AsyncValue<List<VehicleModel>> itemsAsync, BuildContext context, WidgetRef ref) {
  257. final l10n = AppLocalizations.of(context);
  258. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  259. if (itemsAsync.isReloading) {
  260. final oldItems = itemsAsync.valueOrNull ?? [];
  261. if (oldItems.isEmpty) {
  262. return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noVehicles'))]);
  263. }
  264. return ListView.builder(
  265. controller: scrollCtrl,
  266. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  267. itemCount: oldItems.length,
  268. itemBuilder: (_, i) => Padding(
  269. padding: const EdgeInsets.only(bottom: 16),
  270. child: _buildCard(oldItems[i], l10n, colors, context),
  271. ),
  272. );
  273. }
  274. if (itemsAsync.hasError) {
  275. return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('loadFailed'))]);
  276. }
  277. final items = itemsAsync.requireValue;
  278. if (items.isEmpty) {
  279. return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noVehicles'))]);
  280. }
  281. return ListView.builder(
  282. controller: scrollCtrl,
  283. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  284. itemCount: items.length + 1,
  285. itemBuilder: (_, i) {
  286. if (i == items.length) return ListFooter(itemCount: items.length);
  287. return Padding(
  288. padding: const EdgeInsets.only(bottom: 16),
  289. child: _buildCard(items[i], l10n, colors, context),
  290. );
  291. },
  292. );
  293. }
  294. Widget _buildCard(VehicleModel m, AppLocalizations l10n, AppColorsExtension colors, BuildContext context) {
  295. return ListCard(
  296. cardNo: m.ycNo,
  297. applicant: '${m.driverName}${m.deptName.isNotEmpty ? ' · ${m.deptName}' : ''}',
  298. description: '${_purposeLabel(m.purpose, l10n)}\n${m.origin.isNotEmpty ? m.origin : '-'} → ${m.destinAdr.isNotEmpty ? m.destinAdr : '-'}',
  299. date: m.recordDd != null ? du.DateUtils.formatDate(m.recordDd!) : '-',
  300. amount: m.licensePlate.isNotEmpty ? m.licensePlate : '-',
  301. amountColor: colors.textPrimary,
  302. statusTag: _buildAuditTag(m, l10n, colors),
  303. onTap: () => context.push('/vehicle/detail/${m.ycNo}'),
  304. );
  305. }
  306. Widget _buildAuditTag(VehicleModel item, AppLocalizations l10n, AppColorsExtension colors) {
  307. if (item.isAuditApproved) {
  308. return Container(
  309. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
  310. decoration: BoxDecoration(
  311. color: colors.success.withValues(alpha: 0.1),
  312. borderRadius: BorderRadius.circular(4),
  313. ),
  314. child: Text(
  315. l10n.get('statusApproved'),
  316. style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.success),
  317. ),
  318. );
  319. }
  320. return Container(
  321. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
  322. decoration: BoxDecoration(
  323. color: colors.statusGray.withValues(alpha: 0.1),
  324. borderRadius: BorderRadius.circular(4),
  325. ),
  326. child: Text(
  327. l10n.get('statusPending'),
  328. style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.statusGray),
  329. ),
  330. );
  331. }
  332. String _purposeLabel(String key, AppLocalizations l10n) {
  333. switch (key) {
  334. case 'reception':
  335. return l10n.get('customerReception');
  336. case 'business':
  337. return l10n.get('businessTrip');
  338. case 'official':
  339. return l10n.get('official');
  340. case 'other':
  341. return l10n.get('other');
  342. default:
  343. return key;
  344. }
  345. }
  346. }