This commit is contained in:
Phuoc Nguyen
2025-12-05 10:11:03 +07:00
parent b9b6d91a87
commit e0a9b3b9f4
5 changed files with 455 additions and 47 deletions

View File

@@ -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),
),
];

View File

@@ -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),
),
];
}

View File

@@ -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,
),
],
),

View File

@@ -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<List<LoyaltyPointEntryModel>> 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();
}

View File

@@ -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<PointsHistoryFilterNotifier, PointsHistoryFilter> {
/// 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<PointsHistoryFilter>(value),
);
}
}
String _$pointsHistoryFilterNotifierHash() =>
r'ef2587f4461c9488d9b15ed033e1d362042795f8';
/// Points History Filter Provider
abstract class _$PointsHistoryFilterNotifier
extends $Notifier<PointsHistoryFilter> {
PointsHistoryFilter build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<PointsHistoryFilter, PointsHistoryFilter>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<PointsHistoryFilter, PointsHistoryFilter>,
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<LoyaltyPointEntryModel>>,
List<LoyaltyPointEntryModel>,
FutureOr<List<LoyaltyPointEntryModel>>
>
with
$FutureModifier<List<LoyaltyPointEntryModel>>,
$FutureProvider<List<LoyaltyPointEntryModel>> {
/// 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<List<LoyaltyPointEntryModel>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<LoyaltyPointEntryModel>> create(Ref ref) {
return filteredPointsHistory(ref);
}
}
String _$filteredPointsHistoryHash() =>
r'989e2bf824eeb161b44b67d9ee81b713444a6e87';