From e0a9b3b9f40f0bbda550d4528c8128f160f67c25 Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Fri, 5 Dec 2025 10:11:03 +0700 Subject: [PATCH] point --- .../points_history_local_datasource.dart | 12 +- .../points_record_remote_datasource.dart | 26 +- .../pages/points_history_page.dart | 264 ++++++++++++++++-- .../providers/points_history_provider.dart | 91 ++++++ .../providers/points_history_provider.g.dart | 109 ++++++++ 5 files changed, 455 insertions(+), 47 deletions(-) diff --git a/lib/features/loyalty/data/datasources/points_history_local_datasource.dart b/lib/features/loyalty/data/datasources/points_history_local_datasource.dart index d7a4afa..b32c621 100644 --- a/lib/features/loyalty/data/datasources/points_history_local_datasource.dart +++ b/lib/features/loyalty/data/datasources/points_history_local_datasource.dart @@ -93,7 +93,7 @@ class PointsHistoryLocalDataSource { complaintStatus: ComplaintStatus.none, balanceAfter: 604, expiryDate: now.add(const Duration(days: 365)), - timestamp: DateTime(2023, 9, 28, 17, 23, 18), + timestamp: DateTime(2025, 9, 28, 17, 23, 18), ), LoyaltyPointEntryModel( entryId: 'entry_002', @@ -108,7 +108,7 @@ class PointsHistoryLocalDataSource { complaintStatus: ComplaintStatus.none, balanceAfter: 604, expiryDate: now.add(const Duration(days: 365)), - timestamp: DateTime(2023, 9, 27, 17, 23, 18), + timestamp: DateTime(2025, 9, 27, 17, 23, 18), ), LoyaltyPointEntryModel( entryId: 'entry_003', @@ -123,7 +123,7 @@ class PointsHistoryLocalDataSource { complaintStatus: ComplaintStatus.none, balanceAfter: 604, expiryDate: null, - timestamp: DateTime(2023, 9, 20, 17, 23, 18), + timestamp: DateTime(2025, 9, 20, 17, 23, 18), ), LoyaltyPointEntryModel( entryId: 'entry_004', @@ -138,7 +138,7 @@ class PointsHistoryLocalDataSource { complaintStatus: ComplaintStatus.none, balanceAfter: 604, expiryDate: null, - timestamp: DateTime(2023, 9, 19, 17, 23, 18), + timestamp: DateTime(2025, 9, 19, 17, 23, 18), ), LoyaltyPointEntryModel( entryId: 'entry_005', @@ -153,7 +153,7 @@ class PointsHistoryLocalDataSource { complaintStatus: ComplaintStatus.none, balanceAfter: 604, expiryDate: now.add(const Duration(days: 365)), - timestamp: DateTime(2023, 9, 10, 17, 23, 18), + timestamp: DateTime(2025, 9, 10, 17, 23, 18), ), LoyaltyPointEntryModel( entryId: 'entry_006', @@ -168,7 +168,7 @@ class PointsHistoryLocalDataSource { complaintStatus: ComplaintStatus.none, balanceAfter: 604, expiryDate: null, - timestamp: DateTime(2023, 9, 5, 17, 23, 18), + timestamp: DateTime(2025, 9, 5, 17, 23, 18), ), ]; diff --git a/lib/features/loyalty/data/datasources/points_record_remote_datasource.dart b/lib/features/loyalty/data/datasources/points_record_remote_datasource.dart index 43c7888..321cf36 100644 --- a/lib/features/loyalty/data/datasources/points_record_remote_datasource.dart +++ b/lib/features/loyalty/data/datasources/points_record_remote_datasource.dart @@ -30,7 +30,7 @@ class PointsRecordRemoteDataSourceImpl userId: 'user123', invoiceNumber: 'INV-VG-001', storeName: 'Công ty TNHH Vingroup', - transactionDate: DateTime(2023, 11, 15), + transactionDate: DateTime(2025, 11, 15), invoiceAmount: 2500000, notes: 'Gạch granite cao cấp cho khu vực lobby và hành lang', attachments: const [ @@ -39,8 +39,8 @@ class PointsRecordRemoteDataSourceImpl ], status: PointsStatus.approved, pointsEarned: 250, - submittedAt: DateTime(2023, 11, 15, 10, 0), - processedAt: DateTime(2023, 11, 20, 14, 30), + submittedAt: DateTime(2025, 11, 15, 10, 0), + processedAt: DateTime(2025, 11, 20, 14, 30), processedBy: 'admin001', ), PointsRecord( @@ -48,21 +48,21 @@ class PointsRecordRemoteDataSourceImpl userId: 'user123', invoiceNumber: 'INV-BTX-002', storeName: 'Tập đoàn Bitexco', - transactionDate: DateTime(2023, 11, 25), + transactionDate: DateTime(2025, 11, 25), invoiceAmount: 1250000, notes: 'Gạch porcelain 80x80 cho sảnh chính và khu mua sắm', attachments: const [ 'https://example.com/invoice3.jpg', ], status: PointsStatus.pending, - submittedAt: DateTime(2023, 11, 25, 9, 15), + submittedAt: DateTime(2025, 11, 25, 9, 15), ), PointsRecord( recordId: 'PRR003', userId: 'user123', invoiceNumber: 'INV-ABC-003', storeName: 'Công ty TNHH ABC Manufacturing', - transactionDate: DateTime(2023, 11, 20), + transactionDate: DateTime(2025, 11, 20), invoiceAmount: 4200000, notes: 'Gạch chống trơn cho khu vực sản xuất và kho bãi', attachments: const [ @@ -71,8 +71,8 @@ class PointsRecordRemoteDataSourceImpl ], status: PointsStatus.rejected, rejectReason: 'Hình ảnh minh chứng không hợp lệ', - submittedAt: DateTime(2023, 11, 20, 11, 0), - processedAt: DateTime(2023, 11, 28, 16, 45), + submittedAt: DateTime(2025, 11, 20, 11, 0), + processedAt: DateTime(2025, 11, 28, 16, 45), processedBy: 'admin002', ), PointsRecord( @@ -80,7 +80,7 @@ class PointsRecordRemoteDataSourceImpl userId: 'user123', invoiceNumber: 'INV-ECO-004', storeName: 'Ecopark Group', - transactionDate: DateTime(2023, 10, 10), + transactionDate: DateTime(2025, 10, 10), invoiceAmount: 3700000, notes: 'Gạch ceramic vân gỗ cho khu vực phòng khách và sân vườn', attachments: const [ @@ -88,8 +88,8 @@ class PointsRecordRemoteDataSourceImpl ], status: PointsStatus.approved, pointsEarned: 370, - submittedAt: DateTime(2023, 10, 10, 8, 30), - processedAt: DateTime(2023, 10, 15, 10, 20), + submittedAt: DateTime(2025, 10, 10, 8, 30), + processedAt: DateTime(2025, 10, 15, 10, 20), processedBy: 'admin001', ), PointsRecord( @@ -97,7 +97,7 @@ class PointsRecordRemoteDataSourceImpl userId: 'user123', invoiceNumber: 'INV-DMD-005', storeName: 'Diamond Hospitality Group', - transactionDate: DateTime(2023, 12, 1), + transactionDate: DateTime(2025, 12, 1), invoiceAmount: 8600000, notes: 'Gạch marble tự nhiên cho lobby và phòng suite', attachments: const [ @@ -106,7 +106,7 @@ class PointsRecordRemoteDataSourceImpl 'https://example.com/invoice9.jpg', ], status: PointsStatus.pending, - submittedAt: DateTime(2023, 12, 1, 13, 0), + submittedAt: DateTime(2025, 12, 1, 13, 0), ), ]; } diff --git a/lib/features/loyalty/presentation/pages/points_history_page.dart b/lib/features/loyalty/presentation/pages/points_history_page.dart index b28659f..af3e369 100644 --- a/lib/features/loyalty/presentation/pages/points_history_page.dart +++ b/lib/features/loyalty/presentation/pages/points_history_page.dart @@ -17,7 +17,7 @@ import 'package:worker/features/loyalty/presentation/providers/points_history_pr /// Points History Page /// /// Features: -/// - Filter section with date range +/// - Filter section with date range picker /// - List of transaction cards with new design /// - Each card shows: code, date, description, reference, points change, balance after class PointsHistoryPage extends ConsumerWidget { @@ -26,7 +26,11 @@ class PointsHistoryPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; + // Use unfiltered data for now (mock data) final historyAsync = ref.watch(pointsHistoryProvider); + final filter = ref.watch(pointsHistoryFilterProvider); + //todo: implement filtering logic in provider later + //:note: final historyAsync = ref.watch(filteredPointsHistoryProvider); return Scaffold( backgroundColor: colorScheme.surfaceContainerLowest, @@ -50,22 +54,21 @@ class PointsHistoryPage extends ConsumerWidget { }, child: historyAsync.when( data: (entries) { - if (entries.isEmpty) { - return _buildEmptyState(colorScheme); - } - return ListView( padding: const EdgeInsets.all(16), children: [ // Filter Section - _buildFilterSection(colorScheme), + _buildFilterSection(context, ref, colorScheme, filter), const SizedBox(height: 16), - // Transaction List - ...entries.map( - (entry) => _buildTransactionCard(context, entry, colorScheme), - ), + // Empty state or Transaction List + if (entries.isEmpty) + _buildEmptyStateInline(colorScheme) + else + ...entries.map( + (entry) => _buildTransactionCard(context, entry, colorScheme), + ), ], ); }, @@ -76,19 +79,26 @@ class PointsHistoryPage extends ConsumerWidget { ); } - /// Build filter section - Widget _buildFilterSection(ColorScheme colorScheme) { + /// Build filter section with date pickers + Widget _buildFilterSection( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, + PointsHistoryFilter filter, + ) { + final dateFormatter = DateFormat('dd/MM/yyyy'); + return Card( elevation: 1, margin: EdgeInsets.zero, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Bộ lọc', @@ -98,20 +108,216 @@ class PointsHistoryPage extends ConsumerWidget { color: colorScheme.onSurface, ), ), - const SizedBox(height: 4), - Text( - 'Thời gian hiệu lực: 01/01/2023 - 31/12/2023', - style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant), + FaIcon(FontAwesomeIcons.filter, color: colorScheme.primary, size: 18), + ], + ), + const SizedBox(height: 16), + + // Date range row + Row( + children: [ + // Start date + Expanded( + child: _buildDateField( + context: context, + colorScheme: colorScheme, + label: 'Từ ngày', + value: filter.startDate, + dateFormatter: dateFormatter, + onTap: () async { + final date = await showDatePicker( + context: context, + initialDate: filter.startDate ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: filter.endDate ?? DateTime.now(), + ); + if (date != null) { + ref.read(pointsHistoryFilterProvider.notifier).setStartDate(date); + } + }, + ), + ), + const SizedBox(width: 12), + // End date + Expanded( + child: _buildDateField( + context: context, + colorScheme: colorScheme, + label: 'Đến ngày', + value: filter.endDate, + dateFormatter: dateFormatter, + onTap: () async { + final date = await showDatePicker( + context: context, + initialDate: filter.endDate ?? DateTime.now(), + firstDate: filter.startDate ?? DateTime(2020), + lastDate: DateTime.now(), + ); + if (date != null) { + ref.read(pointsHistoryFilterProvider.notifier).setEndDate(date); + } + }, + ), ), ], ), - FaIcon(FontAwesomeIcons.filter, color: colorScheme.primary, size: 18), + + const SizedBox(height: 12), + + // Quick filter chips + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildQuickFilterChip( + context: context, + ref: ref, + colorScheme: colorScheme, + label: 'Hôm nay', + onTap: () { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + ref.read(pointsHistoryFilterProvider.notifier).setDateRange(today, today); + }, + ), + const SizedBox(width: 8), + _buildQuickFilterChip( + context: context, + ref: ref, + colorScheme: colorScheme, + label: '7 ngày', + onTap: () { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final weekAgo = today.subtract(const Duration(days: 7)); + ref.read(pointsHistoryFilterProvider.notifier).setDateRange(weekAgo, today); + }, + ), + const SizedBox(width: 8), + _buildQuickFilterChip( + context: context, + ref: ref, + colorScheme: colorScheme, + label: '30 ngày', + onTap: () { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final monthAgo = today.subtract(const Duration(days: 30)); + ref.read(pointsHistoryFilterProvider.notifier).setDateRange(monthAgo, today); + }, + ), + const SizedBox(width: 8), + _buildQuickFilterChip( + context: context, + ref: ref, + colorScheme: colorScheme, + label: 'Năm nay', + onTap: () { + final now = DateTime.now(); + ref.read(pointsHistoryFilterProvider.notifier).setDateRange( + DateTime(now.year, 1, 1), + DateTime(now.year, 12, 31), + ); + }, + ), + const SizedBox(width: 8), + _buildQuickFilterChip( + context: context, + ref: ref, + colorScheme: colorScheme, + label: 'Tất cả', + onTap: () { + ref.read(pointsHistoryFilterProvider.notifier).clearFilter(); + }, + ), + ], + ), + ), ], ), ), ); } + Widget _buildDateField({ + required BuildContext context, + required ColorScheme colorScheme, + required String label, + required DateTime? value, + required DateFormat dateFormatter, + required VoidCallback onTap, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + value != null ? dateFormatter.format(value) : 'Chọn', + style: TextStyle( + fontSize: 14, + color: value != null ? colorScheme.onSurface : colorScheme.onSurfaceVariant, + ), + ), + FaIcon( + FontAwesomeIcons.calendar, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildQuickFilterChip({ + required BuildContext context, + required WidgetRef ref, + required ColorScheme colorScheme, + required String label, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurface, + ), + ), + ), + ); + } + /// Build transaction card with new design Widget _buildTransactionCard( BuildContext context, @@ -258,30 +464,32 @@ class PointsHistoryPage extends ConsumerWidget { ); } - /// Build empty state - Widget _buildEmptyState(ColorScheme colorScheme) { - return Center( + /// Build inline empty state (inside scrollable list) + Widget _buildEmptyStateInline(ColorScheme colorScheme) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 60), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ FaIcon( FontAwesomeIcons.clockRotateLeft, - size: 80, + size: 64, color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), const SizedBox(height: 16), Text( - 'Chưa có lịch sử điểm', + 'Không có giao dịch', style: TextStyle( - fontSize: 18, + fontSize: 16, fontWeight: FontWeight.w600, color: colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), Text( - 'Kéo xuống để làm mới', + 'Không tìm thấy giao dịch trong khoảng thời gian này', style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, ), ], ), diff --git a/lib/features/loyalty/presentation/providers/points_history_provider.dart b/lib/features/loyalty/presentation/providers/points_history_provider.dart index a044e75..5f6c955 100644 --- a/lib/features/loyalty/presentation/providers/points_history_provider.dart +++ b/lib/features/loyalty/presentation/providers/points_history_provider.dart @@ -9,6 +9,59 @@ import 'package:worker/features/loyalty/data/models/loyalty_point_entry_model.da part 'points_history_provider.g.dart'; +/// Points History Filter State +class PointsHistoryFilter { + const PointsHistoryFilter({ + this.startDate, + this.endDate, + }); + + final DateTime? startDate; + final DateTime? endDate; + + PointsHistoryFilter copyWith({ + DateTime? startDate, + DateTime? endDate, + bool clearStartDate = false, + bool clearEndDate = false, + }) { + return PointsHistoryFilter( + startDate: clearStartDate ? null : (startDate ?? this.startDate), + endDate: clearEndDate ? null : (endDate ?? this.endDate), + ); + } +} + +/// Points History Filter Provider +@riverpod +class PointsHistoryFilterNotifier extends _$PointsHistoryFilterNotifier { + @override + PointsHistoryFilter build() { + // Default: current year + final now = DateTime.now(); + return PointsHistoryFilter( + startDate: DateTime(now.year, 1, 1), + endDate: DateTime(now.year, 12, 31), + ); + } + + void setDateRange(DateTime? start, DateTime? end) { + state = PointsHistoryFilter(startDate: start, endDate: end); + } + + void setStartDate(DateTime? date) { + state = state.copyWith(startDate: date); + } + + void setEndDate(DateTime? date) { + state = state.copyWith(endDate: date); + } + + void clearFilter() { + state = const PointsHistoryFilter(); + } +} + /// Points History Local Data Source Provider @riverpod PointsHistoryLocalDataSource pointsHistoryLocalDataSource(Ref ref) { @@ -44,3 +97,41 @@ class PointsHistory extends _$PointsHistory { }); } } + +/// Filtered Points History Provider +@riverpod +Future> filteredPointsHistory(Ref ref) async { + final entries = await ref.watch(pointsHistoryProvider.future); + final filter = ref.watch(pointsHistoryFilterProvider); + + return entries.where((entry) { + // Filter by start date + if (filter.startDate != null) { + final startOfDay = DateTime( + filter.startDate!.year, + filter.startDate!.month, + filter.startDate!.day, + ); + if (entry.timestamp.isBefore(startOfDay)) { + return false; + } + } + + // Filter by end date + if (filter.endDate != null) { + final endOfDay = DateTime( + filter.endDate!.year, + filter.endDate!.month, + filter.endDate!.day, + 23, + 59, + 59, + ); + if (entry.timestamp.isAfter(endOfDay)) { + return false; + } + } + + return true; + }).toList(); +} diff --git a/lib/features/loyalty/presentation/providers/points_history_provider.g.dart b/lib/features/loyalty/presentation/providers/points_history_provider.g.dart index 9d0ee0d..dade6a7 100644 --- a/lib/features/loyalty/presentation/providers/points_history_provider.g.dart +++ b/lib/features/loyalty/presentation/providers/points_history_provider.g.dart @@ -8,6 +8,68 @@ part of 'points_history_provider.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// Points History Filter Provider + +@ProviderFor(PointsHistoryFilterNotifier) +const pointsHistoryFilterProvider = PointsHistoryFilterNotifierProvider._(); + +/// Points History Filter Provider +final class PointsHistoryFilterNotifierProvider + extends + $NotifierProvider { + /// Points History Filter Provider + const PointsHistoryFilterNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pointsHistoryFilterProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pointsHistoryFilterNotifierHash(); + + @$internal + @override + PointsHistoryFilterNotifier create() => PointsHistoryFilterNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PointsHistoryFilter value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$pointsHistoryFilterNotifierHash() => + r'ef2587f4461c9488d9b15ed033e1d362042795f8'; + +/// Points History Filter Provider + +abstract class _$PointsHistoryFilterNotifier + extends $Notifier { + PointsHistoryFilter build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + PointsHistoryFilter, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + /// Points History Local Data Source Provider @ProviderFor(pointsHistoryLocalDataSource) @@ -130,3 +192,50 @@ abstract class _$PointsHistory element.handleValue(ref, created); } } + +/// Filtered Points History Provider + +@ProviderFor(filteredPointsHistory) +const filteredPointsHistoryProvider = FilteredPointsHistoryProvider._(); + +/// Filtered Points History Provider + +final class FilteredPointsHistoryProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// Filtered Points History Provider + const FilteredPointsHistoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'filteredPointsHistoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$filteredPointsHistoryHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return filteredPointsHistory(ref); + } +} + +String _$filteredPointsHistoryHash() => + r'989e2bf824eeb161b44b67d9ee81b713444a6e87';