point record

This commit is contained in:
Phuoc Nguyen
2025-11-26 11:48:02 +07:00
parent 3741239d83
commit a07f165f0c
12 changed files with 1761 additions and 12 deletions

View File

@@ -0,0 +1,386 @@
/// Page: Points Records List
///
/// Displays list of user's points records with filters.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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/theme/colors.dart';
import 'package:worker/features/loyalty/domain/entities/points_record.dart';
import 'package:worker/features/loyalty/presentation/providers/points_records_provider.dart';
/// Points Records Page
class PointsRecordsPage extends ConsumerWidget {
const PointsRecordsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final recordsAsync = ref.watch(filteredPointsRecordsProvider);
final filter = ref.watch(pointsRecordsFilterProvider);
final selectedStatus = filter.selectedStatus;
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
appBar: AppBar(
leading: IconButton(
icon: const FaIcon(
FontAwesomeIcons.arrowLeft,
color: Colors.black,
size: 20,
),
onPressed: () => context.pop(),
),
title: const Text(
'Danh sách Ghi nhận điểm',
style: TextStyle(color: Colors.black),
),
actions: [
IconButton(
icon: const FaIcon(
FontAwesomeIcons.plus,
color: Colors.black,
size: 20,
),
onPressed: () {
// TODO: Navigate to points record create page
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tính năng tạo ghi nhận điểm sẽ được cập nhật'),
),
);
},
),
const SizedBox(width: AppSpacing.sm),
],
elevation: AppBarSpecs.elevation,
backgroundColor: AppColors.white,
centerTitle: false,
),
body: Column(
children: [
// Search Bar
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
decoration: InputDecoration(
hintText: 'Mã yêu cầu',
prefixIcon: const Icon(Icons.search, color: AppColors.grey500),
filled: true,
fillColor: AppColors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.grey100),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.grey100),
),
),
onChanged: (value) {
ref.read(pointsRecordsFilterProvider.notifier).updateSearchQuery(value);
},
),
),
// Status Filter Tabs
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
_buildFilterChip(
context,
ref,
label: 'Tất cả',
isSelected: selectedStatus == null,
onTap: () =>
ref.read(pointsRecordsFilterProvider.notifier).clearStatusFilter(),
),
const SizedBox(width: 8),
...PointsStatus.values.map(
(status) => Padding(
padding: const EdgeInsets.only(right: 8),
child: _buildFilterChip(
context,
ref,
label: status.displayName,
isSelected: selectedStatus == status,
onTap: () =>
ref.read(pointsRecordsFilterProvider.notifier).selectStatus(status),
),
),
),
],
),
),
const SizedBox(height: 16),
// Points Records List
Expanded(
child: recordsAsync.when(
data: (records) {
if (records.isEmpty) {
return RefreshIndicator(
onRefresh: () async {
await ref.read(allPointsRecordsProvider.notifier).refresh();
},
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FaIcon(
FontAwesomeIcons.folderOpen,
size: 64,
color: AppColors.grey500,
),
SizedBox(height: 16),
Text(
'Không có ghi nhận điểm nào',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
SizedBox(height: 8),
Text(
'Không tìm thấy ghi nhận điểm phù hợp',
style: TextStyle(color: AppColors.grey500),
),
],
),
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
await ref.read(allPointsRecordsProvider.notifier).refresh();
},
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: records.length,
itemBuilder: (context, index) {
final record = records[index];
return _buildRecordCard(context, record);
},
),
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stack) => RefreshIndicator(
onRefresh: () async {
await ref.read(allPointsRecordsProvider.notifier).refresh();
},
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppColors.danger,
),
const SizedBox(height: 16),
const Text(
'Có lỗi xảy ra',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
const SizedBox(height: 8),
Text(
error.toString(),
style: const TextStyle(color: AppColors.grey500),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
const Text(
'Kéo xuống để thử lại',
style: TextStyle(color: AppColors.grey500),
),
],
),
),
),
],
),
),
),
),
],
),
);
}
Widget _buildFilterChip(
BuildContext context,
WidgetRef ref, {
required String label,
required bool isSelected,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? AppColors.primaryBlue : AppColors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected ? AppColors.primaryBlue : AppColors.grey100,
),
),
child: Text(
label,
style: TextStyle(
color: isSelected ? AppColors.white : AppColors.grey900,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
),
);
}
Widget _buildRecordCard(BuildContext context, PointsRecord record) {
final currencyFormat = NumberFormat.currency(
locale: 'vi_VN',
symbol: '',
decimalDigits: 0,
);
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
onTap: () {
// TODO: Navigate to points record detail
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Chi tiết ghi nhận ${record.recordId}')),
);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'#${record.recordId}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
_buildStatusBadge(record.status),
],
),
const SizedBox(height: 8),
Text(
'Ngày gửi: ${DateFormat('dd/MM/yyyy').format(record.submittedAt)}',
style: const TextStyle(
fontSize: 13,
color: AppColors.grey500,
),
),
const SizedBox(height: 4),
Text(
'Giá trị đơn hàng: ${currencyFormat.format(record.invoiceAmount)}',
style: const TextStyle(
fontSize: 14,
color: AppColors.grey900,
),
),
if (record.rejectReason != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const FaIcon(
FontAwesomeIcons.triangleExclamation,
size: 14,
color: AppColors.danger,
),
const SizedBox(width: 8),
Expanded(
child: Text(
record.rejectReason!,
style: const TextStyle(
fontSize: 12,
color: AppColors.danger,
),
),
),
],
),
),
],
],
),
),
),
);
}
Widget _buildStatusBadge(PointsStatus status) {
final color = _getStatusColor(status);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
status.displayName,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
Color _getStatusColor(PointsStatus status) {
switch (status) {
case PointsStatus.pending:
return AppColors.warning;
case PointsStatus.approved:
return AppColors.success;
case PointsStatus.rejected:
return AppColors.danger;
}
}
}

View File

@@ -0,0 +1,118 @@
/// Providers: Points Records
///
/// State management for points records feature.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/features/loyalty/data/datasources/points_record_remote_datasource.dart';
import 'package:worker/features/loyalty/data/repositories/points_record_repository_impl.dart';
import 'package:worker/features/loyalty/domain/entities/points_record.dart';
import 'package:worker/features/loyalty/domain/repositories/points_record_repository.dart';
import 'package:worker/features/loyalty/domain/usecases/get_points_records.dart';
part 'points_records_provider.g.dart';
// ============================================================================
// Data Layer Providers
// ============================================================================
/// Points Record Remote Data Source Provider
@riverpod
PointsRecordRemoteDataSource pointsRecordRemoteDataSource(Ref ref) {
return PointsRecordRemoteDataSourceImpl();
}
/// Points Record Repository Provider
@riverpod
PointsRecordRepository pointsRecordRepository(Ref ref) {
final remoteDataSource = ref.watch(pointsRecordRemoteDataSourceProvider);
return PointsRecordRepositoryImpl(remoteDataSource);
}
/// Get Points Records Use Case Provider
@riverpod
GetPointsRecords getPointsRecords(Ref ref) {
final repository = ref.watch(pointsRecordRepositoryProvider);
return GetPointsRecords(repository);
}
// ============================================================================
// Presentation Layer Providers
// ============================================================================
/// All Points Records Provider (AsyncNotifier)
@riverpod
class AllPointsRecords extends _$AllPointsRecords {
@override
Future<List<PointsRecord>> build() async {
final useCase = ref.watch(getPointsRecordsProvider);
return await useCase();
}
/// Refresh points records
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final useCase = ref.read(getPointsRecordsProvider);
return await useCase();
});
}
}
/// Points Records Filter State Provider
@riverpod
class PointsRecordsFilter extends _$PointsRecordsFilter {
@override
({String searchQuery, PointsStatus? selectedStatus}) build() {
return (searchQuery: '', selectedStatus: null);
}
/// Update search query
void updateSearchQuery(String query) {
state = (searchQuery: query, selectedStatus: state.selectedStatus);
}
/// Select status filter
void selectStatus(PointsStatus? status) {
state = (searchQuery: state.searchQuery, selectedStatus: status);
}
/// Clear status filter
void clearStatusFilter() {
state = (searchQuery: state.searchQuery, selectedStatus: null);
}
}
/// Filtered Points Records Provider
@riverpod
AsyncValue<List<PointsRecord>> filteredPointsRecords(Ref ref) {
final dataAsync = ref.watch(allPointsRecordsProvider);
final filter = ref.watch(pointsRecordsFilterProvider);
return dataAsync.whenData((records) {
var filtered = records;
// Apply status filter
if (filter.selectedStatus != null) {
filtered = filtered
.where((record) => record.status == filter.selectedStatus)
.toList();
}
// Apply search filter
if (filter.searchQuery.isNotEmpty) {
final query = filter.searchQuery.toLowerCase();
filtered = filtered.where((record) {
final idMatch = record.recordId.toLowerCase().contains(query);
final invoiceMatch = record.invoiceNumber.toLowerCase().contains(query);
final storeMatch = record.storeName.toLowerCase().contains(query);
return idMatch || invoiceMatch || storeMatch;
}).toList();
}
// Sort by submission date (newest first)
filtered.sort((a, b) => b.submittedAt.compareTo(a.submittedAt));
return filtered;
});
}

View File

@@ -0,0 +1,352 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'points_records_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Points Record Remote Data Source Provider
@ProviderFor(pointsRecordRemoteDataSource)
const pointsRecordRemoteDataSourceProvider =
PointsRecordRemoteDataSourceProvider._();
/// Points Record Remote Data Source Provider
final class PointsRecordRemoteDataSourceProvider
extends
$FunctionalProvider<
PointsRecordRemoteDataSource,
PointsRecordRemoteDataSource,
PointsRecordRemoteDataSource
>
with $Provider<PointsRecordRemoteDataSource> {
/// Points Record Remote Data Source Provider
const PointsRecordRemoteDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'pointsRecordRemoteDataSourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pointsRecordRemoteDataSourceHash();
@$internal
@override
$ProviderElement<PointsRecordRemoteDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
PointsRecordRemoteDataSource create(Ref ref) {
return pointsRecordRemoteDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(PointsRecordRemoteDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<PointsRecordRemoteDataSource>(value),
);
}
}
String _$pointsRecordRemoteDataSourceHash() =>
r'b0a68a4bb8f05281afa84885a916611c131e172a';
/// Points Record Repository Provider
@ProviderFor(pointsRecordRepository)
const pointsRecordRepositoryProvider = PointsRecordRepositoryProvider._();
/// Points Record Repository Provider
final class PointsRecordRepositoryProvider
extends
$FunctionalProvider<
PointsRecordRepository,
PointsRecordRepository,
PointsRecordRepository
>
with $Provider<PointsRecordRepository> {
/// Points Record Repository Provider
const PointsRecordRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'pointsRecordRepositoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pointsRecordRepositoryHash();
@$internal
@override
$ProviderElement<PointsRecordRepository> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
PointsRecordRepository create(Ref ref) {
return pointsRecordRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(PointsRecordRepository value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<PointsRecordRepository>(value),
);
}
}
String _$pointsRecordRepositoryHash() =>
r'892ad8ca77bd5c59dba2e285163ef5e016c0ca0d';
/// Get Points Records Use Case Provider
@ProviderFor(getPointsRecords)
const getPointsRecordsProvider = GetPointsRecordsProvider._();
/// Get Points Records Use Case Provider
final class GetPointsRecordsProvider
extends
$FunctionalProvider<
GetPointsRecords,
GetPointsRecords,
GetPointsRecords
>
with $Provider<GetPointsRecords> {
/// Get Points Records Use Case Provider
const GetPointsRecordsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'getPointsRecordsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$getPointsRecordsHash();
@$internal
@override
$ProviderElement<GetPointsRecords> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
GetPointsRecords create(Ref ref) {
return getPointsRecords(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GetPointsRecords value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GetPointsRecords>(value),
);
}
}
String _$getPointsRecordsHash() => r'f7ea3c5c9675878967cc34b18416adb3a665ccf8';
/// All Points Records Provider (AsyncNotifier)
@ProviderFor(AllPointsRecords)
const allPointsRecordsProvider = AllPointsRecordsProvider._();
/// All Points Records Provider (AsyncNotifier)
final class AllPointsRecordsProvider
extends $AsyncNotifierProvider<AllPointsRecords, List<PointsRecord>> {
/// All Points Records Provider (AsyncNotifier)
const AllPointsRecordsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'allPointsRecordsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$allPointsRecordsHash();
@$internal
@override
AllPointsRecords create() => AllPointsRecords();
}
String _$allPointsRecordsHash() => r'cd64b6952f9abfe1142773b4b88a051b74e8d763';
/// All Points Records Provider (AsyncNotifier)
abstract class _$AllPointsRecords extends $AsyncNotifier<List<PointsRecord>> {
FutureOr<List<PointsRecord>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref =
this.ref as $Ref<AsyncValue<List<PointsRecord>>, List<PointsRecord>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<PointsRecord>>, List<PointsRecord>>,
AsyncValue<List<PointsRecord>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Points Records Filter State Provider
@ProviderFor(PointsRecordsFilter)
const pointsRecordsFilterProvider = PointsRecordsFilterProvider._();
/// Points Records Filter State Provider
final class PointsRecordsFilterProvider
extends
$NotifierProvider<
PointsRecordsFilter,
({String searchQuery, PointsStatus? selectedStatus})
> {
/// Points Records Filter State Provider
const PointsRecordsFilterProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'pointsRecordsFilterProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pointsRecordsFilterHash();
@$internal
@override
PointsRecordsFilter create() => PointsRecordsFilter();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(
({String searchQuery, PointsStatus? selectedStatus}) value,
) {
return $ProviderOverride(
origin: this,
providerOverride:
$SyncValueProvider<
({String searchQuery, PointsStatus? selectedStatus})
>(value),
);
}
}
String _$pointsRecordsFilterHash() =>
r'ae81040dff096894330a2a744959190545435c48';
/// Points Records Filter State Provider
abstract class _$PointsRecordsFilter
extends $Notifier<({String searchQuery, PointsStatus? selectedStatus})> {
({String searchQuery, PointsStatus? selectedStatus}) build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref =
this.ref
as $Ref<
({String searchQuery, PointsStatus? selectedStatus}),
({String searchQuery, PointsStatus? selectedStatus})
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
({String searchQuery, PointsStatus? selectedStatus}),
({String searchQuery, PointsStatus? selectedStatus})
>,
({String searchQuery, PointsStatus? selectedStatus}),
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Filtered Points Records Provider
@ProviderFor(filteredPointsRecords)
const filteredPointsRecordsProvider = FilteredPointsRecordsProvider._();
/// Filtered Points Records Provider
final class FilteredPointsRecordsProvider
extends
$FunctionalProvider<
AsyncValue<List<PointsRecord>>,
AsyncValue<List<PointsRecord>>,
AsyncValue<List<PointsRecord>>
>
with $Provider<AsyncValue<List<PointsRecord>>> {
/// Filtered Points Records Provider
const FilteredPointsRecordsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'filteredPointsRecordsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$filteredPointsRecordsHash();
@$internal
@override
$ProviderElement<AsyncValue<List<PointsRecord>>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
AsyncValue<List<PointsRecord>> create(Ref ref) {
return filteredPointsRecords(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<List<PointsRecord>> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<List<PointsRecord>>>(
value,
),
);
}
}
String _$filteredPointsRecordsHash() =>
r'afb0691b799f053b5c7fff2f8b64065917b5cd33';