diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index c8a9126..f5db8f6 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart'; import 'package:worker/features/cart/presentation/pages/cart_page.dart'; import 'package:worker/features/favorites/presentation/pages/favorites_page.dart'; import 'package:worker/features/loyalty/presentation/pages/loyalty_page.dart'; +import 'package:worker/features/loyalty/presentation/pages/points_history_page.dart'; import 'package:worker/features/loyalty/presentation/pages/rewards_page.dart'; import 'package:worker/features/main/presentation/pages/main_scaffold.dart'; import 'package:worker/features/orders/presentation/pages/order_detail_page.dart'; @@ -122,6 +123,16 @@ class AppRouter { ), ), + // Points History Route + GoRoute( + path: RouteNames.pointsHistory, + name: 'loyalty_points_history', + pageBuilder: (context, state) => MaterialPage( + key: state.pageKey, + child: const PointsHistoryPage(), + ), + ), + // Orders Route GoRoute( path: RouteNames.orders, diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index bc7bcdc..7c95e2b 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -169,8 +169,7 @@ class HomePage extends ConsumerWidget { QuickAction( icon: Icons.history, label: 'Lịch sử điểm', - onTap: () => - _showComingSoon(context, 'Lịch sử điểm', l10n), + onTap: () => context.push(RouteNames.pointsHistory), ), ], ), diff --git a/lib/features/loyalty/data/datasources/points_history_local_datasource.dart b/lib/features/loyalty/data/datasources/points_history_local_datasource.dart new file mode 100644 index 0000000..8611e2e --- /dev/null +++ b/lib/features/loyalty/data/datasources/points_history_local_datasource.dart @@ -0,0 +1,189 @@ +/// Data Source: Points History Local Data Source +/// +/// Handles local storage operations for loyalty points history using Hive. +library; + +import 'package:hive_ce/hive.dart'; +import 'package:worker/core/constants/storage_constants.dart'; +import 'package:worker/core/database/models/enums.dart'; +import 'package:worker/features/loyalty/data/models/loyalty_point_entry_model.dart'; + +/// Points History Local Data Source +/// +/// Provides methods to interact with locally stored points history data. +class PointsHistoryLocalDataSource { + Box? _entriesBox; + + /// Get Hive box for points entries + Future get entriesBox async { + if (_entriesBox != null) return _entriesBox!; + + // Use the box already opened by HiveService + if (Hive.isBoxOpen(HiveBoxNames.loyaltyBox)) { + _entriesBox = Hive.box(HiveBoxNames.loyaltyBox); + } else { + _entriesBox = await Hive.openBox(HiveBoxNames.loyaltyBox); + } + + return _entriesBox!; + } + + /// Get all points entries + Future> getAllEntries() async { + final box = await entriesBox; + final entries = box.values + .whereType() + .toList(); + entries.sort((a, b) => b.timestamp.compareTo(a.timestamp)); // Newest first + return entries; + } + + /// Get entry by ID + Future getEntryById(String entryId) async { + final box = await entriesBox; + try { + return box.values + .whereType() + .firstWhere((entry) => entry.entryId == entryId); + } catch (e) { + throw Exception('Entry not found'); + } + } + + /// Save entry + Future saveEntry(LoyaltyPointEntryModel entry) async { + final box = await entriesBox; + await box.put(entry.entryId, entry); + } + + /// Save multiple entries + Future saveEntries(List entries) async { + final box = await entriesBox; + final Map entriesMap = { + for (var entry in entries) entry.entryId: entry, + }; + await box.putAll(entriesMap); + } + + /// Delete entry + Future deleteEntry(String entryId) async { + final box = await entriesBox; + await box.delete(entryId); + } + + /// Clear all entries + Future clearEntries() async { + final box = await entriesBox; + await box.clear(); + } + + /// Seed mock data for development + Future seedMockEntries() async { + final now = DateTime.now(); + + final mockEntries = [ + LoyaltyPointEntryModel( + entryId: 'entry_001', + userId: 'user_001', + points: 3, + entryType: EntryType.earn, + source: EntrySource.purchase, + description: 'Giao dịch mua hàng 00083', + referenceId: 'order_00083', + referenceType: 'order', + complaint: null, + complaintStatus: ComplaintStatus.none, + balanceAfter: 604, + expiryDate: now.add(const Duration(days: 365)), + timestamp: DateTime(2023, 9, 28, 17, 23, 18), + ), + LoyaltyPointEntryModel( + entryId: 'entry_002', + userId: 'user_001', + points: 0, + entryType: EntryType.earn, + source: EntrySource.purchase, + description: 'Giao dịch mua hàng 00081', + referenceId: 'order_00081', + referenceType: 'order', + complaint: null, + complaintStatus: ComplaintStatus.none, + balanceAfter: 604, + expiryDate: now.add(const Duration(days: 365)), + timestamp: DateTime(2023, 9, 27, 17, 23, 18), + ), + LoyaltyPointEntryModel( + entryId: 'entry_003', + userId: 'user_001', + points: -5, + entryType: EntryType.expiry, + source: EntrySource.promotion, + description: 'Điểm thưởng hết hạn', + referenceId: null, + referenceType: null, + complaint: null, + complaintStatus: ComplaintStatus.none, + balanceAfter: 604, + expiryDate: null, + timestamp: DateTime(2023, 9, 20, 17, 23, 18), + ), + LoyaltyPointEntryModel( + entryId: 'entry_004', + userId: 'user_001', + points: -500, + entryType: EntryType.redeem, + source: EntrySource.giftRedemption, + description: 'Đổi Voucher HSG', + referenceId: 'gift_001', + referenceType: 'gift', + complaint: null, + complaintStatus: ComplaintStatus.none, + balanceAfter: 604, + expiryDate: null, + timestamp: DateTime(2023, 9, 19, 17, 23, 18), + ), + LoyaltyPointEntryModel( + entryId: 'entry_005', + userId: 'user_001', + points: 5, + entryType: EntryType.earn, + source: EntrySource.referral, + description: 'Giới thiệu người dùng', + referenceId: null, + referenceType: null, + complaint: null, + complaintStatus: ComplaintStatus.none, + balanceAfter: 604, + expiryDate: now.add(const Duration(days: 365)), + timestamp: DateTime(2023, 9, 10, 17, 23, 18), + ), + LoyaltyPointEntryModel( + entryId: 'entry_006', + userId: 'user_001', + points: -200, + entryType: EntryType.redeem, + source: EntrySource.giftRedemption, + description: 'Đổi quà', + referenceId: 'gift_002', + referenceType: 'gift', + complaint: null, + complaintStatus: ComplaintStatus.none, + balanceAfter: 604, + expiryDate: null, + timestamp: DateTime(2023, 9, 5, 17, 23, 18), + ), + ]; + + await saveEntries(mockEntries); + } + + /// Get transaction amount from description (for purchase entries) + int? getTransactionAmount(String description) { + if (description.contains('Giao dịch mua hàng 00083')) { + return 100000000; // 100M VND + } else if (description.contains('Giao dịch mua hàng 00081')) { + return 200000000; // 200M VND + } + return null; + } +} diff --git a/lib/features/loyalty/presentation/pages/loyalty_page.dart b/lib/features/loyalty/presentation/pages/loyalty_page.dart index 1a1f985..b59f82c 100644 --- a/lib/features/loyalty/presentation/pages/loyalty_page.dart +++ b/lib/features/loyalty/presentation/pages/loyalty_page.dart @@ -308,7 +308,7 @@ class LoyaltyPage extends ConsumerWidget { 'icon': Icons.history, 'title': 'Lịch sử điểm', 'subtitle': 'Xem chi tiết cộng/trừ điểm', - 'route': null, + 'route': '/loyalty/points-history', }, { 'icon': Icons.person_add, diff --git a/lib/features/loyalty/presentation/pages/points_history_page.dart b/lib/features/loyalty/presentation/pages/points_history_page.dart new file mode 100644 index 0000000..897a1a5 --- /dev/null +++ b/lib/features/loyalty/presentation/pages/points_history_page.dart @@ -0,0 +1,329 @@ +/// Page: Points History Page +/// +/// Displays history of points earned and spent transactions. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/database/models/enums.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/loyalty/data/datasources/points_history_local_datasource.dart'; +import 'package:worker/features/loyalty/data/models/loyalty_point_entry_model.dart'; +import 'package:worker/features/loyalty/presentation/providers/points_history_provider.dart'; + +/// Points History Page +/// +/// Features: +/// - Filter section with date range +/// - List of transaction cards +/// - Each card shows: description, date, amount, points change, new balance +/// - Complaint button for each transaction +class PointsHistoryPage extends ConsumerWidget { + const PointsHistoryPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final historyAsync = ref.watch(pointsHistoryProvider); + + return Scaffold( + backgroundColor: const Color(0xFFF4F6F8), + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => context.pop(), + ), + title: const Text( + 'Lịch sử điểm', + style: TextStyle(color: Colors.black), + ), + elevation: AppBarSpecs.elevation, + backgroundColor: AppColors.white, + foregroundColor: AppColors.grey900, + centerTitle: false, + ), + body: RefreshIndicator( + onRefresh: () async { + await ref.read(pointsHistoryProvider.notifier).refresh(); + }, + child: historyAsync.when( + data: (entries) { + if (entries.isEmpty) { + return _buildEmptyState(); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Filter Section + _buildFilterSection(), + + const SizedBox(height: 16), + + // Transaction List + ...entries.map((entry) => _buildTransactionCard(context, ref, entry)), + ], + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => _buildErrorState(error), + ), + ), + ); + } + + /// Build filter section + Widget _buildFilterSection() { + return Card( + elevation: 1, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Bộ lọc', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + ), + Icon( + Icons.filter_list, + color: AppColors.primaryBlue, + size: 20, + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Thời gian hiệu lực: 01/01/2023 - 31/12/2023', + style: TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + ), + ], + ), + ), + ); + } + + /// Build transaction card + Widget _buildTransactionCard( + BuildContext context, + WidgetRef ref, + LoyaltyPointEntryModel entry, + ) { + final dateFormatter = DateFormat('dd/MM/yyyy HH:mm:ss'); + final currencyFormatter = NumberFormat.currency( + locale: 'vi_VN', + symbol: 'VND', + decimalDigits: 0, + ); + + // Get transaction amount if it's a purchase + final datasource = ref.read(pointsHistoryLocalDataSourceProvider); + final transactionAmount = datasource.getTransactionAmount(entry.description); + + return Card( + elevation: 1, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top row: Description and Complaint button + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Description + Text( + entry.description, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: AppColors.primaryBlue, + ), + ), + const SizedBox(height: 4), + + // Timestamp + Text( + 'Thời gian: ${dateFormatter.format(entry.timestamp)}', + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + ), + + // Transaction amount (if purchase) + if (transactionAmount != null) ...[ + const SizedBox(height: 2), + Text( + 'Giao dịch: ${currencyFormatter.format(transactionAmount)}', + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + ), + ], + ], + ), + ), + + const SizedBox(width: 12), + + // Complaint button + OutlinedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Chức năng khiếu nại đang phát triển')), + ); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + side: const BorderSide(color: AppColors.grey500), + foregroundColor: AppColors.grey900, + textStyle: const TextStyle(fontSize: 12), + minimumSize: const Size(0, 32), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + child: const Text('Khiếu nại'), + ), + ], + ), + + const SizedBox(height: 12), + + // Bottom row: Points change and new balance + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Points change + Text( + entry.points > 0 ? '+${entry.points}' : '${entry.points}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: entry.points > 0 + ? AppColors.success + : entry.points < 0 + ? AppColors.danger + : AppColors.grey900, + ), + ), + const SizedBox(height: 2), + + // New balance + Text( + 'Điểm mới: ${entry.balanceAfter}', + style: const TextStyle( + fontSize: 12, + color: AppColors.primaryBlue, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ); + } + + /// Build empty state + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.history, + size: 80, + color: AppColors.grey500.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + const Text( + 'Chưa có lịch sử điểm', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.grey500, + ), + ), + const SizedBox(height: 8), + const Text( + 'Kéo xuống để làm mới', + style: TextStyle( + fontSize: 14, + color: AppColors.grey500, + ), + ), + ], + ), + ); + } + + /// Build error state + Widget _buildErrorState(Object error) { + print(error.toString()); + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 80, + color: AppColors.danger.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + const Text( + 'Có lỗi xảy ra', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + ), + const SizedBox(height: 8), + Text( + error.toString(), + style: const TextStyle( + fontSize: 14, + color: AppColors.grey500, + ), + 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 new file mode 100644 index 0000000..b3b871b --- /dev/null +++ b/lib/features/loyalty/presentation/providers/points_history_provider.dart @@ -0,0 +1,44 @@ +/// Providers: Points History +/// +/// Riverpod providers for managing points history state. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/features/loyalty/data/datasources/points_history_local_datasource.dart'; +import 'package:worker/features/loyalty/data/models/loyalty_point_entry_model.dart'; + +part 'points_history_provider.g.dart'; + +/// Points History Local Data Source Provider +@riverpod +PointsHistoryLocalDataSource pointsHistoryLocalDataSource(Ref ref) { + return PointsHistoryLocalDataSource(); +} + +/// Points History Provider +/// +/// Provides list of all points history entries from local data source. +@riverpod +class PointsHistory extends _$PointsHistory { + @override + Future> build() async { + final datasource = ref.read(pointsHistoryLocalDataSourceProvider); + + // Seed mock data on first load + final entries = await datasource.getAllEntries(); + if (entries.isEmpty) { + await datasource.seedMockEntries(); + return await datasource.getAllEntries(); + } + + return entries; + } + + /// Refresh points history + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + return await ref.read(pointsHistoryLocalDataSourceProvider).getAllEntries(); + }); + } +} diff --git a/lib/features/loyalty/presentation/providers/points_history_provider.g.dart b/lib/features/loyalty/presentation/providers/points_history_provider.g.dart new file mode 100644 index 0000000..9d0ee0d --- /dev/null +++ b/lib/features/loyalty/presentation/providers/points_history_provider.g.dart @@ -0,0 +1,132 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'points_history_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Points History Local Data Source Provider + +@ProviderFor(pointsHistoryLocalDataSource) +const pointsHistoryLocalDataSourceProvider = + PointsHistoryLocalDataSourceProvider._(); + +/// Points History Local Data Source Provider + +final class PointsHistoryLocalDataSourceProvider + extends + $FunctionalProvider< + PointsHistoryLocalDataSource, + PointsHistoryLocalDataSource, + PointsHistoryLocalDataSource + > + with $Provider { + /// Points History Local Data Source Provider + const PointsHistoryLocalDataSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pointsHistoryLocalDataSourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pointsHistoryLocalDataSourceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + PointsHistoryLocalDataSource create(Ref ref) { + return pointsHistoryLocalDataSource(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PointsHistoryLocalDataSource value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$pointsHistoryLocalDataSourceHash() => + r'324e4d6d12e0f1ec3f77de8e0fd60f69eaa8c7ce'; + +/// Points History Provider +/// +/// Provides list of all points history entries from local data source. + +@ProviderFor(PointsHistory) +const pointsHistoryProvider = PointsHistoryProvider._(); + +/// Points History Provider +/// +/// Provides list of all points history entries from local data source. +final class PointsHistoryProvider + extends + $AsyncNotifierProvider> { + /// Points History Provider + /// + /// Provides list of all points history entries from local data source. + const PointsHistoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pointsHistoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pointsHistoryHash(); + + @$internal + @override + PointsHistory create() => PointsHistory(); +} + +String _$pointsHistoryHash() => r'e812d6f6707f02c15a1263ae5b5ee912f269358d'; + +/// Points History Provider +/// +/// Provides list of all points history entries from local data source. + +abstract class _$PointsHistory + extends $AsyncNotifier> { + FutureOr> build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = + this.ref + as $Ref< + AsyncValue>, + List + >; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue>, + List + >, + AsyncValue>, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/features/quotes/presentation/providers/quotes_provider.g.dart b/lib/features/quotes/presentation/providers/quotes_provider.g.dart index 8c060d2..ef8b4cc 100644 --- a/lib/features/quotes/presentation/providers/quotes_provider.g.dart +++ b/lib/features/quotes/presentation/providers/quotes_provider.g.dart @@ -59,7 +59,7 @@ final class QuotesLocalDataSourceProvider } String _$quotesLocalDataSourceHash() => - r'02a822db926d8d80460bcc27a08ea494dff6c441'; + r'675d9ae15bf5e3dcbb12c1a893c8a73fbfb8c2ee'; /// Quotes Provider /// @@ -288,7 +288,7 @@ final class FilteredQuotesProvider } } -String _$filteredQuotesHash() => r'77076cfa483cb81cc56972bca6a3c1e97861165c'; +String _$filteredQuotesHash() => r'ce6fc7db1d4f2e90431e7258a3faef2c55db80d5'; /// Quotes Count by Status Provider @@ -335,4 +335,4 @@ final class QuotesCountByStatusProvider } String _$quotesCountByStatusHash() => - r'474b62ad0ccf890df1c33c64a17f9a0f428f676e'; + r'9a2f2f10dd392505d0d51428f45390f16952763d';