add price policy

This commit is contained in:
Phuoc Nguyen
2025-11-03 11:20:09 +07:00
parent c0527a086c
commit 21c1c3372c
53 changed files with 7160 additions and 2361 deletions

View File

@@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/ui_constants.dart';
import '../../../../core/theme/colors.dart';
import '../../domain/entities/price_document.dart';
import '../providers/price_documents_provider.dart';
import '../widgets/document_card.dart';
/// Price policy page with tabs for policies and price lists
class PricePolicyPage extends ConsumerStatefulWidget {
const PricePolicyPage({super.key});
@override
ConsumerState<PricePolicyPage> createState() => _PricePolicyPageState();
}
class _PricePolicyPageState extends ConsumerState<PricePolicyPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.grey50,
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => context.pop(),
),
title: const Text(
'Chính sách giá',
style: TextStyle(color: Colors.black),
),
elevation: AppBarSpecs.elevation,
backgroundColor: AppColors.white,
foregroundColor: AppColors.grey900,
centerTitle: false,
actions: [
IconButton(
icon: const Icon(Icons.info_outline, color: Colors.black),
onPressed: _showInfoDialog,
),
const SizedBox(width: AppSpacing.sm),
],
bottom: TabBar(
controller: _tabController,
labelColor: AppColors.white,
unselectedLabelColor: AppColors.grey900,
indicatorSize: TabBarIndicatorSize.tab,
indicator: BoxDecoration(
color: AppColors.primaryBlue,
borderRadius: BorderRadius.circular(8),
),
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
tabs: const [
Tab(text: 'Chính sách giá'),
Tab(text: 'Bảng giá'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
// Policy tab
_buildDocumentList(DocumentCategory.policy),
// Price list tab
_buildDocumentList(DocumentCategory.priceList),
],
),
);
}
Widget _buildDocumentList(DocumentCategory category) {
final documentsAsync = ref.watch(filteredPriceDocumentsProvider(category));
return documentsAsync.when(
data: (documents) {
if (documents.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.description_outlined,
size: 64,
color: AppColors.grey500,
),
const SizedBox(height: AppSpacing.md),
Text(
'Chưa có tài liệu',
style: TextStyle(fontSize: 16, color: AppColors.grey500),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
// Refresh documents from repository
ref.invalidate(filteredPriceDocumentsProvider(category));
await Future<void>.delayed(const Duration(milliseconds: 500));
},
child: ListView.separated(
padding: const EdgeInsets.all(AppSpacing.md),
itemCount: documents.length,
separatorBuilder: (context, index) =>
const SizedBox(height: AppSpacing.md),
itemBuilder: (context, index) {
final document = documents[index];
return DocumentCard(
document: document,
onDownload: () => _handleDownload(document),
);
},
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: AppColors.danger),
const SizedBox(height: AppSpacing.md),
Text(
'Không thể tải tài liệu',
style: TextStyle(fontSize: 16, color: AppColors.grey500),
),
const SizedBox(height: AppSpacing.sm),
ElevatedButton(
onPressed: () {
ref.invalidate(filteredPriceDocumentsProvider(category));
},
child: const Text('Thử lại'),
),
],
),
),
);
}
void _handleDownload(PriceDocument document) {
// In real app, this would trigger actual download
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Đang tải: ${document.title}'),
duration: const Duration(seconds: 2),
backgroundColor: AppColors.primaryBlue,
behavior: SnackBarBehavior.floating,
),
);
// Simulate download
// TODO: Implement actual file download functionality
// - Use url_launcher or dio to download file
// - Show progress indicator
// - Save to device storage
}
void _showInfoDialog() {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text(
'Hướng dẫn sử dụng',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Đây là nội dung hướng dẫn sử dụng cho tính năng Chính sách giá:',
),
const SizedBox(height: AppSpacing.md),
_buildInfoItem(
'Chọn tab "Chính sách giá" để xem các chính sách giá hiện hành',
),
_buildInfoItem(
'Chọn tab "Bảng giá" để tải về bảng giá chi tiết sản phẩm',
),
_buildInfoItem('Nhấn nút "Tải về" để download file PDF/Excel'),
_buildInfoItem('Các bảng giá được cập nhật định kỳ hàng tháng'),
_buildInfoItem('Liên hệ sales để được tư vấn giá tốt nhất'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Đóng'),
),
],
),
);
}
Widget _buildInfoItem(String text) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('', style: TextStyle(fontSize: 16)),
Expanded(child: Text(text)),
],
),
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/features/price_policy/data/datasources/price_policy_local_datasource.dart';
import 'package:worker/features/price_policy/data/repositories/price_policy_repository_impl.dart';
import 'package:worker/features/price_policy/domain/entities/price_document.dart';
import 'package:worker/features/price_policy/domain/repositories/price_policy_repository.dart';
part 'price_documents_provider.g.dart';
/// Provider for local data source
@riverpod
PricePolicyLocalDataSource pricePolicyLocalDataSource(Ref ref) {
return PricePolicyLocalDataSource();
}
/// Provider for price policy repository
@riverpod
PricePolicyRepository pricePolicyRepository(Ref ref) {
final localDataSource = ref.watch(pricePolicyLocalDataSourceProvider);
return PricePolicyRepositoryImpl(localDataSource: localDataSource);
}
/// Provider for all price policy documents
@riverpod
Future<List<PriceDocument>> priceDocuments(Ref ref) async {
final repository = ref.watch(pricePolicyRepositoryProvider);
return repository.getAllDocuments();
}
/// Provider for filtered documents by category
@riverpod
Future<List<PriceDocument>> filteredPriceDocuments(
Ref ref,
DocumentCategory category,
) async {
final repository = ref.watch(pricePolicyRepositoryProvider);
return repository.getDocumentsByCategory(category);
}

View File

@@ -0,0 +1,254 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'price_documents_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for local data source
@ProviderFor(pricePolicyLocalDataSource)
const pricePolicyLocalDataSourceProvider =
PricePolicyLocalDataSourceProvider._();
/// Provider for local data source
final class PricePolicyLocalDataSourceProvider
extends
$FunctionalProvider<
PricePolicyLocalDataSource,
PricePolicyLocalDataSource,
PricePolicyLocalDataSource
>
with $Provider<PricePolicyLocalDataSource> {
/// Provider for local data source
const PricePolicyLocalDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'pricePolicyLocalDataSourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pricePolicyLocalDataSourceHash();
@$internal
@override
$ProviderElement<PricePolicyLocalDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
PricePolicyLocalDataSource create(Ref ref) {
return pricePolicyLocalDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(PricePolicyLocalDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<PricePolicyLocalDataSource>(value),
);
}
}
String _$pricePolicyLocalDataSourceHash() =>
r'dd1bee761fa7f050835508cf33bf34a788829483';
/// Provider for price policy repository
@ProviderFor(pricePolicyRepository)
const pricePolicyRepositoryProvider = PricePolicyRepositoryProvider._();
/// Provider for price policy repository
final class PricePolicyRepositoryProvider
extends
$FunctionalProvider<
PricePolicyRepository,
PricePolicyRepository,
PricePolicyRepository
>
with $Provider<PricePolicyRepository> {
/// Provider for price policy repository
const PricePolicyRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'pricePolicyRepositoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pricePolicyRepositoryHash();
@$internal
@override
$ProviderElement<PricePolicyRepository> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
PricePolicyRepository create(Ref ref) {
return pricePolicyRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(PricePolicyRepository value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<PricePolicyRepository>(value),
);
}
}
String _$pricePolicyRepositoryHash() =>
r'296555a45936d8e43a28bf5add5e7db40495009c';
/// Provider for all price policy documents
@ProviderFor(priceDocuments)
const priceDocumentsProvider = PriceDocumentsProvider._();
/// Provider for all price policy documents
final class PriceDocumentsProvider
extends
$FunctionalProvider<
AsyncValue<List<PriceDocument>>,
List<PriceDocument>,
FutureOr<List<PriceDocument>>
>
with
$FutureModifier<List<PriceDocument>>,
$FutureProvider<List<PriceDocument>> {
/// Provider for all price policy documents
const PriceDocumentsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'priceDocumentsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$priceDocumentsHash();
@$internal
@override
$FutureProviderElement<List<PriceDocument>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<PriceDocument>> create(Ref ref) {
return priceDocuments(ref);
}
}
String _$priceDocumentsHash() => r'cf2ccf6bd9aaae0c56ab01529fd034a090d99263';
/// Provider for filtered documents by category
@ProviderFor(filteredPriceDocuments)
const filteredPriceDocumentsProvider = FilteredPriceDocumentsFamily._();
/// Provider for filtered documents by category
final class FilteredPriceDocumentsProvider
extends
$FunctionalProvider<
AsyncValue<List<PriceDocument>>,
List<PriceDocument>,
FutureOr<List<PriceDocument>>
>
with
$FutureModifier<List<PriceDocument>>,
$FutureProvider<List<PriceDocument>> {
/// Provider for filtered documents by category
const FilteredPriceDocumentsProvider._({
required FilteredPriceDocumentsFamily super.from,
required DocumentCategory super.argument,
}) : super(
retry: null,
name: r'filteredPriceDocumentsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$filteredPriceDocumentsHash();
@override
String toString() {
return r'filteredPriceDocumentsProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<List<PriceDocument>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<PriceDocument>> create(Ref ref) {
final argument = this.argument as DocumentCategory;
return filteredPriceDocuments(ref, argument);
}
@override
bool operator ==(Object other) {
return other is FilteredPriceDocumentsProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$filteredPriceDocumentsHash() =>
r'8f5b2ed822694b4dd9523e1a61e202a7ba0c1fbc';
/// Provider for filtered documents by category
final class FilteredPriceDocumentsFamily extends $Family
with
$FunctionalFamilyOverride<
FutureOr<List<PriceDocument>>,
DocumentCategory
> {
const FilteredPriceDocumentsFamily._()
: super(
retry: null,
name: r'filteredPriceDocumentsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Provider for filtered documents by category
FilteredPriceDocumentsProvider call(DocumentCategory category) =>
FilteredPriceDocumentsProvider._(argument: category, from: this);
@override
String toString() => r'filteredPriceDocumentsProvider';
}

View File

@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/ui_constants.dart';
import '../../../../core/theme/colors.dart';
import '../../domain/entities/price_document.dart';
/// Document card widget displaying price policy or price list document
class DocumentCard extends StatelessWidget {
final PriceDocument document;
final VoidCallback onDownload;
const DocumentCard({
super.key,
required this.document,
required this.onDownload,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.grey100),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onDownload,
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: LayoutBuilder(
builder: (context, constraints) {
// Responsive layout: column on mobile, row on larger screens
final isNarrow = constraints.maxWidth < 600;
if (isNarrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_buildIcon(),
const SizedBox(width: AppSpacing.md),
Expanded(child: _buildInfo()),
],
),
const SizedBox(height: AppSpacing.md),
SizedBox(
width: double.infinity,
child: _buildDownloadButton(),
),
],
);
}
return Row(
children: [
_buildIcon(),
const SizedBox(width: AppSpacing.md),
Expanded(child: _buildInfo()),
const SizedBox(width: AppSpacing.md),
_buildDownloadButton(),
],
);
},
),
),
),
),
);
}
Widget _buildIcon() {
final iconData = document.isPdf ? Icons.picture_as_pdf : Icons.table_chart;
final iconColor = document.isPdf
? Colors.red.shade600
: Colors.green.shade600;
return Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: AppColors.grey50,
borderRadius: BorderRadius.circular(8),
),
child: Icon(iconData, size: 28, color: iconColor),
);
}
Widget _buildInfo() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
document.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
const SizedBox(height: 4),
Row(
children: [
const Icon(
Icons.calendar_today,
size: 13,
color: AppColors.grey500,
),
const SizedBox(width: 4),
Text(
document.formattedDateWithPrefix,
style: const TextStyle(fontSize: 13, color: AppColors.grey500),
),
if (document.fileSize != null) ...[
const SizedBox(width: 8),
const Text(
'',
style: TextStyle(fontSize: 13, color: AppColors.grey500),
),
const SizedBox(width: 8),
Text(
document.fileSize!,
style: const TextStyle(fontSize: 13, color: AppColors.grey500),
),
],
],
),
const SizedBox(height: 6),
Text(
document.description,
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
height: 1.4,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
);
}
Widget _buildDownloadButton() {
return ElevatedButton.icon(
onPressed: onDownload,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
icon: const Icon(Icons.download, size: 18),
label: const Text(
'Tải về',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
);
}
}