| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570 |
- import 'package:flutter/material.dart';
- import 'package:flutter_riverpod/flutter_riverpod.dart';
- import 'package:go_router/go_router.dart';
- import 'package:tdesign_flutter/tdesign_flutter.dart';
- import 'package:easy_refresh/easy_refresh.dart';
- import '../../core/theme/app_colors.dart';
- import '../../core/theme/app_colors_extension.dart';
- import '../../core/auth/role_provider.dart';
- import '../shell/nav_bar_config.dart';
- import '../../core/utils/date_utils.dart' as du;
- import '../../shared/widgets/empty_state.dart';
- import '../../shared/widgets/skeleton_list_card.dart';
- import '../../shared/widgets/filter_bar.dart';
- import '../../core/i18n/app_localizations.dart';
- import 'vehicle_list_controller.dart';
- import 'vehicle_model.dart';
- final _scopeProvider = StateProvider<String>((ref) => 'my');
- class VehicleListPage extends ConsumerStatefulWidget {
- const VehicleListPage({super.key});
- @override
- ConsumerState<VehicleListPage> createState() => _VehicleListPageState();
- }
- class _VehicleListPageState extends ConsumerState<VehicleListPage>
- with TickerProviderStateMixin {
- List<String> _getTabLabels(AppLocalizations l10n) => [
- l10n.get('all'),
- l10n.get('draft'),
- l10n.get('pending'),
- l10n.get('approved'),
- l10n.get('rejected'),
- l10n.get('revoked'),
- l10n.get('returned'),
- ];
- static const _tabKeys = [
- '',
- 'draft',
- 'pending',
- 'approved',
- 'rejected',
- 'withdrawn',
- 'returned',
- ];
- late final TabController _tabCtrl;
- @override
- void initState() {
- super.initState();
- _tabCtrl = TabController(length: _tabKeys.length, vsync: this);
- _tabCtrl.addListener(() {
- if (!_tabCtrl.indexIsChanging) {
- ref.read(vehicleStatusFilterProvider.notifier).state =
- _tabKeys[_tabCtrl.index];
- }
- });
- }
- @override
- void dispose() {
- _tabCtrl.dispose();
- super.dispose();
- }
- @override
- Widget build(BuildContext context) {
- final status = ref.watch(vehicleStatusFilterProvider);
- final dateStart = ref.watch(vehicleDateStartProvider);
- final dateEnd = ref.watch(vehicleDateEndProvider);
- final purposeFilter = ref.watch(vehiclePurposeFilterProvider);
- final l10n = AppLocalizations.of(context);
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- final isManager = ref.watch(isManagerProvider);
- // Sync TabController with external filter changes
- final targetIdx = _tabKeys.indexOf(status);
- if (targetIdx >= 0 &&
- _tabCtrl.index != targetIdx &&
- !_tabCtrl.indexIsChanging) {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (mounted) _tabCtrl.animateTo(targetIdx);
- });
- }
- final filterGroups = [
- FilterGroup(
- title: '日期范围',
- type: FilterGroupType.dateRange,
- sections: [
- FilterSection(
- label: '起始日期',
- type: FilterSectionType.dateRange,
- startDate: dateStart,
- endDate: dateEnd,
- onStartChanged: (v) =>
- ref.read(vehicleDateStartProvider.notifier).state = v,
- onEndChanged: (v) =>
- ref.read(vehicleDateEndProvider.notifier).state = v,
- ),
- FilterSection(
- label: '结束日期',
- type: FilterSectionType.dateRange,
- startDate: dateStart,
- endDate: dateEnd,
- onStartChanged: (v) =>
- ref.read(vehicleDateStartProvider.notifier).state = v,
- onEndChanged: (v) =>
- ref.read(vehicleDateEndProvider.notifier).state = v,
- ),
- ],
- ),
- FilterGroup(
- title: '其它',
- type: FilterGroupType.other,
- sections: [
- FilterSection(
- label: '用车目的',
- type: FilterSectionType.singleSelect,
- options: const [
- FilterOption(value: 'reception', label: '客户接待'),
- FilterOption(value: 'business', label: '商务出行'),
- FilterOption(value: 'official', label: '公务'),
- ],
- selectedValue: purposeFilter,
- onChanged: (v) =>
- ref.read(vehiclePurposeFilterProvider.notifier).state = v,
- ),
- ],
- ),
- ];
- final hasFilter = FilterBar.hasActiveFilter(filterGroups);
- void onFilterReset() {
- ref.read(vehicleDateStartProvider.notifier).state = null;
- ref.read(vehicleDateEndProvider.notifier).state = null;
- ref.read(vehiclePurposeFilterProvider.notifier).state = null;
- }
- ref
- .read(navBarConfigProvider.notifier)
- .update(
- NavBarConfig(
- title: l10n.get('vehicleList'),
- showBack: true,
- showRight: true,
- rightWidget: GestureDetector(
- onTap: () => FilterBar.show(
- context,
- groups: filterGroups,
- onReset: onFilterReset,
- onConfirm: () {},
- ),
- child: Stack(
- children: [
- Icon(
- TDIcons.filter,
- color: hasFilter ? colors.primary : colors.textPrimary,
- ),
- if (hasFilter)
- Positioned(
- right: -2,
- top: -2,
- child: Container(
- width: 6,
- height: 6,
- decoration: BoxDecoration(
- color: colors.danger,
- shape: BoxShape.circle,
- ),
- ),
- ),
- ],
- ),
- ),
- onBack: () => context.pop(),
- ),
- );
- return Column(
- children: [
- if (isManager)
- _buildScopeChip(colors),
- Container(
- color: colors.bgCard,
- padding: const EdgeInsets.symmetric(horizontal: 8),
- child: TDTabBar(
- tabs: _getTabLabels(l10n).map((l) => TDTab(text: l)).toList(),
- controller: _tabCtrl,
- isScrollable: true,
- labelColor: colors.primary,
- unselectedLabelColor: colors.textSecondary,
- outlineType: TDTabBarOutlineType.filled,
- showIndicator: true,
- indicatorColor: colors.primary,
- indicatorHeight: 3,
- dividerHeight: 0,
- labelPadding: const EdgeInsets.symmetric(horizontal: 12),
- onTap: (index) {
- ref.read(vehicleStatusFilterProvider.notifier).state =
- _tabKeys[index];
- },
- ),
- ),
- Expanded(
- child: Container(
- color: colors.bgPage,
- child: TabBarView(
- controller: _tabCtrl,
- children: List.generate(_tabKeys.length, (tabIdx) {
- return _buildTabContent(tabIdx);
- }),
- ),
- ),
- ),
- ],
- );
- }
- Widget _buildScopeChip(AppColorsExtension colors) {
- final scope = ref.watch(_scopeProvider);
- return Padding(
- padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
- child: Row(
- children: [
- GestureDetector(
- onTap: () => ref.read(_scopeProvider.notifier).state = 'my',
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
- decoration: BoxDecoration(
- color: scope == 'my' ? colors.primary : colors.bgPage,
- borderRadius: BorderRadius.circular(16),
- ),
- child: Text(
- '我的发起',
- style: TextStyle(
- fontSize: 13,
- color: scope == 'my' ? Colors.white : colors.textSecondary,
- ),
- ),
- ),
- ),
- const SizedBox(width: 8),
- GestureDetector(
- onTap: () => ref.read(_scopeProvider.notifier).state = 'sub',
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
- decoration: BoxDecoration(
- color: scope == 'sub' ? colors.primary : colors.bgPage,
- borderRadius: BorderRadius.circular(16),
- ),
- child: Text(
- '下属审批',
- style: TextStyle(
- fontSize: 13,
- color: scope == 'sub' ? Colors.white : colors.textSecondary,
- ),
- ),
- ),
- ),
- ],
- ),
- );
- }
- Widget _buildTabContent(int tabIdx) {
- return _VehicleTabContent(statusKey: _tabKeys[tabIdx]);
- }
- }
- class _VehicleTabContent extends ConsumerWidget {
- final String statusKey;
- const _VehicleTabContent({required this.statusKey});
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final itemsAsync = ref.watch(vehicleListProvider(statusKey));
- final scope = ref.watch(_scopeProvider);
- if (itemsAsync.isLoading && !itemsAsync.hasValue) {
- return SkeletonLoadingList(
- cardBuilder: () => const SkeletonVehicleCard(),
- );
- }
- return EasyRefresh(
- header: TDRefreshHeader(),
- onRefresh: () async {
- ref.read(vehicleRefreshProvider.notifier).state++;
- },
- child: _buildContent(itemsAsync, context, ref, scope),
- );
- }
- Widget _buildContent(
- AsyncValue<List<VehicleModel>> itemsAsync,
- BuildContext context,
- WidgetRef ref,
- String scope,
- ) {
- final l10n = AppLocalizations.of(context);
- final isSub = scope == 'sub';
- if (itemsAsync.isReloading) {
- final oldItems = itemsAsync.valueOrNull ?? [];
- if (oldItems.isEmpty) {
- return ListView(
- children: [
- const SizedBox(height: 120),
- EmptyState(message: l10n.get('noVehicles')),
- ],
- );
- }
- return ListView.builder(
- padding: const EdgeInsets.all(16),
- itemCount: oldItems.length,
- itemBuilder: (_, i) {
- final card = _buildVehicleListItem(context, oldItems[i], isSub: isSub);
- if (isSub && oldItems[i].status == 'pending') {
- return Padding(
- padding: const EdgeInsets.only(bottom: 16),
- child: _buildSwipeApprove(card, oldItems[i].id),
- );
- }
- return Padding(
- padding: const EdgeInsets.only(bottom: 16),
- child: card,
- );
- },
- );
- }
- if (itemsAsync.hasError) {
- return ListView(
- children: [
- const SizedBox(height: 120),
- EmptyState(message: l10n.get('loadFailed')),
- ],
- );
- }
- final items = itemsAsync.requireValue;
- if (items.isEmpty) {
- return ListView(
- children: [
- const SizedBox(height: 120),
- EmptyState(message: l10n.get('noVehicles')),
- ],
- );
- }
- return ListView.builder(
- padding: const EdgeInsets.all(16),
- itemCount: items.length,
- itemBuilder: (_, i) {
- final card = _buildVehicleListItem(context, items[i], isSub: isSub);
- if (isSub && items[i].status == 'pending') {
- return Padding(
- padding: const EdgeInsets.only(bottom: 16),
- child: _buildSwipeApprove(card, items[i].id),
- );
- }
- return Padding(
- padding: const EdgeInsets.only(bottom: 16),
- child: card,
- );
- },
- );
- }
- Widget _buildVehicleListItem(BuildContext context, VehicleModel item, {bool isSub = false}) {
- final l10n = AppLocalizations.of(context);
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- Color bg, fg;
- String label;
- switch (item.status) {
- case 'pending':
- bg = colors.warningBg;
- fg = colors.warning;
- label = l10n.get('pending');
- case 'approved':
- bg = colors.successBg;
- fg = colors.success;
- label = l10n.get('approved');
- case 'rejected':
- bg = colors.dangerBg;
- fg = colors.danger;
- label = l10n.get('rejected');
- case 'draft':
- bg = colors.bgPage;
- fg = colors.statusGray;
- label = l10n.get('draft');
- case 'revoked':
- bg = colors.revokedBg;
- fg = colors.revokedText;
- label = l10n.get('revoked');
- case 'returned':
- bg = const Color(0xFFEDF2FC);
- fg = const Color(0xFF5A8CDB);
- label = l10n.get('returned');
- default:
- bg = colors.bgPage;
- fg = colors.statusGray;
- label = item.status;
- }
- return GestureDetector(
- onTap: () => context.push('/vehicle/detail/${item.id}'),
- child: Container(
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(
- color: colors.bgCard,
- borderRadius: BorderRadius.circular(8),
- ),
- child: Column(
- children: [
- // R1: 车牌号 + 状态标签
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- item.licensePlate.isNotEmpty ? item.licensePlate : '未指定车辆',
- style: TextStyle(
- fontSize: AppFontSizes.subtitle,
- fontWeight: FontWeight.w700,
- color: colors.textPrimary,
- ),
- ),
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 8,
- vertical: 2,
- ),
- decoration: BoxDecoration(
- color: bg,
- borderRadius: BorderRadius.circular(4),
- ),
- child: Text(
- label,
- style: TextStyle(
- fontSize: AppFontSizes.caption,
- fontWeight: FontWeight.w500,
- color: fg,
- ),
- ),
- ),
- ],
- ),
- if (isSub) ...[
- const SizedBox(height: 4),
- Align(
- alignment: Alignment.centerLeft,
- child: Text(
- '申请人: ${item.applicantName} · ${item.deptName}',
- style: TextStyle(
- fontSize: AppFontSizes.caption,
- color: colors.textPlaceholder,
- ),
- ),
- ),
- ],
- const SizedBox(height: 6),
- // R2: 申请单号 + 用途标签
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- item.applicationNo,
- style: TextStyle(
- fontSize: AppFontSizes.caption,
- color: colors.textSecondary,
- ),
- ),
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 6,
- vertical: 1,
- ),
- decoration: BoxDecoration(
- color: colors.primaryLight,
- borderRadius: BorderRadius.circular(3),
- ),
- child: Text(
- item.purpose.isNotEmpty ? item.purpose : '公务',
- style: TextStyle(fontSize: 10, color: colors.primary),
- ),
- ),
- ],
- ),
- const SizedBox(height: 6),
- // R3: 路线 + 时间
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Flexible(
- child: Text(
- '${item.origin.isNotEmpty ? item.origin : '未知'} → ${item.destination.isNotEmpty ? item.destination : '未知'}',
- style: TextStyle(fontSize: 13, color: colors.textSecondary),
- overflow: TextOverflow.ellipsis,
- ),
- ),
- const SizedBox(width: 8),
- Text(
- '${du.DateUtils.formatMonthDay(item.startTime)} ${du.DateUtils.formatTime(item.startTime)}',
- style: TextStyle(
- fontSize: AppFontSizes.caption,
- color: colors.textPlaceholder,
- ),
- ),
- ],
- ),
- ],
- ),
- ),
- );
- }
- Widget _buildSwipeApprove(Widget card, String itemId) {
- return Builder(
- builder: (ctx) {
- final screenWidth = MediaQuery.of(ctx).size.width;
- return TDSwipeCell(
- groupTag: 'vehicle_approve',
- right: TDSwipeCellPanel(
- extentRatio: 100 / screenWidth,
- children: [
- TDSwipeCellAction(
- label: '',
- backgroundColor: Colors.transparent,
- builder: (_) => Container(
- margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
- decoration: BoxDecoration(
- color: Colors.green,
- borderRadius: BorderRadius.circular(8),
- ),
- alignment: Alignment.center,
- padding: const EdgeInsets.symmetric(horizontal: 12),
- child: const Text(
- '一键同意',
- style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600),
- ),
- ),
- onPressed: (_) async {
- final confirmed = await showDialog<bool>(
- context: ctx,
- builder: (dCtx) => TDAlertDialog(
- title: '确认审批',
- content: '确认同意该用车申请?',
- leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.of(dCtx).pop(false)),
- rightBtn: TDDialogButtonOptions(title: '确认', action: () => Navigator.of(dCtx).pop(true)),
- ),
- );
- if (confirmed == true) {
- // TODO: 接入实际审批 API
- if (ctx.mounted) {
- TDToast.showSuccess('已审批通过', context: ctx);
- }
- }
- },
- ),
- ],
- ),
- cell: card,
- );
- },
- );
- }
- }
|