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

@@ -20,6 +20,7 @@ import 'package:worker/features/products/presentation/pages/product_detail_page.
import 'package:worker/features/products/presentation/pages/products_page.dart';
import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart';
import 'package:worker/features/quotes/presentation/pages/quotes_page.dart';
import 'package:worker/features/price_policy/price_policy.dart';
/// App Router
///
@@ -189,6 +190,16 @@ class AppRouter {
),
),
// Price Policy Route
GoRoute(
path: RouteNames.pricePolicy,
name: RouteNames.pricePolicy,
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const PricePolicyPage(),
),
),
// TODO: Add more routes as features are implemented
],
@@ -308,6 +319,9 @@ class RouteNames {
static const String promotionDetail = '/promotions/:id';
static const String notifications = '/notifications';
// Price Policy Route
static const String pricePolicy = '/price-policy';
// Chat Route
static const String chat = '/chat';

View File

@@ -151,6 +151,29 @@ class HomePage extends ConsumerWidget {
],
),
// Orders & Payments Section
QuickActionSection(
title: 'Đơn hàng & thanh toán',
actions: [
QuickAction(
icon: Icons.description,
label: 'Chính sách giá',
onTap: () => context.push(RouteNames.pricePolicy),
),
QuickAction(
icon: Icons.inventory_2,
label: 'Đơn hàng',
onTap: () => context.push(RouteNames.orders),
),
QuickAction(
icon: Icons.receipt_long,
label: 'Thanh toán',
onTap: () =>
context.push(RouteNames.payments)
),
],
),
// Loyalty Section
QuickActionSection(
title: 'Khách hàng thân thiết',
@@ -174,28 +197,7 @@ class HomePage extends ConsumerWidget {
],
),
// Orders & Payments Section
QuickActionSection(
title: 'Đơn hàng & thanh toán',
actions: [
QuickAction(
icon: Icons.description,
label: 'Yêu cầu báo giá',
onTap: () => context.push(RouteNames.quotes),
),
QuickAction(
icon: Icons.inventory_2,
label: 'Đơn hàng',
onTap: () => context.push(RouteNames.orders),
),
QuickAction(
icon: Icons.receipt_long,
label: 'Thanh toán',
onTap: () =>
context.push(RouteNames.payments)
),
],
),
// Sample Houses & News Section
QuickActionSection(
@@ -212,11 +214,11 @@ class HomePage extends ConsumerWidget {
onTap: () =>
_showComingSoon(context, 'Đăng ký dự án', l10n),
),
QuickAction(
icon: Icons.article,
label: 'Tin tức',
onTap: () => _showComingSoon(context, 'Tin tức', l10n),
),
// QuickAction(
// icon: Icons.article,
// label: 'Tin tức',
// onTap: () => _showComingSoon(context, 'Tin tức', l10n),
// ),
],
),

View File

@@ -83,26 +83,70 @@ class QuickActionSection extends StatelessWidget {
}
Widget _buildActionGrid() {
return GridView.builder(
padding: EdgeInsets.zero, // Remove default GridView padding
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // Always 3 columns to match HTML
childAspectRatio: 1.0,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
// Determine grid columns based on item count
// If 2 items: 2 columns (no scroll, rectangular aspect ratio)
// If 3 items: 3 columns (no scroll)
// If more than 3: 3 columns (scrollable horizontally)
final int crossAxisCount = actions.length == 2 ? 2 : 3;
final bool isScrollable = actions.length > 3;
// Use rectangular aspect ratio for 2 items to reduce height
// 1.5 means width is 1.5x the height (more rectangular/wider)
final double aspectRatio = actions.length == 2 ? 1.5 : 0.85;
if (!isScrollable) {
// Non-scrollable grid for 2 or 3 items
return GridView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: aspectRatio,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: actions.length,
itemBuilder: (context, index) {
final action = actions[index];
return QuickActionItem(
icon: action.icon,
label: action.label,
badge: action.badge,
onTap: action.onTap,
);
},
);
}
// Scrollable horizontal grid for more than 3 items
// Calculate grid height based on number of rows needed
final int rows = (actions.length / crossAxisCount).ceil();
const double itemHeight = 100; // Approximate height of each item
final double gridHeight = (rows * itemHeight) + ((rows - 1) * 8);
return SizedBox(
height: gridHeight,
child: GridView.builder(
padding: EdgeInsets.zero,
scrollDirection: Axis.horizontal,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: 1.0,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: actions.length,
itemBuilder: (context, index) {
final action = actions[index];
return QuickActionItem(
icon: action.icon,
label: action.label,
badge: action.badge,
onTap: action.onTap,
);
},
),
itemCount: actions.length,
itemBuilder: (context, index) {
final action = actions[index];
return QuickActionItem(
icon: action.icon,
label: action.label,
badge: action.badge,
onTap: action.onTap,
);
},
);
}
}

View File

@@ -0,0 +1,185 @@
/// Price Policy Local DataSource
///
/// Handles all local data operations for price policy documents.
/// Currently provides mock data for development and testing.
/// Will be extended to use Hive cache when backend API is available.
library;
import 'package:worker/features/price_policy/data/models/price_document_model.dart';
/// Price Policy Local Data Source
///
/// Provides mock data for price policy documents.
/// In production, this will cache data from the remote API.
class PricePolicyLocalDataSource {
/// Get all price policy documents
///
/// Returns a list of all documents from mock data.
/// In production, this will fetch from Hive cache.
Future<List<PriceDocumentModel>> getAllDocuments() async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 300));
return _mockDocuments;
}
/// Get documents by category
///
/// Returns filtered list of documents matching the [category].
Future<List<PriceDocumentModel>> getDocumentsByCategory(
String category,
) async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 200));
return _mockDocuments
.where((doc) => doc.category.toLowerCase() == category.toLowerCase())
.toList();
}
/// Get a specific document by ID
///
/// Returns the document if found, null otherwise.
Future<PriceDocumentModel?> getDocumentById(String documentId) async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 100));
try {
return _mockDocuments.firstWhere((doc) => doc.id == documentId);
} catch (e) {
return null;
}
}
/// Check if cache is valid
///
/// Returns true if cached data is still valid.
/// Currently always returns false since we're using mock data.
Future<bool> isCacheValid() async {
// TODO: Implement cache validation when using Hive
return false;
}
/// Cache documents locally
///
/// Saves documents to Hive for offline access.
/// Currently not implemented (using mock data).
Future<void> cacheDocuments(List<PriceDocumentModel> documents) async {
// TODO: Implement Hive caching when backend API is ready
}
/// Clear cached documents
///
/// Removes all cached documents from Hive.
/// Currently not implemented (using mock data).
Future<void> clearCache() async {
// TODO: Implement cache clearing when using Hive
}
/// Mock documents matching HTML design
///
/// This data will be replaced with real API data in production.
static final List<PriceDocumentModel> _mockDocuments = [
// Policy documents (Chính sách giá)
const PriceDocumentModel(
id: 'policy-eurotile-10-2025',
title: 'Chính sách giá Eurotile T10/2025',
description:
'Chính sách giá mới nhất cho sản phẩm gạch Eurotile, áp dụng từ tháng 10/2025',
publishedDate: '2025-10-01T00:00:00.000Z',
documentType: 'pdf',
category: 'policy',
downloadUrl: '/documents/policy-eurotile-10-2025.pdf',
fileSize: '2.5 MB',
),
const PriceDocumentModel(
id: 'policy-vasta-10-2025',
title: 'Chính sách giá Vasta Stone T10/2025',
description:
'Chính sách giá đá tự nhiên Vasta Stone, hiệu lực từ tháng 10/2025',
publishedDate: '2025-10-01T00:00:00.000Z',
documentType: 'pdf',
category: 'policy',
downloadUrl: '/documents/policy-vasta-10-2025.pdf',
fileSize: '1.8 MB',
),
const PriceDocumentModel(
id: 'policy-dealer-2025',
title: 'Chính sách chiết khấu đại lý 2025',
description:
'Chương trình chiết khấu và ưu đãi dành cho đại lý, thầu thợ',
publishedDate: '2025-09-15T00:00:00.000Z',
documentType: 'pdf',
category: 'policy',
downloadUrl: '/documents/policy-dealer-2025.pdf',
fileSize: '3.2 MB',
),
const PriceDocumentModel(
id: 'policy-payment-2025',
title: 'Điều kiện thanh toán & giao hàng',
description:
'Điều khoản thanh toán, chính sách giao hàng và bảo hành sản phẩm',
publishedDate: '2025-08-01T00:00:00.000Z',
documentType: 'pdf',
category: 'policy',
downloadUrl: '/documents/policy-payment-2025.pdf',
fileSize: '1.5 MB',
),
// Price list documents (Bảng giá)
const PriceDocumentModel(
id: 'pricelist-granite-2025',
title: 'Bảng giá Gạch Granite Eurotile 2025',
description:
'Bảng giá chi tiết toàn bộ sản phẩm gạch granite, kích thước 60x60, 80x80, 120x120',
publishedDate: '2025-10-01T00:00:00.000Z',
documentType: 'excel',
category: 'priceList',
downloadUrl: '/documents/pricelist-granite-2025.xlsx',
fileSize: '850 KB',
),
const PriceDocumentModel(
id: 'pricelist-ceramic-2025',
title: 'Bảng giá Gạch Ceramic Eurotile 2025',
description: 'Bảng giá gạch ceramic vân gỗ, vân đá, vân xi măng các loại',
publishedDate: '2025-10-01T00:00:00.000Z',
documentType: 'excel',
category: 'priceList',
downloadUrl: '/documents/pricelist-ceramic-2025.xlsx',
fileSize: '720 KB',
),
const PriceDocumentModel(
id: 'pricelist-stone-2025',
title: 'Bảng giá Đá tự nhiên Vasta Stone 2025',
description:
'Bảng giá đá marble, granite tự nhiên nhập khẩu, kích thước tấm lớn',
publishedDate: '2025-10-01T00:00:00.000Z',
documentType: 'excel',
category: 'priceList',
downloadUrl: '/documents/pricelist-stone-2025.xlsx',
fileSize: '950 KB',
),
const PriceDocumentModel(
id: 'pricelist-accessories-2025',
title: 'Bảng giá Phụ kiện & Vật liệu 2025',
description:
'Giá keo dán, chà ron, nẹp nhựa, nẹp inox và các phụ kiện thi công',
publishedDate: '2025-09-15T00:00:00.000Z',
documentType: 'excel',
category: 'priceList',
downloadUrl: '/documents/pricelist-accessories-2025.xlsx',
fileSize: '640 KB',
),
const PriceDocumentModel(
id: 'pricelist-outdoor-2025',
title: 'Bảng giá Gạch Outdoor & Chống trơn 2025',
description:
'Bảng giá sản phẩm outdoor, gạch chống trơn dành cho ngoại thất',
publishedDate: '2025-09-01T00:00:00.000Z',
documentType: 'excel',
category: 'priceList',
downloadUrl: '/documents/pricelist-outdoor-2025.xlsx',
fileSize: '780 KB',
),
];
}

View File

@@ -0,0 +1,158 @@
/// Data Model: Price Document Model
///
/// Data layer model for price policy documents.
/// Handles JSON serialization and conversion to/from domain entity.
library;
import 'package:worker/features/price_policy/domain/entities/price_document.dart';
/// Price Document Model
///
/// Used in the data layer for:
/// - JSON serialization/deserialization from API
/// - Conversion to domain entity
/// - Local storage (if needed)
class PriceDocumentModel {
/// Unique document ID
final String id;
/// Document title
final String title;
/// Document description
final String description;
/// Date the document was published (ISO 8601 string)
final String publishedDate;
/// Type of document (pdf or excel)
final String documentType;
/// Category (policy or priceList)
final String category;
/// URL to download the document
final String downloadUrl;
/// Optional file size display string
final String? fileSize;
/// Constructor
const PriceDocumentModel({
required this.id,
required this.title,
required this.description,
required this.publishedDate,
required this.documentType,
required this.category,
required this.downloadUrl,
this.fileSize,
});
/// Create model from JSON
factory PriceDocumentModel.fromJson(Map<String, dynamic> json) {
return PriceDocumentModel(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
publishedDate: json['published_date'] as String,
documentType: json['document_type'] as String,
category: json['category'] as String,
downloadUrl: json['download_url'] as String,
fileSize: json['file_size'] as String?,
);
}
/// Convert model to JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'published_date': publishedDate,
'document_type': documentType,
'category': category,
'download_url': downloadUrl,
'file_size': fileSize,
};
}
/// Convert model to domain entity
PriceDocument toEntity() {
return PriceDocument(
id: id,
title: title,
description: description,
publishedDate: DateTime.parse(publishedDate),
documentType: _parseDocumentType(documentType),
category: _parseCategory(category),
downloadUrl: downloadUrl,
fileSize: fileSize,
);
}
/// Create model from domain entity
factory PriceDocumentModel.fromEntity(PriceDocument entity) {
return PriceDocumentModel(
id: entity.id,
title: entity.title,
description: entity.description,
publishedDate: entity.publishedDate.toIso8601String(),
documentType: _documentTypeToString(entity.documentType),
category: _categoryToString(entity.category),
downloadUrl: entity.downloadUrl,
fileSize: entity.fileSize,
);
}
/// Parse document type from string
static DocumentType _parseDocumentType(String type) {
switch (type.toLowerCase()) {
case 'pdf':
return DocumentType.pdf;
case 'excel':
return DocumentType.excel;
default:
return DocumentType.pdf;
}
}
/// Parse category from string
static DocumentCategory _parseCategory(String category) {
switch (category.toLowerCase()) {
case 'policy':
return DocumentCategory.policy;
case 'pricelist':
case 'price_list':
return DocumentCategory.priceList;
default:
return DocumentCategory.policy;
}
}
/// Convert document type to string
static String _documentTypeToString(DocumentType type) {
switch (type) {
case DocumentType.pdf:
return 'pdf';
case DocumentType.excel:
return 'excel';
}
}
/// Convert category to string
static String _categoryToString(DocumentCategory category) {
switch (category) {
case DocumentCategory.policy:
return 'policy';
case DocumentCategory.priceList:
return 'priceList';
}
}
@override
String toString() {
return 'PriceDocumentModel(id: $id, title: $title, category: $category, '
'documentType: $documentType, publishedDate: $publishedDate)';
}
}

View File

@@ -0,0 +1,134 @@
/// Repository Implementation: Price Policy Repository
///
/// Concrete implementation of the PricePolicyRepository interface.
/// Coordinates between local and remote data sources to provide price policy data.
///
/// Currently uses mock data from local datasource.
/// Will implement offline-first strategy when backend API is available.
library;
import 'package:worker/features/price_policy/data/datasources/price_policy_local_datasource.dart';
import 'package:worker/features/price_policy/domain/entities/price_document.dart';
import 'package:worker/features/price_policy/domain/repositories/price_policy_repository.dart';
/// Price Policy Repository Implementation
///
/// Responsibilities:
/// - Coordinate between local cache and remote API (when available)
/// - Convert data models to domain entities
/// - Handle errors gracefully
/// - Manage cache invalidation
class PricePolicyRepositoryImpl implements PricePolicyRepository {
/// Local data source
final PricePolicyLocalDataSource localDataSource;
/// Remote data source (API) - TODO: Add when API is ready
// final PricePolicyRemoteDataSource remoteDataSource;
/// Constructor
PricePolicyRepositoryImpl({
required this.localDataSource,
// required this.remoteDataSource, // TODO: Add when API ready
});
@override
Future<List<PriceDocument>> getAllDocuments() async {
try {
// TODO: Implement offline-first strategy
// 1. Check if cache is valid
// 2. Return cached data if valid
// 3. If cache invalid, fetch from remote
// For now, get from local datasource (mock data)
final models = await localDataSource.getAllDocuments();
// Convert models to entities
final entities = models.map((model) => model.toEntity()).toList();
// Sort by published date (newest first)
entities.sort((a, b) => b.publishedDate.compareTo(a.publishedDate));
return entities;
} catch (e) {
// Log error and return empty list
// In production, this should throw proper domain failures
print('[PricePolicyRepository] Error getting documents: $e');
return [];
}
}
@override
Future<List<PriceDocument>> getDocumentsByCategory(
DocumentCategory category,
) async {
try {
// Convert category to string for datasource
final categoryString = _categoryToString(category);
// Get documents from local datasource
final models = await localDataSource.getDocumentsByCategory(
categoryString,
);
// Convert models to entities
final entities = models.map((model) => model.toEntity()).toList();
// Sort by published date (newest first)
entities.sort((a, b) => b.publishedDate.compareTo(a.publishedDate));
return entities;
} catch (e) {
print('[PricePolicyRepository] Error getting documents by category: $e');
return [];
}
}
@override
Future<PriceDocument?> getDocumentById(String documentId) async {
try {
// Get document from local datasource
final model = await localDataSource.getDocumentById(documentId);
// Convert model to entity
return model?.toEntity();
} catch (e) {
print('[PricePolicyRepository] Error getting document by id: $e');
return null;
}
}
@override
Future<List<PriceDocument>> refreshDocuments() async {
try {
// TODO: Implement remote fetch when API is available
// 1. Fetch from remote API
// 2. Cache the results locally
// 3. Return fresh data
// For now, just clear and refetch from local
await localDataSource.clearCache();
final models = await localDataSource.getAllDocuments();
// Convert models to entities
final entities = models.map((model) => model.toEntity()).toList();
// Sort by published date (newest first)
entities.sort((a, b) => b.publishedDate.compareTo(a.publishedDate));
return entities;
} catch (e) {
print('[PricePolicyRepository] Error refreshing documents: $e');
return [];
}
}
/// Helper method to convert category enum to string
String _categoryToString(DocumentCategory category) {
switch (category) {
case DocumentCategory.policy:
return 'policy';
case DocumentCategory.priceList:
return 'priceList';
}
}
}

View File

@@ -0,0 +1,166 @@
/// Domain Entity: Price Document
///
/// Pure business entity representing a price policy or price list document.
/// This entity is framework-independent and contains only business logic.
library;
/// Price policy document entity
class PriceDocument {
/// Unique document ID
final String id;
/// Document title
final String title;
/// Document description
final String description;
/// Date the document was published
final DateTime publishedDate;
/// Type of document (PDF or Excel)
final DocumentType documentType;
/// Category (policy or price list)
final DocumentCategory category;
/// URL to download the document
final String downloadUrl;
/// Optional file size display string
final String? fileSize;
/// Constructor
const PriceDocument({
required this.id,
required this.title,
required this.description,
required this.publishedDate,
required this.documentType,
required this.category,
required this.downloadUrl,
this.fileSize,
});
/// Check if document is a PDF
bool get isPdf => documentType == DocumentType.pdf;
/// Check if document is an Excel file
bool get isExcel => documentType == DocumentType.excel;
/// Check if document is a policy document
bool get isPolicy => category == DocumentCategory.policy;
/// Check if document is a price list
bool get isPriceList => category == DocumentCategory.priceList;
/// Get formatted published date (dd/MM/yyyy)
String get formattedDate {
return '${publishedDate.day.toString().padLeft(2, '0')}/'
'${publishedDate.month.toString().padLeft(2, '0')}/'
'${publishedDate.year}';
}
/// Get formatted date with prefix based on category
String get formattedDateWithPrefix {
final prefix = isPolicy ? 'Công bố' : 'Cập nhật';
return '$prefix: $formattedDate';
}
/// Get icon name based on document type
String get iconName => documentType == DocumentType.pdf ? 'PDF' : 'Excel';
/// Copy with method for immutability
PriceDocument copyWith({
String? id,
String? title,
String? description,
DateTime? publishedDate,
DocumentType? documentType,
DocumentCategory? category,
String? downloadUrl,
String? fileSize,
}) {
return PriceDocument(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
publishedDate: publishedDate ?? this.publishedDate,
documentType: documentType ?? this.documentType,
category: category ?? this.category,
downloadUrl: downloadUrl ?? this.downloadUrl,
fileSize: fileSize ?? this.fileSize,
);
}
/// Equality operator
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PriceDocument &&
other.id == id &&
other.title == title &&
other.description == description &&
other.publishedDate == publishedDate &&
other.documentType == documentType &&
other.category == category &&
other.downloadUrl == downloadUrl &&
other.fileSize == fileSize;
}
/// Hash code
@override
int get hashCode {
return Object.hash(
id,
title,
description,
publishedDate,
documentType,
category,
downloadUrl,
fileSize,
);
}
/// String representation
@override
String toString() {
return 'PriceDocument(id: $id, title: $title, description: $description, '
'publishedDate: $publishedDate, documentType: $documentType, '
'category: $category, downloadUrl: $downloadUrl, fileSize: $fileSize)';
}
}
/// Document type enum
enum DocumentType { pdf, excel }
/// Document category enum
enum DocumentCategory {
policy, // Chính sách giá
priceList, // Bảng giá
}
// Extension for display
extension DocumentTypeX on DocumentType {
String get displayName {
switch (this) {
case DocumentType.pdf:
return 'PDF';
case DocumentType.excel:
return 'Excel';
}
}
}
extension DocumentCategoryX on DocumentCategory {
String get displayName {
switch (this) {
case DocumentCategory.policy:
return 'Chính sách giá';
case DocumentCategory.priceList:
return 'Bảng giá';
}
}
}

View File

@@ -0,0 +1,50 @@
/// Domain Repository Interface: Price Policy Repository
///
/// Defines the contract for price policy document data operations.
/// This is an abstract interface following the Repository Pattern.
///
/// The actual implementation will be in the data layer.
/// This allows for dependency inversion and easier testing.
library;
import 'package:worker/features/price_policy/domain/entities/price_document.dart';
/// Price Policy Repository Interface
///
/// Provides methods to:
/// - Get all price policy documents
/// - Filter documents by category
/// - Fetch individual document details
///
/// Implementation will be in data/repositories/price_policy_repository_impl.dart
abstract class PricePolicyRepository {
/// Get all price policy documents
///
/// Returns list of [PriceDocument] objects.
/// Returns empty list if no documents available.
///
/// This should fetch from local cache first, then sync with server.
/// Documents should be ordered by published date (newest first).
Future<List<PriceDocument>> getAllDocuments();
/// Get documents filtered by category
///
/// Returns list of [PriceDocument] objects matching the [category].
/// Returns empty list if no matching documents.
///
/// [category] - The category to filter by (policy or priceList)
Future<List<PriceDocument>> getDocumentsByCategory(DocumentCategory category);
/// Get a specific document by ID
///
/// Returns [PriceDocument] if found, null otherwise.
///
/// [documentId] - The unique identifier of the document
Future<PriceDocument?> getDocumentById(String documentId);
/// Refresh documents from server
///
/// Force refresh documents from remote source.
/// Updates local cache after successful fetch.
Future<List<PriceDocument>> refreshDocuments();
}

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

View File

@@ -0,0 +1,12 @@
/// Price Policy Feature Barrel Export
///
/// Provides easy access to all price policy feature components.
library;
// Domain
export 'domain/entities/price_document.dart';
// Presentation
export 'presentation/pages/price_policy_page.dart';
export 'presentation/widgets/document_card.dart';
export 'presentation/providers/price_documents_provider.dart';