Compare commits

..

2 Commits

Author SHA1 Message Date
Phuoc Nguyen
ce7396f729 add start/business unit 2025-11-07 13:56:51 +07:00
Phuoc Nguyen
3803bd26e0 add auth, format 2025-11-07 11:52:06 +07:00
177 changed files with 9180 additions and 7116 deletions

View File

@@ -13,7 +13,7 @@ You are a Riverpod 3.0 expert specializing in:
- Modern code generation with `@riverpod` annotation
- Creating providers with Notifier, AsyncNotifier, and StreamNotifier
- Creating providers with NotifierProvider, AsyncNotifierProvider, and StreamNotifier
- Implementing proper state management patterns

View File

@@ -1,235 +0,0 @@
# Domain Entities Summary
This document provides an overview of all domain entities created based on the database schema.
## Created Domain Entities by Feature
### 1. Auth Feature (/lib/features/auth/domain/entities/)
- **user.dart** - User account with authentication and loyalty information
- Enums: UserRole, UserStatus, LoyaltyTier
- Supporting class: CompanyInfo
- **user_session.dart** - Active user session with device information
### 2. Products Feature (/lib/features/products/domain/entities/)
- **product.dart** - Product catalog item (UPDATED to match database schema)
- Product with images, specifications, 360 view, ERPNext integration
- **stock_level.dart** - Inventory stock level for products
- Available, reserved, and ordered quantities
- **category.dart** - Product category (existing)
### 3. Cart Feature (/lib/features/cart/domain/entities/)
- **cart.dart** - Shopping cart
- Cart-level totals and sync status
- **cart_item.dart** - Individual cart item
- Product reference, quantity, pricing
### 4. Orders Feature (/lib/features/orders/domain/entities/)
- **order.dart** - Customer order
- Enums: OrderStatus
- Supporting class: Address
- **order_item.dart** - Order line item
- Product, quantity, pricing, discounts
- **invoice.dart** - Invoice for orders
- Enums: InvoiceType, InvoiceStatus
- Payment tracking
- **payment_line.dart** - Payment transaction
- Enums: PaymentMethod, PaymentStatus
- Bank details, receipts
### 5. Loyalty Feature (/lib/features/loyalty/domain/entities/)
- **loyalty_point_entry.dart** - Points transaction
- Enums: EntryType, EntrySource, ComplaintStatus
- Points earned/spent tracking
- **gift_catalog.dart** - Redeemable gift catalog
- Enums: GiftCategory
- Availability and validity tracking
- **redeemed_gift.dart** - User-redeemed gift
- Enums: GiftStatus
- Voucher codes, QR codes, expiry
- **points_record.dart** - User-submitted invoice for points
- Enums: PointsStatus
- Invoice submission and approval
### 6. Projects Feature (/lib/features/projects/domain/entities/)
- **project_submission.dart** - Completed project submission
- Enums: ProjectType, SubmissionStatus
- Before/after photos, points earning
- **design_request.dart** - Design consultation request
- Enums: DesignStatus
- Project requirements, designer assignment
### 7. Quotes Feature (/lib/features/quotes/domain/entities/)
- **quote.dart** - Price quotation
- Enums: QuoteStatus
- Supporting class: DeliveryAddress
- **quote_item.dart** - Quote line item
- Price negotiation, discounts
### 8. Chat Feature (/lib/features/chat/domain/entities/)
- **chat_room.dart** - Chat conversation room
- Enums: RoomType
- Participants, context (order/quote)
- **message.dart** - Chat message
- Enums: ContentType
- Attachments, read status, product references
### 9. Notifications Feature (/lib/features/notifications/domain/entities/)
- **notification.dart** - User notification
- Type-based notifications (order, loyalty, promotion, system)
- Read status, push delivery
### 10. Showrooms Feature (/lib/features/showrooms/domain/entities/)
- **showroom.dart** - Virtual showroom display
- 360 views, gallery, metadata
- **showroom_product.dart** - Product used in showroom
- Quantity tracking
### 11. Account Feature (/lib/features/account/domain/entities/)
- **payment_reminder.dart** - Invoice payment reminder
- Enums: ReminderType
- Scheduling and delivery tracking
- **audit_log.dart** - System audit trail
- Action tracking, change history
### 12. Home Feature (/lib/features/home/domain/entities/)
- **member_card.dart** - Membership card (existing)
- Enums: MemberTier, MemberType
- **promotion.dart** - Promotion (existing)
## Entity Design Patterns
All entities follow these consistent patterns:
### Immutability
- All fields are `final`
- Constructor uses `const` when possible
- `copyWith()` method for creating modified copies
### Value Equality
- Proper `==` operator override
- `hashCode` override using `Object.hash()`
- `toString()` method for debugging
### Business Logic
- Computed properties (getters) for derived values
- Boolean checks (e.g., `isActive`, `isExpired`)
- Helper methods for common operations
### Type Safety
- Enums for status/type fields with `displayName` getters
- Nullable types (`?`) where appropriate
- List/Map types for collections and JSONB fields
### Documentation
- Comprehensive documentation comments
- Feature-level context
- Field descriptions
## Database Field Mapping
All entities map 1:1 with database schema:
- `varchar``String` or `String?`
- `numeric``double`
- `integer``int`
- `boolean``bool`
- `timestamp``DateTime`
- `date``DateTime`
- `jsonb``Map<String, dynamic>` or typed classes
- `inet``String?`
- `text``String?`
- Arrays → `List<T>`
## Enums Created
### Auth
- UserRole (customer, sales, admin, accountant, designer)
- UserStatus (pending, active, suspended, rejected)
- LoyaltyTier (none, gold, platinum, diamond)
### Orders
- OrderStatus (draft, confirmed, processing, ready, shipped, delivered, completed, cancelled, returned)
- InvoiceType (standard, proforma, creditNote, debitNote)
- InvoiceStatus (draft, submitted, partiallyPaid, paid, overdue, cancelled)
- PaymentMethod (cash, bankTransfer, creditCard, ewallet, check, other)
- PaymentStatus (pending, processing, completed, failed, refunded, cancelled)
### Loyalty
- EntryType (earn, redeem, adjustment, expiry)
- EntrySource (order, referral, redemption, project, pointsRecord, manual, birthday, welcome, other)
- ComplaintStatus (none, submitted, reviewing, approved, rejected)
- GiftCategory (voucher, product, service, discount, other)
- GiftStatus (active, used, expired, cancelled)
- PointsStatus (pending, approved, rejected)
### Projects
- ProjectType (residential, commercial, industrial, infrastructure, other)
- SubmissionStatus (pending, reviewing, approved, rejected)
- DesignStatus (pending, assigned, inProgress, completed, cancelled)
### Quotes
- QuoteStatus (draft, sent, accepted, rejected, expired, converted)
### Chat
- RoomType (direct, group, support, order, quote)
- ContentType (text, image, file, product, system)
### Account
- ReminderType (initial, dueDate, firstOverdue, secondOverdue, finalWarning)
## Key Features
### ERPNext Integration
Many entities include ERPNext reference fields:
- `erpnextCustomerId` (User)
- `erpnextItemCode` (Product)
- `erpnextSalesOrder` (Order)
- `erpnextInvoice` (Invoice)
- `erpnextPaymentEntry` (PaymentLine)
- `erpnextQuotation` (Quote)
- `erpnextEntryId` (LoyaltyPointEntry)
### Address Handling
Reusable address classes for different contexts:
- `Address` (Order entity)
- `DeliveryAddress` (Quote entity)
- Both with JSON serialization support
### Attachment Management
List-based attachment fields for multiple files:
- User attachments (ID cards, licenses)
- Project photos (before/after)
- Points record invoices
- Design request references
- Chat message files
### Audit Trail Support
- Creation timestamps (`createdAt`)
- Update timestamps (`updatedAt`)
- User tracking (`createdBy`, `processedBy`, `reviewedBy`)
- Change tracking (AuditLog entity)
## Next Steps
These domain entities are ready for:
1. Data layer model creation (extending entities with JSON serialization)
2. Repository interface definitions
3. Use case implementation
4. Provider/state management integration
All entities follow clean architecture principles with no external dependencies.

View File

@@ -1,138 +0,0 @@
# Final Provider Fix - Riverpod 3.0 Compatibility
## ✅ Issue Resolved
The provider was updated to work with the latest Riverpod 3.0 code generation.
## 🔧 Changes Made
### Before (Custom Ref Types)
```dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../domain/entities/price_document.dart';
part 'price_documents_provider.g.dart';
@riverpod
List<PriceDocument> priceDocuments(PriceDocumentsRef ref) {
return _mockDocuments;
}
@riverpod
List<PriceDocument> filteredPriceDocuments(
FilteredPriceDocumentsRef ref,
DocumentCategory category,
) {
final allDocs = ref.watch(priceDocumentsProvider);
return allDocs.where((doc) => doc.category == category).toList();
}
```
**Issue**: Using custom ref types `PriceDocumentsRef` and `FilteredPriceDocumentsRef` which are not compatible with Riverpod 3.0 generated code.
### After (Standard Ref Type) ✅
```dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../domain/entities/price_document.dart';
part 'price_documents_provider.g.dart';
@riverpod
List<PriceDocument> priceDocuments(Ref ref) {
return _mockDocuments;
}
@riverpod
List<PriceDocument> filteredPriceDocuments(
Ref ref,
DocumentCategory category,
) {
final allDocs = ref.watch(priceDocumentsProvider);
return allDocs.where((doc) => doc.category == category).toList();
}
```
**Solution**: Use the standard `Ref` type from `riverpod_annotation` package.
## 📋 Key Points
### 1. **Ref Type Usage**
- ✅ Use `Ref` from `riverpod_annotation` (NOT custom types)
- ✅ Works with both simple and family providers
- ✅ Compatible with Riverpod 3.0 code generation
### 2. **Generated Code**
The build runner now generates Riverpod 3.0 compatible code:
```dart
// New Riverpod 3.0 pattern
final class PriceDocumentsProvider
extends $FunctionalProvider<List<PriceDocument>, ...>
with $Provider<List<PriceDocument>> {
// ...
}
```
This is the **correct** generated format for Riverpod 3.0+.
### 3. **Pattern Matches Project Convention**
Other providers in the project using the same pattern:
-`lib/features/loyalty/presentation/providers/gifts_provider.dart`
-`lib/features/favorites/presentation/providers/favorites_provider.dart`
## ✅ What Works Now
### Basic Provider
```dart
// Provider definition
@riverpod
List<PriceDocument> priceDocuments(Ref ref) {
return _mockDocuments;
}
// Usage in widget
final documents = ref.watch(priceDocumentsProvider);
```
### Family Provider (with parameter)
```dart
// Provider definition
@riverpod
List<PriceDocument> filteredPriceDocuments(
Ref ref,
DocumentCategory category,
) {
final allDocs = ref.watch(priceDocumentsProvider);
return allDocs.where((doc) => doc.category == category).toList();
}
// Usage in widget
final policyDocs = ref.watch(
filteredPriceDocumentsProvider(DocumentCategory.policy),
);
```
## 📁 Files Updated
1.`lib/features/price_policy/presentation/providers/price_documents_provider.dart`
- Changed `PriceDocumentsRef``Ref`
- Changed `FilteredPriceDocumentsRef``Ref`
- Removed redundant imports
2.`lib/features/price_policy/presentation/providers/price_documents_provider.g.dart`
- Auto-generated by build_runner with Riverpod 3.0 format
3.`lib/features/price_policy/domain/entities/price_document.freezed.dart`
- Auto-generated by build_runner with latest Freezed format
## 🎯 Result
The Price Policy feature now:
- ✅ Uses correct Riverpod 3.0 syntax
- ✅ Matches project conventions
- ✅ Compiles without errors
- ✅ Works with both simple and family providers
- ✅ Fully compatible with latest code generation
## 🚀 Ready to Use!
The provider is now production-ready and follows all Riverpod 3.0 best practices.

View File

@@ -1,400 +0,0 @@
# Hive CE Data Models - Completion Summary
## ✅ All Models Created Successfully!
A total of **25 Hive CE data models** have been created for the Worker mobile app, covering all features from the database schema.
## 📊 Created Models by Feature
### 1. Authentication (2 models)
-`user_model.dart` - Type ID: 0
- Maps to `users` table
- Includes: user info, loyalty tier, points, company info, referral
-`user_session_model.dart` - Type ID: 1
- Maps to `user_sessions` table
- Includes: session management, device info, tokens
### 2. Products (2 models)
-`product_model.dart` - Type ID: 2
- Maps to `products` table
- Includes: product details, images, specifications, pricing
-`stock_level_model.dart` - Type ID: 3
- Maps to `stock_levels` table
- Includes: inventory tracking, warehouse info
### 3. Cart (2 models)
-`cart_model.dart` - Type ID: 4
- Maps to `carts` table
- Includes: cart totals, sync status
-`cart_item_model.dart` - Type ID: 5
- Maps to `cart_items` table
- Includes: product items, quantities, prices
### 4. Orders (4 models)
-`order_model.dart` - Type ID: 6
- Maps to `orders` table
- Includes: order details, status, addresses, delivery
-`order_item_model.dart` - Type ID: 7
- Maps to `order_items` table
- Includes: line items, discounts
-`invoice_model.dart` - Type ID: 8
- Maps to `invoices` table
- Includes: invoice details, payment status, amounts
-`payment_line_model.dart` - Type ID: 9
- Maps to `payment_lines` table
- Includes: payment records, methods, receipts
### 5. Loyalty (4 models)
-`loyalty_point_entry_model.dart` - Type ID: 10
- Maps to `loyalty_point_entries` table
- Includes: points transactions, balance, complaints
-`gift_catalog_model.dart` - Type ID: 11
- Maps to `gift_catalog` table
- Includes: available rewards, points cost
-`redeemed_gift_model.dart` - Type ID: 12
- Maps to `redeemed_gifts` table
- Includes: user gifts, vouchers, QR codes
-`points_record_model.dart` - Type ID: 13
- Maps to `points_records` table
- Includes: invoice submissions for points
### 6. Projects (2 models)
-`project_submission_model.dart` - Type ID: 14
- Maps to `project_submissions` table
- Includes: project photos, review status
-`design_request_model.dart` - Type ID: 15
- Maps to `design_requests` table
- Includes: design requirements, assignments
### 7. Quotes (2 models)
-`quote_model.dart` - Type ID: 16
- Maps to `quotes` table
- Includes: quotation details, validity, conversion
-`quote_item_model.dart` - Type ID: 17
- Maps to `quote_items` table
- Includes: quoted products, negotiated prices
### 8. Chat (2 models)
-`chat_room_model.dart` - Type ID: 18
- Maps to `chat_rooms` table
- Includes: room info, participants, related entities
-`message_model.dart` - Type ID: 19
- Maps to `chat_messages` table
- Includes: message content, attachments, read status
### 9. Notifications (1 model)
-`notification_model.dart` - Type ID: 20
- Maps to `notifications` table
- Includes: notification type, data, read status
### 10. Showrooms (2 models)
-`showroom_model.dart` - Type ID: 21
- Maps to `showrooms` table
- Includes: showroom details, images, 360 view
-`showroom_product_model.dart` - Type ID: 22
- Maps to `showroom_products` table
- Includes: products used in showrooms
### 11. Account (2 models)
-`payment_reminder_model.dart` - Type ID: 23
- Maps to `payment_reminders` table
- Includes: reminder scheduling, status
-`audit_log_model.dart` - Type ID: 24
- Maps to `audit_logs` table
- Includes: user actions, changes tracking
## 🎯 Enum Types Created (21 enums)
All enum types are defined in `/Users/ssg/project/worker/lib/core/database/models/enums.dart`:
- UserRole (Type ID: 30)
- UserStatus (Type ID: 31)
- LoyaltyTier (Type ID: 32)
- OrderStatus (Type ID: 33)
- InvoiceType (Type ID: 34)
- InvoiceStatus (Type ID: 35)
- PaymentMethod (Type ID: 36)
- PaymentStatus (Type ID: 37)
- EntryType (Type ID: 38)
- EntrySource (Type ID: 39)
- ComplaintStatus (Type ID: 40)
- GiftCategory (Type ID: 41)
- GiftStatus (Type ID: 42)
- PointsStatus (Type ID: 43)
- ProjectType (Type ID: 44)
- SubmissionStatus (Type ID: 45)
- DesignStatus (Type ID: 46)
- QuoteStatus (Type ID: 47)
- RoomType (Type ID: 48)
- ContentType (Type ID: 49)
- ReminderType (Type ID: 50)
## 📦 Model Features
Each model includes:
-`@HiveType` annotation with unique Type ID
-`@HiveField` annotations for all fields
-`fromJson()` factory constructor for API deserialization
-`toJson()` method for API serialization
- ✅ Helper methods for JSONB fields (get as Map/List)
- ✅ Computed properties and validation
-`copyWith()` method for immutability
-`toString()` override
- ✅ Equality operators (`==` and `hashCode`)
- ✅ Comprehensive documentation
## 🚀 Next Steps
### 1. Generate Type Adapters
Run the Hive code generator to create `.g.dart` files:
```bash
cd /Users/ssg/project/worker
dart run build_runner build --delete-conflicting-outputs
```
This will generate adapter files for all models and enums.
### 2. Register Adapters
Create or update Hive initialization file (e.g., `lib/core/database/hive_service.dart`):
```dart
import 'package:hive_ce/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';
// Import all generated adapters
import 'package:worker/core/database/models/enums.dart';
import 'package:worker/features/auth/data/models/user_model.dart';
import 'package:worker/features/auth/data/models/user_session_model.dart';
// ... import all other models
class HiveService {
static Future<void> init() async {
await Hive.initFlutter();
// Register all enum adapters
Hive.registerAdapter(UserRoleAdapter());
Hive.registerAdapter(UserStatusAdapter());
Hive.registerAdapter(LoyaltyTierAdapter());
Hive.registerAdapter(OrderStatusAdapter());
Hive.registerAdapter(InvoiceTypeAdapter());
Hive.registerAdapter(InvoiceStatusAdapter());
Hive.registerAdapter(PaymentMethodAdapter());
Hive.registerAdapter(PaymentStatusAdapter());
Hive.registerAdapter(EntryTypeAdapter());
Hive.registerAdapter(EntrySourceAdapter());
Hive.registerAdapter(ComplaintStatusAdapter());
Hive.registerAdapter(GiftCategoryAdapter());
Hive.registerAdapter(GiftStatusAdapter());
Hive.registerAdapter(PointsStatusAdapter());
Hive.registerAdapter(ProjectTypeAdapter());
Hive.registerAdapter(SubmissionStatusAdapter());
Hive.registerAdapter(DesignStatusAdapter());
Hive.registerAdapter(QuoteStatusAdapter());
Hive.registerAdapter(RoomTypeAdapter());
Hive.registerAdapter(ContentTypeAdapter());
Hive.registerAdapter(ReminderTypeAdapter());
// Register all model adapters
Hive.registerAdapter(UserModelAdapter());
Hive.registerAdapter(UserSessionModelAdapter());
Hive.registerAdapter(ProductModelAdapter());
Hive.registerAdapter(StockLevelModelAdapter());
Hive.registerAdapter(CartModelAdapter());
Hive.registerAdapter(CartItemModelAdapter());
Hive.registerAdapter(OrderModelAdapter());
Hive.registerAdapter(OrderItemModelAdapter());
Hive.registerAdapter(InvoiceModelAdapter());
Hive.registerAdapter(PaymentLineModelAdapter());
Hive.registerAdapter(LoyaltyPointEntryModelAdapter());
Hive.registerAdapter(GiftCatalogModelAdapter());
Hive.registerAdapter(RedeemedGiftModelAdapter());
Hive.registerAdapter(PointsRecordModelAdapter());
Hive.registerAdapter(ProjectSubmissionModelAdapter());
Hive.registerAdapter(DesignRequestModelAdapter());
Hive.registerAdapter(QuoteModelAdapter());
Hive.registerAdapter(QuoteItemModelAdapter());
Hive.registerAdapter(ChatRoomModelAdapter());
Hive.registerAdapter(MessageModelAdapter());
Hive.registerAdapter(NotificationModelAdapter());
Hive.registerAdapter(ShowroomModelAdapter());
Hive.registerAdapter(ShowroomProductModelAdapter());
Hive.registerAdapter(PaymentReminderModelAdapter());
Hive.registerAdapter(AuditLogModelAdapter());
// Open boxes
await Hive.openBox(HiveBoxNames.userBox);
await Hive.openBox(HiveBoxNames.productBox);
await Hive.openBox(HiveBoxNames.cartBox);
await Hive.openBox(HiveBoxNames.orderBox);
await Hive.openBox(HiveBoxNames.loyaltyBox);
await Hive.openBox(HiveBoxNames.projectBox);
await Hive.openBox(HiveBoxNames.notificationBox);
// ... open all other boxes
}
}
```
### 3. Create Datasources
Implement local datasources using these models:
```dart
// Example: lib/features/auth/data/datasources/auth_local_datasource.dart
class AuthLocalDataSource {
final Box userBox;
Future<void> cacheUser(UserModel user) async {
await userBox.put(HiveKeys.currentUser, user);
}
UserModel? getCachedUser() {
return userBox.get(HiveKeys.currentUser) as UserModel?;
}
}
```
### 4. Update Repository Implementations
Use models in repository implementations for caching:
```dart
class ProductRepositoryImpl implements ProductRepository {
final ProductRemoteDataSource remoteDataSource;
final ProductLocalDataSource localDataSource;
@override
Future<List<Product>> getProducts() async {
try {
// Try to fetch from API
final products = await remoteDataSource.getProducts();
// Cache locally
await localDataSource.cacheProducts(products);
return products;
} catch (e) {
// Return cached data on error
return localDataSource.getCachedProducts();
}
}
}
```
## 📁 File Structure
```
lib/features/
├── auth/data/models/
│ ├── user_model.dart ✅
│ ├── user_model.g.dart (generated)
│ ├── user_session_model.dart ✅
│ └── user_session_model.g.dart (generated)
├── products/data/models/
│ ├── product_model.dart ✅
│ ├── product_model.g.dart (generated)
│ ├── stock_level_model.dart ✅
│ └── stock_level_model.g.dart (generated)
├── cart/data/models/
│ ├── cart_model.dart ✅
│ ├── cart_model.g.dart (generated)
│ ├── cart_item_model.dart ✅
│ └── cart_item_model.g.dart (generated)
├── orders/data/models/
│ ├── order_model.dart ✅
│ ├── order_model.g.dart (generated)
│ ├── order_item_model.dart ✅
│ ├── order_item_model.g.dart (generated)
│ ├── invoice_model.dart ✅
│ ├── invoice_model.g.dart (generated)
│ ├── payment_line_model.dart ✅
│ └── payment_line_model.g.dart (generated)
├── loyalty/data/models/
│ ├── loyalty_point_entry_model.dart ✅
│ ├── loyalty_point_entry_model.g.dart (generated)
│ ├── gift_catalog_model.dart ✅
│ ├── gift_catalog_model.g.dart (generated)
│ ├── redeemed_gift_model.dart ✅
│ ├── redeemed_gift_model.g.dart (generated)
│ ├── points_record_model.dart ✅
│ └── points_record_model.g.dart (generated)
├── projects/data/models/
│ ├── project_submission_model.dart ✅
│ ├── project_submission_model.g.dart (generated)
│ ├── design_request_model.dart ✅
│ └── design_request_model.g.dart (generated)
├── quotes/data/models/
│ ├── quote_model.dart ✅
│ ├── quote_model.g.dart (generated)
│ ├── quote_item_model.dart ✅
│ └── quote_item_model.g.dart (generated)
├── chat/data/models/
│ ├── chat_room_model.dart ✅
│ ├── chat_room_model.g.dart (generated)
│ ├── message_model.dart ✅
│ └── message_model.g.dart (generated)
├── notifications/data/models/
│ ├── notification_model.dart ✅
│ └── notification_model.g.dart (generated)
├── showrooms/data/models/
│ ├── showroom_model.dart ✅
│ ├── showroom_model.g.dart (generated)
│ ├── showroom_product_model.dart ✅
│ └── showroom_product_model.g.dart (generated)
└── account/data/models/
├── payment_reminder_model.dart ✅
├── payment_reminder_model.g.dart (generated)
├── audit_log_model.dart ✅
└── audit_log_model.g.dart (generated)
```
## ⚠️ Important Notes
### Type ID Management
- All Type IDs are unique across the app (0-24 for models, 30-50 for enums)
- Never change a Type ID once assigned - it will break existing cached data
- Type IDs are centrally managed in `/Users/ssg/project/worker/lib/core/constants/storage_constants.dart`
### JSONB Field Handling
- All JSONB fields from database are stored as JSON-encoded strings in Hive
- Helper methods provide easy access to parsed data (e.g., `companyInfoMap`, `participantsList`)
- Always use try-catch when decoding JSON fields
### DateTime Support
- Hive CE natively supports DateTime
- No need for custom serialization
- Use `DateTime.parse()` for JSON and `toIso8601String()` for API
### Best Practices
- Always extend `HiveObject` for automatic key management
- Use sequential field numbering (0, 1, 2, ...)
- Include comprehensive documentation
- Implement helper methods for computed properties
- Handle null values appropriately
## 🎉 Summary
-**25 data models** created
-**21 enum types** defined
-**All database tables** mapped
- ✅ Complete **JSON serialization/deserialization**
- ✅ Comprehensive **helper methods**
- ✅ Full **documentation**
-**Type-safe** implementations
-**Clean architecture** compliant
The Worker app now has a complete, production-ready Hive CE local database implementation for offline-first functionality and API response caching!
## 📚 Reference Documents
- `HIVE_MODELS_REFERENCE.md` - Detailed reference and templates
- `/Users/ssg/project/worker/database.md` - Original database schema
- `/Users/ssg/project/worker/lib/core/constants/storage_constants.dart` - Type IDs and constants
- `/Users/ssg/project/worker/lib/core/database/models/enums.dart` - All enum definitions
---
**Generated**: 2025-10-24
**Status**: ✅ Complete and ready for code generation

View File

@@ -1,423 +0,0 @@
# Hive CE Data Models Reference
This document provides a complete reference for all Hive CE data models in the Worker app, mapped to the database schema.
## Summary of Created Models
### ✅ Completed Models
1. **UserModel** (`lib/features/auth/data/models/user_model.dart`) - Type ID: 0
2. **UserSessionModel** (`lib/features/auth/data/models/user_session_model.dart`) - Type ID: 1
3. **ProductModel** (`lib/features/products/data/models/product_model.dart`) - Type ID: 2
4. **StockLevelModel** (`lib/features/products/data/models/stock_level_model.dart`) - Type ID: 3
### 📋 Models To Be Created
The following model files need to be created following the same pattern:
#### Cart Models
- `lib/features/cart/data/models/cart_model.dart` - Type ID: 4
- `lib/features/cart/data/models/cart_item_model.dart` - Type ID: 5
#### Order Models
- `lib/features/orders/data/models/order_model.dart` - Type ID: 6
- `lib/features/orders/data/models/order_item_model.dart` - Type ID: 7
- `lib/features/orders/data/models/invoice_model.dart` - Type ID: 8
- `lib/features/orders/data/models/payment_line_model.dart` - Type ID: 9
#### Loyalty Models
- `lib/features/loyalty/data/models/loyalty_point_entry_model.dart` - Type ID: 10
- `lib/features/loyalty/data/models/gift_catalog_model.dart` - Type ID: 11
- `lib/features/loyalty/data/models/redeemed_gift_model.dart` - Type ID: 12
- `lib/features/loyalty/data/models/points_record_model.dart` - Type ID: 13
#### Project Models
- `lib/features/projects/data/models/project_submission_model.dart` - Type ID: 14
- `lib/features/projects/data/models/design_request_model.dart` - Type ID: 15
#### Quote Models
- `lib/features/quotes/data/models/quote_model.dart` - Type ID: 16
- `lib/features/quotes/data/models/quote_item_model.dart` - Type ID: 17
#### Chat Models
- `lib/features/chat/data/models/chat_room_model.dart` - Type ID: 18
- `lib/features/chat/data/models/message_model.dart` - Type ID: 19
#### Other Models
- `lib/features/notifications/data/models/notification_model.dart` - Type ID: 20
- `lib/features/showrooms/data/models/showroom_model.dart` - Type ID: 21
- `lib/features/showrooms/data/models/showroom_product_model.dart` - Type ID: 22
- `lib/features/account/data/models/payment_reminder_model.dart` - Type ID: 23
- `lib/features/account/data/models/audit_log_model.dart` - Type ID: 24
## Model Template
Each Hive model should follow this structure:
```dart
import 'dart:convert';
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/core/database/models/enums.dart'; // If using enums
part 'model_name.g.dart';
/// Model Name
///
/// Hive CE model for caching [entity] data locally.
/// Maps to the '[table_name]' table in the database.
///
/// Type ID: [X]
@HiveType(typeId: HiveTypeIds.modelName)
class ModelName extends HiveObject {
ModelName({
required this.id,
required this.field1,
this.field2,
// ... other fields
});
/// Primary key
@HiveField(0)
final String id;
/// Field description
@HiveField(1)
final String field1;
/// Optional field description
@HiveField(2)
final String? field2;
// Add @HiveField for each database column with sequential numbering
// =========================================================================
// JSON SERIALIZATION
// =========================================================================
factory ModelName.fromJson(Map<String, dynamic> json) {
return ModelName(
id: json['id'] as String,
field1: json['field_1'] as String,
field2: json['field_2'] as String?,
// Map all fields from JSON (snake_case keys)
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'field_1': field1,
'field_2': field2,
// Map all fields to JSON (snake_case keys)
};
}
// =========================================================================
// HELPER METHODS
// =========================================================================
// Add helper methods for:
// - Parsing JSONB fields (use jsonEncode/jsonDecode)
// - Computed properties
// - Validation checks
// - Formatting methods
// =========================================================================
// COPY WITH
// =========================================================================
ModelName copyWith({
String? id,
String? field1,
String? field2,
}) {
return ModelName(
id: id ?? this.id,
field1: field1 ?? this.field1,
field2: field2 ?? this.field2,
);
}
@override
String toString() {
return 'ModelName(id: $id, field1: $field1)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ModelName && other.id == id;
}
@override
int get hashCode => id.hashCode;
}
```
## Important Notes
### JSONB Field Handling
For JSONB fields in the database, store as String in Hive using `jsonEncode`:
```dart
// In model:
@HiveField(X)
final String? jsonbField;
// In fromJson:
jsonbField: json['jsonb_field'] != null
? jsonEncode(json['jsonb_field'])
: null,
// In toJson:
'jsonb_field': jsonbField != null ? jsonDecode(jsonbField!) : null,
// Helper method:
Map<String, dynamic>? get jsonbFieldMap {
if (jsonbField == null) return null;
try {
return jsonDecode(jsonbField!) as Map<String, dynamic>;
} catch (e) {
return null;
}
}
```
### Enum Handling
Use the enums defined in `lib/core/database/models/enums.dart`:
```dart
// In model:
@HiveField(X)
final OrderStatus status;
// In fromJson:
status: OrderStatus.values.firstWhere(
(e) => e.name == (json['status'] as String),
orElse: () => OrderStatus.pending,
),
// In toJson:
'status': status.name,
```
### DateTime Handling
Hive CE supports DateTime natively:
```dart
// In model:
@HiveField(X)
final DateTime createdAt;
@HiveField(Y)
final DateTime? updatedAt;
// In fromJson:
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'] as String)
: null,
// In toJson:
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
```
### Numeric Fields
Handle numeric precision:
```dart
// For numeric(12,2) fields:
@HiveField(X)
final double amount;
// In fromJson:
amount: (json['amount'] as num).toDouble(),
```
### Array Fields (Lists)
For JSONB arrays, store as JSON string:
```dart
// In model:
@HiveField(X)
final String? participants; // JSONB array
// Helper:
List<String>? get participantsList {
if (participants == null) return null;
try {
final decoded = jsonDecode(participants!) as List;
return decoded.map((e) => e.toString()).toList();
} catch (e) {
return null;
}
}
```
## Database Table to Model Mapping
| Table Name | Model Name | Type ID | Feature | Status |
|------------|------------|---------|---------|--------|
| users | UserModel | 0 | auth | ✅ Created |
| user_sessions | UserSessionModel | 1 | auth | ✅ Created |
| products | ProductModel | 2 | products | ✅ Created |
| stock_levels | StockLevelModel | 3 | products | ✅ Created |
| carts | CartModel | 4 | cart | 📋 To Do |
| cart_items | CartItemModel | 5 | cart | 📋 To Do |
| orders | OrderModel | 6 | orders | 📋 To Do |
| order_items | OrderItemModel | 7 | orders | 📋 To Do |
| invoices | InvoiceModel | 8 | orders | 📋 To Do |
| payment_lines | PaymentLineModel | 9 | orders | 📋 To Do |
| loyalty_point_entries | LoyaltyPointEntryModel | 10 | loyalty | 📋 To Do |
| gift_catalog | GiftCatalogModel | 11 | loyalty | 📋 To Do |
| redeemed_gifts | RedeemedGiftModel | 12 | loyalty | 📋 To Do |
| points_records | PointsRecordModel | 13 | loyalty | 📋 To Do |
| project_submissions | ProjectSubmissionModel | 14 | projects | 📋 To Do |
| design_requests | DesignRequestModel | 15 | projects | 📋 To Do |
| quotes | QuoteModel | 16 | quotes | 📋 To Do |
| quote_items | QuoteItemModel | 17 | quotes | 📋 To Do |
| chat_rooms | ChatRoomModel | 18 | chat | 📋 To Do |
| chat_messages | MessageModel | 19 | chat | 📋 To Do |
| notifications | NotificationModel | 20 | notifications | 📋 To Do |
| showrooms | ShowroomModel | 21 | showrooms | 📋 To Do |
| showroom_products | ShowroomProductModel | 22 | showrooms | 📋 To Do |
| payment_reminders | PaymentReminderModel | 23 | account | 📋 To Do |
| audit_logs | AuditLogModel | 24 | account | 📋 To Do |
## Enum Type IDs (30-59)
All enum types are defined in `lib/core/database/models/enums.dart`:
| Enum Name | Type ID | Values |
|-----------|---------|--------|
| UserRole | 30 | customer, distributor, admin, staff |
| UserStatus | 31 | active, inactive, suspended, pending |
| LoyaltyTier | 32 | bronze, silver, gold, platinum, diamond, titan |
| OrderStatus | 33 | draft, pending, confirmed, processing, shipped, delivered, completed, cancelled, refunded |
| InvoiceType | 34 | sales, proforma, creditNote, debitNote |
| InvoiceStatus | 35 | draft, issued, partiallyPaid, paid, overdue, cancelled, refunded |
| PaymentMethod | 36 | cash, bankTransfer, creditCard, debitCard, eWallet, cheque, creditTerm |
| PaymentStatus | 37 | pending, processing, completed, failed, refunded, cancelled |
| EntryType | 38 | earn, redeem, adjustment, expiry, refund |
| EntrySource | 39 | purchase, referral, promotion, bonus, giftRedemption, projectSubmission, pointsRecord, manualAdjustment |
| ComplaintStatus | 40 | none, pending, investigating, resolved, rejected |
| GiftCategory | 41 | voucher, product, service, discount, experience |
| GiftStatus | 42 | active, used, expired, cancelled |
| PointsStatus | 43 | pending, approved, rejected |
| ProjectType | 44 | residential, commercial, industrial, infrastructure, renovation, interior, exterior |
| SubmissionStatus | 45 | pending, reviewing, approved, rejected, needsRevision |
| DesignStatus | 46 | pending, assigned, inProgress, reviewing, completed, cancelled, onHold |
| QuoteStatus | 47 | draft, sent, viewed, accepted, rejected, expired, converted, cancelled |
| RoomType | 48 | support, sales, orderInquiry, quoteDiscussion, general |
| ContentType | 49 | text, image, file, video, audio, productReference, orderReference, quoteReference |
| ReminderType | 50 | beforeDue, dueDate, overdue, final |
## Code Generation
After creating all models, run the Hive code generator:
```bash
# Generate adapter files for all models
dart run build_runner build --delete-conflicting-outputs
# OR watch for changes
dart run build_runner watch --delete-conflicting-outputs
```
This will generate `.g.dart` files for all models with the `@HiveType` annotation.
## Hive Box Registration
Register all type adapters in the main initialization:
```dart
// In lib/core/database/hive_service.dart or similar
Future<void> initializeHive() async {
await Hive.initFlutter();
// Register enum adapters
Hive.registerAdapter(UserRoleAdapter());
Hive.registerAdapter(UserStatusAdapter());
Hive.registerAdapter(LoyaltyTierAdapter());
Hive.registerAdapter(OrderStatusAdapter());
// ... register all enum adapters
// Register model adapters
Hive.registerAdapter(UserModelAdapter());
Hive.registerAdapter(UserSessionModelAdapter());
Hive.registerAdapter(ProductModelAdapter());
Hive.registerAdapter(StockLevelModelAdapter());
// ... register all model adapters
// Open boxes
await Hive.openBox(HiveBoxNames.userBox);
await Hive.openBox(HiveBoxNames.productBox);
await Hive.openBox(HiveBoxNames.cartBox);
// ... open all boxes
}
```
## Next Steps
1. Create all remaining model files following the template above
2. Ensure all typeIds are unique and match HiveTypeIds constants
3. Run code generation: `dart run build_runner build --delete-conflicting-outputs`
4. Register all adapters in Hive initialization
5. Test model serialization/deserialization
6. Create datasource implementations to use these models
## File Paths Reference
```
lib/features/
├── auth/data/models/
│ ├── user_model.dart ✅
│ └── user_session_model.dart ✅
├── products/data/models/
│ ├── product_model.dart ✅
│ └── stock_level_model.dart ✅
├── cart/data/models/
│ ├── cart_model.dart 📋
│ └── cart_item_model.dart 📋
├── orders/data/models/
│ ├── order_model.dart 📋
│ ├── order_item_model.dart 📋
│ ├── invoice_model.dart 📋
│ └── payment_line_model.dart 📋
├── loyalty/data/models/
│ ├── loyalty_point_entry_model.dart 📋
│ ├── gift_catalog_model.dart 📋
│ ├── redeemed_gift_model.dart 📋
│ └── points_record_model.dart 📋
├── projects/data/models/
│ ├── project_submission_model.dart 📋
│ └── design_request_model.dart 📋
├── quotes/data/models/
│ ├── quote_model.dart 📋
│ └── quote_item_model.dart 📋
├── chat/data/models/
│ ├── chat_room_model.dart 📋
│ └── message_model.dart 📋
├── notifications/data/models/
│ └── notification_model.dart 📋
├── showrooms/data/models/
│ ├── showroom_model.dart 📋
│ └── showroom_product_model.dart 📋
└── account/data/models/
├── payment_reminder_model.dart 📋
└── audit_log_model.dart 📋
```
---
**Legend:**
- ✅ Created and complete
- 📋 To be created following the template

View File

@@ -1,522 +0,0 @@
# Hive CE Database Setup - Worker App
## Overview
The Worker Flutter app now has a complete Hive CE (Community Edition) database setup for offline-first functionality. This document provides a comprehensive summary of the setup.
## What Was Created
### 1. Core Files
#### `/lib/core/constants/storage_constants.dart`
Centralized constants for Hive database:
- **HiveBoxNames**: All box names (13 boxes total)
- **HiveTypeIds**: Type adapter IDs (0-223 range, 32 IDs assigned)
- **HiveKeys**: Storage keys for settings and cache
- **CacheDuration**: Default cache expiration times
- **HiveDatabaseConfig**: Database configuration settings
#### `/lib/core/database/hive_service.dart`
Main database service handling:
- Hive initialization and configuration
- Type adapter registration (auto-generated)
- Box opening and management
- Database migrations and versioning
- Encryption support
- Maintenance and compaction
- Cleanup operations
#### `/lib/core/database/database_manager.dart`
High-level database operations:
- Generic CRUD operations
- Cache management with expiration
- Sync state tracking
- Settings operations
- Offline queue management
- Database statistics
#### `/lib/core/database/hive_initializer.dart`
Simple initialization API:
- Easy app startup initialization
- Database reset functionality
- Logout data cleanup
- Statistics helpers
#### `/lib/core/database/database.dart`
Export file for convenient imports
### 2. Models
#### `/lib/core/database/models/enums.dart`
Type adapters for all enums (10 enums):
- `MemberTier` - Loyalty tiers (Gold, Platinum, Diamond)
- `UserType` - User categories (Contractor, Architect, Distributor, Broker)
- `OrderStatus` - Order states (6 statuses)
- `ProjectStatus` - Project states (5 statuses)
- `ProjectType` - Project categories (5 types)
- `TransactionType` - Loyalty transaction types (8 types)
- `GiftStatus` - Gift/reward states (5 statuses)
- `PaymentStatus` - Payment states (6 statuses)
- `NotificationType` - Notification categories (7 types)
- `PaymentMethod` - Payment methods (6 methods)
Each enum includes:
- Extension methods for display names
- Helper properties for state checking
- Vietnamese localization
#### `/lib/core/database/models/cached_data.dart`
Generic cache wrapper model:
- Wraps any data type with timestamp
- Expiration tracking
- Freshness checking
- Cache age calculation
### 3. Generated Files
#### `/lib/hive_registrar.g.dart` (Auto-generated)
Automatic adapter registration extension:
- Registers all type adapters automatically
- No manual registration needed
- Updates automatically when new models are added
#### `/lib/core/database/models/*.g.dart` (Auto-generated)
Individual type adapters for each model and enum
### 4. Configuration
#### `pubspec.yaml`
Updated dependencies:
```yaml
dependencies:
hive_ce: ^2.6.0
hive_ce_flutter: ^2.1.0
dev_dependencies:
hive_ce_generator: ^1.6.0
build_runner: ^2.4.11
```
#### `build.yaml`
Build runner configuration for code generation
## Database Architecture
### Box Structure
The app uses 13 Hive boxes organized by functionality:
**Encrypted Boxes (Sensitive Data):**
1. `user_box` - User profile and authentication
2. `cart_box` - Shopping cart items
3. `order_box` - Order history
4. `project_box` - Construction projects
5. `loyalty_box` - Loyalty transactions
6. `address_box` - Delivery addresses
7. `offline_queue_box` - Failed API requests
**Non-Encrypted Boxes:**
8. `product_box` - Product catalog cache
9. `rewards_box` - Rewards catalog
10. `settings_box` - App settings
11. `cache_box` - Generic API cache
12. `sync_state_box` - Sync timestamps
13. `notification_box` - Notifications
### Type ID Allocation
Reserved type IDs (never change once assigned):
**Core Models (0-9):**
- 0: UserModel (TODO)
- 1: ProductModel (TODO)
- 2: CartItemModel (TODO)
- 3: OrderModel (TODO)
- 4: ProjectModel (TODO)
- 5: LoyaltyTransactionModel (TODO)
**Extended Models (10-19):**
- 10: OrderItemModel (TODO)
- 11: AddressModel (TODO)
- 12: CategoryModel (TODO)
- 13: RewardModel (TODO)
- 14: GiftModel (TODO)
- 15: NotificationModel (TODO)
- 16: QuoteModel (TODO)
- 17: PaymentModel (TODO)
- 18: PromotionModel (TODO)
- 19: ReferralModel (TODO)
**Enums (20-29):** ✓ Created
- 20: MemberTier
- 21: UserType
- 22: OrderStatus
- 23: ProjectStatus
- 24: ProjectType
- 25: TransactionType
- 26: GiftStatus
- 27: PaymentStatus
- 28: NotificationType
- 29: PaymentMethod
**Cache & Sync Models (30-39):**
- 30: CachedData ✓ Created
- 31: SyncState (TODO)
- 32: OfflineRequest (TODO)
## How to Use
### 1. Initialize Hive in main.dart
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/database/hive_initializer.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Hive database
await HiveInitializer.initialize(
verbose: true, // Enable logging in debug mode
);
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
```
### 2. Basic Database Operations
```dart
import 'package:worker/core/database/database.dart';
// Get database manager instance
final dbManager = DatabaseManager();
// Save data
await dbManager.save(
boxName: HiveBoxNames.productBox,
key: 'product_123',
value: product,
);
// Get data
final product = dbManager.get(
boxName: HiveBoxNames.productBox,
key: 'product_123',
);
// Get all items
final products = dbManager.getAll(boxName: HiveBoxNames.productBox);
// Delete data
await dbManager.delete(
boxName: HiveBoxNames.productBox,
key: 'product_123',
);
```
### 3. Caching with Expiration
```dart
import 'package:worker/core/database/database.dart';
final dbManager = DatabaseManager();
// Save to cache with timestamp
await dbManager.saveToCache(
key: HiveKeys.productsCacheKey,
data: products,
);
// Get from cache (returns null if expired)
final cachedProducts = dbManager.getFromCache<List<Product>>(
key: HiveKeys.productsCacheKey,
maxAge: CacheDuration.products, // 6 hours
);
if (cachedProducts == null) {
// Cache miss or expired - fetch from API
final freshProducts = await api.getProducts();
await dbManager.saveToCache(
key: HiveKeys.productsCacheKey,
data: freshProducts,
);
}
```
### 4. Offline Queue
```dart
import 'package:worker/core/database/database.dart';
final dbManager = DatabaseManager();
// Add failed request to queue
try {
await api.createOrder(orderData);
} catch (e) {
await dbManager.addToOfflineQueue({
'endpoint': '/api/orders',
'method': 'POST',
'body': orderData,
});
}
// Process queue when back online
final queue = dbManager.getOfflineQueue();
for (var i = 0; i < queue.length; i++) {
try {
await api.request(queue[i]);
await dbManager.removeFromOfflineQueue(i);
} catch (e) {
// Keep in queue for next retry
}
}
```
### 5. Settings Management
```dart
import 'package:worker/core/database/database.dart';
final dbManager = DatabaseManager();
// Save setting
await dbManager.saveSetting(
key: HiveKeys.languageCode,
value: 'vi',
);
// Get setting
final language = dbManager.getSetting<String>(
key: HiveKeys.languageCode,
defaultValue: 'vi',
);
```
### 6. Sync State Tracking
```dart
import 'package:worker/core/database/database.dart';
final dbManager = DatabaseManager();
// Update sync timestamp
await dbManager.updateSyncTime(HiveKeys.productsSyncTime);
// Get last sync time
final lastSync = dbManager.getLastSyncTime(HiveKeys.productsSyncTime);
// Check if needs sync
final needsSync = dbManager.needsSync(
dataType: HiveKeys.productsSyncTime,
syncInterval: Duration(hours: 6),
);
if (needsSync) {
// Perform sync
await syncProducts();
await dbManager.updateSyncTime(HiveKeys.productsSyncTime);
}
```
### 7. Logout (Clear User Data)
```dart
import 'package:worker/core/database/hive_initializer.dart';
// Clear user data while keeping settings and cache
await HiveInitializer.logout();
```
## Creating New Hive Models
### Step 1: Create Model File
```dart
// lib/features/products/data/models/product_model.dart
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
part 'product_model.g.dart';
@HiveType(typeId: HiveTypeIds.product) // Use typeId: 1
class ProductModel extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String name;
@HiveField(2)
final String sku;
@HiveField(3)
final double price;
@HiveField(4)
final List<String> images;
@HiveField(5)
final String categoryId;
@HiveField(6)
final int stock;
ProductModel({
required this.id,
required this.name,
required this.sku,
required this.price,
required this.images,
required this.categoryId,
required this.stock,
});
}
```
### Step 2: Generate Adapter
```bash
dart run build_runner build --delete-conflicting-outputs
```
This automatically:
- Generates `product_model.g.dart` with `ProductModelAdapter`
- Updates `hive_registrar.g.dart` to register the new adapter
- No manual registration needed!
### Step 3: Use the Model
```dart
import 'package:worker/core/database/database.dart';
final product = ProductModel(
id: '123',
name: 'Ceramic Tile',
sku: 'TILE-001',
price: 299000,
images: ['image1.jpg'],
categoryId: 'cat_1',
stock: 100,
);
final dbManager = DatabaseManager();
await dbManager.save(
boxName: HiveBoxNames.productBox,
key: product.id,
value: product,
);
```
## Important Rules
1. **Never change Type IDs** - Once assigned, they are permanent
2. **Never change Field numbers** - Breaks existing data
3. **Run build_runner** after creating/modifying models
4. **Use the auto-generated registrar** - Don't manually register adapters
5. **Always use try-catch** around Hive operations
6. **Check box is open** before accessing it
7. **Use DatabaseManager** for high-level operations
8. **Set appropriate cache durations** for different data types
## Features
**Offline-First**: All data stored locally and synced
**Type-Safe**: Strong typing with generated adapters
**Fast**: Optimized NoSQL database
**Encrypted**: Optional AES encryption for sensitive data
**Auto-Maintenance**: Compaction and cleanup
**Migration Support**: Schema versioning built-in
**Cache Management**: Automatic expiration handling
**Offline Queue**: Failed request retry system
**Sync Tracking**: Data freshness monitoring
**Statistics**: Debug utilities for monitoring
## Next Steps
### To Complete the Database Setup:
1. **Create Model Classes** for the TODO items (typeIds 0-19, 31-32)
2. **Run build_runner** to generate adapters
3. **Implement sync logic** in repository layers
4. **Add encryption** in production (if needed)
5. **Test migrations** when schema changes
6. **Monitor database size** in production
### Models to Create:
Priority 1 (Core):
- UserModel (typeId: 0)
- ProductModel (typeId: 1)
- CartItemModel (typeId: 2)
- OrderModel (typeId: 3)
Priority 2 (Extended):
- ProjectModel (typeId: 4)
- LoyaltyTransactionModel (typeId: 5)
- AddressModel (typeId: 11)
- NotificationModel (typeId: 15)
Priority 3 (Additional):
- OrderItemModel (typeId: 10)
- CategoryModel (typeId: 12)
- RewardModel (typeId: 13)
- GiftModel (typeId: 14)
- QuoteModel (typeId: 16)
- PaymentModel (typeId: 17)
- PromotionModel (typeId: 18)
- ReferralModel (typeId: 19)
## Troubleshooting
### Build Runner Fails
```bash
# Clean and rebuild
flutter clean
flutter pub get
dart run build_runner clean
dart run build_runner build --delete-conflicting-outputs
```
### Box Not Found Error
- Ensure `HiveInitializer.initialize()` is called in main.dart
- Check box name matches `HiveBoxNames` constant
### Adapter Not Registered
- Run build_runner to generate adapters
- Check `hive_registrar.g.dart` includes the adapter
- Ensure `Hive.registerAdapters()` is called in HiveService
### Data Corruption
- Enable backups before migrations
- Test migrations on copy of production data
- Validate data before saving
## Resources
- Hive CE Documentation: https://github.com/IO-Design-Team/hive_ce
- Project README: `/lib/core/database/README.md`
- Storage Constants: `/lib/core/constants/storage_constants.dart`
- Type Adapter Registry: See README.md for complete list
## Summary
The Hive CE database is now fully configured and ready to use. All enum type adapters are created and registered automatically. Future models will follow the same pattern - just create the model file with annotations and run build_runner to generate adapters.
The database supports offline-first functionality with:
- 13 pre-configured boxes
- 32 reserved type IDs
- Auto-generated adapter registration
- Cache management with expiration
- Offline request queuing
- Sync state tracking
- Maintenance and cleanup
Start creating models and building the offline-first features!

View File

@@ -1,159 +0,0 @@
# Hive TypeId Assignment Summary
## Overview
This document provides a complete mapping of all Hive TypeIds used in the Worker Flutter app.
All typeIds are now centralized in `lib/core/constants/storage_constants.dart` to prevent conflicts.
## TypeId Assignments
### Core Models (0-9)
| ID | Constant Name | Model | File Path |
|----|---------------|-------|-----------|
| 0 | userModel | UserModel | lib/features/auth/data/models/user_model.dart |
| 1 | userSessionModel | UserSessionModel | lib/features/auth/data/models/user_session_model.dart |
| 2 | productModel | ProductModel | lib/features/products/data/models/product_model.dart |
| 3 | stockLevelModel | StockLevelModel | lib/features/products/data/models/stock_level_model.dart |
| 4 | cartModel | CartModel | lib/features/cart/data/models/cart_model.dart |
| 5 | cartItemModel | CartItemModel | lib/features/cart/data/models/cart_item_model.dart |
| 6 | orderModel | OrderModel | lib/features/orders/data/models/order_model.dart |
| 7 | orderItemModel | OrderItemModel | lib/features/orders/data/models/order_item_model.dart |
| 8 | invoiceModel | InvoiceModel | lib/features/orders/data/models/invoice_model.dart |
| 9 | paymentLineModel | PaymentLineModel | lib/features/orders/data/models/payment_line_model.dart |
### Loyalty Models (10-13)
| ID | Constant Name | Model | File Path |
|----|---------------|-------|-----------|
| 10 | loyaltyPointEntryModel | LoyaltyPointEntryModel | lib/features/loyalty/data/models/loyalty_point_entry_model.dart |
| 11 | giftCatalogModel | GiftCatalogModel | lib/features/loyalty/data/models/gift_catalog_model.dart |
| 12 | redeemedGiftModel | RedeemedGiftModel | lib/features/loyalty/data/models/redeemed_gift_model.dart |
| 13 | pointsRecordModel | PointsRecordModel | lib/features/loyalty/data/models/points_record_model.dart |
### Project & Quote Models (14-17)
| ID | Constant Name | Model | File Path |
|----|---------------|-------|-----------|
| 14 | projectSubmissionModel | ProjectSubmissionModel | lib/features/projects/data/models/project_submission_model.dart |
| 15 | designRequestModel | DesignRequestModel | lib/features/projects/data/models/design_request_model.dart |
| 16 | quoteModel | QuoteModel | lib/features/quotes/data/models/quote_model.dart |
| 17 | quoteItemModel | QuoteItemModel | lib/features/quotes/data/models/quote_item_model.dart |
### Chat Models (18-19)
| ID | Constant Name | Model | File Path |
|----|---------------|-------|-----------|
| 18 | chatRoomModel | ChatRoomModel | lib/features/chat/data/models/chat_room_model.dart |
| 19 | messageModel | MessageModel | lib/features/chat/data/models/message_model.dart |
### Extended Models (20-29)
| ID | Constant Name | Model | File Path |
|----|---------------|-------|-----------|
| 20 | notificationModel | NotificationModel | lib/features/notifications/data/models/notification_model.dart |
| 21 | showroomModel | ShowroomModel | lib/features/showrooms/data/models/showroom_model.dart |
| 22 | showroomProductModel | ShowroomProductModel | lib/features/showrooms/data/models/showroom_product_model.dart |
| 23 | paymentReminderModel | PaymentReminderModel | lib/features/account/data/models/payment_reminder_model.dart |
| 24 | auditLogModel | AuditLogModel | lib/features/account/data/models/audit_log_model.dart |
| 25 | memberCardModel | MemberCardModel | lib/features/home/data/models/member_card_model.dart |
| 26 | promotionModel | PromotionModel | lib/features/home/data/models/promotion_model.dart |
| 27 | categoryModel | CategoryModel | lib/features/products/data/models/category_model.dart |
| 28 | *AVAILABLE* | - | - |
| 29 | *AVAILABLE* | - | - |
### Enum Types (30-59)
| ID | Constant Name | Enum | File Path |
|----|---------------|------|-----------|
| 30 | userRole | UserRole | lib/core/database/models/enums.dart |
| 31 | userStatus | UserStatus | lib/core/database/models/enums.dart |
| 32 | loyaltyTier | LoyaltyTier | lib/core/database/models/enums.dart |
| 33 | orderStatus | OrderStatus | lib/core/database/models/enums.dart |
| 34 | invoiceType | InvoiceType | lib/core/database/models/enums.dart |
| 35 | invoiceStatus | InvoiceStatus | lib/core/database/models/enums.dart |
| 36 | paymentMethod | PaymentMethod | lib/core/database/models/enums.dart |
| 37 | paymentStatus | PaymentStatus | lib/core/database/models/enums.dart |
| 38 | entryType | EntryType | lib/core/database/models/enums.dart |
| 39 | entrySource | EntrySource | lib/core/database/models/enums.dart |
| 40 | complaintStatus | ComplaintStatus | lib/core/database/models/enums.dart |
| 41 | giftCategory | GiftCategory | lib/core/database/models/enums.dart |
| 42 | giftStatus | GiftStatus | lib/core/database/models/enums.dart |
| 43 | pointsStatus | PointsStatus | lib/core/database/models/enums.dart |
| 44 | projectType | ProjectType | lib/core/database/models/enums.dart |
| 45 | submissionStatus | SubmissionStatus | lib/core/database/models/enums.dart |
| 46 | designStatus | DesignStatus | lib/core/database/models/enums.dart |
| 47 | quoteStatus | QuoteStatus | lib/core/database/models/enums.dart |
| 48 | roomType | RoomType | lib/core/database/models/enums.dart |
| 49 | contentType | ContentType | lib/core/database/models/enums.dart |
| 50 | reminderType | ReminderType | lib/core/database/models/enums.dart |
| 51 | notificationType | NotificationType | lib/core/database/models/enums.dart |
| 52-59 | *AVAILABLE* | - | - |
### Cache & Sync Models (60-69)
| ID | Constant Name | Model | File Path |
|----|---------------|-------|-----------|
| 60 | cachedData | CachedData | lib/core/database/models/cached_data.dart |
| 61 | syncState | SyncState | *Not yet implemented* |
| 62 | offlineRequest | OfflineRequest | *Not yet implemented* |
| 63-69 | *AVAILABLE* | - | - |
## Conflicts Resolved
The following typeIds had conflicts and were reassigned:
### Previous Conflicts (Now Fixed)
1. **TypeId 10** - Was hardcoded in MemberCardModel
- **CONFLICT**: loyaltyPointEntryModel already used 10
- **RESOLUTION**: MemberCardModel moved to typeId 25
2. **TypeId 11** - Was hardcoded in PromotionModel
- **CONFLICT**: giftCatalogModel already used 11
- **RESOLUTION**: PromotionModel moved to typeId 26
3. **TypeId 12** - Was hardcoded in CategoryModel
- **CONFLICT**: redeemedGiftModel already used 12
- **RESOLUTION**: CategoryModel moved to typeId 27
## Important Notes
1. **Never Change TypeIds**: Once a typeId is assigned and used in production, it should NEVER be changed as it will break existing data.
2. **Always Use Constants**: All models must use `HiveTypeIds` constants from `storage_constants.dart`. Never use hardcoded numbers.
3. **TypeId Range**: User-defined types must use typeIds in the range 0-223.
4. **Check Before Adding**: Always check this document and `storage_constants.dart` before adding a new typeId to avoid conflicts.
5. **Aliases**: Some typeIds have aliases for backward compatibility:
- `memberTier``loyaltyTier` (32)
- `userType``userRole` (30)
- `projectStatus``submissionStatus` (45)
- `transactionType``entryType` (38)
## Next Steps
After updating typeIds, you must:
1. ✅ Update storage_constants.dart (COMPLETED)
2. ✅ Update model files to use constants (COMPLETED)
3. ⏳ Regenerate Hive adapters: `flutter pub run build_runner build --delete-conflicting-outputs`
4. ⏳ Test the app to ensure no runtime errors
5. ⏳ Clear existing Hive boxes if necessary (only for development)
## Verification Commands
```bash
# Search for any remaining hardcoded typeIds
grep -r "@HiveType(typeId: [0-9]" lib/
# Verify all models use constants
grep -r "@HiveType(typeId: HiveTypeIds" lib/
# Check for duplicates in storage_constants.dart
grep "static const int" lib/core/constants/storage_constants.dart | awk '{print $5}' | sort | uniq -d
```
## Last Updated
2025-10-24 - Fixed typeId conflicts for MemberCardModel, PromotionModel, and CategoryModel

View File

@@ -1,760 +0,0 @@
# Localization Guide - Worker Mobile App
Complete guide for managing translations and localization in the Worker Mobile App.
## Overview
The Worker app supports **Vietnamese** (primary) and **English** (secondary) languages with **450+ translation keys** covering all UI elements, messages, and user interactions.
### Key Features
- **Primary Language**: Vietnamese (`vi_VN`)
- **Secondary Language**: English (`en_US`)
- **Translation Keys**: 450+ comprehensive translations
- **Auto-generation**: Flutter's `gen-l10n` tool
- **Type Safety**: Fully type-safe localization API
- **Fallback Support**: Automatic fallback to Vietnamese if device locale is unsupported
- **Pluralization**: Full ICU message format support
- **Parameterized Strings**: Support for dynamic values
- **Helper Extensions**: Convenient access utilities
- **Date/Time Formatting**: Locale-specific formatting
## Configuration
### l10n.yaml
```yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-dir: lib/generated/l10n
nullable-getter: false
```
### pubspec.yaml
```yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
flutter:
generate: true
```
## File Structure
```
lib/
l10n/
app_en.arb # English translations (template)
app_vi.arb # Vietnamese translations (PRIMARY)
generated/l10n/ # Auto-generated (DO NOT EDIT)
app_localizations.dart # Generated base class
app_localizations_en.dart # Generated English implementation
app_localizations_vi.dart # Generated Vietnamese implementation
core/
utils/
l10n_extensions.dart # Helper extensions for easy access
l10n.yaml # Localization configuration
LOCALIZATION.md # This guide
```
## Translation Coverage
### Comprehensive Feature Coverage (450+ Keys)
| Category | Keys | Examples |
|----------|------|----------|
| **Authentication** | 25+ | login, phone, verifyOTP, enterOTP, resendOTP, register, logout |
| **Navigation** | 10+ | home, products, loyalty, account, more, backToHome, goToHomePage |
| **Common Actions** | 30+ | save, cancel, delete, edit, search, filter, confirm, apply, clear, refresh, share, copy |
| **Status Labels** | 20+ | pending, processing, shipping, completed, cancelled, active, inactive, expired, draft, sent, accepted, rejected |
| **Form Labels** | 30+ | name, email, password, address, street, city, district, ward, postalCode, company, taxId, dateOfBirth, gender |
| **User Types** | 5+ | contractor, architect, distributor, broker, selectUserType |
| **Loyalty System** | 50+ | points, diamond, platinum, gold, rewards, referral, tierBenefits, pointsMultiplier, specialOffers, exclusiveDiscounts |
| **Products & Shopping** | 35+ | product, price, addToCart, cart, checkout, sku, brand, model, specification, availability, newArrival, bestSeller |
| **Cart & Checkout** | 30+ | cartEmpty, updateQuantity, removeFromCart, clearCart, proceedToCheckout, orderSummary, selectAddress, selectPaymentMethod |
| **Orders & Payments** | 40+ | orders, orderNumber, orderStatus, paymentMethod, deliveryAddress, trackOrder, cancelOrder, orderTimeline, trackingNumber |
| **Projects & Quotes** | 45+ | projects, createProject, quotes, budget, progress, client, location, projectPhotos, projectDocuments, quoteItems |
| **Account & Profile** | 40+ | profile, editProfile, addresses, changePassword, uploadAvatar, passwordStrength, enableNotifications, selectLanguage |
| **Loyalty Transactions** | 20+ | transactionType, earnPoints, redeemPoints, bonusPoints, refundPoints, pointsExpiry, disputeTransaction |
| **Gifts & Rewards** | 25+ | myGifts, activeGifts, usedGifts, expiredGifts, giftDetails, rewardCategory, vouchers, pointsCost, expiryDate |
| **Referral Program** | 15+ | referralInvite, referralReward, shareYourCode, friendRegisters, bothGetRewards, totalReferrals |
| **Validation Messages** | 20+ | fieldRequired, invalidEmail, invalidPhone, passwordTooShort, passwordsNotMatch, incorrectPassword |
| **Error Messages** | 15+ | error, networkError, serverError, sessionExpired, notFound, unauthorized, connectionError, syncFailed |
| **Success Messages** | 15+ | success, savedSuccessfully, updatedSuccessfully, deletedSuccessfully, redeemSuccessful, photoUploaded |
| **Loading States** | 10+ | loading, loadingData, processing, pleaseWait, syncInProgress, syncCompleted |
| **Empty States** | 15+ | noData, noResults, noProductsFound, noOrdersYet, noProjectsYet, noNotifications, noGiftsYet |
| **Date & Time** | 20+ | today, yesterday, thisWeek, thisMonth, dateRange, from, to, minutesAgo, hoursAgo, daysAgo, justNow |
| **Notifications** | 25+ | notifications, markAsRead, markAllAsRead, deleteNotification, clearNotifications, unreadNotifications |
| **Chat** | 20+ | chat, sendMessage, typeMessage, typingIndicator, online, offline, messageRead, messageDelivered |
| **Filters & Sorting** | 15+ | filterBy, sortBy, priceAscending, priceDescending, nameAscending, dateAscending, applyFilters |
| **Offline & Sync** | 15+ | offlineMode, syncData, lastSyncAt, noInternetConnection, checkConnection, retryConnection |
| **Miscellaneous** | 20+ | version, help, aboutUs, privacyPolicy, termsOfService, feedback, rateApp, comingSoon, underMaintenance |
### Special Features
#### Pluralization Support
- `itemsInCart` - 0/1/many items
- `ordersCount` - 0/1/many orders
- `projectsCount` - 0/1/many projects
- `daysRemaining` - 0/1/many days
#### Parameterized Translations
- `welcomeTo(appName)` - Dynamic app name
- `otpSentTo(phone)` - Phone number
- `pointsToNextTier(points, tier)` - Points and tier
- `redeemConfirmMessage(points, reward)` - Redemption confirmation
- `orderNumberIs(orderNumber)` - Order number display
- `estimatedDeliveryDate(date)` - Delivery date
#### Date/Time Formatting
- `formatDate` - DD/MM/YYYY (VI) or MM/DD/YYYY (EN)
- `formatDateTime` - Full date-time with locale
- `minutesAgo`, `hoursAgo`, `daysAgo`, etc. - Relative time
#### Currency Formatting
- `formatCurrency` - Vietnamese Dong (₫) with proper grouping
## Usage Examples
### Basic Usage
```dart
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LoginPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.login),
),
body: Column(
children: [
TextField(
decoration: InputDecoration(
labelText: l10n.phone,
hintText: l10n.enterPhone,
),
),
ElevatedButton(
onPressed: () {},
child: Text(l10n.continueButton),
),
],
),
);
}
}
```
### Using Extension for Cleaner Code (Recommended)
```dart
import 'package:worker/core/utils/l10n_extensions.dart';
class ProductCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Much cleaner than AppLocalizations.of(context)!
return Column(
children: [
Text(context.l10n.product),
Text(context.l10n.price),
ElevatedButton(
onPressed: () {},
child: Text(context.l10n.addToCart),
),
],
);
}
}
```
### Using Helper Utilities
```dart
import 'package:worker/core/utils/l10n_extensions.dart';
class OrderCard extends StatelessWidget {
final Order order;
@override
Widget build(BuildContext context) {
return Card(
child: Column(
children: [
// Format currency
Text(L10nHelper.formatCurrency(context, order.total)),
// Vietnamese: "1.500.000 ₫"
// English: "1,500,000 ₫"
// Format date
Text(L10nHelper.formatDate(context, order.createdAt)),
// Vietnamese: "17/10/2025"
// English: "10/17/2025"
// Relative time
Text(L10nHelper.formatRelativeTime(context, order.createdAt)),
// Vietnamese: "5 phút trước"
// English: "5 minutes ago"
// Status with helper
Text(L10nHelper.getOrderStatus(context, order.status)),
// Returns localized status string
// Item count with pluralization
Text(L10nHelper.formatItemCount(context, order.itemCount)),
// Vietnamese: "3 sản phẩm"
// English: "3 items"
],
),
);
}
}
```
### Parameterized Translations
```dart
// Points balance with parameter
final pointsText = context.l10n.pointsBalance;
// Result: "1,000 điểm" (Vietnamese) or "1,000 points" (English)
// OTP sent message with phone parameter
final message = AppLocalizations.of(context)!.otpSentTo('0912345678');
// Result: "Mã OTP đã được gửi đến 0912345678"
// Points to next tier with multiple parameters
final tierMessage = context.l10n.pointsToNextTier;
// Uses placeholders: {points} and {tier}
```
### Checking Current Language
```dart
import 'package:worker/core/utils/l10n_extensions.dart';
class LanguageIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Language Code: ${context.languageCode}'), // "vi" or "en"
Text('Is Vietnamese: ${context.isVietnamese}'), // true/false
Text('Is English: ${context.isEnglish}'), // true/false
],
);
}
}
```
## Adding New Translations
### Step 1: Add to ARB Files
**lib/l10n/app_en.arb** (English - Template):
```json
{
"newFeature": "New Feature",
"@newFeature": {
"description": "Description of the new feature"
}
}
```
**lib/l10n/app_vi.arb** (Vietnamese):
```json
{
"newFeature": "Tính năng mới",
"@newFeature": {
"description": "Description of the new feature"
}
}
```
### Step 2: Regenerate Localization Files
```bash
flutter gen-l10n
```
### Step 3: Use in Code
```dart
Text(context.l10n.newFeature)
```
## Parameterized Translations
### Simple Parameter
**ARB File:**
```json
{
"welcome": "Welcome, {name}!",
"@welcome": {
"description": "Welcome message",
"placeholders": {
"name": {
"type": "String",
"example": "John"
}
}
}
}
```
**Usage:**
```dart
Text(context.l10n.welcome('John'))
// Result: "Welcome, John!"
```
### Multiple Parameters
**ARB File:**
```json
{
"orderSummary": "Order #{orderNumber} for {amount}",
"@orderSummary": {
"description": "Order summary text",
"placeholders": {
"orderNumber": {
"type": "String",
"example": "12345"
},
"amount": {
"type": "String",
"example": "100,000 ₫"
}
}
}
}
```
**Usage:**
```dart
Text(context.l10n.orderSummary('12345', '100,000 ₫'))
```
### Number Parameters
**ARB File:**
```json
{
"itemCount": "{count} items",
"@itemCount": {
"description": "Number of items",
"placeholders": {
"count": {
"type": "int",
"example": "5"
}
}
}
}
```
**Usage:**
```dart
Text(context.l10n.itemCount(5))
// Result: "5 items"
```
## Pluralization
Flutter's localization supports pluralization with the ICU message format:
**ARB File:**
```json
{
"itemCountPlural": "{count,plural, =0{No items} =1{1 item} other{{count} items}}",
"@itemCountPlural": {
"description": "Item count with pluralization",
"placeholders": {
"count": {
"type": "int"
}
}
}
}
```
**Usage:**
```dart
Text(context.l10n.itemCountPlural(0)) // "No items"
Text(context.l10n.itemCountPlural(1)) // "1 item"
Text(context.l10n.itemCountPlural(5)) // "5 items"
```
## Date & Time Formatting
Use the `intl` package for locale-aware date/time formatting:
```dart
import 'package:intl/intl.dart';
// Format date based on current locale
final now = DateTime.now();
final locale = Localizations.localeOf(context).toString();
// Vietnamese: "17/10/2025"
// English: "10/17/2025"
final dateFormatter = DateFormat.yMd(locale);
final formattedDate = dateFormatter.format(now);
// Vietnamese: "17 tháng 10, 2025"
// English: "October 17, 2025"
final longDateFormatter = DateFormat.yMMMMd(locale);
final formattedLongDate = longDateFormatter.format(now);
```
## Changing Language at Runtime
### Create Language Provider
```dart
// lib/core/providers/language_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
final languageProvider = StateNotifierProvider<LanguageNotifier, Locale>((ref) {
return LanguageNotifier();
});
class LanguageNotifier extends StateNotifier<Locale> {
LanguageNotifier() : super(const Locale('vi', 'VN')) {
_loadSavedLanguage();
}
Future<void> _loadSavedLanguage() async {
final prefs = await SharedPreferences.getInstance();
final languageCode = prefs.getString('language_code') ?? 'vi';
final countryCode = prefs.getString('country_code') ?? 'VN';
state = Locale(languageCode, countryCode);
}
Future<void> setLanguage(Locale locale) async {
state = locale;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('language_code', locale.languageCode);
await prefs.setString('country_code', locale.countryCode ?? '');
}
void setVietnamese() => setLanguage(const Locale('vi', 'VN'));
void setEnglish() => setLanguage(const Locale('en', 'US'));
}
```
### Update WorkerApp
```dart
class WorkerApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final locale = ref.watch(languageProvider);
return MaterialApp(
locale: locale,
// ... other configurations
);
}
}
```
### Language Selector Widget
```dart
class LanguageSelector extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentLocale = ref.watch(languageProvider);
return DropdownButton<Locale>(
value: currentLocale,
items: const [
DropdownMenuItem(
value: Locale('vi', 'VN'),
child: Text('Tiếng Việt'),
),
DropdownMenuItem(
value: Locale('en', 'US'),
child: Text('English'),
),
],
onChanged: (locale) {
if (locale != null) {
ref.read(languageProvider.notifier).setLanguage(locale);
}
},
);
}
}
```
## Best Practices
### 1. Naming Conventions
- Use **camelCase** for translation keys
- Be descriptive but concise
- Group related translations with prefixes (e.g., `order*`, `payment*`)
- Avoid abbreviations
**Good:**
```json
{
"loginButton": "Login",
"orderNumber": "Order Number",
"paymentMethod": "Payment Method"
}
```
**Bad:**
```json
{
"btn_login": "Login",
"ord_num": "Order Number",
"pay_meth": "Payment Method"
}
```
### 2. Reserved Keywords
Avoid Dart reserved keywords as translation keys:
- `continue` → Use `continueButton` instead
- `switch` → Use `switchButton` instead
- `class` → Use `className` instead
- `return` → Use `returnButton` instead
### 3. Context in Descriptions
Always add `@` descriptions to provide context:
```json
{
"save": "Save",
"@save": {
"description": "Button label to save changes"
}
}
```
### 4. Consistent Formatting
Maintain consistent capitalization and punctuation:
**Vietnamese:**
- Sentence case for labels
- No period at the end of single phrases
- Use full Vietnamese diacritics
**English:**
- Title Case for buttons and headers
- Sentence case for descriptions
- Consistent use of punctuation
### 5. Placeholder Examples
Always provide example values for placeholders:
```json
{
"greeting": "Hello, {name}!",
"@greeting": {
"description": "Greeting message",
"placeholders": {
"name": {
"type": "String",
"example": "John"
}
}
}
}
```
## Testing Localizations
### Widget Tests
```dart
testWidgets('Login page shows Vietnamese translations', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('vi', 'VN'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: LoginPage(),
),
);
expect(find.text('Đăng nhập'), findsOneWidget);
expect(find.text('Số điện thoại'), findsOneWidget);
});
testWidgets('Login page shows English translations', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en', 'US'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: LoginPage(),
),
);
expect(find.text('Login'), findsOneWidget);
expect(find.text('Phone Number'), findsOneWidget);
});
```
### Translation Completeness Test
```dart
void main() {
test('All Vietnamese translations match English keys', () {
final enFile = File('lib/l10n/app_en.arb');
final viFile = File('lib/l10n/app_vi.arb');
final enJson = jsonDecode(enFile.readAsStringSync());
final viJson = jsonDecode(viFile.readAsStringSync());
final enKeys = enJson.keys.where((k) => !k.startsWith('@')).toList();
final viKeys = viJson.keys.where((k) => !k.startsWith('@')).toList();
expect(enKeys.length, viKeys.length);
for (final key in enKeys) {
expect(viKeys.contains(key), isTrue, reason: 'Missing key: $key');
}
});
}
```
## Troubleshooting
### Issue: "AppLocalizations not found"
**Solution:** Run the code generator:
```bash
flutter gen-l10n
```
### Issue: "Duplicate keys in ARB file"
**Solution:** Each key must be unique within an ARB file. Check for duplicates.
### Issue: "Invalid placeholder type"
**Solution:** Supported types are: `String`, `num`, `int`, `double`, `DateTime`, `Object`
### Issue: "Translations not updating"
**Solution:**
1. Run `flutter gen-l10n`
2. Hot restart (not hot reload) the app
3. Clear build cache if needed: `flutter clean`
## Translation Workflow
### For Developers
1. **Add English translation** to `app_en.arb`
2. **Add Vietnamese translation** to `app_vi.arb`
3. **Run code generator**: `flutter gen-l10n`
4. **Use in code**: `context.l10n.newKey`
5. **Test both languages**
### For Translators
1. **Review** the English ARB file (`app_en.arb`)
2. **Translate** each key to Vietnamese in `app_vi.arb`
3. **Maintain** the same structure and placeholders
4. **Add** `@key` descriptions if needed
5. **Test** context and meaning
## Resources
- [Flutter Internationalization](https://docs.flutter.dev/development/accessibility-and-localization/internationalization)
- [ARB File Format](https://github.com/google/app-resource-bundle/wiki/ApplicationResourceBundleSpecification)
- [ICU Message Format](https://unicode-org.github.io/icu/userguide/format_parse/messages/)
- [Intl Package](https://pub.dev/packages/intl)
## Translation Statistics
- **Total Translation Keys**: 450+
- **Languages**: 2 (Vietnamese, English)
- **Coverage**: 100% (Both languages fully translated)
- **Parameterized Keys**: 20+
- **Pluralization Keys**: 10+
- **Categories**: 26 major categories
- **Helper Functions**: 15+ utility methods
## Quick Reference Table
| Category | Key Count | Examples |
|----------|-----------|----------|
| Authentication | 25+ | login, phone, verifyOTP, register, logout |
| Navigation | 10+ | home, products, loyalty, account, more |
| Common Actions | 30+ | save, cancel, delete, edit, search, filter |
| Status Labels | 20+ | pending, completed, active, expired |
| Form Labels | 30+ | name, email, address, company, taxId |
| User Types | 5+ | contractor, architect, distributor, broker |
| Loyalty System | 50+ | points, rewards, referral, tierBenefits |
| Products | 35+ | product, price, cart, sku, brand |
| Cart & Checkout | 30+ | cartEmpty, updateQuantity, orderSummary |
| Orders & Payments | 40+ | orderNumber, payment, trackOrder |
| Projects & Quotes | 45+ | projectName, budget, quotes |
| Account & Profile | 40+ | profile, settings, addresses |
| Loyalty Transactions | 20+ | earnPoints, redeemPoints, bonusPoints |
| Gifts & Rewards | 25+ | myGifts, activeGifts, rewardCategory |
| Referral Program | 15+ | referralInvite, shareYourCode |
| Validation Messages | 20+ | fieldRequired, invalidEmail |
| Error Messages | 15+ | error, networkError, sessionExpired |
| Success Messages | 15+ | success, savedSuccessfully |
| Loading States | 10+ | loading, processing, syncInProgress |
| Empty States | 15+ | noData, noResults, noProductsFound |
| Date & Time | 20+ | today, yesterday, minutesAgo |
| Notifications | 25+ | notifications, markAsRead |
| Chat | 20+ | chat, sendMessage, typingIndicator |
| Filters & Sorting | 15+ | filterBy, sortBy, applyFilters |
| Offline & Sync | 15+ | offlineMode, syncData, lastSyncAt |
| Miscellaneous | 20+ | version, help, feedback, comingSoon |
---
## Summary
The Worker app localization system provides:
- **Comprehensive Coverage**: 450+ translation keys across 26 categories
- **Full Bilingual Support**: Vietnamese (primary) and English (secondary)
- **Advanced Features**: Pluralization, parameterization, date/time formatting
- **Developer-Friendly**: Helper extensions and utilities for easy integration
- **Type-Safe**: Flutter's code generation ensures compile-time safety
- **Maintainable**: Clear organization and documentation
### Key Files
- `/Users/ssg/project/worker/lib/l10n/app_vi.arb` - Vietnamese translations
- `/Users/ssg/project/worker/lib/l10n/app_en.arb` - English translations
- `/Users/ssg/project/worker/lib/core/utils/l10n_extensions.dart` - Helper utilities
- `/Users/ssg/project/worker/l10n.yaml` - Configuration
- `/Users/ssg/project/worker/LOCALIZATION.md` - This documentation
---
**Last Updated**: October 17, 2025
**Version**: 1.0.0
**Languages Supported**: Vietnamese (Primary), English (Secondary)
**Total Translation Keys**: 450+
**Maintained By**: Worker App Development Team

View File

@@ -1,485 +0,0 @@
# News Detail Page Implementation Summary
## Overview
Complete implementation of the news detail page following the HTML reference at `html/news-detail.html`. The page displays full article content with HTML rendering, social engagement features, and related articles.
---
## Files Created
### 1. **Highlight Box Widget** (`lib/features/news/presentation/widgets/highlight_box.dart`)
**Purpose**: Display tips and warnings in article content
**Features**:
- Two types: `tip` (lightbulb icon) and `warning` (exclamation icon)
- Yellow/orange gradient background
- Brown text color for contrast
- Rounded corners with border
- Title and content sections
**Usage**:
```dart
HighlightBox(
type: HighlightType.tip,
title: 'Mẹo từ chuyên gia',
content: 'Chọn gạch men vân đá với kích thước lớn...',
)
```
---
### 2. **Related Article Card Widget** (`lib/features/news/presentation/widgets/related_article_card.dart`)
**Purpose**: Display related articles in compact horizontal layout
**Features**:
- 60x60 thumbnail image with CachedNetworkImage
- Title (max 2 lines, 14px bold)
- Metadata: date and view count
- OnTap navigation handler
- Border and rounded corners
**Usage**:
```dart
RelatedArticleCard(
article: relatedArticle,
onTap: () => context.push('/news/${relatedArticle.id}'),
)
```
---
### 3. **News Detail Page** (`lib/features/news/presentation/pages/news_detail_page.dart`)
**Purpose**: Main article detail page with full content
**Features**:
- **AppBar**:
- Back button (black)
- Share button (copies link to clipboard)
- Bookmark button (toggles state with color change)
- **Hero Image**: 250px height, full width, CachedNetworkImage
- **Article Metadata**:
- Category badge (primary blue)
- Date, reading time, views
- Horizontal wrap layout
- **Content Sections**:
- Title (24px, bold)
- Excerpt (italic, blue left border)
- Full article body with HTML rendering
- Tags section (chip-style layout)
- Social engagement stats and action buttons
- Related articles (3 items)
- **HTML Content Rendering**:
- H2 headings (20px, bold, blue underline)
- H3 headings (18px, bold)
- Paragraphs (16px, line height 1.7)
- Bullet lists
- Numbered lists
- Blockquotes (blue background, left border)
- Highlight boxes (custom tag parsing)
- **Social Features**:
- Like button (heart icon, toggles red)
- Bookmark button (bookmark icon, toggles yellow)
- Share button (copy link to clipboard)
- Engagement stats display
- **State Management**:
- ConsumerStatefulWidget for local state
- Provider: `newsArticleByIdProvider` (family provider)
- Bookmark and like states managed locally
**HTML Parsing Logic**:
- Custom parser for simplified HTML tags
- Supports: `<h2>`, `<h3>`, `<p>`, `<ul>`, `<li>`, `<ol>`, `<blockquote>`, `<highlight>`
- Custom `<highlight>` tag with `type` attribute
- Renders widgets based on tag types
---
## Files Modified
### 1. **News Article Entity** (`lib/features/news/domain/entities/news_article.dart`)
**Added Fields**:
- `tags: List<String>` - Article tags/keywords
- `likeCount: int` - Number of likes
- `commentCount: int` - Number of comments
- `shareCount: int` - Number of shares
**Updates**:
- Updated `copyWith()` method
- Simplified equality operator (ID-based only)
- Simplified hashCode
---
### 2. **News Article Model** (`lib/features/news/data/models/news_article_model.dart`)
**Added Fields**:
- `tags`, `likeCount`, `commentCount`, `shareCount`
**Updates**:
- Updated `fromJson()` to parse tags array
- Updated `toJson()` to include new fields
- Updated `toEntity()` and `fromEntity()` conversions
---
### 3. **News Local DataSource** (`lib/features/news/data/datasources/news_local_datasource.dart`)
**Updates**:
- Added full HTML content to featured article (id: 'featured-1')
- Content includes 5 sections about bathroom tile trends
- Added 6 tags: `#gạch-men`, `#phòng-tắm`, `#xu-hướng-2024`, etc.
- Added engagement stats: 156 likes, 23 comments, 45 shares
**Content Structure**:
- Introduction paragraph
- 5 main sections (H2 headings)
- 2 highlight boxes (tip and warning)
- Bullet list for color tones
- Numbered list for texture types
- Blockquote from architect
- Conclusion paragraphs
---
### 4. **App Router** (`lib/core/router/app_router.dart`)
**Added Route**:
```dart
GoRoute(
path: RouteNames.newsDetail, // '/news/:id'
name: RouteNames.newsDetail,
pageBuilder: (context, state) {
final articleId = state.pathParameters['id'];
return MaterialPage(
key: state.pageKey,
child: NewsDetailPage(articleId: articleId ?? ''),
);
},
)
```
**Added Import**:
```dart
import 'package:worker/features/news/presentation/pages/news_detail_page.dart';
```
---
### 5. **News List Page** (`lib/features/news/presentation/pages/news_list_page.dart`)
**Updates**:
- Added `go_router` import
- Updated `_onArticleTap()` to navigate: `context.push('/news/${article.id}')`
- Removed temporary snackbar code
---
## Providers Created
### `newsArticleByIdProvider`
**Type**: `FutureProvider.family<NewsArticle?, String>`
**Purpose**: Get article by ID from news articles list
**Location**: `lib/features/news/presentation/pages/news_detail_page.dart`
**Usage**:
```dart
final articleAsync = ref.watch(newsArticleByIdProvider(articleId));
```
**Returns**: `NewsArticle?` (null if not found)
---
## Navigation Flow
1. **News List Page** → Tap on article card
2. **Router** → Extract article ID from tap
3. **Navigation**`context.push('/news/${article.id}')`
4. **Detail Page** → Load article via `newsArticleByIdProvider`
5. **Display** → Render full article content
**Example Navigation**:
```dart
// From FeaturedNewsCard or NewsCard
FeaturedNewsCard(
article: article,
onTap: () => context.push('/news/${article.id}'),
)
```
---
## HTML Content Format
### Custom HTML-like Tags
The article content uses simplified HTML tags that are parsed into Flutter widgets:
**Supported Tags**:
- `<h2>...</h2>` → Section heading with blue underline
- `<h3>...</h3>` → Subsection heading
- `<p>...</p>` → Paragraph text
- `<ul><li>...</li></ul>` → Bullet list
- `<ol><li>...</li></ol>` → Numbered list
- `<blockquote>...</blockquote>` → Quote box
- `<highlight type="tip|warning">...</highlight>` → Highlight box
**Example**:
```html
<h2>1. Gạch men họa tiết đá tự nhiên</h2>
<p>Xu hướng bắt chước kết cấu và màu sắc...</p>
<highlight type="tip">Chọn gạch men vân đá...</highlight>
<h3>Các loại texture phổ biến:</h3>
<ol>
<li>Matt finish: Bề mặt nhám</li>
<li>Structured surface: Có kết cấu</li>
</ol>
<blockquote>"Việc sử dụng gạch men..." - KTS Nguyễn Minh Tuấn</blockquote>
```
---
## UI/UX Design Specifications
### AppBar
- **Background**: White (`AppColors.white`)
- **Elevation**: `AppBarSpecs.elevation`
- **Back Arrow**: Black
- **Actions**: Share and Bookmark icons (black, toggles to colored)
- **Spacing**: `SizedBox(width: AppSpacing.sm)` after actions
### Hero Image
- **Height**: 250px
- **Width**: Full screen
- **Fit**: Cover
- **Loading**: CircularProgressIndicator in grey background
- **Error**: Image icon placeholder
### Content Padding
- **Main Padding**: 24px all sides
- **Spacing Between Sections**: 16-32px
### Typography
- **Title**: 24px, bold, black, line height 1.3
- **H2**: 20px, bold, blue underline
- **H3**: 18px, bold
- **Body**: 16px, line height 1.7
- **Meta Text**: 12px, grey
- **Excerpt**: 16px, italic, grey
### Colors
- **Primary Blue**: `AppColors.primaryBlue` (#005B9A)
- **Text Primary**: #1E293B
- **Text Secondary**: #64748B
- **Border**: #E2E8F0
- **Background**: #F8FAFC
- **Highlight**: Yellow-orange gradient
### Tags
- **Background**: White
- **Border**: 1px solid #E2E8F0
- **Padding**: 12px horizontal, 4px vertical
- **Border Radius**: 16px
- **Font Size**: 12px
- **Color**: Grey (#64748B)
### Social Actions
- **Button Style**: Outlined, 2px border
- **Icon Size**: 20px
- **Padding**: 12px all sides
- **Border Radius**: 8px
- **Active Colors**: Red (like), Yellow (bookmark)
---
## State Management
### Local State (in NewsDetailPage)
```dart
bool _isBookmarked = false;
bool _isLiked = false;
```
### Provider State
```dart
// Get article by ID
final articleAsync = ref.watch(newsArticleByIdProvider(articleId));
// Get related articles (filtered by category)
final relatedArticles = ref
.watch(filteredNewsArticlesProvider)
.value
?.where((a) => a.id != article.id && a.category == article.category)
.take(3)
.toList();
```
---
## Error Handling
### Not Found State
- Icon: `Icons.article_outlined` (grey)
- Title: "Không tìm thấy bài viết"
- Message: "Bài viết này không tồn tại hoặc đã bị xóa"
- Action: "Quay lại" button
### Error State
- Icon: `Icons.error_outline` (danger color)
- Title: "Không thể tải bài viết"
- Message: Error details
- Action: "Quay lại" button
### Loading State
- `CircularProgressIndicator` centered on screen
---
## User Interactions
### Share Article
**Action**: Tap share button in AppBar or social actions
**Behavior**:
1. Copy article link to clipboard
2. Show SnackBar: "Đã sao chép link bài viết!"
3. TODO: Add native share when `share_plus` package is integrated
### Bookmark Article
**Action**: Tap bookmark button in AppBar or social actions
**Behavior**:
1. Toggle `_isBookmarked` state
2. Change icon color (black ↔ yellow)
3. Show SnackBar: "Đã lưu bài viết!" or "Đã bỏ lưu bài viết!"
### Like Article
**Action**: Tap heart button in social actions
**Behavior**:
1. Toggle `_isLiked` state
2. Change icon color (black ↔ red)
3. Show SnackBar: "Đã thích bài viết!" or "Đã bỏ thích bài viết!"
### Navigate to Related Article
**Action**: Tap on related article card
**Behavior**: Navigate to detail page of related article
---
## Testing Checklist
- [x] Article loads successfully with full content
- [x] Hero image displays correctly
- [x] Metadata shows all fields (category, date, time, views)
- [x] HTML content parses into proper widgets
- [x] H2 headings have blue underline
- [x] Blockquotes have blue background and border
- [x] Highlight boxes show correct icons and colors
- [x] Tags display in chip format
- [x] Social stats display correctly
- [x] Like button toggles state
- [x] Bookmark button toggles state
- [x] Share button copies link
- [x] Related articles load (3 items)
- [x] Navigation to related articles works
- [x] Back button returns to list
- [x] Not found state displays for invalid ID
- [x] Error state displays on provider error
- [x] Loading state shows while fetching
---
## Future Enhancements
### Phase 1 (Current)
- ✅ Basic HTML rendering
- ✅ Social engagement UI
- ✅ Related articles
### Phase 2 (Planned)
- [ ] Native share dialog (share_plus package)
- [ ] Persistent bookmark state (Hive)
- [ ] Comments section
- [ ] Reading progress indicator
- [ ] Font size adjustment
- [ ] Dark mode support
### Phase 3 (Advanced)
- [ ] Rich text editor for content
- [ ] Image gallery view
- [ ] Video embedding
- [ ] Audio player for podcasts
- [ ] Social media embeds
- [ ] PDF export
---
## Dependencies
### Existing Packages (Used)
- `flutter_riverpod: ^2.5.3` - State management
- `go_router: ^14.6.2` - Navigation
- `cached_network_image: ^3.4.1` - Image caching
### Required Packages (TODO)
- `share_plus: ^latest` - Native share functionality
- `flutter_html: ^latest` (optional) - Advanced HTML rendering
- `url_launcher: ^latest` - Open external links
---
## File Locations
### New Files
```
lib/features/news/
presentation/
pages/
news_detail_page.dart ✅ CREATED
widgets/
highlight_box.dart ✅ CREATED
related_article_card.dart ✅ CREATED
```
### Modified Files
```
lib/features/news/
domain/entities/
news_article.dart ✅ MODIFIED (added tags, engagement)
data/
models/
news_article_model.dart ✅ MODIFIED (added tags, engagement)
datasources/
news_local_datasource.dart ✅ MODIFIED (added full content)
presentation/
pages/
news_list_page.dart ✅ MODIFIED (navigation)
lib/core/router/
app_router.dart ✅ MODIFIED (added route)
```
---
## Summary
The news detail page is now fully functional with:
1.**Complete UI Implementation** - All sections from HTML reference
2.**HTML Content Rendering** - Custom parser for article content
3.**Social Engagement** - Like, bookmark, share functionality
4.**Navigation** - Seamless routing from list to detail
5.**Related Articles** - Context-aware suggestions
6.**Error Handling** - Not found and error states
7.**Responsive Design** - Follows app design system
8.**State Management** - Clean Riverpod integration
**Total Files**: 3 created, 5 modified
**Total Lines**: ~1000+ lines of production code
**Design Match**: 100% faithful to HTML reference
The implementation follows all Flutter best practices, uses proper state management with Riverpod, implements clean architecture patterns, and maintains consistency with the existing codebase style.

View File

@@ -1,133 +0,0 @@
# Price Policy Feature Implementation
## ✅ Files Created
### Domain Layer
- `lib/features/price_policy/domain/entities/price_document.dart`
- `lib/features/price_policy/domain/entities/price_document.freezed.dart`
### Presentation Layer
- `lib/features/price_policy/presentation/providers/price_documents_provider.dart`
- `lib/features/price_policy/presentation/providers/price_documents_provider.g.dart`
- `lib/features/price_policy/presentation/pages/price_policy_page.dart`
- `lib/features/price_policy/presentation/widgets/document_card.dart`
### Exports
- `lib/features/price_policy/price_policy.dart` (barrel export)
### Router
- Updated `lib/core/router/app_router.dart`
## 🔧 Fixes Applied
### 1. Removed Unused Import
**File**: `price_policy_page.dart`
- ❌ Removed: `import 'package:intl/intl.dart';` (unused)
### 2. Fixed Provider Pattern
**File**: `price_documents_provider.dart`
- ❌ Before: Used class-based `NotifierProvider` pattern
- ✅ After: Used functional `@riverpod` provider pattern
- This matches the pattern used by simple providers in the project (like `gifts_provider.dart`)
```dart
// ✅ Correct pattern
@riverpod
List<PriceDocument> priceDocuments(PriceDocumentsRef ref) {
return _mockDocuments;
}
@riverpod
List<PriceDocument> filteredPriceDocuments(
FilteredPriceDocumentsRef ref,
DocumentCategory category,
) {
final allDocs = ref.watch(priceDocumentsProvider);
return allDocs.where((doc) => doc.category == category).toList();
}
```
### 3. Fixed Hash Code Generation
**File**: `price_documents_provider.g.dart`
- ❌ Before: Used `_SystemHash` (undefined)
- ✅ After: Used `Object.hash` (built-in Dart)
### 4. Added Barrel Export
**File**: `price_policy.dart`
- Created centralized export file for cleaner imports
### 5. Updated Router Import
**File**: `app_router.dart`
- ❌ Before: `import 'package:worker/features/price_policy/presentation/pages/price_policy_page.dart';`
- ✅ After: `import 'package:worker/features/price_policy/price_policy.dart';`
## 🎨 Features Implemented
### Page Structure
- **AppBar**: Standard black text, white background, info button
- **Tabs**: 2 tabs (Chính sách giá / Bảng giá)
- **Document Cards**: Responsive layout with icon, info, and download button
### Documents Included
#### Tab 1: Chính sách giá (4 PDF documents)
1. Chính sách giá Eurotile T10/2025
2. Chính sách giá Vasta Stone T10/2025
3. Chính sách chiết khấu đại lý 2025
4. Điều kiện thanh toán & giao hàng
#### Tab 2: Bảng giá (5 Excel documents)
1. Bảng giá Gạch Granite Eurotile 2025
2. Bảng giá Gạch Ceramic Eurotile 2025
3. Bảng giá Đá tự nhiên Vasta Stone 2025
4. Bảng giá Phụ kiện & Vật liệu 2025
5. Bảng giá Gạch Outdoor & Chống trơn 2025
## 🚀 Usage
### Navigation
```dart
// Push to price policy page
context.push(RouteNames.pricePolicy);
// or
context.push('/price-policy');
```
### Import
```dart
import 'package:worker/features/price_policy/price_policy.dart';
```
## ✅ Testing Checklist
- [x] Domain entity created with Freezed
- [x] Providers created with Riverpod
- [x] Page UI matches HTML reference
- [x] Tabs work correctly
- [x] Document cards display properly
- [x] Download button shows SnackBar
- [x] Info dialog displays
- [x] Pull-to-refresh works
- [x] Empty state handling
- [x] Responsive layout (mobile/desktop)
- [x] Route added to router
- [x] All imports resolved
- [x] No build errors
## 📝 Next Steps (Optional)
### Backend Integration
- [ ] Create API endpoints for document list
- [ ] Implement actual file download
- [ ] Add document upload for admin
### Enhanced Features
- [ ] Add search functionality
- [ ] Add date range filter
- [ ] Add document preview
- [ ] Add offline caching with Hive
- [ ] Add download progress indicator
- [ ] Add file sharing functionality
## 🎯 Reference
Based on HTML design: `html/chinh-sach-gia.html`

View File

@@ -1,84 +0,0 @@
# Provider Fix Summary
## ✅ Problem Fixed
The `price_documents_provider.dart` was using the wrong Riverpod pattern.
## ❌ Before (Incorrect - NotifierProvider Pattern)
```dart
@riverpod
class PriceDocuments extends _$PriceDocuments {
@override
List<PriceDocument> build() {
return _mockDocuments;
}
}
```
**Issue**: This pattern is for stateful providers that need methods to mutate state. For simple data providers that just return a value, this is overkill and causes unnecessary complexity.
## ✅ After (Correct - Functional Provider Pattern)
```dart
@riverpod
List<PriceDocument> priceDocuments(PriceDocumentsRef ref) {
return _mockDocuments;
}
@riverpod
List<PriceDocument> filteredPriceDocuments(
FilteredPriceDocumentsRef ref,
DocumentCategory category,
) {
final allDocs = ref.watch(priceDocumentsProvider);
return allDocs.where((doc) => doc.category == category).toList();
}
```
**Benefits**:
- ✅ Simpler and more readable
- ✅ Matches pattern used by other simple providers in the project
- ✅ No need for extending base classes
- ✅ Perfect for read-only data
- ✅ Supports family modifiers for filtered data
## 📋 When to Use Each Pattern
### Use Functional Providers (@riverpod function)
**When you have:**
- ✅ Read-only data
- ✅ Computed/derived state
- ✅ Simple transformations
- ✅ No state mutations needed
**Examples in project:**
- `gifts_provider.dart` - Returns list of gifts
- `selected_category_provider.dart` - Returns current category
- `search_query_provider.dart` - Returns search text
- **`price_documents_provider.dart`** - Returns list of documents ✅
### Use Class-Based Notifiers (@riverpod class)
**When you need:**
- ✅ Mutable state with methods
- ✅ State that changes over time
- ✅ Methods to update/modify state
- ✅ Complex state management logic
**Examples in project:**
- `cart_provider.dart` - Has `addItem()`, `removeItem()`, `updateQuantity()`
- `favorites_provider.dart` - Has `toggleFavorite()`, `addFavorite()`
- `loyalty_points_provider.dart` - Has `deductPoints()`, `addPoints()`
## 🎯 Key Takeaway
For the Price Policy feature, since we're just displaying a static list of documents with filtering, the **functional provider pattern** is the correct choice. No state mutations are needed, so we don't need the class-based notifier pattern.
## 📁 Files Changed
1. `lib/features/price_policy/presentation/providers/price_documents_provider.dart`
2. `lib/features/price_policy/presentation/providers/price_documents_provider.g.dart`
## ✅ Result
The provider now works correctly and follows the project's conventions for simple data providers!

View File

@@ -1,626 +0,0 @@
# Riverpod 3.0 Setup - Worker Flutter App
## Overview
This document provides a complete guide to the Riverpod 3.0 state management setup for the Worker Flutter app.
## What's Configured
### 1. Dependencies (pubspec.yaml)
**Production Dependencies:**
- `flutter_riverpod: ^3.0.0` - Main Riverpod package
- `riverpod_annotation: ^3.0.0` - Annotations for code generation
**Development Dependencies:**
- `build_runner: ^2.4.11` - Code generation runner
- `riverpod_generator: ^3.0.0` - Generates provider code from annotations
- `riverpod_lint: ^3.0.0` - Riverpod-specific linting rules
- `custom_lint: ^0.7.0` - Required for riverpod_lint
### 2. Build Configuration (build.yaml)
Configured to generate code for:
- `**_provider.dart` files
- Files in `**/providers/` directories
- Files in `**/notifiers/` directories
### 3. Analysis Options (analysis_options.yaml)
Configured with:
- Custom lint plugin enabled
- Exclusion of generated files (*.g.dart, *.freezed.dart)
- Riverpod-specific lint rules
- Comprehensive code quality rules
### 4. App Initialization (main.dart)
Wrapped with `ProviderScope`:
```dart
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
```
## Directory Structure
```
lib/core/providers/
├── connectivity_provider.dart # Network connectivity monitoring
├── provider_examples.dart # Comprehensive Riverpod 3.0 examples
└── README.md # Provider architecture documentation
```
## Quick Start
### 1. Install Dependencies
```bash
flutter pub get
```
### 2. Generate Provider Code
```bash
# One-time generation
dart run build_runner build --delete-conflicting-outputs
# Watch mode (auto-regenerates on file changes)
dart run build_runner watch -d
```
### 3. Use the Setup Script
```bash
chmod +x scripts/setup_riverpod.sh
./scripts/setup_riverpod.sh
```
## Core Providers
### Connectivity Provider
Location: `/lib/core/providers/connectivity_provider.dart`
**Purpose:** Monitor network connectivity status across the app.
**Providers Available:**
1. **connectivityProvider** - Connectivity instance
```dart
final connectivity = ref.watch(connectivityProvider);
```
2. **connectivityStreamProvider** - Real-time connectivity stream
```dart
final status = ref.watch(connectivityStreamProvider);
status.when(
data: (status) => Text('Status: $status'),
loading: () => CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
);
```
3. **currentConnectivityProvider** - One-time connectivity check
```dart
final status = await ref.read(currentConnectivityProvider.future);
```
4. **isOnlineProvider** - Boolean online/offline stream
```dart
final isOnline = ref.watch(isOnlineProvider);
```
**Usage Example:**
```dart
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final connectivityState = ref.watch(connectivityStreamProvider);
return connectivityState.when(
data: (status) {
if (status == ConnectivityStatus.offline) {
return OfflineBanner();
}
return OnlineContent();
},
loading: () => LoadingIndicator(),
error: (error, _) => ErrorWidget(error),
);
}
}
```
## Riverpod 3.0 Key Features
### 1. @riverpod Annotation (Code Generation)
The modern, recommended approach:
```dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'my_provider.g.dart';
// Simple value
@riverpod
String greeting(GreetingRef ref) => 'Hello';
// Async value
@riverpod
Future<User> user(UserRef ref, String id) async {
return await fetchUser(id);
}
// Mutable state
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state++;
}
```
### 2. Unified Ref Type
No more separate `FutureProviderRef`, `StreamProviderRef`, etc. - just `Ref`:
```dart
@riverpod
Future<String> example(ExampleRef ref) async {
ref.watch(provider1);
ref.read(provider2);
ref.listen(provider3, (prev, next) {});
}
```
### 3. Family as Function Parameters
```dart
// Simple parameter
@riverpod
Future<User> user(UserRef ref, String id) async {
return await fetchUser(id);
}
// Multiple parameters with named, optional, defaults
@riverpod
Future<List<Post>> posts(
PostsRef ref, {
required String userId,
int page = 1,
int limit = 20,
String? category,
}) async {
return await fetchPosts(userId, page, limit, category);
}
// Usage
ref.watch(userProvider('user123'));
ref.watch(postsProvider(userId: 'user123', page: 2));
```
### 4. AutoDispose vs KeepAlive
```dart
// AutoDispose (default) - cleaned up when not watched
@riverpod
String autoExample(AutoExampleRef ref) => 'Auto disposed';
// KeepAlive - stays alive until app closes
@Riverpod(keepAlive: true)
String keepExample(KeepExampleRef ref) => 'Kept alive';
```
### 5. ref.mounted Check
New in Riverpod 3.0 - check if provider is still alive after async operations:
```dart
@riverpod
class DataManager extends _$DataManager {
@override
String build() => 'Initial';
Future<void> updateData() async {
await Future.delayed(Duration(seconds: 2));
// Check if provider is still mounted
if (!ref.mounted) return;
state = 'Updated';
}
}
```
### 6. AsyncValue.guard() for Error Handling
```dart
@riverpod
class UserProfile extends _$UserProfile {
@override
Future<User> build() async => await fetchUser();
Future<void> update(String name) async {
state = const AsyncValue.loading();
// AsyncValue.guard catches errors automatically
state = await AsyncValue.guard(() async {
return await updateUser(name);
});
}
}
```
## Provider Patterns
### 1. Simple Provider (Immutable Value)
```dart
@riverpod
String appVersion(AppVersionRef ref) => '1.0.0';
@riverpod
int pointsMultiplier(PointsMultiplierRef ref) {
final tier = ref.watch(userTierProvider);
return tier == 'diamond' ? 3 : 2;
}
```
### 2. FutureProvider (Async Data)
```dart
@riverpod
Future<User> currentUser(CurrentUserRef ref) async {
final token = await ref.watch(authTokenProvider.future);
return await fetchUser(token);
}
```
### 3. StreamProvider (Real-time Data)
```dart
@riverpod
Stream<List<Message>> chatMessages(ChatMessagesRef ref, String roomId) {
return ref.watch(webSocketProvider).messages(roomId);
}
```
### 4. Notifier (Mutable State)
```dart
@riverpod
class Cart extends _$Cart {
@override
List<CartItem> build() => [];
void addItem(Product product) {
state = [...state, CartItem.fromProduct(product)];
}
void removeItem(String id) {
state = state.where((item) => item.id != id).toList();
}
void clear() {
state = [];
}
}
```
### 5. AsyncNotifier (Async Mutable State)
```dart
@riverpod
class UserProfile extends _$UserProfile {
@override
Future<User> build() async {
return await ref.read(userRepositoryProvider).getCurrentUser();
}
Future<void> updateName(String name) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final updated = await ref.read(userRepositoryProvider).updateName(name);
return updated;
});
}
Future<void> refresh() async {
ref.invalidateSelf();
}
}
```
### 6. StreamNotifier (Stream Mutable State)
```dart
@riverpod
class LiveChat extends _$LiveChat {
@override
Stream<List<Message>> build(String roomId) {
return ref.watch(chatServiceProvider).messagesStream(roomId);
}
Future<void> sendMessage(String text) async {
await ref.read(chatServiceProvider).send(roomId, text);
}
}
```
## Usage in Widgets
### ConsumerWidget
```dart
class ProductList extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final products = ref.watch(productsProvider);
return products.when(
data: (list) => ListView.builder(
itemCount: list.length,
itemBuilder: (context, index) => ProductCard(list[index]),
),
loading: () => CircularProgressIndicator(),
error: (error, stack) => ErrorView(error),
);
}
}
```
### ConsumerStatefulWidget
```dart
class OrderPage extends ConsumerStatefulWidget {
@override
ConsumerState<OrderPage> createState() => _OrderPageState();
}
class _OrderPageState extends ConsumerState<OrderPage> {
@override
void initState() {
super.initState();
// Can use ref in all lifecycle methods
Future.microtask(
() => ref.read(ordersProvider.notifier).loadOrders(),
);
}
@override
Widget build(BuildContext context) {
final orders = ref.watch(ordersProvider);
return OrderList(orders);
}
}
```
### Consumer (Optimization)
```dart
Column(
children: [
const StaticHeader(),
Consumer(
builder: (context, ref, child) {
final count = ref.watch(cartCountProvider);
return CartBadge(count);
},
),
],
)
```
## Performance Optimization
### Use .select() to Watch Specific Fields
```dart
// Bad - rebuilds on any user change
final user = ref.watch(userProvider);
// Good - rebuilds only when name changes
final name = ref.watch(userProvider.select((user) => user.name));
// Good with AsyncValue
final userName = ref.watch(
userProfileProvider.select((async) => async.value?.name),
);
```
### Provider Composition
```dart
@riverpod
Future<Dashboard> dashboard(DashboardRef ref) async {
// Depend on other providers
final user = await ref.watch(userProvider.future);
final stats = await ref.watch(statsProvider.future);
final orders = await ref.watch(recentOrdersProvider.future);
return Dashboard(
user: user,
stats: stats,
recentOrders: orders,
);
}
```
## Testing
### Unit Testing Providers
```dart
test('counter increments', () {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(counterProvider), 0);
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
});
test('async provider fetches data', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
final user = await container.read(userProvider.future);
expect(user.name, 'John Doe');
});
```
### Widget Testing
```dart
testWidgets('displays user name', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userProvider.overrideWith((ref) => User(name: 'Test User')),
],
child: MaterialApp(home: UserScreen()),
),
);
expect(find.text('Test User'), findsOneWidget);
});
```
### Mocking Providers
```dart
testWidgets('handles loading state', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userProvider.overrideWith((ref) {
return Future.delayed(
Duration(seconds: 10),
() => User(name: 'Test'),
);
}),
],
child: MaterialApp(home: UserScreen()),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
```
## Linting
### Run Riverpod Lint
```bash
# Check for Riverpod-specific issues
dart run custom_lint
# Auto-fix issues
dart run custom_lint --fix
```
### Riverpod Lint Rules Enabled
- `provider_dependencies` - Ensure proper dependency usage
- `scoped_providers_should_specify_dependencies` - Scoped provider safety
- `avoid_public_notifier_properties` - Encapsulation
- `avoid_ref_read_inside_build` - Performance (don't use ref.read in build)
- `avoid_manual_providers_as_generated_provider_dependency` - Use generated providers
- `functional_ref` - Proper ref usage
- `notifier_build` - Proper Notifier implementation
## Common Issues & Solutions
### Issue 1: Generated files not found
**Solution:**
```bash
dart run build_runner build --delete-conflicting-outputs
```
### Issue 2: Provider not updating
**Solution:** Check if you're using `ref.watch()` not `ref.read()` in build method.
### Issue 3: Memory leaks
**Solution:** Use autoDispose (default) for providers that should clean up. Only use keepAlive for global state.
### Issue 4: Too many rebuilds
**Solution:** Use `.select()` to watch specific fields instead of entire objects.
## Migration from Riverpod 2.x
### StateNotifierProvider → Notifier
```dart
// Old (2.x)
class Counter extends StateNotifier<int> {
Counter() : super(0);
void increment() => state++;
}
final counterProvider = StateNotifierProvider<Counter, int>(Counter.new);
// New (3.0)
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state++;
}
```
### Provider.family → Function Parameters
```dart
// Old (2.x)
final userProvider = FutureProvider.family<User, String>((ref, id) async {
return fetchUser(id);
});
// New (3.0)
@riverpod
Future<User> user(UserRef ref, String id) async {
return fetchUser(id);
}
```
## Examples
Comprehensive examples are available in:
- `/lib/core/providers/provider_examples.dart` - All Riverpod 3.0 patterns
- `/lib/core/providers/connectivity_provider.dart` - Real-world connectivity monitoring
## Resources
- [Riverpod Documentation](https://riverpod.dev)
- [Code Generation Guide](https://riverpod.dev/docs/concepts/about_code_generation)
- [Migration Guide](https://riverpod.dev/docs/migration/from_state_notifier)
- [Provider Examples](./lib/core/providers/provider_examples.dart)
## Next Steps
1. Run `flutter pub get` to install dependencies
2. Run `dart run build_runner watch -d` to start code generation
3. Create feature-specific providers in `lib/features/*/presentation/providers/`
4. Follow the patterns in `provider_examples.dart`
5. Use connectivity_provider as a reference for real-world implementation
## Support
For questions or issues:
1. Check provider_examples.dart for patterns
2. Review the Riverpod documentation
3. Run custom_lint to catch common mistakes
4. Use ref.watch() in build methods, ref.read() in event handlers

View File

@@ -1,551 +0,0 @@
# Riverpod 3.0 Setup Summary - Worker Flutter App
## ✅ Setup Complete!
Riverpod 3.0 with code generation has been successfully configured for the Worker Flutter app.
## What Was Configured
### 1. Dependencies Updated (pubspec.yaml)
**Production:**
- `flutter_riverpod: ^3.0.0` - Core Riverpod package for Flutter
- `riverpod_annotation: ^3.0.0` - Annotations for code generation
**Development:**
- `build_runner: ^2.4.11` - Code generation engine
- `riverpod_generator: ^3.0.0` - Generates provider code
- `riverpod_lint: ^3.0.0` - Riverpod-specific linting
- `custom_lint: ^0.8.0` - Required for riverpod_lint
### 2. Build Configuration (build.yaml)
✅ Configured to auto-generate code for:
- `**_provider.dart` files
- `**/providers/*.dart` directories
- `**/notifiers/*.dart` directories
### 3. Linting (analysis_options.yaml)
✅ Enabled custom_lint with Riverpod rules:
- `provider_dependencies` - Proper dependency tracking
- `avoid_ref_read_inside_build` - Performance optimization
- `avoid_public_notifier_properties` - Encapsulation
- `functional_ref` - Proper ref usage
- `notifier_build` - Correct Notifier implementation
- And more...
### 4. App Initialization (main.dart)
✅ Wrapped with ProviderScope:
```dart
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
```
### 5. Core Providers Created
#### **connectivity_provider.dart** - Network Monitoring
Four providers for connectivity management:
1. **connectivityProvider** - Connectivity instance
2. **connectivityStreamProvider** - Real-time connectivity stream
3. **currentConnectivityProvider** - One-time connectivity check
4. **isOnlineProvider** - Boolean online/offline stream
**Usage Example:**
```dart
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final connectivityState = ref.watch(connectivityStreamProvider);
return connectivityState.when(
data: (status) {
if (status == ConnectivityStatus.offline) {
return OfflineBanner();
}
return OnlineContent();
},
loading: () => CircularProgressIndicator(),
error: (error, _) => ErrorView(error),
);
}
}
```
#### **provider_examples.dart** - Comprehensive Examples
Complete examples of all Riverpod 3.0 patterns:
- ✅ Simple providers (immutable values)
- ✅ Async providers (FutureProvider pattern)
- ✅ Stream providers
- ✅ Notifier (mutable state with methods)
- ✅ AsyncNotifier (async mutable state)
- ✅ StreamNotifier (stream mutable state)
- ✅ Family (parameters as function arguments)
- ✅ Provider composition
- ✅ AutoDispose vs KeepAlive
- ✅ Lifecycle hooks
- ✅ Error handling with AsyncValue.guard()
- ✅ ref.mounted checks
- ✅ Invalidation and refresh
### 6. Documentation
**RIVERPOD_SETUP.md** - Complete setup guide with:
- Installation instructions
- Code generation commands
- Usage patterns and examples
- Testing strategies
- Migration guide from Riverpod 2.x
- Common issues and solutions
**lib/core/providers/README.md** - Provider architecture documentation
**scripts/setup_riverpod.sh** - Automated setup script
## Directory Structure
```
lib/core/providers/
├── connectivity_provider.dart # Network monitoring provider
├── connectivity_provider.g.dart # ✅ Generated code
├── provider_examples.dart # All Riverpod 3.0 patterns
├── provider_examples.g.dart # ✅ Generated code
└── README.md # Architecture docs
scripts/
└── setup_riverpod.sh # Automated setup script
Root files:
├── build.yaml # Build configuration
├── analysis_options.yaml # Linting configuration
├── RIVERPOD_SETUP.md # Complete guide
└── RIVERPOD_SUMMARY.md # This file
```
## ✅ Verification
All code generation completed successfully:
- ✅ connectivity_provider.g.dart generated
- ✅ provider_examples.g.dart generated
- ✅ No Riverpod-related errors in flutter analyze
- ✅ Dependencies installed
- ✅ ProviderScope configured
## Quick Commands
### Install Dependencies
```bash
flutter pub get
```
### Generate Provider Code
```bash
# One-time generation
dart run build_runner build --delete-conflicting-outputs
# Watch mode (recommended during development)
dart run build_runner watch -d
# Clean and rebuild
dart run build_runner clean && dart run build_runner build --delete-conflicting-outputs
```
### Run Linting
```bash
# Check for Riverpod issues
dart run custom_lint
# Auto-fix issues
dart run custom_lint --fix
```
### Analyze Code
```bash
flutter analyze
```
### Use Setup Script
```bash
./scripts/setup_riverpod.sh
```
## Riverpod 3.0 Key Features
### 1. @riverpod Annotation
```dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'my_provider.g.dart';
@riverpod
String myValue(MyValueRef ref) => 'Hello';
```
### 2. Family as Function Parameters
```dart
@riverpod
Future<User> user(UserRef ref, String userId) async {
return await fetchUser(userId);
}
// Usage
ref.watch(userProvider('user123'));
```
### 3. Notifier for Mutable State
```dart
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state++;
}
// Usage
ref.watch(counterProvider); // Get state
ref.read(counterProvider.notifier).increment(); // Call method
```
### 4. AsyncNotifier for Async Mutable State
```dart
@riverpod
class UserProfile extends _$UserProfile {
@override
Future<User> build() async => await fetchUser();
Future<void> update(String name) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
return await updateUser(name);
});
}
}
```
### 5. Unified Ref Type
```dart
// All providers use the same Ref type
@riverpod
Future<String> example(ExampleRef ref) async {
ref.watch(provider1);
ref.read(provider2);
ref.listen(provider3, (prev, next) {});
}
```
### 6. ref.mounted Check
```dart
@riverpod
class Example extends _$Example {
@override
String build() => 'Initial';
Future<void> update() async {
await Future.delayed(Duration(seconds: 2));
// Check if still mounted
if (!ref.mounted) return;
state = 'Updated';
}
}
```
## Usage in Widgets
### ConsumerWidget
```dart
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.watch(myProvider);
return Text(value);
}
}
```
### ConsumerStatefulWidget
```dart
class MyWidget extends ConsumerStatefulWidget {
@override
ConsumerState<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends ConsumerState<MyWidget> {
@override
Widget build(BuildContext context) {
final value = ref.watch(myProvider);
return Text(value);
}
}
```
### Consumer (for optimization)
```dart
Consumer(
builder: (context, ref, child) {
final count = ref.watch(counterProvider);
return Text('$count');
},
)
```
## Best Practices
### ✅ DO
1. **Use .select() for optimization**
```dart
final name = ref.watch(userProvider.select((user) => user.name));
```
2. **Use AsyncValue.guard() for error handling**
```dart
state = await AsyncValue.guard(() async {
return await api.call();
});
```
3. **Check ref.mounted after async operations**
```dart
await Future.delayed(Duration(seconds: 1));
if (!ref.mounted) return;
state = newValue;
```
4. **Use autoDispose by default**
```dart
@riverpod // autoDispose by default
String example(ExampleRef ref) => 'value';
```
5. **Keep providers in dedicated directories**
```
lib/features/auth/presentation/providers/
lib/features/products/presentation/providers/
```
### ❌ DON'T
1. **Don't use ref.read() in build methods**
```dart
// BAD
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.read(myProvider); // ❌
return Text(value);
}
// GOOD
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.watch(myProvider); // ✅
return Text(value);
}
```
2. **Don't use StateNotifierProvider** (deprecated in Riverpod 3.0)
```dart
// Use Notifier instead
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state++;
}
```
3. **Don't forget the part directive**
```dart
// Required!
part 'my_provider.g.dart';
```
## Next Steps
### 1. For Feature Development
Create providers in feature-specific directories:
```
lib/features/auth/presentation/providers/
├── auth_provider.dart
├── auth_provider.g.dart # Generated
├── login_form_provider.dart
└── login_form_provider.g.dart # Generated
```
### 2. Provider Template
```dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'my_provider.g.dart';
@riverpod
class MyFeature extends _$MyFeature {
@override
MyState build() {
// Initialize
return MyState.initial();
}
void updateState() {
// Modify state
state = state.copyWith(/* ... */);
}
}
```
### 3. Run Code Generation
After creating a provider:
```bash
dart run build_runner watch -d
```
### 4. Use in Widgets
```dart
class MyScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(myFeatureProvider);
return Column(
children: [
Text(state.value),
ElevatedButton(
onPressed: () {
ref.read(myFeatureProvider.notifier).updateState();
},
child: Text('Update'),
),
],
);
}
}
```
## Testing
### Unit Test Example
```dart
test('counter increments', () {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(counterProvider), 0);
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
});
```
### Widget Test Example
```dart
testWidgets('displays user name', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userProvider.overrideWith((ref) => User(name: 'Test')),
],
child: MaterialApp(home: UserScreen()),
),
);
expect(find.text('Test'), findsOneWidget);
});
```
## Examples Reference
All Riverpod 3.0 patterns are documented with working examples in:
📄 **lib/core/providers/provider_examples.dart**
This file includes:
- ✅ 11 different provider patterns
- ✅ Real code examples (not pseudocode)
- ✅ Detailed comments explaining each pattern
- ✅ Usage examples in comments
- ✅ Migration notes from Riverpod 2.x
## Connectivity Provider
The connectivity provider is a real-world example showing:
- ✅ Simple provider (Connectivity instance)
- ✅ Stream provider (connectivity changes)
- ✅ Future provider (one-time check)
- ✅ Derived provider (isOnline boolean)
- ✅ Proper documentation
- ✅ Usage examples
Use it as a template for creating your own providers!
## Resources
- 📚 [Riverpod Documentation](https://riverpod.dev)
- 📚 [Code Generation Guide](https://riverpod.dev/docs/concepts/about_code_generation)
- 📚 [Migration Guide](https://riverpod.dev/docs/migration/from_state_notifier)
- 📄 [Provider Examples](./lib/core/providers/provider_examples.dart)
- 📄 [Connectivity Provider](./lib/core/providers/connectivity_provider.dart)
- 📄 [Complete Setup Guide](./RIVERPOD_SETUP.md)
## Support & Help
If you encounter issues:
1. **Check examples** in provider_examples.dart
2. **Review documentation** in RIVERPOD_SETUP.md
3. **Run linting** with `dart run custom_lint`
4. **Check generated files** (*.g.dart) exist
5. **Verify part directive** is present in provider files
6. **Ensure ProviderScope** wraps the app in main.dart
## Common Issues & Solutions
### Issue: "Target of URI doesn't exist"
**Solution:** Run code generation:
```bash
dart run build_runner build --delete-conflicting-outputs
```
### Issue: "Classes can only mix in mixins"
**Solution:** Make sure the part directive is correct:
```dart
part 'my_provider.g.dart'; // Must match filename
```
### Issue: Provider not updating
**Solution:** Use ref.watch() in build, ref.read() in callbacks
### Issue: Too many rebuilds
**Solution:** Use .select() to watch specific fields
## Conclusion
✅ Riverpod 3.0 with code generation is fully configured and ready to use!
**Key Benefits:**
- ✅ Type-safe state management
- ✅ Less boilerplate with code generation
- ✅ Automatic provider type selection
- ✅ Better hot-reload support
- ✅ Comprehensive linting
- ✅ Excellent documentation
**You can now:**
1. Create providers using @riverpod annotation
2. Use connectivity monitoring immediately
3. Reference provider_examples.dart for patterns
4. Start building feature-specific providers
5. Test providers with ProviderContainer
**Happy coding! 🚀**

View File

@@ -1,168 +0,0 @@
# TypeId Verification Report
## ✅ All TypeId Conflicts Resolved
### Changes Made
1. **storage_constants.dart** - Added new typeIds:
- `memberCardModel = 25`
- `promotionModel = 26`
- `categoryModel = 27`
2. **member_card_model.dart** - Changed from hardcoded `typeId: 10` to `typeId: HiveTypeIds.memberCardModel` (25)
3. **promotion_model.dart** - Changed from hardcoded `typeId: 11` to `typeId: HiveTypeIds.promotionModel` (26)
4. **category_model.dart** - Changed from hardcoded `typeId: 12` to `typeId: HiveTypeIds.categoryModel` (27)
### TypeId Range Summary
```
Core Models: 0-9 (10 slots, all assigned)
Loyalty Models: 10-13 (4 slots, all assigned)
Project/Quote: 14-17 (4 slots, all assigned)
Chat: 18-19 (2 slots, all assigned)
Extended Models: 20-27 (8 used, 2 available: 28-29)
Enums: 30-51 (22 assigned, 8 available: 52-59)
Cache/Sync: 60-62 (3 assigned, 7 available: 63-69)
Reserved Future: 70-99 (30 slots available)
Special: 100 (2 values - max cache size config)
```
### Unique TypeIds Assigned (0-69)
**Models: 0-27**
```
0 = userModel
1 = userSessionModel
2 = productModel
3 = stockLevelModel
4 = cartModel
5 = cartItemModel
6 = orderModel
7 = orderItemModel
8 = invoiceModel
9 = paymentLineModel
10 = loyaltyPointEntryModel
11 = giftCatalogModel
12 = redeemedGiftModel
13 = pointsRecordModel
14 = projectSubmissionModel
15 = designRequestModel
16 = quoteModel
17 = quoteItemModel
18 = chatRoomModel
19 = messageModel
20 = notificationModel
21 = showroomModel
22 = showroomProductModel
23 = paymentReminderModel
24 = auditLogModel
25 = memberCardModel ✨ (NEW - was conflicting at 10)
26 = promotionModel ✨ (NEW - was conflicting at 11)
27 = categoryModel ✨ (NEW - was conflicting at 12)
```
**Enums: 30-51**
```
30 = userRole
31 = userStatus
32 = loyaltyTier
33 = orderStatus
34 = invoiceType
35 = invoiceStatus
36 = paymentMethod
37 = paymentStatus
38 = entryType
39 = entrySource
40 = complaintStatus
41 = giftCategory
42 = giftStatus
43 = pointsStatus
44 = projectType
45 = submissionStatus
46 = designStatus
47 = quoteStatus
48 = roomType
49 = contentType
50 = reminderType
51 = notificationType
```
**Cache/Sync: 60-62**
```
60 = cachedData
61 = syncState
62 = offlineRequest
```
### Aliases (Reference existing typeIds)
```
memberTier = loyaltyTier (32)
userType = userRole (30)
projectStatus = submissionStatus (45)
transactionType = entryType (38)
```
## ✅ No Duplicates Found
Verified using command:
```bash
grep "static const int" lib/core/constants/storage_constants.dart | awk '{print $5}' | sort | uniq -d
```
Result: **No duplicate values**
## ✅ No Hardcoded TypeIds Found
Verified using command:
```bash
grep -r "@HiveType(typeId: [0-9]" lib/
```
Result: **No hardcoded typeIds in lib/ directory**
## ✅ All Models Use Constants
Verified that all 47+ models now use `HiveTypeIds.constantName` format:
- ✅ All auth models
- ✅ All product models
- ✅ All cart models
- ✅ All order models
- ✅ All loyalty models
- ✅ All project models
- ✅ All quote models
- ✅ All chat models
- ✅ All showroom models
- ✅ All notification models
- ✅ All enum types
- ✅ All cache models
## Next Actions Required
### 1. Regenerate Hive Adapters
```bash
flutter pub run build_runner build --delete-conflicting-outputs
```
This will regenerate:
- `member_card_model.g.dart` with new typeId 25
- `promotion_model.g.dart` with new typeId 26
- `category_model.g.dart` with new typeId 27
### 2. Clear Development Data (Optional)
If testing locally, you may want to clear existing Hive boxes:
```dart
await Hive.deleteBoxFromDisk('home_box');
await Hive.deleteBoxFromDisk('products_box');
```
### 3. Test the App
Run the app and verify:
- ✅ No HiveError about duplicate typeIds
- ✅ Member cards load correctly
- ✅ Promotions load correctly
- ✅ Product categories load correctly
- ✅ All Hive operations work as expected
## Status: ✅ READY FOR ADAPTER GENERATION
All typeId conflicts have been resolved. The codebase is now ready for running the build_runner to regenerate adapters.

View File

@@ -182,13 +182,13 @@
</div>
<!-- ID ĐVKD -->
<div class="form-group">
<label class="form-label" for="DVKD">Mã ĐVKD *</label>
<div class="form-input-icon">
<i class="fas fa-briefcase icon"></i>
<input type="text" id="DVKD" class="form-input" placeholder="Nhập mã ĐVKD" required>
</div>
</div>
<!-- <div class="form-group">-->
<!-- <label class="form-label" for="DVKD">Mã ĐVKD *</label>-->
<!-- <div class="form-input-icon">-->
<!-- <i class="fas fa-briefcase icon"></i>-->
<!-- <input type="text" id="DVKD" class="form-input" placeholder="Nhập mã ĐVKD" required>-->
<!-- </div>-->
<!-- </div>-->
<!-- Role Selection -->
<div class="form-group">

80
html/start.html Normal file
View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Truy cập - EuroTile Worker</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="assets/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="page-wrapper">
<!-- Header -->
<div class="header">
<a href="login.html" class="back-button">
<i class="fas fa-arrow-left"></i>
</a>
<h1 class="header-title">Đơn vị kinh doanh</h1>
<!--<div style="width: 32px;"></div>-->
<button class="back-button" onclick="openInfoModal()">
<i class="fas fa-info-circle"></i>
</button>
</div>
<div class="container">
<!-- Logo Section -->
<div class="text-center mt-4 mb-4">
<div style="display: inline-block; background: linear-gradient(135deg, #005B9A 0%, #38B6FF 100%); padding: 20px; border-radius: 20px;">
<h1 style="color: white; font-size: 32px; font-weight: 700; margin: 0;">DBIZ</h1>
<p style="color: white; font-size: 12px; margin: 0;">Worker App</p>
</div>
</div>
<!-- Welcome Message -->
<div class="text-center mb-4">
<p class="text-muted">Chọn đơn vị kinh doanh để tiếp tục</p>
</div>
<!-- Start Form -->
<form action="register.html" class="card" style="margin-top: 40px;">
<div class="form-group">
<label class="form-label" for="phone">Đơn vị kinh doanh</label>
<div class="form-input-icon">
<i class="fas fa-briefcase icon"></i>
<select id="role" class="form-input form-select" required onchange="toggleVerification()">
<option value="">Chọn đơn vị kinh doanh</option>
<option value="dealer">VIKD</option>
<option value="worker">HSKD</option>
<option value="worker">LPKD</option>
</select>
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
Tiếp tục
</button>
</form>
<!-- Brand Selection -->
<!-- <div class="mt-4">
<p class="text-center text-small text-muted mb-3">Hoặc chọn thương hiệu</p>
<div class="grid grid-2">
<button class="btn btn-secondary">
<i class="fas fa-building"></i> EuroTile
</button>
<button class="btn btn-secondary">
<i class="fas fa-gem"></i> Vasta Stone
</button>
</div>
</div>-->
<!-- Support -->
<!--<div class="text-center mt-4" style="margin-top: 320px;">
<a href="#" class="text-small text-primary" style="text-decoration: none;">
<i class="fas fa-info-circle"></i> Hỗ trợ khách hàng
</a>
</div>-->
</div>
</div>
</body>
</html>

View File

@@ -1,7 +1,43 @@
PODS:
- connectivity_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Flutter (1.0.0)
- flutter_secure_storage (6.0.0):
- Flutter
- GoogleDataTransport (9.4.1):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30911.0, >= 2.30908.0)
@@ -62,6 +98,9 @@ PODS:
- Flutter
- FlutterMacOS
- PromisesObjC (2.4.0)
- SDWebImage (5.21.2):
- SDWebImage/Core (= 5.21.2)
- SDWebImage/Core (5.21.2)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -70,10 +109,15 @@ PODS:
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
@@ -81,9 +125,12 @@ DEPENDENCIES:
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- GoogleDataTransport
- GoogleMLKit
- GoogleToolboxForMac
@@ -96,12 +143,18 @@ SPEC REPOS:
- MLKitVision
- nanopb
- PromisesObjC
- SDWebImage
- SwiftyGif
EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
integration_test:
@@ -116,10 +169,16 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS:
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
@@ -136,9 +195,12 @@ SPEC CHECKSUMS:
nanopb: 438bc412db1928dac798aa6fd75726007be04262
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e

View File

@@ -33,7 +33,6 @@ class WorkerApp extends ConsumerWidget {
theme: AppTheme.lightTheme(),
darkTheme: AppTheme.darkTheme(),
themeMode: ThemeMode.light, // TODO: Make this configurable from settings
// ==================== Localization Configuration ====================
// Support for Vietnamese (primary) and English (secondary)
localizationsDelegates: const [
@@ -53,8 +52,10 @@ class WorkerApp extends ConsumerWidget {
],
// Default locale (Vietnamese)
locale: const Locale('vi', 'VN'), // TODO: Make this configurable from settings
locale: const Locale(
'vi',
'VN',
), // TODO: Make this configurable from settings
// Locale resolution strategy
localeResolutionCallback: (locale, supportedLocales) {
// Check if the device locale is supported
@@ -71,9 +72,7 @@ class WorkerApp extends ConsumerWidget {
// ==================== Material App Configuration ====================
// Builder for additional context-dependent widgets
builder: (context, child) {
return _AppBuilder(
child: child ?? const SizedBox.shrink(),
);
return _AppBuilder(child: child ?? const SizedBox.shrink());
},
);
}
@@ -86,9 +85,7 @@ class WorkerApp extends ConsumerWidget {
/// - Connectivity listener
/// - Global overlays (loading, snackbars)
class _AppBuilder extends ConsumerWidget {
const _AppBuilder({
required this.child,
});
const _AppBuilder({required this.child});
final Widget child;
@@ -116,4 +113,3 @@ class _AppBuilder extends ConsumerWidget {
);
}
}

View File

@@ -398,7 +398,10 @@ class ApiConstants {
/// final url = ApiConstants.buildUrlWithParams('/products/{id}', {'id': '123'});
/// // Returns: https://api.worker.example.com/v1/products/123
/// ```
static String buildUrlWithParams(String endpoint, Map<String, String> params) {
static String buildUrlWithParams(
String endpoint,
Map<String, String> params,
) {
String url = endpoint;
params.forEach((key, value) {
url = url.replaceAll('{$key}', value);

View File

@@ -440,7 +440,12 @@ class AppConstants {
static const int maxProductImageSize = 3;
/// Supported image formats
static const List<String> supportedImageFormats = ['jpg', 'jpeg', 'png', 'webp'];
static const List<String> supportedImageFormats = [
'jpg',
'jpeg',
'png',
'webp',
];
/// Image quality for compression (0-100)
static const int imageQuality = 85;

View File

@@ -59,22 +59,22 @@ class HiveBoxNames {
/// Get all box names for initialization
static List<String> get allBoxes => [
userBox,
productBox,
cartBox,
orderBox,
projectBox,
quotes,
loyaltyBox,
rewardsBox,
settingsBox,
cacheBox,
syncStateBox,
notificationBox,
addressBox,
favoriteBox,
offlineQueueBox,
];
userBox,
productBox,
cartBox,
orderBox,
projectBox,
quotes,
loyaltyBox,
rewardsBox,
settingsBox,
cacheBox,
syncStateBox,
notificationBox,
addressBox,
favoriteBox,
offlineQueueBox,
];
}
/// Hive Type Adapter IDs
@@ -124,6 +124,7 @@ class HiveTypeIds {
static const int promotionModel = 26;
static const int categoryModel = 27;
static const int favoriteModel = 28;
static const int businessUnitModel = 29;
// Enums (30-59)
static const int userRole = 30;
@@ -152,7 +153,8 @@ class HiveTypeIds {
// Aliases for backward compatibility and clarity
static const int memberTier = loyaltyTier; // Alias for loyaltyTier
static const int userType = userRole; // Alias for userRole
static const int projectStatus = submissionStatus; // Alias for submissionStatus
static const int projectStatus =
submissionStatus; // Alias for submissionStatus
static const int transactionType = entryType; // Alias for entryType
// Cache & Sync Models (60-69)

View File

@@ -17,14 +17,16 @@ import 'package:worker/core/database/hive_service.dart';
/// - Sync state tracking
class DatabaseManager {
DatabaseManager({HiveService? hiveService})
: _hiveService = hiveService ?? HiveService();
: _hiveService = hiveService ?? HiveService();
final HiveService _hiveService;
/// Get a box safely
Box<T> _getBox<T>(String boxName) {
if (!_hiveService.isBoxOpen(boxName)) {
throw HiveError('Box $boxName is not open. Initialize HiveService first.');
throw HiveError(
'Box $boxName is not open. Initialize HiveService first.',
);
}
return _hiveService.getBox<T>(boxName);
}
@@ -49,11 +51,7 @@ class DatabaseManager {
}
/// Get a value from a box
T? get<T>({
required String boxName,
required String key,
T? defaultValue,
}) {
T? get<T>({required String boxName, required String key, T? defaultValue}) {
try {
final box = _getBox<T>(boxName);
return box.get(key, defaultValue: defaultValue);
@@ -65,10 +63,7 @@ class DatabaseManager {
}
/// Delete a value from a box
Future<void> delete({
required String boxName,
required String key,
}) async {
Future<void> delete({required String boxName, required String key}) async {
try {
final box = _getBox<dynamic>(boxName);
await box.delete(key);
@@ -81,10 +76,7 @@ class DatabaseManager {
}
/// Check if a key exists in a box
bool exists({
required String boxName,
required String key,
}) {
bool exists({required String boxName, required String key}) {
try {
final box = _getBox<dynamic>(boxName);
return box.containsKey(key);
@@ -138,10 +130,7 @@ class DatabaseManager {
// ==================== Cache Operations ====================
/// Save data to cache with timestamp
Future<void> saveToCache<T>({
required String key,
required T data,
}) async {
Future<void> saveToCache<T>({required String key, required T data}) async {
try {
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
await cacheBox.put(key, {
@@ -159,10 +148,7 @@ class DatabaseManager {
/// Get data from cache
///
/// Returns null if cache is expired or doesn't exist
T? getFromCache<T>({
required String key,
Duration? maxAge,
}) {
T? getFromCache<T>({required String key, Duration? maxAge}) {
try {
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
final cachedData = cacheBox.get(key) as Map<dynamic, dynamic>?;
@@ -193,10 +179,7 @@ class DatabaseManager {
}
/// Check if cache is valid (exists and not expired)
bool isCacheValid({
required String key,
Duration? maxAge,
}) {
bool isCacheValid({required String key, Duration? maxAge}) {
try {
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
final cachedData = cacheBox.get(key) as Map<dynamic, dynamic>?;
@@ -244,7 +227,9 @@ class DatabaseManager {
await cacheBox.delete(key);
}
debugPrint('DatabaseManager: Cleared ${keysToDelete.length} expired cache entries');
debugPrint(
'DatabaseManager: Cleared ${keysToDelete.length} expired cache entries',
);
} catch (e, stackTrace) {
debugPrint('DatabaseManager: Error clearing expired cache: $e');
debugPrint('StackTrace: $stackTrace');
@@ -281,10 +266,7 @@ class DatabaseManager {
}
/// Check if data needs sync
bool needsSync({
required String dataType,
required Duration syncInterval,
}) {
bool needsSync({required String dataType, required Duration syncInterval}) {
final lastSync = getLastSyncTime(dataType);
if (lastSync == null) return true;
@@ -296,22 +278,12 @@ class DatabaseManager {
// ==================== Settings Operations ====================
/// Save a setting
Future<void> saveSetting<T>({
required String key,
required T value,
}) async {
await save(
boxName: HiveBoxNames.settingsBox,
key: key,
value: value,
);
Future<void> saveSetting<T>({required String key, required T value}) async {
await save(boxName: HiveBoxNames.settingsBox, key: key, value: value);
}
/// Get a setting
T? getSetting<T>({
required String key,
T? defaultValue,
}) {
T? getSetting<T>({required String key, T? defaultValue}) {
return get(
boxName: HiveBoxNames.settingsBox,
key: key,
@@ -328,7 +300,9 @@ class DatabaseManager {
// Check queue size limit
if (queueBox.length >= HiveDatabaseConfig.maxOfflineQueueSize) {
debugPrint('DatabaseManager: Offline queue is full, removing oldest item');
debugPrint(
'DatabaseManager: Offline queue is full, removing oldest item',
);
await queueBox.deleteAt(0);
}
@@ -386,10 +360,7 @@ class DatabaseManager {
try {
if (_hiveService.isBoxOpen(boxName)) {
final box = _getBox<dynamic>(boxName);
stats[boxName] = {
'count': box.length,
'keys': box.keys.length,
};
stats[boxName] = {'count': box.length, 'keys': box.keys.length};
}
} catch (e) {
stats[boxName] = {'error': e.toString()};

View File

@@ -5,9 +5,7 @@ import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:path_provider/path_provider.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/favorites/data/models/favorite_model.dart';
// TODO: Re-enable when build_runner generates this file successfully
// import 'package:worker/hive_registrar.g.dart';
import 'package:worker/hive_registrar.g.dart';
/// Hive CE (Community Edition) Database Service
///
@@ -92,40 +90,45 @@ class HiveService {
debugPrint('HiveService: Registering type adapters...');
// Register all adapters using the auto-generated extension
// This automatically registers:
// - CachedDataAdapter (typeId: 30)
// - All enum adapters (typeIds: 20-29)
// TODO: Re-enable when build_runner generates hive_registrar.g.dart successfully
// Hive.registerAdapters();
// This automatically registers all model and enum adapters
Hive.registerAdapters();
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.memberTier) ? "" : ""} MemberTier adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.userType) ? "" : ""} UserType adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.orderStatus) ? "" : ""} OrderStatus adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectStatus) ? "" : ""} ProjectStatus adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectType) ? "" : ""} ProjectType adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.transactionType) ? "" : ""} TransactionType adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.giftStatus) ? "" : ""} GiftStatus adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentStatus) ? "" : ""} PaymentStatus adapter');
// NotificationType adapter not needed - notification model uses String type
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentMethod) ? "" : ""} PaymentMethod adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.cachedData) ? "" : ""} CachedData adapter');
// Register model type adapters manually
// FavoriteModel adapter (typeId: 28)
if (!Hive.isAdapterRegistered(HiveTypeIds.favoriteModel)) {
Hive.registerAdapter(FavoriteModelAdapter());
debugPrint('HiveService: ✓ FavoriteModel adapter registered');
}
// TODO: Register other model type adapters when created
// Example:
// - UserModel (typeId: 0)
// - ProductModel (typeId: 1)
// - CartItemModel (typeId: 2)
// - OrderModel (typeId: 3)
// - ProjectModel (typeId: 4)
// - LoyaltyTransactionModel (typeId: 5)
// etc.
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.loyaltyTier) ? "" : ""} LoyaltyTier adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.userRole) ? "" : ""} UserRole adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.orderStatus) ? "" : ""} OrderStatus adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectType) ? "" : ""} ProjectType adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.entryType) ? "" : ""} EntryType adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.giftStatus) ? "" : ""} GiftStatus adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentStatus) ? "" : ""} PaymentStatus adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentMethod) ? "" : ""} PaymentMethod adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.cachedData) ? "" : ""} CachedData adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.favoriteModel) ? "" : ""} FavoriteModel adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.productModel) ? "" : ""} ProductModel adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.userModel) ? "" : ""} UserModel adapter',
);
debugPrint('HiveService: Type adapters registered successfully');
}
@@ -188,21 +191,23 @@ class HiveService {
/// Handles schema version upgrades and data migrations.
Future<void> _performMigrations() async {
final settingsBox = Hive.box<dynamic>(HiveBoxNames.settingsBox);
final currentVersion = settingsBox.get(
HiveKeys.schemaVersion,
defaultValue: 0,
) as int;
final currentVersion =
settingsBox.get(HiveKeys.schemaVersion, defaultValue: 0) as int;
debugPrint('HiveService: Current schema version: $currentVersion');
debugPrint('HiveService: Target schema version: ${HiveDatabaseConfig.currentSchemaVersion}');
debugPrint(
'HiveService: Target schema version: ${HiveDatabaseConfig.currentSchemaVersion}',
);
if (currentVersion < HiveDatabaseConfig.currentSchemaVersion) {
debugPrint('HiveService: Performing migrations...');
// Perform migrations sequentially
for (int version = currentVersion + 1;
version <= HiveDatabaseConfig.currentSchemaVersion;
version++) {
for (
int version = currentVersion + 1;
version <= HiveDatabaseConfig.currentSchemaVersion;
version++
) {
await _migrateToVersion(version);
}
@@ -278,10 +283,9 @@ class HiveService {
/// Clear expired cache entries
Future<void> _clearExpiredCache() async {
final cacheBox = Hive.box<dynamic>(HiveBoxNames.cacheBox);
// TODO: Implement cache expiration logic
// This will be implemented when cache models are created
// final cacheBox = Hive.box<dynamic>(HiveBoxNames.cacheBox);
debugPrint('HiveService: Cleared expired cache entries');
}
@@ -291,14 +295,17 @@ class HiveService {
final queueBox = Hive.box<dynamic>(HiveBoxNames.offlineQueueBox);
if (queueBox.length > HiveDatabaseConfig.maxOfflineQueueSize) {
final itemsToRemove = queueBox.length - HiveDatabaseConfig.maxOfflineQueueSize;
final itemsToRemove =
queueBox.length - HiveDatabaseConfig.maxOfflineQueueSize;
// Remove oldest items
for (int i = 0; i < itemsToRemove; i++) {
await queueBox.deleteAt(0);
}
debugPrint('HiveService: Removed $itemsToRemove old items from offline queue');
debugPrint(
'HiveService: Removed $itemsToRemove old items from offline queue',
);
}
}

View File

@@ -10,35 +10,27 @@ library;
/// Base exception for all network-related errors
class NetworkException implements Exception {
const NetworkException(
this.message, {
this.statusCode,
this.data,
});
const NetworkException(this.message, {this.statusCode, this.data});
final String message;
final int? statusCode;
final dynamic data;
@override
String toString() => 'NetworkException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}';
String toString() =>
'NetworkException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}';
}
/// Exception thrown when there's no internet connection
class NoInternetException extends NetworkException {
const NoInternetException()
: super(
'Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.',
);
: super('Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.');
}
/// Exception thrown when connection times out
class TimeoutException extends NetworkException {
const TimeoutException()
: super(
'Kết nối quá lâu. Vui lòng thử lại.',
statusCode: 408,
);
: super('Kết nối quá lâu. Vui lòng thử lại.', statusCode: 408);
}
/// Exception thrown when server returns 500+ errors
@@ -52,10 +44,7 @@ class ServerException extends NetworkException {
/// Exception thrown when server is unreachable
class ServiceUnavailableException extends ServerException {
const ServiceUnavailableException()
: super(
'Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.',
503,
);
: super('Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.', 503);
}
// ============================================================================
@@ -64,10 +53,7 @@ class ServiceUnavailableException extends ServerException {
/// Base exception for authentication-related errors
class AuthException implements Exception {
const AuthException(
this.message, {
this.statusCode,
});
const AuthException(this.message, {this.statusCode});
final String message;
final int? statusCode;
@@ -79,10 +65,7 @@ class AuthException implements Exception {
/// Exception thrown when authentication credentials are invalid
class InvalidCredentialsException extends AuthException {
const InvalidCredentialsException()
: super(
'Thông tin đăng nhập không hợp lệ.',
statusCode: 401,
);
: super('Thông tin đăng nhập không hợp lệ.', statusCode: 401);
}
/// Exception thrown when user is not authenticated
@@ -95,46 +78,37 @@ class UnauthorizedException extends AuthException {
/// Exception thrown when user doesn't have permission
class ForbiddenException extends AuthException {
const ForbiddenException()
: super(
'Bạn không có quyền truy cập tài nguyên này.',
statusCode: 403,
);
: super('Bạn không có quyền truy cập tài nguyên này.', statusCode: 403);
}
/// Exception thrown when auth token is expired
class TokenExpiredException extends AuthException {
const TokenExpiredException()
: super(
'Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.',
statusCode: 401,
);
: super(
'Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.',
statusCode: 401,
);
}
/// Exception thrown when refresh token is invalid
class InvalidRefreshTokenException extends AuthException {
const InvalidRefreshTokenException()
: super(
'Không thể làm mới phiên đăng nhập. Vui lòng đăng nhập lại.',
statusCode: 401,
);
: super(
'Không thể làm mới phiên đăng nhập. Vui lòng đăng nhập lại.',
statusCode: 401,
);
}
/// Exception thrown when OTP is invalid
class InvalidOTPException extends AuthException {
const InvalidOTPException()
: super(
'Mã OTP không hợp lệ. Vui lòng thử lại.',
statusCode: 400,
);
: super('Mã OTP không hợp lệ. Vui lòng thử lại.', statusCode: 400);
}
/// Exception thrown when OTP is expired
class OTPExpiredException extends AuthException {
const OTPExpiredException()
: super(
'Mã OTP đã hết hạn. Vui lòng yêu cầu mã mới.',
statusCode: 400,
);
: super('Mã OTP đã hết hạn. Vui lòng yêu cầu mã mới.', statusCode: 400);
}
// ============================================================================
@@ -143,10 +117,7 @@ class OTPExpiredException extends AuthException {
/// Exception thrown when request data is invalid
class ValidationException implements Exception {
const ValidationException(
this.message, {
this.errors,
});
const ValidationException(this.message, {this.errors});
final String message;
final Map<String, List<String>>? errors;
@@ -198,9 +169,7 @@ class NotFoundException implements Exception {
/// Exception thrown when trying to create a duplicate resource
class ConflictException implements Exception {
const ConflictException([
this.message = 'Tài nguyên đã tồn tại.',
]);
const ConflictException([this.message = 'Tài nguyên đã tồn tại.']);
final String message;
@@ -237,10 +206,7 @@ class RateLimitException implements Exception {
/// Exception thrown for payment-related errors
class PaymentException implements Exception {
const PaymentException(
this.message, {
this.transactionId,
});
const PaymentException(this.message, {this.transactionId});
final String message;
final String? transactionId;
@@ -259,8 +225,7 @@ class PaymentFailedException extends PaymentException {
/// Exception thrown when payment is cancelled
class PaymentCancelledException extends PaymentException {
const PaymentCancelledException()
: super('Thanh toán đã bị hủy.');
const PaymentCancelledException() : super('Thanh toán đã bị hủy.');
}
// ============================================================================
@@ -269,9 +234,7 @@ class PaymentCancelledException extends PaymentException {
/// Exception thrown for cache-related errors
class CacheException implements Exception {
const CacheException([
this.message = 'Lỗi khi truy cập bộ nhớ đệm.',
]);
const CacheException([this.message = 'Lỗi khi truy cập bộ nhớ đệm.']);
final String message;
@@ -281,8 +244,7 @@ class CacheException implements Exception {
/// Exception thrown when cache data is corrupted
class CacheCorruptedException extends CacheException {
const CacheCorruptedException()
: super('Dữ liệu bộ nhớ đệm bị hỏng.');
const CacheCorruptedException() : super('Dữ liệu bộ nhớ đệm bị hỏng.');
}
// ============================================================================
@@ -291,9 +253,7 @@ class CacheCorruptedException extends CacheException {
/// Exception thrown for local storage errors
class StorageException implements Exception {
const StorageException([
this.message = 'Lỗi khi truy cập bộ nhớ cục bộ.',
]);
const StorageException([this.message = 'Lỗi khi truy cập bộ nhớ cục bộ.']);
final String message;
@@ -304,7 +264,7 @@ class StorageException implements Exception {
/// Exception thrown when storage is full
class StorageFullException extends StorageException {
const StorageFullException()
: super('Bộ nhớ đã đầy. Vui lòng giải phóng không gian.');
: super('Bộ nhớ đã đầy. Vui lòng giải phóng không gian.');
}
// ============================================================================

View File

@@ -9,16 +9,12 @@ sealed class Failure {
const Failure({required this.message});
/// Network-related failure
const factory Failure.network({
required String message,
int? statusCode,
}) = NetworkFailure;
const factory Failure.network({required String message, int? statusCode}) =
NetworkFailure;
/// Server error failure (5xx errors)
const factory Failure.server({
required String message,
int? statusCode,
}) = ServerFailure;
const factory Failure.server({required String message, int? statusCode}) =
ServerFailure;
/// Authentication failure
const factory Failure.authentication({
@@ -33,20 +29,14 @@ sealed class Failure {
}) = ValidationFailure;
/// Not found failure (404)
const factory Failure.notFound({
required String message,
}) = NotFoundFailure;
const factory Failure.notFound({required String message}) = NotFoundFailure;
/// Conflict failure (409)
const factory Failure.conflict({
required String message,
}) = ConflictFailure;
const factory Failure.conflict({required String message}) = ConflictFailure;
/// Rate limit exceeded failure (429)
const factory Failure.rateLimit({
required String message,
int? retryAfter,
}) = RateLimitFailure;
const factory Failure.rateLimit({required String message, int? retryAfter}) =
RateLimitFailure;
/// Payment failure
const factory Failure.payment({
@@ -55,19 +45,13 @@ sealed class Failure {
}) = PaymentFailure;
/// Cache failure
const factory Failure.cache({
required String message,
}) = CacheFailure;
const factory Failure.cache({required String message}) = CacheFailure;
/// Storage failure
const factory Failure.storage({
required String message,
}) = StorageFailure;
const factory Failure.storage({required String message}) = StorageFailure;
/// Parse failure
const factory Failure.parse({
required String message,
}) = ParseFailure;
const factory Failure.parse({required String message}) = ParseFailure;
/// No internet connection failure
const factory Failure.noInternet() = NoInternetFailure;
@@ -76,9 +60,7 @@ sealed class Failure {
const factory Failure.timeout() = TimeoutFailure;
/// Unknown failure
const factory Failure.unknown({
required String message,
}) = UnknownFailure;
const factory Failure.unknown({required String message}) = UnknownFailure;
final String message;
@@ -120,15 +102,21 @@ sealed class Failure {
/// Get user-friendly error message
String getUserMessage() {
return switch (this) {
ValidationFailure(:final message, :final errors) => _formatValidationMessage(message, errors),
RateLimitFailure(:final message, :final retryAfter) => _formatRateLimitMessage(message, retryAfter),
NoInternetFailure() => 'Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.',
ValidationFailure(:final message, :final errors) =>
_formatValidationMessage(message, errors),
RateLimitFailure(:final message, :final retryAfter) =>
_formatRateLimitMessage(message, retryAfter),
NoInternetFailure() =>
'Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.',
TimeoutFailure() => 'Kết nối quá lâu. Vui lòng thử lại.',
_ => message,
};
}
String _formatValidationMessage(String message, Map<String, List<String>>? errors) {
String _formatValidationMessage(
String message,
Map<String, List<String>>? errors,
) {
if (errors != null && errors.isNotEmpty) {
final firstError = errors.values.first.first;
return '$message: $firstError';
@@ -146,10 +134,7 @@ sealed class Failure {
/// Network-related failure
final class NetworkFailure extends Failure {
const NetworkFailure({
required super.message,
this.statusCode,
});
const NetworkFailure({required super.message, this.statusCode});
@override
final int? statusCode;
@@ -157,10 +142,7 @@ final class NetworkFailure extends Failure {
/// Server error failure (5xx errors)
final class ServerFailure extends Failure {
const ServerFailure({
required super.message,
this.statusCode,
});
const ServerFailure({required super.message, this.statusCode});
@override
final int? statusCode;
@@ -168,10 +150,7 @@ final class ServerFailure extends Failure {
/// Authentication failure
final class AuthenticationFailure extends Failure {
const AuthenticationFailure({
required super.message,
this.statusCode,
});
const AuthenticationFailure({required super.message, this.statusCode});
@override
final int? statusCode;
@@ -179,84 +158,61 @@ final class AuthenticationFailure extends Failure {
/// Validation failure
final class ValidationFailure extends Failure {
const ValidationFailure({
required super.message,
this.errors,
});
const ValidationFailure({required super.message, this.errors});
final Map<String, List<String>>? errors;
}
/// Not found failure (404)
final class NotFoundFailure extends Failure {
const NotFoundFailure({
required super.message,
});
const NotFoundFailure({required super.message});
}
/// Conflict failure (409)
final class ConflictFailure extends Failure {
const ConflictFailure({
required super.message,
});
const ConflictFailure({required super.message});
}
/// Rate limit exceeded failure (429)
final class RateLimitFailure extends Failure {
const RateLimitFailure({
required super.message,
this.retryAfter,
});
const RateLimitFailure({required super.message, this.retryAfter});
final int? retryAfter;
}
/// Payment failure
final class PaymentFailure extends Failure {
const PaymentFailure({
required super.message,
this.transactionId,
});
const PaymentFailure({required super.message, this.transactionId});
final String? transactionId;
}
/// Cache failure
final class CacheFailure extends Failure {
const CacheFailure({
required super.message,
});
const CacheFailure({required super.message});
}
/// Storage failure
final class StorageFailure extends Failure {
const StorageFailure({
required super.message,
});
const StorageFailure({required super.message});
}
/// Parse failure
final class ParseFailure extends Failure {
const ParseFailure({
required super.message,
});
const ParseFailure({required super.message});
}
/// No internet connection failure
final class NoInternetFailure extends Failure {
const NoInternetFailure()
: super(message: 'Không có kết nối internet');
const NoInternetFailure() : super(message: 'Không có kết nối internet');
}
/// Timeout failure
final class TimeoutFailure extends Failure {
const TimeoutFailure()
: super(message: 'Kết nối quá lâu');
const TimeoutFailure() : super(message: 'Kết nối quá lâu');
}
/// Unknown failure
final class UnknownFailure extends Failure {
const UnknownFailure({
required super.message,
});
const UnknownFailure({required super.message});
}

View File

@@ -10,11 +10,12 @@ library;
import 'dart:developer' as developer;
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/errors/exceptions.dart';
import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart';
part 'api_interceptor.g.dart';
@@ -23,6 +24,7 @@ part 'api_interceptor.g.dart';
// ============================================================================
/// Keys for storing auth tokens in SharedPreferences
/// @deprecated Use AuthLocalDataSource with Hive instead
class AuthStorageKeys {
static const String accessToken = 'auth_access_token';
static const String refreshToken = 'auth_refresh_token';
@@ -33,12 +35,15 @@ class AuthStorageKeys {
// Auth Interceptor
// ============================================================================
/// Interceptor for adding authentication tokens to requests
/// Interceptor for adding ERPNext session tokens to requests
///
/// Adds SID (Session ID) and CSRF token from Hive storage to request headers.
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._prefs, this._dio);
AuthInterceptor(this._prefs, this._dio, this._authLocalDataSource);
final SharedPreferences _prefs;
final Dio _dio;
final AuthLocalDataSource _authLocalDataSource;
@override
void onRequest(
@@ -47,10 +52,19 @@ class AuthInterceptor extends Interceptor {
) async {
// Check if this endpoint requires authentication
if (_requiresAuth(options.path)) {
final token = await _getAccessToken();
// Get session data from secure storage (async)
final sid = await _authLocalDataSource.getSid();
final csrfToken = await _authLocalDataSource.getCsrfToken();
if (sid != null && csrfToken != null) {
// Add ERPNext session headers
options.headers['Cookie'] = 'sid=$sid';
options.headers['X-Frappe-CSRF-Token'] = csrfToken;
}
// Legacy: Also check for access token (for backward compatibility)
final token = await _getAccessToken();
if (token != null) {
// Add bearer token to headers
options.headers['Authorization'] = 'Bearer $token';
}
}
@@ -66,10 +80,7 @@ class AuthInterceptor extends Interceptor {
}
@override
void onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
void onError(DioException err, ErrorInterceptorHandler handler) async {
// Check if error is 401 Unauthorized
if (err.response?.statusCode == 401) {
// Try to refresh token
@@ -113,15 +124,16 @@ class AuthInterceptor extends Interceptor {
}
/// Check if token is expired
Future<bool> _isTokenExpired() async {
final expiryString = _prefs.getString(AuthStorageKeys.tokenExpiry);
if (expiryString == null) return true;
final expiry = DateTime.tryParse(expiryString);
if (expiry == null) return true;
return DateTime.now().isAfter(expiry);
}
// TODO: Use this method when implementing token refresh logic
// Future<bool> _isTokenExpired() async {
// final expiryString = _prefs.getString(AuthStorageKeys.tokenExpiry);
// if (expiryString == null) return true;
//
// final expiry = DateTime.tryParse(expiryString);
// if (expiry == null) return true;
//
// return DateTime.now().isAfter(expiry);
// }
/// Refresh access token using refresh token
Future<bool> _refreshAccessToken() async {
@@ -135,11 +147,7 @@ class AuthInterceptor extends Interceptor {
// Call refresh token endpoint
final response = await _dio.post<Map<String, dynamic>>(
'${ApiConstants.apiBaseUrl}${ApiConstants.refreshToken}',
options: Options(
headers: {
'Authorization': 'Bearer $refreshToken',
},
),
options: Options(headers: {'Authorization': 'Bearer $refreshToken'}),
);
if (response.statusCode == 200) {
@@ -185,10 +193,7 @@ class AuthInterceptor extends Interceptor {
final options = Options(
method: requestOptions.method,
headers: {
...requestOptions.headers,
'Authorization': 'Bearer $token',
},
headers: {...requestOptions.headers, 'Authorization': 'Bearer $token'},
);
return _dio.request(
@@ -217,19 +222,13 @@ class LoggingInterceptor extends Interceptor {
final bool enableErrorLogging;
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) {
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (enableRequestLogging) {
developer.log(
'╔══════════════════════════════════════════════════════════════',
name: 'HTTP Request',
);
developer.log(
'${options.method} ${options.uri}',
name: 'HTTP Request',
);
developer.log('${options.method} ${options.uri}', name: 'HTTP Request');
developer.log(
'║ Headers: ${_sanitizeHeaders(options.headers)}',
name: 'HTTP Request',
@@ -290,10 +289,7 @@ class LoggingInterceptor extends Interceptor {
}
@override
void onError(
DioException err,
ErrorInterceptorHandler handler,
) {
void onError(DioException err, ErrorInterceptorHandler handler) {
if (enableErrorLogging) {
developer.log(
'╔══════════════════════════════════════════════════════════════',
@@ -303,18 +299,12 @@ class LoggingInterceptor extends Interceptor {
'${err.requestOptions.method} ${err.requestOptions.uri}',
name: 'HTTP Error',
);
developer.log(
'║ Error Type: ${err.type}',
name: 'HTTP Error',
);
developer.log('║ Error Type: ${err.type}', name: 'HTTP Error');
developer.log(
'║ Status Code: ${err.response?.statusCode}',
name: 'HTTP Error',
);
developer.log(
'║ Message: ${err.message}',
name: 'HTTP Error',
);
developer.log('║ Message: ${err.message}', name: 'HTTP Error');
if (err.response?.data != null) {
developer.log(
@@ -389,10 +379,7 @@ class LoggingInterceptor extends Interceptor {
/// Interceptor for transforming Dio errors into custom exceptions
class ErrorTransformerInterceptor extends Interceptor {
@override
void onError(
DioException err,
ErrorInterceptorHandler handler,
) {
void onError(DioException err, ErrorInterceptorHandler handler) {
Exception exception;
switch (err.type) {
@@ -415,9 +402,7 @@ class ErrorTransformerInterceptor extends Interceptor {
break;
case DioExceptionType.unknown:
exception = NetworkException(
'Lỗi không xác định: ${err.message}',
);
exception = NetworkException('Lỗi không xác định: ${err.message}');
break;
default:
@@ -447,7 +432,8 @@ class ErrorTransformerInterceptor extends Interceptor {
// Extract error message from response
String? message;
if (data is Map<String, dynamic>) {
message = data['message'] as String? ??
message =
data['message'] as String? ??
data['error'] as String? ??
data['msg'] as String?;
}
@@ -460,9 +446,7 @@ class ErrorTransformerInterceptor extends Interceptor {
final validationErrors = errors.map(
(key, value) => MapEntry(
key,
value is List
? value.cast<String>()
: [value.toString()],
value is List ? value.cast<String>() : [value.toString()],
),
);
return ValidationException(
@@ -498,9 +482,7 @@ class ErrorTransformerInterceptor extends Interceptor {
final validationErrors = errors.map(
(key, value) => MapEntry(
key,
value is List
? value.cast<String>()
: [value.toString()],
value is List ? value.cast<String>() : [value.toString()],
),
);
return ValidationException(
@@ -513,7 +495,9 @@ class ErrorTransformerInterceptor extends Interceptor {
case 429:
final retryAfter = response.headers.value('retry-after');
final retrySeconds = retryAfter != null ? int.tryParse(retryAfter) : null;
final retrySeconds = retryAfter != null
? int.tryParse(retryAfter)
: null;
return RateLimitException(message ?? 'Quá nhiều yêu cầu', retrySeconds);
case 500:
@@ -549,7 +533,15 @@ Future<SharedPreferences> sharedPreferences(Ref ref) async {
@riverpod
Future<AuthInterceptor> authInterceptor(Ref ref, Dio dio) async {
final prefs = await ref.watch(sharedPreferencesProvider.future);
return AuthInterceptor(prefs, dio);
// Create AuthLocalDataSource with FlutterSecureStorage
const secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
final authLocalDataSource = AuthLocalDataSource(secureStorage);
return AuthInterceptor(prefs, dio, authLocalDataSource);
}
/// Provider for LoggingInterceptor

View File

@@ -114,7 +114,7 @@ final class AuthInterceptorProvider
}
}
String _$authInterceptorHash() => r'b54ba9af62c3cd7b922ef4030a8e2debb0220e10';
String _$authInterceptorHash() => r'3f964536e03e204d09cc9120dd9d961b6d6d4b71';
/// Provider for AuthInterceptor

View File

@@ -215,14 +215,14 @@ class DioClient {
/// Clear all cached responses
Future<void> clearCache() async {
if (_cacheStore != null) {
await _cacheStore!.clean();
await _cacheStore.clean();
}
}
/// Clear specific cached response by key
Future<void> clearCacheByKey(String key) async {
if (_cacheStore != null) {
await _cacheStore!.delete(key);
await _cacheStore.delete(key);
}
}
@@ -232,7 +232,7 @@ class DioClient {
final key = CacheOptions.defaultCacheKeyBuilder(
RequestOptions(path: path),
);
await _cacheStore!.delete(key);
await _cacheStore.delete(key);
}
}
}
@@ -258,10 +258,7 @@ class RetryInterceptor extends Interceptor {
final double delayMultiplier;
@override
void onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
void onError(DioException err, ErrorInterceptorHandler handler) async {
// Get retry count from request extra
final retries = err.requestOptions.extra['retries'] as int? ?? 0;
@@ -279,8 +276,9 @@ class RetryInterceptor extends Interceptor {
}
// Calculate delay with exponential backoff
final delayMs = (initialDelay.inMilliseconds *
(delayMultiplier * (retries + 1))).toInt();
final delayMs =
(initialDelay.inMilliseconds * (delayMultiplier * (retries + 1)))
.toInt();
final delay = Duration(
milliseconds: delayMs.clamp(
initialDelay.inMilliseconds,
@@ -341,10 +339,7 @@ class RetryInterceptor extends Interceptor {
@riverpod
Future<CacheStore> cacheStore(Ref ref) async {
final directory = await getTemporaryDirectory();
return HiveCacheStore(
directory.path,
hiveBoxName: 'dio_cache',
);
return HiveCacheStore(directory.path, hiveBoxName: 'dio_cache');
}
/// Provider for cache options
@@ -371,31 +366,32 @@ Future<Dio> dio(Ref ref) async {
// Base configuration
dio
..options = BaseOptions(
baseUrl: ApiConstants.apiBaseUrl,
connectTimeout: ApiConstants.connectionTimeout,
receiveTimeout: ApiConstants.receiveTimeout,
sendTimeout: ApiConstants.sendTimeout,
headers: {
'Content-Type': ApiConstants.contentTypeJson,
'Accept': ApiConstants.acceptJson,
'Accept-Language': ApiConstants.acceptLanguageVi,
},
responseType: ResponseType.json,
validateStatus: (status) {
// Accept all status codes and handle errors in interceptor
return status != null && status < 500;
},
)
// Add interceptors in order
baseUrl: ApiConstants.apiBaseUrl,
connectTimeout: ApiConstants.connectionTimeout,
receiveTimeout: ApiConstants.receiveTimeout,
sendTimeout: ApiConstants.sendTimeout,
headers: {
'Content-Type': ApiConstants.contentTypeJson,
'Accept': ApiConstants.acceptJson,
'Accept-Language': ApiConstants.acceptLanguageVi,
},
responseType: ResponseType.json,
validateStatus: (status) {
// Accept all status codes and handle errors in interceptor
return status != null && status < 500;
},
)
// Add interceptors in order
// 1. Logging interceptor (first to log everything)
..interceptors.add(ref.watch(loggingInterceptorProvider))
// 2. Auth interceptor (add tokens to requests)
..interceptors.add(await ref.watch(authInterceptorProvider(dio).future))
// 3. Cache interceptor
..interceptors.add(DioCacheInterceptor(options: await ref.watch(cacheOptionsProvider.future)))
..interceptors.add(
DioCacheInterceptor(
options: await ref.watch(cacheOptionsProvider.future),
),
)
// 4. Retry interceptor
..interceptors.add(RetryInterceptor(ref.watch(networkInfoProvider)))
// 5. Error transformer (last to transform all errors)
@@ -430,9 +426,7 @@ class ApiRequestOptions {
final bool forceRefresh;
/// Options with cache enabled
static const cached = ApiRequestOptions(
cachePolicy: CachePolicy.forceCache,
);
static const cached = ApiRequestOptions(cachePolicy: CachePolicy.forceCache);
/// Options with network-first strategy
static const networkFirst = ApiRequestOptions(
@@ -449,12 +443,9 @@ class ApiRequestOptions {
Options toDioOptions() {
return Options(
extra: <String, dynamic>{
if (cachePolicy != null)
CacheResponse.cacheKey: cachePolicy!.index,
if (cacheDuration != null)
'maxStale': cacheDuration,
if (forceRefresh)
'policy': CachePolicy.refresh.index,
if (cachePolicy != null) CacheResponse.cacheKey: cachePolicy!.index,
if (cacheDuration != null) 'maxStale': cacheDuration,
if (forceRefresh) 'policy': CachePolicy.refresh.index,
},
);
}
@@ -487,10 +478,10 @@ class QueuedRequest {
final DateTime timestamp;
Map<String, dynamic> toJson() => <String, dynamic>{
'method': method,
'path': path,
'data': data,
'queryParameters': queryParameters,
'timestamp': timestamp.toIso8601String(),
};
'method': method,
'path': path,
'data': data,
'queryParameters': queryParameters,
'timestamp': timestamp.toIso8601String(),
};
}

View File

@@ -191,7 +191,9 @@ class NetworkInfoImpl implements NetworkInfo {
return !results.contains(ConnectivityResult.none);
}
NetworkConnectionType _mapConnectivityResult(List<ConnectivityResult> results) {
NetworkConnectionType _mapConnectivityResult(
List<ConnectivityResult> results,
) {
if (results.isEmpty || results.contains(ConnectivityResult.none)) {
return NetworkConnectionType.none;
}
@@ -273,14 +275,11 @@ class NetworkStatusNotifier extends _$NetworkStatusNotifier {
final status = await networkInfo.networkStatus;
// Listen to network changes
ref.listen(
networkStatusStreamProvider,
(_, next) {
next.whenData((newStatus) {
state = AsyncValue.data(newStatus);
});
},
);
ref.listen(networkStatusStreamProvider, (_, next) {
next.whenData((newStatus) {
state = AsyncValue.data(newStatus);
});
});
return status;
}

View File

@@ -95,7 +95,7 @@ Stream<bool> isOnline(Ref ref) {
return connectivity.onConnectivityChanged.map((result) {
// Online if connected to WiFi or mobile
return result.contains(ConnectivityResult.wifi) ||
result.contains(ConnectivityResult.mobile);
result.contains(ConnectivityResult.mobile);
});
}

View File

@@ -34,7 +34,11 @@ String appVersion(Ref ref) {
int pointsMultiplier(Ref ref) {
// Can read other providers
final userTier = 'diamond'; // This would come from another provider
return userTier == 'diamond' ? 3 : userTier == 'platinum' ? 2 : 1;
return userTier == 'diamond'
? 3
: userTier == 'platinum'
? 2
: 1;
}
// ============================================================================
@@ -46,7 +50,7 @@ int pointsMultiplier(Ref ref) {
@riverpod
Future<String> userData(Ref ref) async {
// Simulate API call
await Future.delayed(const Duration(seconds: 1));
await Future<void>.delayed(const Duration(seconds: 1));
return 'User Data';
}
@@ -55,7 +59,7 @@ Future<String> userData(Ref ref) async {
@riverpod
Future<String> userProfile(Ref ref, String userId) async {
// Simulate API call with userId
await Future.delayed(const Duration(seconds: 1));
await Future<void>.delayed(const Duration(seconds: 1));
return 'Profile for user: $userId';
}
@@ -70,7 +74,7 @@ Future<List<String>> productList(
String? searchQuery,
}) async {
// Simulate API call with parameters
await Future.delayed(const Duration(milliseconds: 500));
await Future<void>.delayed(const Duration(milliseconds: 500));
return ['Product 1', 'Product 2', 'Product 3'];
}
@@ -82,10 +86,7 @@ Future<List<String>> productList(
/// Use this for WebSocket connections, real-time updates, etc.
@riverpod
Stream<int> timer(Ref ref) {
return Stream.periodic(
const Duration(seconds: 1),
(count) => count,
);
return Stream.periodic(const Duration(seconds: 1), (count) => count);
}
/// Stream provider with parameters
@@ -176,7 +177,7 @@ class UserProfileNotifier extends _$UserProfileNotifier {
@override
Future<UserProfileData> build() async {
// Fetch initial data
await Future.delayed(const Duration(seconds: 1));
await Future<void>.delayed(const Duration(seconds: 1));
return UserProfileData(name: 'John Doe', email: 'john@example.com');
}
@@ -225,10 +226,7 @@ class UserProfileData {
final String email;
UserProfileData copyWith({String? name, String? email}) {
return UserProfileData(
name: name ?? this.name,
email: email ?? this.email,
);
return UserProfileData(name: name ?? this.name, email: email ?? this.email);
}
}

View File

@@ -189,7 +189,7 @@ final class UserDataProvider
}
}
String _$userDataHash() => r'3df905d6ea9f81ce7ca8205bd785ad4d4376b399';
String _$userDataHash() => r'1b754e931a5d4c202189fcdd3de54815f93aaba2';
/// Async provider with parameters (Family pattern)
/// Parameters are just function parameters - much simpler than before!
@@ -248,7 +248,7 @@ final class UserProfileProvider
}
}
String _$userProfileHash() => r'd42ed517f41ce0dfde58d74b2beb3d8415b81a22';
String _$userProfileHash() => r'35cdc3f9117e81c0150399d7015840ed034661d3';
/// Async provider with parameters (Family pattern)
/// Parameters are just function parameters - much simpler than before!
@@ -346,7 +346,7 @@ final class ProductListProvider
}
}
String _$productListHash() => r'aacee7761543692ccd59f0ecd2f290e1a7de203a';
String _$productListHash() => r'db1568fb33a3615db0a31265c953d6db62b6535b';
/// Async provider with multiple parameters
/// Named parameters, optional parameters, defaults - all supported!
@@ -732,7 +732,7 @@ final class UserProfileNotifierProvider
}
String _$userProfileNotifierHash() =>
r'87c9a9277552095a0ed0b768829e2930fa475c7f';
r'be7bcbe81f84be6ef50e94f52e0c65b00230291c';
/// AsyncNotifier for state that requires async initialization
/// Perfect for fetching data that can then be modified

View File

@@ -10,6 +10,10 @@ import 'package:go_router/go_router.dart';
import 'package:worker/features/account/presentation/pages/addresses_page.dart';
import 'package:worker/features/account/presentation/pages/change_password_page.dart';
import 'package:worker/features/account/presentation/pages/profile_edit_page.dart';
import 'package:worker/features/auth/domain/entities/business_unit.dart';
import 'package:worker/features/auth/presentation/pages/business_unit_selection_page.dart';
import 'package:worker/features/auth/presentation/pages/login_page.dart';
import 'package:worker/features/auth/presentation/pages/register_page.dart';
import 'package:worker/features/cart/presentation/pages/cart_page.dart';
import 'package:worker/features/cart/presentation/pages/checkout_page.dart';
import 'package:worker/features/chat/presentation/pages/chat_list_page.dart';
@@ -46,10 +50,46 @@ class AppRouter {
/// Router configuration
static final GoRouter router = GoRouter(
// Initial route
initialLocation: RouteNames.home,
initialLocation: RouteNames.login,
// Route definitions
routes: [
// Authentication Routes
GoRoute(
path: RouteNames.login,
name: RouteNames.login,
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const LoginPage()),
),
GoRoute(
path: RouteNames.register,
name: RouteNames.register,
pageBuilder: (context, state) {
final extra = state.extra as Map<String, dynamic>?;
return MaterialPage(
key: state.pageKey,
child: RegisterPage(
selectedBusinessUnit: extra?['businessUnit'] as BusinessUnit?,
),
);
},
),
GoRoute(
path: RouteNames.businessUnitSelection,
name: RouteNames.businessUnitSelection,
pageBuilder: (context, state) {
final extra = state.extra as Map<String, dynamic>?;
return MaterialPage(
key: state.pageKey,
child: BusinessUnitSelectionPage(
businessUnits: extra?['businessUnits'] as List<BusinessUnit>?,
isRegistrationFlow:
(extra?['isRegistrationFlow'] as bool?) ?? false,
),
);
},
),
// Main Route (with bottom navigation)
GoRoute(
path: RouteNames.home,
@@ -278,8 +318,10 @@ class AppRouter {
GoRoute(
path: RouteNames.designRequestCreate,
name: RouteNames.designRequestCreate,
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const DesignRequestCreatePage()),
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const DesignRequestCreatePage(),
),
),
// Design Request Detail Route
@@ -421,19 +463,27 @@ class RouteNames {
// Model Houses & Design Requests Routes
static const String modelHouses = '/model-houses';
static const String designRequestCreate = '/model-houses/design-request/create';
static const String designRequestCreate =
'/model-houses/design-request/create';
static const String designRequestDetail = '/model-houses/design-request/:id';
// Authentication Routes (TODO: implement when auth feature is ready)
static const String login = '/login';
static const String otpVerification = '/otp-verification';
static const String register = '/register';
static const String businessUnitSelection = '/business-unit-selection';
}
/// Route Extensions
///
/// Helper extensions for common navigation patterns.
extension GoRouterExtension on BuildContext {
/// Navigate to login page
void goLogin() => go(RouteNames.login);
/// Navigate to register page
void goRegister() => go(RouteNames.register);
/// Navigate to home page
void goHome() => go(RouteNames.home);

View File

@@ -40,10 +40,7 @@ class AppTheme {
color: AppColors.white,
fontWeight: FontWeight.w600,
),
iconTheme: const IconThemeData(
color: AppColors.white,
size: 24,
),
iconTheme: const IconThemeData(color: AppColors.white, size: 24),
systemOverlayStyle: SystemUiOverlayStyle.light,
),
@@ -65,9 +62,7 @@ class AppTheme {
foregroundColor: AppColors.white,
elevation: 2,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: AppTypography.buttonText,
minimumSize: const Size(64, 48),
),
@@ -78,9 +73,7 @@ class AppTheme {
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryBlue,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: AppTypography.buttonText,
),
),
@@ -91,9 +84,7 @@ class AppTheme {
foregroundColor: AppColors.primaryBlue,
side: const BorderSide(color: AppColors.primaryBlue, width: 1.5),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: AppTypography.buttonText,
minimumSize: const Size(64, 48),
),
@@ -103,7 +94,10 @@ class AppTheme {
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.grey100, width: 1),
@@ -124,15 +118,9 @@ class AppTheme {
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.danger, width: 2),
),
labelStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.grey500,
),
hintStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.grey500,
),
errorStyle: AppTypography.bodySmall.copyWith(
color: AppColors.danger,
),
labelStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
hintStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
errorStyle: AppTypography.bodySmall.copyWith(color: AppColors.danger),
),
// ==================== Bottom Navigation Bar Theme ====================
@@ -144,10 +132,7 @@ class AppTheme {
size: 28,
color: AppColors.primaryBlue,
),
unselectedIconTheme: IconThemeData(
size: 24,
color: AppColors.grey500,
),
unselectedIconTheme: IconThemeData(size: 24, color: AppColors.grey500),
selectedLabelStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
@@ -182,26 +167,25 @@ class AppTheme {
color: AppColors.white,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
// ==================== Dialog Theme ====================
dialogTheme: const DialogThemeData(
backgroundColor: AppColors.white,
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
).copyWith(
titleTextStyle: AppTypography.headlineMedium.copyWith(
color: AppColors.grey900,
),
contentTextStyle: AppTypography.bodyLarge.copyWith(
color: AppColors.grey900,
),
),
dialogTheme:
const DialogThemeData(
backgroundColor: AppColors.white,
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
).copyWith(
titleTextStyle: AppTypography.headlineMedium.copyWith(
color: AppColors.grey900,
),
contentTextStyle: AppTypography.bodyLarge.copyWith(
color: AppColors.grey900,
),
),
// ==================== Snackbar Theme ====================
snackBarTheme: SnackBarThemeData(
@@ -209,9 +193,7 @@ class AppTheme {
contentTextStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.white,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
behavior: SnackBarBehavior.floating,
elevation: 4,
),
@@ -224,10 +206,7 @@ class AppTheme {
),
// ==================== Icon Theme ====================
iconTheme: const IconThemeData(
color: AppColors.grey900,
size: 24,
),
iconTheme: const IconThemeData(color: AppColors.grey900, size: 24),
// ==================== List Tile Theme ====================
listTileTheme: ListTileThemeData(
@@ -266,9 +245,7 @@ class AppTheme {
return AppColors.white;
}),
checkColor: MaterialStateProperty.all(AppColors.white),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
// ==================== Radio Theme ====================
@@ -297,14 +274,15 @@ class AppTheme {
),
// ==================== Tab Bar Theme ====================
tabBarTheme: const TabBarThemeData(
labelColor: AppColors.primaryBlue,
unselectedLabelColor: AppColors.grey500,
indicatorColor: AppColors.primaryBlue,
).copyWith(
labelStyle: AppTypography.labelLarge,
unselectedLabelStyle: AppTypography.labelLarge,
),
tabBarTheme:
const TabBarThemeData(
labelColor: AppColors.primaryBlue,
unselectedLabelColor: AppColors.grey500,
indicatorColor: AppColors.primaryBlue,
).copyWith(
labelStyle: AppTypography.labelLarge,
unselectedLabelStyle: AppTypography.labelLarge,
),
);
}
@@ -338,10 +316,7 @@ class AppTheme {
color: AppColors.white,
fontWeight: FontWeight.w600,
),
iconTheme: const IconThemeData(
color: AppColors.white,
size: 24,
),
iconTheme: const IconThemeData(color: AppColors.white, size: 24),
systemOverlayStyle: SystemUiOverlayStyle.light,
),
@@ -363,9 +338,7 @@ class AppTheme {
foregroundColor: AppColors.white,
elevation: 2,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: AppTypography.buttonText,
minimumSize: const Size(64, 48),
),
@@ -375,7 +348,10 @@ class AppTheme {
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF2A2A2A),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF3A3A3A), width: 1),
@@ -396,15 +372,9 @@ class AppTheme {
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.danger, width: 2),
),
labelStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.grey500,
),
hintStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.grey500,
),
errorStyle: AppTypography.bodySmall.copyWith(
color: AppColors.danger,
),
labelStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
hintStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
errorStyle: AppTypography.bodySmall.copyWith(color: AppColors.danger),
),
// ==================== Bottom Navigation Bar Theme ====================
@@ -412,14 +382,8 @@ class AppTheme {
backgroundColor: Color(0xFF1E1E1E),
selectedItemColor: AppColors.lightBlue,
unselectedItemColor: AppColors.grey500,
selectedIconTheme: IconThemeData(
size: 28,
color: AppColors.lightBlue,
),
unselectedIconTheme: IconThemeData(
size: 24,
color: AppColors.grey500,
),
selectedIconTheme: IconThemeData(size: 28, color: AppColors.lightBlue),
unselectedIconTheme: IconThemeData(size: 24, color: AppColors.grey500),
selectedLabelStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
@@ -449,9 +413,7 @@ class AppTheme {
contentTextStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.white,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
behavior: SnackBarBehavior.floating,
elevation: 4,
),

View File

@@ -423,16 +423,14 @@ extension BuildContextExtensions on BuildContext {
/// Navigate to route
Future<T?> push<T>(Widget page) {
return Navigator.of(this).push<T>(
MaterialPageRoute(builder: (_) => page),
);
return Navigator.of(this).push<T>(MaterialPageRoute(builder: (_) => page));
}
/// Navigate and replace current route
Future<T?> pushReplacement<T>(Widget page) {
return Navigator.of(this).pushReplacement<T, void>(
MaterialPageRoute(builder: (_) => page),
);
return Navigator.of(
this,
).pushReplacement<T, void>(MaterialPageRoute(builder: (_) => page));
}
/// Pop current route

View File

@@ -313,15 +313,21 @@ class TextFormatter {
}
/// Truncate text with ellipsis
static String truncate(String text, int maxLength, {String ellipsis = '...'}) {
static String truncate(
String text,
int maxLength, {
String ellipsis = '...',
}) {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - ellipsis.length) + ellipsis;
}
/// Remove diacritics from Vietnamese text
static String removeDiacritics(String text) {
const withDiacritics = 'àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ';
const withoutDiacritics = 'aaaaaaaaaaaaaaaaaeeeeeeeeeeeiiiiioooooooooooooooooouuuuuuuuuuuyyyyyd';
const withDiacritics =
'àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ';
const withoutDiacritics =
'aaaaaaaaaaaaaaaaaeeeeeeeeeeeiiiiioooooooooooooooooouuuuuuuuuuuyyyyyd';
var result = text.toLowerCase();
for (var i = 0; i < withDiacritics.length; i++) {

View File

@@ -241,8 +241,11 @@ class L10nHelper {
/// final pointsText = L10nHelper.formatPoints(context, 100);
/// // Returns: "+100 điểm" (Vietnamese) or "+100 points" (English)
/// ```
static String formatPoints(BuildContext context, int points,
{bool showSign = true}) {
static String formatPoints(
BuildContext context,
int points, {
bool showSign = true,
}) {
if (showSign && points > 0) {
return context.l10n.earnedPoints(points);
} else if (showSign && points < 0) {

View File

@@ -66,9 +66,7 @@ class QRGenerator {
),
padding: const EdgeInsets.all(16),
gapless: true,
embeddedImageStyle: const QrEmbeddedImageStyle(
size: Size(48, 48),
),
embeddedImageStyle: const QrEmbeddedImageStyle(size: Size(48, 48)),
);
}
@@ -189,9 +187,7 @@ class QRGenerator {
embeddedImage: embeddedImage is AssetImage
? (embeddedImage as AssetImage).assetName as ImageProvider
: null,
embeddedImageStyle: QrEmbeddedImageStyle(
size: embeddedImageSize,
),
embeddedImageStyle: QrEmbeddedImageStyle(size: embeddedImageSize),
);
}
@@ -203,18 +199,12 @@ class QRGenerator {
if (data.contains(':')) {
final parts = data.split(':');
if (parts.length == 2) {
return {
'type': parts[0].toUpperCase(),
'value': parts[1],
};
return {'type': parts[0].toUpperCase(), 'value': parts[1]};
}
}
// If no type prefix, return as generic data
return {
'type': 'GENERIC',
'value': data,
};
return {'type': 'GENERIC', 'value': data};
} catch (e) {
return null;
}

View File

@@ -244,9 +244,7 @@ class Validators {
}
if (double.tryParse(value) == null) {
return fieldName != null
? '$fieldName phải là số'
: 'Giá trị phải là số';
return fieldName != null ? '$fieldName phải là số' : 'Giá trị phải là số';
}
return null;
@@ -351,7 +349,8 @@ class Validators {
);
final today = DateTime.now();
final age = today.year -
final age =
today.year -
birthDate.year -
(today.month > birthDate.month ||
(today.month == birthDate.month && today.day >= birthDate.day)
@@ -456,11 +455,7 @@ class Validators {
// ========================================================================
/// Validate against custom regex pattern
static String? pattern(
String? value,
RegExp pattern,
String errorMessage,
) {
static String? pattern(String? value, RegExp pattern, String errorMessage) {
if (value == null || value.trim().isEmpty) {
return 'Trường này là bắt buộc';
}
@@ -491,12 +486,7 @@ class Validators {
}
/// Password strength enum
enum PasswordStrength {
weak,
medium,
strong,
veryStrong,
}
enum PasswordStrength { weak, medium, strong, veryStrong }
/// Password strength calculator
class PasswordStrengthCalculator {

View File

@@ -58,10 +58,7 @@ class CustomBottomNavBar extends StatelessWidget {
selectedFontSize: 12,
unselectedFontSize: 12,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(
icon: Icon(Icons.shopping_bag),
label: 'Products',
@@ -70,14 +67,8 @@ class CustomBottomNavBar extends StatelessWidget {
icon: Icon(Icons.card_membership),
label: 'Loyalty',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Account',
),
BottomNavigationBarItem(
icon: Icon(Icons.menu),
label: 'More',
),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Account'),
BottomNavigationBarItem(icon: Icon(Icons.menu), label: 'More'),
],
);
}

View File

@@ -124,10 +124,7 @@ class CustomButton extends StatelessWidget {
const SizedBox(width: 8),
Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
],
);
@@ -135,10 +132,7 @@ class CustomButton extends StatelessWidget {
return Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
);
}
}

View File

@@ -54,11 +54,7 @@ class EmptyState extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: iconSize,
color: AppColors.grey500,
),
Icon(icon, size: iconSize, color: AppColors.grey500),
const SizedBox(height: 16),
Text(
title,
@@ -73,10 +69,7 @@ class EmptyState extends StatelessWidget {
const SizedBox(height: 8),
Text(
subtitle!,
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
textAlign: TextAlign.center,
),
],

View File

@@ -58,15 +58,9 @@ class ChatFloatingButton extends StatelessWidget {
decoration: BoxDecoration(
color: AppColors.danger,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2,
),
),
constraints: const BoxConstraints(
minWidth: 20,
minHeight: 20,
border: Border.all(color: Colors.white, width: 2),
),
constraints: const BoxConstraints(minWidth: 20, minHeight: 20),
child: Center(
child: Text(
unreadCount! > 99 ? '99+' : unreadCount.toString(),

View File

@@ -50,10 +50,7 @@ class CustomLoadingIndicator extends StatelessWidget {
const SizedBox(height: 16),
Text(
message!,
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
textAlign: TextAlign.center,
),
],

View File

@@ -6,18 +6,39 @@ part 'audit_log_model.g.dart';
@HiveType(typeId: HiveTypeIds.auditLogModel)
class AuditLogModel extends HiveObject {
AuditLogModel({required this.logId, required this.userId, required this.action, required this.entityType, required this.entityId, this.oldValue, this.newValue, this.ipAddress, this.userAgent, required this.timestamp});
AuditLogModel({
required this.logId,
required this.userId,
required this.action,
required this.entityType,
required this.entityId,
this.oldValue,
this.newValue,
this.ipAddress,
this.userAgent,
required this.timestamp,
});
@HiveField(0) final int logId;
@HiveField(1) final String userId;
@HiveField(2) final String action;
@HiveField(3) final String entityType;
@HiveField(4) final String entityId;
@HiveField(5) final String? oldValue;
@HiveField(6) final String? newValue;
@HiveField(7) final String? ipAddress;
@HiveField(8) final String? userAgent;
@HiveField(9) final DateTime timestamp;
@HiveField(0)
final int logId;
@HiveField(1)
final String userId;
@HiveField(2)
final String action;
@HiveField(3)
final String entityType;
@HiveField(4)
final String entityId;
@HiveField(5)
final String? oldValue;
@HiveField(6)
final String? newValue;
@HiveField(7)
final String? ipAddress;
@HiveField(8)
final String? userAgent;
@HiveField(9)
final DateTime timestamp;
factory AuditLogModel.fromJson(Map<String, dynamic> json) => AuditLogModel(
logId: json['log_id'] as int,

View File

@@ -6,33 +6,65 @@ part 'payment_reminder_model.g.dart';
@HiveType(typeId: HiveTypeIds.paymentReminderModel)
class PaymentReminderModel extends HiveObject {
PaymentReminderModel({required this.reminderId, required this.invoiceId, required this.userId, required this.reminderType, required this.subject, required this.message, required this.isRead, required this.isSent, this.scheduledAt, this.sentAt, this.readAt});
PaymentReminderModel({
required this.reminderId,
required this.invoiceId,
required this.userId,
required this.reminderType,
required this.subject,
required this.message,
required this.isRead,
required this.isSent,
this.scheduledAt,
this.sentAt,
this.readAt,
});
@HiveField(0) final String reminderId;
@HiveField(1) final String invoiceId;
@HiveField(2) final String userId;
@HiveField(3) final ReminderType reminderType;
@HiveField(4) final String subject;
@HiveField(5) final String message;
@HiveField(6) final bool isRead;
@HiveField(7) final bool isSent;
@HiveField(8) final DateTime? scheduledAt;
@HiveField(9) final DateTime? sentAt;
@HiveField(10) final DateTime? readAt;
@HiveField(0)
final String reminderId;
@HiveField(1)
final String invoiceId;
@HiveField(2)
final String userId;
@HiveField(3)
final ReminderType reminderType;
@HiveField(4)
final String subject;
@HiveField(5)
final String message;
@HiveField(6)
final bool isRead;
@HiveField(7)
final bool isSent;
@HiveField(8)
final DateTime? scheduledAt;
@HiveField(9)
final DateTime? sentAt;
@HiveField(10)
final DateTime? readAt;
factory PaymentReminderModel.fromJson(Map<String, dynamic> json) => PaymentReminderModel(
reminderId: json['reminder_id'] as String,
invoiceId: json['invoice_id'] as String,
userId: json['user_id'] as String,
reminderType: ReminderType.values.firstWhere((e) => e.name == json['reminder_type']),
subject: json['subject'] as String,
message: json['message'] as String,
isRead: json['is_read'] as bool? ?? false,
isSent: json['is_sent'] as bool? ?? false,
scheduledAt: json['scheduled_at'] != null ? DateTime.parse(json['scheduled_at']?.toString() ?? '') : null,
sentAt: json['sent_at'] != null ? DateTime.parse(json['sent_at']?.toString() ?? '') : null,
readAt: json['read_at'] != null ? DateTime.parse(json['read_at']?.toString() ?? '') : null,
);
factory PaymentReminderModel.fromJson(Map<String, dynamic> json) =>
PaymentReminderModel(
reminderId: json['reminder_id'] as String,
invoiceId: json['invoice_id'] as String,
userId: json['user_id'] as String,
reminderType: ReminderType.values.firstWhere(
(e) => e.name == json['reminder_type'],
),
subject: json['subject'] as String,
message: json['message'] as String,
isRead: json['is_read'] as bool? ?? false,
isSent: json['is_sent'] as bool? ?? false,
scheduledAt: json['scheduled_at'] != null
? DateTime.parse(json['scheduled_at']?.toString() ?? '')
: null,
sentAt: json['sent_at'] != null
? DateTime.parse(json['sent_at']?.toString() ?? '')
: null,
readAt: json['read_at'] != null
? DateTime.parse(json['read_at']?.toString() ?? '')
: null,
);
Map<String, dynamic> toJson() => {
'reminder_id': reminderId,

View File

@@ -134,13 +134,7 @@ class AuditLog {
@override
int get hashCode {
return Object.hash(
logId,
userId,
action,
entityType,
entityId,
);
return Object.hash(logId, userId, action, entityType, entityId);
}
@override

View File

@@ -59,10 +59,7 @@ class AccountMenuItem extends StatelessWidget {
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: AppColors.grey100,
width: 1.0,
),
bottom: BorderSide(color: AppColors.grey100, width: 1.0),
),
),
child: Row(
@@ -72,7 +69,9 @@ class AccountMenuItem extends StatelessWidget {
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconBackgroundColor ?? AppColors.lightBlue.withValues(alpha: 0.1),
color:
iconBackgroundColor ??
AppColors.lightBlue.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(

View File

@@ -0,0 +1,122 @@
/// Authentication Local Data Source
///
/// Handles secure local storage of authentication session data.
/// Uses flutter_secure_storage for SID and CSRF token (encrypted).
library;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:worker/features/auth/data/models/auth_session_model.dart';
/// Authentication Local Data Source
///
/// Manages session data (SID, CSRF token) using secure storage.
/// Session tokens are stored encrypted on device.
class AuthLocalDataSource {
final FlutterSecureStorage _secureStorage;
/// Secure storage keys
static const String _sidKey = 'auth_session_sid';
static const String _csrfTokenKey = 'auth_session_csrf_token';
static const String _fullNameKey = 'auth_session_full_name';
static const String _createdAtKey = 'auth_session_created_at';
static const String _appsKey = 'auth_session_apps';
AuthLocalDataSource(this._secureStorage);
/// Save session data securely
///
/// Stores SID, CSRF token, and user info in encrypted storage.
Future<void> saveSession(SessionData session) async {
await _secureStorage.write(key: _sidKey, value: session.sid);
await _secureStorage.write(key: _csrfTokenKey, value: session.csrfToken);
await _secureStorage.write(key: _fullNameKey, value: session.fullName);
await _secureStorage.write(
key: _createdAtKey,
value: session.createdAt.toIso8601String(),
);
// Store apps as JSON string if available
if (session.apps != null && session.apps!.isNotEmpty) {
final appsJson = session.apps!.map((app) => app.toJson()).toList();
// Convert to JSON string for storage
await _secureStorage.write(key: _appsKey, value: appsJson.toString());
}
}
/// Get stored session data
///
/// Returns null if no session is stored.
Future<SessionData?> getSession() async {
final sid = await _secureStorage.read(key: _sidKey);
final csrfToken = await _secureStorage.read(key: _csrfTokenKey);
final fullName = await _secureStorage.read(key: _fullNameKey);
final createdAtStr = await _secureStorage.read(key: _createdAtKey);
if (sid == null || csrfToken == null || fullName == null) {
return null;
}
final createdAt = createdAtStr != null
? DateTime.tryParse(createdAtStr) ?? DateTime.now()
: DateTime.now();
// TODO: Parse apps from JSON string if needed
// For now, apps are optional
return SessionData(
sid: sid,
csrfToken: csrfToken,
fullName: fullName,
createdAt: createdAt,
apps: null, // TODO: Parse from stored JSON if needed
);
}
/// Get SID (Session ID)
///
/// Returns null if not logged in.
Future<String?> getSid() async {
return await _secureStorage.read(key: _sidKey);
}
/// Get CSRF Token
///
/// Returns null if not logged in.
Future<String?> getCsrfToken() async {
return await _secureStorage.read(key: _csrfTokenKey);
}
/// Get Full Name
///
/// Returns null if not logged in.
Future<String?> getFullName() async {
return await _secureStorage.read(key: _fullNameKey);
}
/// Check if user has valid session
///
/// Returns true if SID and CSRF token are present.
Future<bool> hasValidSession() async {
final sid = await getSid();
final csrfToken = await getCsrfToken();
return sid != null && csrfToken != null;
}
/// Clear session data
///
/// Called during logout to remove all session information.
Future<void> clearSession() async {
await _secureStorage.delete(key: _sidKey);
await _secureStorage.delete(key: _csrfTokenKey);
await _secureStorage.delete(key: _fullNameKey);
await _secureStorage.delete(key: _createdAtKey);
await _secureStorage.delete(key: _appsKey);
}
/// Clear all authentication data
///
/// Complete cleanup of all stored auth data.
Future<void> clearAll() async {
await _secureStorage.deleteAll();
}
}

View File

@@ -0,0 +1,86 @@
/// Authentication Session Model
///
/// Models for API authentication response structure.
/// Matches the ERPNext login API response format.
library;
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_session_model.freezed.dart';
part 'auth_session_model.g.dart';
/// App Information
///
/// Represents an available app in the system.
@freezed
sealed class AppInfo with _$AppInfo {
const factory AppInfo({
@JsonKey(name: 'app_title') required String appTitle,
@JsonKey(name: 'app_endpoint') required String appEndpoint,
@JsonKey(name: 'app_logo') required String appLogo,
}) = _AppInfo;
factory AppInfo.fromJson(Map<String, dynamic> json) =>
_$AppInfoFromJson(json);
}
/// Login Response Message
///
/// Contains the core authentication data from login response.
@freezed
sealed class LoginMessage with _$LoginMessage {
const factory LoginMessage({
required bool success,
required String message,
required String sid,
@JsonKey(name: 'csrf_token') required String csrfToken,
@Default([]) List<AppInfo> apps,
}) = _LoginMessage;
factory LoginMessage.fromJson(Map<String, dynamic> json) =>
_$LoginMessageFromJson(json);
}
/// Authentication Session Response
///
/// Complete authentication response from ERPNext login API.
@freezed
sealed class AuthSessionResponse with _$AuthSessionResponse {
const factory AuthSessionResponse({
@JsonKey(name: 'session_expired') required int sessionExpired,
required LoginMessage message,
@JsonKey(name: 'home_page') required String homePage,
@JsonKey(name: 'full_name') required String fullName,
}) = _AuthSessionResponse;
factory AuthSessionResponse.fromJson(Map<String, dynamic> json) =>
_$AuthSessionResponseFromJson(json);
}
/// Session Storage Model
///
/// Simplified model for storing session data in Hive.
@freezed
sealed class SessionData with _$SessionData {
const factory SessionData({
required String sid,
required String csrfToken,
required String fullName,
required DateTime createdAt,
List<AppInfo>? apps,
}) = _SessionData;
factory SessionData.fromJson(Map<String, dynamic> json) =>
_$SessionDataFromJson(json);
/// Create from API response
factory SessionData.fromAuthResponse(AuthSessionResponse response) {
return SessionData(
sid: response.message.sid,
csrfToken: response.message.csrfToken,
fullName: response.fullName,
createdAt: DateTime.now(),
apps: response.message.apps,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_session_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_AppInfo _$AppInfoFromJson(Map<String, dynamic> json) => $checkedCreate(
'_AppInfo',
json,
($checkedConvert) {
final val = _AppInfo(
appTitle: $checkedConvert('app_title', (v) => v as String),
appEndpoint: $checkedConvert('app_endpoint', (v) => v as String),
appLogo: $checkedConvert('app_logo', (v) => v as String),
);
return val;
},
fieldKeyMap: const {
'appTitle': 'app_title',
'appEndpoint': 'app_endpoint',
'appLogo': 'app_logo',
},
);
Map<String, dynamic> _$AppInfoToJson(_AppInfo instance) => <String, dynamic>{
'app_title': instance.appTitle,
'app_endpoint': instance.appEndpoint,
'app_logo': instance.appLogo,
};
_LoginMessage _$LoginMessageFromJson(Map<String, dynamic> json) =>
$checkedCreate('_LoginMessage', json, ($checkedConvert) {
final val = _LoginMessage(
success: $checkedConvert('success', (v) => v as bool),
message: $checkedConvert('message', (v) => v as String),
sid: $checkedConvert('sid', (v) => v as String),
csrfToken: $checkedConvert('csrf_token', (v) => v as String),
apps: $checkedConvert(
'apps',
(v) =>
(v as List<dynamic>?)
?.map((e) => AppInfo.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
),
);
return val;
}, fieldKeyMap: const {'csrfToken': 'csrf_token'});
Map<String, dynamic> _$LoginMessageToJson(_LoginMessage instance) =>
<String, dynamic>{
'success': instance.success,
'message': instance.message,
'sid': instance.sid,
'csrf_token': instance.csrfToken,
'apps': instance.apps.map((e) => e.toJson()).toList(),
};
_AuthSessionResponse _$AuthSessionResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate(
'_AuthSessionResponse',
json,
($checkedConvert) {
final val = _AuthSessionResponse(
sessionExpired: $checkedConvert(
'session_expired',
(v) => (v as num).toInt(),
),
message: $checkedConvert(
'message',
(v) => LoginMessage.fromJson(v as Map<String, dynamic>),
),
homePage: $checkedConvert('home_page', (v) => v as String),
fullName: $checkedConvert('full_name', (v) => v as String),
);
return val;
},
fieldKeyMap: const {
'sessionExpired': 'session_expired',
'homePage': 'home_page',
'fullName': 'full_name',
},
);
Map<String, dynamic> _$AuthSessionResponseToJson(
_AuthSessionResponse instance,
) => <String, dynamic>{
'session_expired': instance.sessionExpired,
'message': instance.message.toJson(),
'home_page': instance.homePage,
'full_name': instance.fullName,
};
_SessionData _$SessionDataFromJson(Map<String, dynamic> json) => $checkedCreate(
'_SessionData',
json,
($checkedConvert) {
final val = _SessionData(
sid: $checkedConvert('sid', (v) => v as String),
csrfToken: $checkedConvert('csrf_token', (v) => v as String),
fullName: $checkedConvert('full_name', (v) => v as String),
createdAt: $checkedConvert(
'created_at',
(v) => DateTime.parse(v as String),
),
apps: $checkedConvert(
'apps',
(v) => (v as List<dynamic>?)
?.map((e) => AppInfo.fromJson(e as Map<String, dynamic>))
.toList(),
),
);
return val;
},
fieldKeyMap: const {
'csrfToken': 'csrf_token',
'fullName': 'full_name',
'createdAt': 'created_at',
},
);
Map<String, dynamic> _$SessionDataToJson(_SessionData instance) =>
<String, dynamic>{
'sid': instance.sid,
'csrf_token': instance.csrfToken,
'full_name': instance.fullName,
'created_at': instance.createdAt.toIso8601String(),
'apps': ?instance.apps?.map((e) => e.toJson()).toList(),
};

View File

@@ -0,0 +1,91 @@
/// Business Unit Data Model
///
/// Hive model for local storage of business units.
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/auth/domain/entities/business_unit.dart';
part 'business_unit_model.g.dart';
/// Business Unit Model for Hive storage
@HiveType(typeId: HiveTypeIds.businessUnitModel)
class BusinessUnitModel extends HiveObject {
/// Unique business unit identifier
@HiveField(0)
String id;
/// Business unit code (e.g., "VIKD", "HSKD", "LPKD")
@HiveField(1)
String code;
/// Display name
@HiveField(2)
String name;
/// Description
@HiveField(3)
String? description;
/// Whether this is the default unit
@HiveField(4)
bool isDefault;
BusinessUnitModel({
required this.id,
required this.code,
required this.name,
this.description,
this.isDefault = false,
});
/// Convert to domain entity
BusinessUnit toEntity() {
return BusinessUnit(
id: id,
code: code,
name: name,
description: description,
isDefault: isDefault,
);
}
/// Create from domain entity
factory BusinessUnitModel.fromEntity(BusinessUnit entity) {
return BusinessUnitModel(
id: entity.id,
code: entity.code,
name: entity.name,
description: entity.description,
isDefault: entity.isDefault,
);
}
/// Create from JSON
factory BusinessUnitModel.fromJson(Map<String, dynamic> json) {
return BusinessUnitModel(
id: json['id'] as String,
code: json['code'] as String,
name: json['name'] as String,
description: json['description'] as String?,
isDefault: json['is_default'] as bool? ?? false,
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'code': code,
'name': name,
'description': description,
'is_default': isDefault,
};
}
@override
String toString() {
return 'BusinessUnitModel(id: $id, code: $code, name: $name)';
}
}

View File

@@ -0,0 +1,53 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'business_unit_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class BusinessUnitModelAdapter extends TypeAdapter<BusinessUnitModel> {
@override
final typeId = 29;
@override
BusinessUnitModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return BusinessUnitModel(
id: fields[0] as String,
code: fields[1] as String,
name: fields[2] as String,
description: fields[3] as String?,
isDefault: fields[4] == null ? false : fields[4] as bool,
);
}
@override
void write(BinaryWriter writer, BusinessUnitModel obj) {
writer
..writeByte(5)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.code)
..writeByte(2)
..write(obj.name)
..writeByte(3)
..write(obj.description)
..writeByte(4)
..write(obj.isDefault);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BusinessUnitModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,93 @@
/// Domain Entity: Business Unit
///
/// Represents a business unit that a user can access.
library;
/// Business Unit Entity
///
/// Represents a business division or unit that a user has access to.
class BusinessUnit {
/// Unique business unit identifier
final String id;
/// Business unit code (e.g., "VIKD", "HSKD", "LPKD")
final String code;
/// Display name
final String name;
/// Description
final String? description;
/// Whether this is the default unit
final bool isDefault;
const BusinessUnit({
required this.id,
required this.code,
required this.name,
this.description,
this.isDefault = false,
});
/// Create from JSON map
factory BusinessUnit.fromJson(Map<String, dynamic> json) {
return BusinessUnit(
id: json['id'] as String,
code: json['code'] as String,
name: json['name'] as String,
description: json['description'] as String?,
isDefault: json['is_default'] as bool? ?? false,
);
}
/// Convert to JSON map
Map<String, dynamic> toJson() {
return {
'id': id,
'code': code,
'name': name,
'description': description,
'is_default': isDefault,
};
}
/// Copy with method for immutability
BusinessUnit copyWith({
String? id,
String? code,
String? name,
String? description,
bool? isDefault,
}) {
return BusinessUnit(
id: id ?? this.id,
code: code ?? this.code,
name: name ?? this.name,
description: description ?? this.description,
isDefault: isDefault ?? this.isDefault,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is BusinessUnit &&
other.id == id &&
other.code == code &&
other.name == name &&
other.description == description &&
other.isDefault == isDefault;
}
@override
int get hashCode {
return Object.hash(id, code, name, description, isDefault);
}
@override
String toString() {
return 'BusinessUnit(id: $id, code: $code, name: $name, isDefault: $isDefault)';
}
}

View File

@@ -19,7 +19,7 @@ enum UserRole {
accountant,
/// Designer
designer;
designer,
}
/// User status enum
@@ -34,7 +34,7 @@ enum UserStatus {
suspended,
/// Rejected account
rejected;
rejected,
}
/// Loyalty tier enum

View File

@@ -0,0 +1,396 @@
/// Business Unit Selection Page
///
/// Allows users to select a business unit during registration or after login
/// when they have access to multiple units.
library;
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/auth/domain/entities/business_unit.dart';
/// Business Unit Selection Page
///
/// Flow:
/// 1. During registration: User selects unit before going to register page
/// 2. After login: User selects unit if they have multiple units
class BusinessUnitSelectionPage extends StatefulWidget {
const BusinessUnitSelectionPage({
super.key,
this.businessUnits,
this.isRegistrationFlow = false,
this.onUnitSelected,
});
/// List of available business units
final List<BusinessUnit>? businessUnits;
/// Whether this is part of registration flow
final bool isRegistrationFlow;
/// Callback when business unit is selected (for registration flow)
final void Function(BusinessUnit)? onUnitSelected;
@override
State<BusinessUnitSelectionPage> createState() =>
_BusinessUnitSelectionPageState();
}
class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
BusinessUnit? _selectedUnit;
late List<BusinessUnit> _availableUnits;
@override
void initState() {
super.initState();
// Use provided units or mock data for registration flow
_availableUnits = widget.businessUnits ?? _getMockBusinessUnits();
}
/// Mock business units for registration flow
/// TODO: Replace with actual API data when backend is ready
List<BusinessUnit> _getMockBusinessUnits() {
return [
const BusinessUnit(
id: '1',
code: 'VIKD',
name: 'VIKD',
description: 'Đơn vị kinh doanh VIKD',
),
const BusinessUnit(
id: '2',
code: 'HSKD',
name: 'HSKD',
description: 'Đơn vị kinh doanh HSKD',
),
const BusinessUnit(
id: '3',
code: 'LPKD',
name: 'LPKD',
description: 'Đơn vị kinh doanh LPKD',
),
];
}
void _handleContinue() {
if (_selectedUnit == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Vui lòng chọn đơn vị kinh doanh'),
backgroundColor: AppColors.danger,
),
);
return;
}
if (widget.isRegistrationFlow) {
// Registration flow: pass selected unit to register page
widget.onUnitSelected?.call(_selectedUnit!);
context.pushNamed(
RouteNames.register,
extra: {'businessUnit': _selectedUnit},
);
} else {
// Login flow: save selected unit and navigate to home
// TODO: Save selected unit to local storage/state
context.goNamed(RouteNames.home);
}
}
void _showInfoDialog() {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Đơn vị kinh doanh'),
content: const Text(
'Chọn đơn vị kinh doanh mà bạn muốn truy cập. '
'Bạn có thể thay đổi đơn vị sau khi đăng nhập.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Đóng'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.white,
appBar: AppBar(
backgroundColor: AppColors.white,
elevation: AppBarSpecs.elevation,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => context.pop(),
),
title: const Text(
'Đơn vị kinh doanh',
style: TextStyle(
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
centerTitle: false,
actions: [
IconButton(
icon: const Icon(Icons.info_outline, color: Colors.black),
onPressed: _showInfoDialog,
),
const SizedBox(width: AppSpacing.sm),
],
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo Section
Center(
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primaryBlue, AppColors.lightBlue],
),
borderRadius: BorderRadius.circular(20),
),
child: const Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'DBIZ',
style: TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.w700,
),
),
Text(
'Worker App',
style: TextStyle(color: Colors.white, fontSize: 12),
),
],
),
),
),
const SizedBox(height: AppSpacing.xl),
// Welcome Message
const Text(
'Chọn đơn vị kinh doanh để tiếp tục',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.grey500, fontSize: 14),
),
const SizedBox(height: 40),
// Business Unit Selection List
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.sm),
child: Text(
'Đơn vị kinh doanh',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
),
),
),
const SizedBox(height: AppSpacing.md),
// Business Unit List Tiles
...(_availableUnits.asMap().entries.map((entry) {
final index = entry.key;
final unit = entry.value;
final isSelected = _selectedUnit?.id == unit.id;
final isFirst = index == 0;
final isLast = index == _availableUnits.length - 1;
return Container(
margin: EdgeInsets.only(
bottom: isLast ? 0 : AppSpacing.xs,
),
decoration: BoxDecoration(
color: AppColors.white,
border: Border.all(
color: isSelected
? AppColors.primaryBlue
: AppColors.grey100,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.vertical(
top: isFirst
? const Radius.circular(
InputFieldSpecs.borderRadius,
)
: Radius.zero,
bottom: isLast
? const Radius.circular(
InputFieldSpecs.borderRadius,
)
: Radius.zero,
),
boxShadow: isSelected
? [
BoxShadow(
color: AppColors.primaryBlue.withValues(
alpha: 0.1,
),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: InkWell(
onTap: () {
setState(() {
_selectedUnit = unit;
});
},
borderRadius: BorderRadius.vertical(
top: isFirst
? const Radius.circular(
InputFieldSpecs.borderRadius,
)
: Radius.zero,
bottom: isLast
? const Radius.circular(
InputFieldSpecs.borderRadius,
)
: Radius.zero,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
),
child: Row(
children: [
// Icon
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isSelected
? AppColors.primaryBlue.withValues(
alpha: 0.1,
)
: AppColors.grey50,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.business,
color: isSelected
? AppColors.primaryBlue
: AppColors.grey500,
size: 20,
),
),
const SizedBox(width: AppSpacing.md),
// Unit Name
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
unit.name,
style: TextStyle(
fontSize: 16,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w500,
color: isSelected
? AppColors.primaryBlue
: AppColors.grey900,
),
),
if (unit.description != null) ...[
const SizedBox(height: 2),
Text(
unit.description!,
style: const TextStyle(
fontSize: 12,
color: AppColors.grey500,
),
),
],
],
),
),
// Radio indicator
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? AppColors.primaryBlue
: AppColors.grey500,
width: 2,
),
color: isSelected
? AppColors.primaryBlue
: Colors.transparent,
),
child: isSelected
? const Icon(
Icons.circle,
size: 10,
color: AppColors.white,
)
: null,
),
],
),
),
),
);
}).toList()),
],
),
const SizedBox(height: AppSpacing.xl),
// Continue Button
SizedBox(
height: ButtonSpecs.height,
child: ElevatedButton(
onPressed: _handleContinue,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
ButtonSpecs.borderRadius,
),
),
),
child: const Text(
'Tiếp tục',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,489 @@
/// Login Page
///
/// Main authentication page for the Worker app.
/// Allows users to login with phone number and password.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/core/utils/validators.dart';
import 'package:worker/features/auth/presentation/providers/auth_provider.dart';
import 'package:worker/features/auth/presentation/providers/password_visibility_provider.dart';
import 'package:worker/features/auth/presentation/widgets/phone_input_field.dart';
/// Login Page
///
/// Provides phone and password authentication.
/// On successful login, navigates to home page.
/// Links to registration page for new users.
///
/// Features:
/// - Phone number input with Vietnamese format validation
/// - Password input with visibility toggle
/// - Form validation
/// - Loading states
/// - Error handling with snackbar
/// - Link to registration
/// - Customer support link
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
// Form key for validation
final _formKey = GlobalKey<FormState>();
// Controllers
final _phoneController = TextEditingController(text: "0988111111");
final _passwordController = TextEditingController(text: "123456");
// Focus nodes
final _phoneFocusNode = FocusNode();
final _passwordFocusNode = FocusNode();
@override
void dispose() {
_phoneController.dispose();
_passwordController.dispose();
_phoneFocusNode.dispose();
_passwordFocusNode.dispose();
super.dispose();
}
/// Handle login button press
Future<void> _handleLogin() async {
// Validate form
if (!_formKey.currentState!.validate()) {
return;
}
// Unfocus keyboard
FocusScope.of(context).unfocus();
try {
// Call login method
await ref
.read(authProvider.notifier)
.login(
phoneNumber: _phoneController.text.trim(),
password: _passwordController.text,
);
// Check if login was successful
final authState = ref.read(authProvider);
authState.when(
data: (user) {
if (user != null && mounted) {
// Navigate to home on success
context.goHome();
}
},
loading: () {},
error: (error, stack) {
// Show error snackbar
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(error.toString()),
backgroundColor: AppColors.danger,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
),
);
}
},
);
} catch (e) {
// Show error snackbar
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Đăng nhập thất bại: ${e.toString()}'),
backgroundColor: AppColors.danger,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
),
);
}
}
}
/// Navigate to business unit selection (registration flow)
void _navigateToRegister() {
// Navigate to business unit selection page first
context.pushNamed(
RouteNames.businessUnitSelection,
extra: {'isRegistrationFlow': true},
);
}
/// Show support dialog
void _showSupport() {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Hỗ trợ khách hàng'),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Hotline: 1900 xxxx'),
SizedBox(height: AppSpacing.sm),
Text('Email: support@eurotile.vn'),
SizedBox(height: AppSpacing.sm),
Text('Giờ làm việc: 8:00 - 17:00 (T2-T6)'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Đóng'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
// Watch auth state for loading indicator
final authState = ref.watch(authProvider);
final isPasswordVisible = ref.watch(passwordVisibilityProvider);
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: AppSpacing.xl),
// Logo Section
_buildLogo(),
const SizedBox(height: AppSpacing.xl),
// Welcome Message
_buildWelcomeMessage(),
const SizedBox(height: AppSpacing.xl),
// Login Form Card
_buildLoginForm(authState, isPasswordVisible),
const SizedBox(height: AppSpacing.lg),
// Register Link
_buildRegisterLink(),
const SizedBox(height: AppSpacing.xl),
// Support Link
_buildSupportLink(),
],
),
),
),
),
);
}
/// Build logo section
Widget _buildLogo() {
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 20.0),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.primaryBlue, AppColors.lightBlue],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20.0),
),
child: const Column(
children: [
Text(
'EUROTILE',
style: TextStyle(
color: AppColors.white,
fontSize: 32.0,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
),
),
SizedBox(height: 4.0),
Text(
'Worker App',
style: TextStyle(
color: AppColors.white,
fontSize: 12.0,
letterSpacing: 0.5,
),
),
],
),
),
);
}
/// Build welcome message
Widget _buildWelcomeMessage() {
return const Column(
children: [
Text(
'Xin chào!',
style: TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.bold,
color: AppColors.grey900,
),
),
SizedBox(height: AppSpacing.xs),
Text(
'Đăng nhập để tiếp tục',
style: TextStyle(fontSize: 16.0, color: AppColors.grey500),
),
],
);
}
/// Build login form card
Widget _buildLoginForm(
AsyncValue<dynamic> authState,
bool isPasswordVisible,
) {
final isLoading = authState.isLoading;
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10.0,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Phone Input
PhoneInputField(
controller: _phoneController,
focusNode: _phoneFocusNode,
validator: Validators.phone,
enabled: !isLoading,
onFieldSubmitted: (_) {
// Move focus to password field
FocusScope.of(context).requestFocus(_passwordFocusNode);
},
),
const SizedBox(height: AppSpacing.md),
// Password Input
TextFormField(
controller: _passwordController,
focusNode: _passwordFocusNode,
enabled: !isLoading,
obscureText: !isPasswordVisible,
textInputAction: TextInputAction.done,
style: const TextStyle(
fontSize: InputFieldSpecs.fontSize,
color: AppColors.grey900,
),
decoration: InputDecoration(
labelText: 'Mật khẩu',
labelStyle: const TextStyle(
fontSize: InputFieldSpecs.labelFontSize,
color: AppColors.grey500,
),
hintText: 'Nhập mật khẩu',
hintStyle: const TextStyle(
fontSize: InputFieldSpecs.hintFontSize,
color: AppColors.grey500,
),
prefixIcon: const Icon(
Icons.lock,
color: AppColors.primaryBlue,
size: AppIconSize.md,
),
suffixIcon: IconButton(
icon: Icon(
isPasswordVisible ? Icons.visibility : Icons.visibility_off,
color: AppColors.grey500,
size: AppIconSize.md,
),
onPressed: () {
ref.read(passwordVisibilityProvider.notifier).toggle();
},
),
filled: true,
fillColor: AppColors.white,
contentPadding: InputFieldSpecs.contentPadding,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(
InputFieldSpecs.borderRadius,
),
borderSide: const BorderSide(
color: AppColors.grey100,
width: 1.0,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(
InputFieldSpecs.borderRadius,
),
borderSide: const BorderSide(
color: AppColors.grey100,
width: 1.0,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(
InputFieldSpecs.borderRadius,
),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
width: 2.0,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(
InputFieldSpecs.borderRadius,
),
borderSide: const BorderSide(
color: AppColors.danger,
width: 1.0,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(
InputFieldSpecs.borderRadius,
),
borderSide: const BorderSide(
color: AppColors.danger,
width: 2.0,
),
),
errorStyle: const TextStyle(
fontSize: 12.0,
color: AppColors.danger,
),
),
validator: (value) =>
Validators.passwordSimple(value, minLength: 6),
onFieldSubmitted: (_) {
if (!isLoading) {
_handleLogin();
}
},
),
const SizedBox(height: AppSpacing.lg),
// Login Button
SizedBox(
height: ButtonSpecs.height,
child: ElevatedButton(
onPressed: isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
elevation: ButtonSpecs.elevation,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ButtonSpecs.borderRadius),
),
),
child: isLoading
? const SizedBox(
height: 20.0,
width: 20.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.white,
),
),
)
: const Text(
'Đăng nhập',
style: TextStyle(
fontSize: ButtonSpecs.fontSize,
fontWeight: ButtonSpecs.fontWeight,
),
),
),
),
],
),
);
}
/// Build register link
Widget _buildRegisterLink() {
return Center(
child: RichText(
text: TextSpan(
text: 'Chưa có tài khoản? ',
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
children: [
WidgetSpan(
child: GestureDetector(
onTap: _navigateToRegister,
child: const Text(
'Đăng ký ngay',
style: TextStyle(
fontSize: 14.0,
color: AppColors.primaryBlue,
fontWeight: FontWeight.w500,
decoration: TextDecoration.none,
),
),
),
),
],
),
),
);
}
/// Build support link
Widget _buildSupportLink() {
return Center(
child: TextButton.icon(
onPressed: _showSupport,
icon: const Icon(
Icons.headset_mic,
size: AppIconSize.sm,
color: AppColors.primaryBlue,
),
label: const Text(
'Hỗ trợ khách hàng',
style: TextStyle(
fontSize: 14.0,
color: AppColors.primaryBlue,
fontWeight: FontWeight.w500,
),
),
),
);
}
}

View File

@@ -0,0 +1,806 @@
/// Registration Page
///
/// User registration form with role-based verification requirements.
/// Matches design from html/register.html
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/core/utils/validators.dart';
import 'package:worker/features/auth/domain/entities/business_unit.dart';
import 'package:worker/features/auth/presentation/widgets/phone_input_field.dart';
import 'package:worker/features/auth/presentation/widgets/file_upload_card.dart';
import 'package:worker/features/auth/presentation/widgets/role_dropdown.dart';
/// Registration Page
///
/// Features:
/// - Full name, phone, email, password fields
/// - Role selection (dealer/worker/broker/other)
/// - Conditional verification section for workers/dealers
/// - File upload for ID card and certificate
/// - Company name and city selection
/// - Terms and conditions checkbox
///
/// Navigation:
/// - From: Business unit selection page
/// - To: OTP verification (broker/other) or pending approval (worker/dealer)
class RegisterPage extends ConsumerStatefulWidget {
/// Selected business unit from previous screen
final BusinessUnit? selectedBusinessUnit;
const RegisterPage({super.key, this.selectedBusinessUnit});
@override
ConsumerState<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends ConsumerState<RegisterPage> {
// Form key
final _formKey = GlobalKey<FormState>();
// Text controllers
final _fullNameController = TextEditingController();
final _phoneController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _idNumberController = TextEditingController();
final _taxCodeController = TextEditingController();
final _companyController = TextEditingController();
// Focus nodes
final _fullNameFocus = FocusNode();
final _phoneFocus = FocusNode();
final _emailFocus = FocusNode();
final _passwordFocus = FocusNode();
final _idNumberFocus = FocusNode();
final _taxCodeFocus = FocusNode();
final _companyFocus = FocusNode();
// State
String? _selectedRole;
String? _selectedCity;
File? _idCardFile;
File? _certificateFile;
bool _termsAccepted = false;
bool _passwordVisible = false;
bool _isLoading = false;
final _imagePicker = ImagePicker();
@override
void dispose() {
_fullNameController.dispose();
_phoneController.dispose();
_emailController.dispose();
_passwordController.dispose();
_idNumberController.dispose();
_taxCodeController.dispose();
_companyController.dispose();
_fullNameFocus.dispose();
_phoneFocus.dispose();
_emailFocus.dispose();
_passwordFocus.dispose();
_idNumberFocus.dispose();
_taxCodeFocus.dispose();
_companyFocus.dispose();
super.dispose();
}
/// Check if verification section should be shown
bool get _shouldShowVerification {
return _selectedRole == 'worker' || _selectedRole == 'dealer';
}
/// Pick image from gallery or camera
Future<void> _pickImage(bool isIdCard) async {
try {
// Show bottom sheet to select source
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (context) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Chụp ảnh'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Chọn từ thư viện'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
),
);
if (source == null) return;
final pickedFile = await _imagePicker.pickImage(
source: source,
maxWidth: 1920,
maxHeight: 1080,
imageQuality: 85,
);
if (pickedFile == null) return;
final file = File(pickedFile.path);
// Validate file size (max 5MB)
final fileSize = await file.length();
if (fileSize > 5 * 1024 * 1024) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('File không được vượt quá 5MB'),
backgroundColor: AppColors.danger,
),
);
}
return;
}
setState(() {
if (isIdCard) {
_idCardFile = file;
} else {
_certificateFile = file;
}
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi chọn ảnh: $e'),
backgroundColor: AppColors.danger,
),
);
}
}
}
/// Remove selected image
void _removeImage(bool isIdCard) {
setState(() {
if (isIdCard) {
_idCardFile = null;
} else {
_certificateFile = null;
}
});
}
/// Validate form and submit
Future<void> _handleRegister() async {
// Validate form
if (!_formKey.currentState!.validate()) {
return;
}
// Check terms acceptance
if (!_termsAccepted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Vui lòng đồng ý với Điều khoản sử dụng và Chính sách bảo mật',
),
backgroundColor: AppColors.warning,
),
);
return;
}
// Validate verification requirements for workers/dealers
if (_shouldShowVerification) {
if (_idNumberController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vui lòng nhập số CCCD/CMND'),
backgroundColor: AppColors.warning,
),
);
return;
}
if (_idCardFile == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vui lòng tải lên ảnh CCCD/CMND'),
backgroundColor: AppColors.warning,
),
);
return;
}
if (_certificateFile == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vui lòng tải lên ảnh chứng chỉ hành nghề hoặc GPKD'),
backgroundColor: AppColors.warning,
),
);
return;
}
}
setState(() {
_isLoading = true;
});
try {
// TODO: Implement actual registration API call
// Include widget.selectedBusinessUnit?.id in the API request
// Example:
// final result = await authRepository.register(
// fullName: _fullNameController.text.trim(),
// phone: _phoneController.text.trim(),
// email: _emailController.text.trim(),
// password: _passwordController.text,
// role: _selectedRole,
// businessUnitId: widget.selectedBusinessUnit?.id,
// ...
// );
// For now, simulate API delay
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
// Navigate based on role
if (_shouldShowVerification) {
// For workers/dealers with verification, show pending page
// TODO: Navigate to pending approval page
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Đăng ký thành công! Tài khoản đang chờ xét duyệt.',
),
backgroundColor: AppColors.success,
),
);
context.pop();
} else {
// For other roles, navigate to OTP verification
// TODO: Navigate to OTP verification page
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Đăng ký thành công! Vui lòng xác thực OTP.'),
backgroundColor: AppColors.success,
),
);
// context.push('/otp-verification');
context.pop();
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Đăng ký thất bại: $e'),
backgroundColor: AppColors.danger,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
appBar: AppBar(
backgroundColor: AppColors.white,
elevation: AppBarSpecs.elevation,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => context.pop(),
),
title: const Text(
'Đăng ký tài khoản',
style: TextStyle(
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
centerTitle: false,
),
body: SafeArea(
child: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Welcome section
const Text(
'Tạo tài khoản mới',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.grey900,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.xs),
const Text(
'Điền thông tin để bắt đầu',
style: TextStyle(fontSize: 14, color: AppColors.grey500),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.lg),
// Form card
Container(
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Full Name
_buildLabel('Họ và tên *'),
TextFormField(
controller: _fullNameController,
focusNode: _fullNameFocus,
textInputAction: TextInputAction.next,
decoration: _buildInputDecoration(
hintText: 'Nhập họ và tên',
prefixIcon: Icons.person,
),
validator: (value) => Validators.minLength(
value,
3,
fieldName: 'Họ và tên',
),
),
const SizedBox(height: AppSpacing.md),
// Phone Number
_buildLabel('Số điện thoại *'),
PhoneInputField(
controller: _phoneController,
focusNode: _phoneFocus,
validator: Validators.phone,
),
const SizedBox(height: AppSpacing.md),
// Email
_buildLabel('Email *'),
TextFormField(
controller: _emailController,
focusNode: _emailFocus,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: _buildInputDecoration(
hintText: 'Nhập email',
prefixIcon: Icons.email,
),
validator: Validators.email,
),
const SizedBox(height: AppSpacing.md),
// Password
_buildLabel('Mật khẩu *'),
TextFormField(
controller: _passwordController,
focusNode: _passwordFocus,
obscureText: !_passwordVisible,
textInputAction: TextInputAction.done,
decoration: _buildInputDecoration(
hintText: 'Tạo mật khẩu mới',
prefixIcon: Icons.lock,
suffixIcon: IconButton(
icon: Icon(
_passwordVisible
? Icons.visibility
: Icons.visibility_off,
color: AppColors.grey500,
),
onPressed: () {
setState(() {
_passwordVisible = !_passwordVisible;
});
},
),
),
validator: (value) =>
Validators.passwordSimple(value, minLength: 6),
),
const SizedBox(height: AppSpacing.xs),
const Text(
'Mật khẩu tối thiểu 6 ký tự',
style: TextStyle(
fontSize: 12,
color: AppColors.grey500,
),
),
const SizedBox(height: AppSpacing.md),
// Role Selection
_buildLabel('Vai trò *'),
RoleDropdown(
value: _selectedRole,
onChanged: (value) {
setState(() {
_selectedRole = value;
// Clear verification fields when role changes
if (!_shouldShowVerification) {
_idNumberController.clear();
_taxCodeController.clear();
_idCardFile = null;
_certificateFile = null;
}
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vui lòng chọn vai trò';
}
return null;
},
),
const SizedBox(height: AppSpacing.md),
// Verification Section (conditional)
if (_shouldShowVerification) ...[
_buildVerificationSection(),
const SizedBox(height: AppSpacing.md),
],
// Company Name (optional)
_buildLabel('Tên công ty/Cửa hàng'),
TextFormField(
controller: _companyController,
focusNode: _companyFocus,
textInputAction: TextInputAction.next,
decoration: _buildInputDecoration(
hintText: 'Nhập tên công ty (không bắt buộc)',
prefixIcon: Icons.business,
),
),
const SizedBox(height: AppSpacing.md),
// City/Province
_buildLabel('Tỉnh/Thành phố *'),
DropdownButtonFormField<String>(
value: _selectedCity,
decoration: _buildInputDecoration(
hintText: 'Chọn tỉnh/thành phố',
prefixIcon: Icons.location_city,
),
items: const [
DropdownMenuItem(
value: 'hanoi',
child: Text('Hà Nội'),
),
DropdownMenuItem(
value: 'hcm',
child: Text('TP. Hồ Chí Minh'),
),
DropdownMenuItem(
value: 'danang',
child: Text('Đà Nẵng'),
),
DropdownMenuItem(
value: 'haiphong',
child: Text('Hải Phòng'),
),
DropdownMenuItem(
value: 'cantho',
child: Text('Cần Thơ'),
),
DropdownMenuItem(value: 'other', child: Text('Khác')),
],
onChanged: (value) {
setState(() {
_selectedCity = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vui lòng chọn tỉnh/thành phố';
}
return null;
},
),
const SizedBox(height: AppSpacing.md),
// Terms and Conditions
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
value: _termsAccepted,
onChanged: (value) {
setState(() {
_termsAccepted = value ?? false;
});
},
activeColor: AppColors.primaryBlue,
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 12.0),
child: GestureDetector(
onTap: () {
setState(() {
_termsAccepted = !_termsAccepted;
});
},
child: const Text.rich(
TextSpan(
text: 'Tôi đồng ý với ',
style: TextStyle(fontSize: 13),
children: [
TextSpan(
text: 'Điều khoản sử dụng',
style: TextStyle(
color: AppColors.primaryBlue,
fontWeight: FontWeight.w500,
),
),
TextSpan(text: ''),
TextSpan(
text: 'Chính sách bảo mật',
style: TextStyle(
color: AppColors.primaryBlue,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
),
],
),
const SizedBox(height: AppSpacing.lg),
// Register Button
SizedBox(
height: ButtonSpecs.height,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleRegister,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
ButtonSpecs.borderRadius,
),
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.white,
),
),
)
: const Text(
'Đăng ký',
style: TextStyle(
fontSize: ButtonSpecs.fontSize,
fontWeight: ButtonSpecs.fontWeight,
),
),
),
),
],
),
),
const SizedBox(height: AppSpacing.lg),
// Login Link
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Đã có tài khoản? ',
style: TextStyle(fontSize: 13, color: AppColors.grey500),
),
GestureDetector(
onTap: () => context.pop(),
child: const Text(
'Đăng nhập',
style: TextStyle(
fontSize: 13,
color: AppColors.primaryBlue,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: AppSpacing.lg),
],
),
),
),
),
);
}
/// Build label widget
Widget _buildLabel(String text) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
child: Text(
text,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
),
),
);
}
/// Build input decoration
InputDecoration _buildInputDecoration({
required String hintText,
required IconData prefixIcon,
Widget? suffixIcon,
}) {
return InputDecoration(
hintText: hintText,
hintStyle: const TextStyle(
fontSize: InputFieldSpecs.hintFontSize,
color: AppColors.grey500,
),
prefixIcon: Icon(
prefixIcon,
color: AppColors.primaryBlue,
size: AppIconSize.md,
),
suffixIcon: suffixIcon,
filled: true,
fillColor: AppColors.white,
contentPadding: InputFieldSpecs.contentPadding,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2.0),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.danger, width: 1.0),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.danger, width: 2.0),
),
);
}
/// Build verification section
Widget _buildVerificationSection() {
return Container(
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
border: Border.all(color: const Color(0xFFE2E8F0), width: 2),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.shield, color: AppColors.primaryBlue, size: 20),
const SizedBox(width: AppSpacing.xs),
const Text(
'Thông tin xác thực',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.primaryBlue,
),
),
],
),
const SizedBox(height: AppSpacing.xs),
const Text(
'Thông tin này sẽ được dùng để xác minh tư cách chuyên môn của bạn',
style: TextStyle(fontSize: 12, color: AppColors.grey500),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.md),
// ID Number
_buildLabel('Số CCCD/CMND'),
TextFormField(
controller: _idNumberController,
focusNode: _idNumberFocus,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
decoration: _buildInputDecoration(
hintText: 'Nhập số CCCD/CMND',
prefixIcon: Icons.badge,
),
),
const SizedBox(height: AppSpacing.md),
// Tax Code
_buildLabel('Mã số thuế cá nhân/Công ty'),
TextFormField(
controller: _taxCodeController,
focusNode: _taxCodeFocus,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
decoration: _buildInputDecoration(
hintText: 'Nhập mã số thuế (không bắt buộc)',
prefixIcon: Icons.receipt_long,
),
validator: Validators.taxIdOptional,
),
const SizedBox(height: AppSpacing.md),
// ID Card Upload
_buildLabel('Ảnh mặt trước CCCD/CMND'),
FileUploadCard(
file: _idCardFile,
onTap: () => _pickImage(true),
onRemove: () => _removeImage(true),
icon: Icons.camera_alt,
title: 'Chụp ảnh hoặc chọn file',
subtitle: 'JPG, PNG tối đa 5MB',
),
const SizedBox(height: AppSpacing.md),
// Certificate Upload
_buildLabel('Ảnh chứng chỉ hành nghề hoặc GPKD'),
FileUploadCard(
file: _certificateFile,
onTap: () => _pickImage(false),
onRemove: () => _removeImage(false),
icon: Icons.file_present,
title: 'Chụp ảnh hoặc chọn file',
subtitle: 'JPG, PNG tối đa 5MB',
),
],
),
);
}
}

View File

@@ -0,0 +1,279 @@
/// Authentication State Provider
///
/// Manages authentication state for the Worker application.
/// Handles login, logout, and user session management.
///
/// Uses Riverpod 3.0 with code generation for type-safe state management.
library;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart';
import 'package:worker/features/auth/data/models/auth_session_model.dart';
import 'package:worker/features/auth/domain/entities/user.dart';
part 'auth_provider.g.dart';
/// Provide FlutterSecureStorage instance
@riverpod
FlutterSecureStorage secureStorage(Ref ref) {
return const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
}
/// Provide AuthLocalDataSource instance
@riverpod
AuthLocalDataSource authLocalDataSource(Ref ref) {
final secureStorage = ref.watch(secureStorageProvider);
return AuthLocalDataSource(secureStorage);
}
/// Authentication state result
///
/// Represents the result of authentication operations.
/// Contains either the authenticated user or null if logged out.
typedef AuthState = AsyncValue<User?>;
/// Authentication Provider
///
/// Main provider for authentication state management.
/// Provides login and logout functionality with async state handling.
///
/// Usage in widgets:
/// ```dart
/// final authState = ref.watch(authProvider);
/// authState.when(
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
/// loading: () => LoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
@riverpod
class Auth extends _$Auth {
/// Get auth local data source
AuthLocalDataSource get _localDataSource =>
ref.read(authLocalDataSourceProvider);
/// Initialize with saved session if available
@override
Future<User?> build() async {
// Check for saved session in secure storage
final session = await _localDataSource.getSession();
if (session != null) {
// User has saved session, create User entity
final now = DateTime.now();
return User(
userId: 'user_saved', // TODO: Get from API
phoneNumber: '', // TODO: Get from saved user data
fullName: session.fullName,
email: '', // TODO: Get from saved user data
role: UserRole.customer,
status: UserStatus.active,
loyaltyTier: LoyaltyTier.gold,
totalPoints: 0,
companyInfo: null,
cccd: null,
attachments: [],
address: null,
avatarUrl: null,
referralCode: null,
referredBy: null,
erpnextCustomerId: null,
createdAt: session.createdAt,
updatedAt: now,
lastLoginAt: now,
);
}
return null;
}
/// Login with phone number and password
///
/// Simulates ERPNext API authentication with mock response.
/// Stores session data (SID, CSRF token) in Hive.
///
/// Parameters:
/// - [phoneNumber]: User's phone number (Vietnamese format)
/// - [password]: User's password
///
/// Returns: Authenticated User object on success
///
/// Throws: Exception on authentication failure
Future<void> login({
required String phoneNumber,
required String password,
}) async {
// Set loading state
state = const AsyncValue.loading();
// Simulate API call delay
state = await AsyncValue.guard(() async {
await Future<void>.delayed(const Duration(seconds: 2));
// Mock validation
if (phoneNumber.isEmpty || password.isEmpty) {
throw Exception('Số điện thoại và mật khẩu không được để trống');
}
if (password.length < 6) {
throw Exception('Mật khẩu phải có ít nhất 6 ký tự');
}
// Simulate API response matching ERPNext format
final mockApiResponse = AuthSessionResponse(
sessionExpired: 1,
message: const LoginMessage(
success: true,
message: 'Login successful',
sid: 'df7fd4e7ef1041aa3422b0ee861315ba8c28d4fe008a7d7e0e7e0e01',
csrfToken: '6b6e37563854e951c36a7af4177956bb15ca469ca4f498b742648d70',
apps: [
AppInfo(
appTitle: 'App nhân viên kinh doanh',
appEndpoint: '/ecommerce/app-sales',
appLogo:
'https://assets.digitalbiz.com.vn/DBIZ_Internal/Logo/logo_app_sales.png',
),
],
),
homePage: '/apps',
fullName: 'Tân Duy Nguyễn',
);
// Save session data to Hive
final sessionData = SessionData.fromAuthResponse(mockApiResponse);
await _localDataSource.saveSession(sessionData);
// Create and return User entity
final now = DateTime.now();
return User(
userId: 'user_${phoneNumber.replaceAll('+84', '')}',
phoneNumber: phoneNumber,
fullName: mockApiResponse.fullName,
email: 'user@eurotile.vn',
role: UserRole.customer,
status: UserStatus.active,
loyaltyTier: LoyaltyTier.gold,
totalPoints: 1500,
companyInfo: const CompanyInfo(
name: 'Công ty TNHH XYZ',
taxId: '0123456789',
businessType: 'Xây dựng',
),
cccd: '001234567890',
attachments: [],
address: '123 Đường ABC, Quận 1, TP.HCM',
avatarUrl: null,
referralCode: 'REF${phoneNumber.replaceAll('+84', '').substring(0, 6)}',
referredBy: null,
erpnextCustomerId: null,
createdAt: now.subtract(const Duration(days: 30)),
updatedAt: now,
lastLoginAt: now,
);
});
}
/// Logout current user
///
/// Clears authentication state and removes saved session from Hive.
Future<void> logout() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
// Clear saved session from Hive
await _localDataSource.clearSession();
// TODO: Call logout API to invalidate token on server
await Future<void>.delayed(const Duration(milliseconds: 500));
// Return null to indicate logged out
return null;
});
}
/// Get current authenticated user
///
/// Returns the current user if logged in, null otherwise.
User? get currentUser => state.value;
/// Check if user is authenticated
///
/// Returns true if there is a logged-in user.
bool get isAuthenticated => currentUser != null;
/// Check if authentication is in progress
///
/// Returns true during login/logout operations.
bool get isLoading => state.isLoading;
/// Get authentication error if any
///
/// Returns error message or null if no error.
Object? get error => state.error;
}
/// Convenience provider for checking if user is authenticated
///
/// Usage:
/// ```dart
/// final isLoggedIn = ref.watch(isAuthenticatedProvider);
/// if (isLoggedIn) {
/// // Show home screen
/// }
/// ```
@riverpod
bool isAuthenticated(Ref ref) {
final authState = ref.watch(authProvider);
return authState.value != null;
}
/// Convenience provider for getting current user
///
/// Usage:
/// ```dart
/// final user = ref.watch(currentUserProvider);
/// if (user != null) {
/// Text('Welcome ${user.fullName}');
/// }
/// ```
@riverpod
User? currentUser(Ref ref) {
final authState = ref.watch(authProvider);
return authState.value;
}
/// Convenience provider for user's loyalty tier
///
/// Returns the current user's loyalty tier or null if not logged in.
///
/// Usage:
/// ```dart
/// final tier = ref.watch(userLoyaltyTierProvider);
/// if (tier != null) {
/// Text('Tier: ${tier.displayName}');
/// }
/// ```
@riverpod
LoyaltyTier? userLoyaltyTier(Ref ref) {
final user = ref.watch(currentUserProvider);
return user?.loyaltyTier;
}
/// Convenience provider for user's total points
///
/// Returns the current user's total loyalty points or 0 if not logged in.
///
/// Usage:
/// ```dart
/// final points = ref.watch(userTotalPointsProvider);
/// Text('Points: $points');
/// ```
@riverpod
int userTotalPoints(Ref ref) {
final user = ref.watch(currentUserProvider);
return user?.totalPoints ?? 0;
}

View File

@@ -0,0 +1,500 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provide FlutterSecureStorage instance
@ProviderFor(secureStorage)
const secureStorageProvider = SecureStorageProvider._();
/// Provide FlutterSecureStorage instance
final class SecureStorageProvider
extends
$FunctionalProvider<
FlutterSecureStorage,
FlutterSecureStorage,
FlutterSecureStorage
>
with $Provider<FlutterSecureStorage> {
/// Provide FlutterSecureStorage instance
const SecureStorageProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'secureStorageProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$secureStorageHash();
@$internal
@override
$ProviderElement<FlutterSecureStorage> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
FlutterSecureStorage create(Ref ref) {
return secureStorage(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(FlutterSecureStorage value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<FlutterSecureStorage>(value),
);
}
}
String _$secureStorageHash() => r'c3d90388f6d1bb7c95a29ceeda2e56c57deb1ecb';
/// Provide AuthLocalDataSource instance
@ProviderFor(authLocalDataSource)
const authLocalDataSourceProvider = AuthLocalDataSourceProvider._();
/// Provide AuthLocalDataSource instance
final class AuthLocalDataSourceProvider
extends
$FunctionalProvider<
AuthLocalDataSource,
AuthLocalDataSource,
AuthLocalDataSource
>
with $Provider<AuthLocalDataSource> {
/// Provide AuthLocalDataSource instance
const AuthLocalDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'authLocalDataSourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$authLocalDataSourceHash();
@$internal
@override
$ProviderElement<AuthLocalDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
AuthLocalDataSource create(Ref ref) {
return authLocalDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AuthLocalDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AuthLocalDataSource>(value),
);
}
}
String _$authLocalDataSourceHash() =>
r'f104de00a8ab431f6736387fb499c2b6e0ab4924';
/// Authentication Provider
///
/// Main provider for authentication state management.
/// Provides login and logout functionality with async state handling.
///
/// Usage in widgets:
/// ```dart
/// final authState = ref.watch(authProvider);
/// authState.when(
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
/// loading: () => LoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
@ProviderFor(Auth)
const authProvider = AuthProvider._();
/// Authentication Provider
///
/// Main provider for authentication state management.
/// Provides login and logout functionality with async state handling.
///
/// Usage in widgets:
/// ```dart
/// final authState = ref.watch(authProvider);
/// authState.when(
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
/// loading: () => LoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
final class AuthProvider extends $AsyncNotifierProvider<Auth, User?> {
/// Authentication Provider
///
/// Main provider for authentication state management.
/// Provides login and logout functionality with async state handling.
///
/// Usage in widgets:
/// ```dart
/// final authState = ref.watch(authProvider);
/// authState.when(
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
/// loading: () => LoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
const AuthProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'authProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$authHash();
@$internal
@override
Auth create() => Auth();
}
String _$authHash() => r'6f410d1abe6c53a6cbfa52fde7ea7a2d22a7f78d';
/// Authentication Provider
///
/// Main provider for authentication state management.
/// Provides login and logout functionality with async state handling.
///
/// Usage in widgets:
/// ```dart
/// final authState = ref.watch(authProvider);
/// authState.when(
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
/// loading: () => LoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
abstract class _$Auth extends $AsyncNotifier<User?> {
FutureOr<User?> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<User?>, User?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<User?>, User?>,
AsyncValue<User?>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Convenience provider for checking if user is authenticated
///
/// Usage:
/// ```dart
/// final isLoggedIn = ref.watch(isAuthenticatedProvider);
/// if (isLoggedIn) {
/// // Show home screen
/// }
/// ```
@ProviderFor(isAuthenticated)
const isAuthenticatedProvider = IsAuthenticatedProvider._();
/// Convenience provider for checking if user is authenticated
///
/// Usage:
/// ```dart
/// final isLoggedIn = ref.watch(isAuthenticatedProvider);
/// if (isLoggedIn) {
/// // Show home screen
/// }
/// ```
final class IsAuthenticatedProvider
extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
/// Convenience provider for checking if user is authenticated
///
/// Usage:
/// ```dart
/// final isLoggedIn = ref.watch(isAuthenticatedProvider);
/// if (isLoggedIn) {
/// // Show home screen
/// }
/// ```
const IsAuthenticatedProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'isAuthenticatedProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$isAuthenticatedHash();
@$internal
@override
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
bool create(Ref ref) {
return isAuthenticated(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$isAuthenticatedHash() => r'dc783f052ad2ddb7fa18c58e5dc6d212e6c32a96';
/// Convenience provider for getting current user
///
/// Usage:
/// ```dart
/// final user = ref.watch(currentUserProvider);
/// if (user != null) {
/// Text('Welcome ${user.fullName}');
/// }
/// ```
@ProviderFor(currentUser)
const currentUserProvider = CurrentUserProvider._();
/// Convenience provider for getting current user
///
/// Usage:
/// ```dart
/// final user = ref.watch(currentUserProvider);
/// if (user != null) {
/// Text('Welcome ${user.fullName}');
/// }
/// ```
final class CurrentUserProvider extends $FunctionalProvider<User?, User?, User?>
with $Provider<User?> {
/// Convenience provider for getting current user
///
/// Usage:
/// ```dart
/// final user = ref.watch(currentUserProvider);
/// if (user != null) {
/// Text('Welcome ${user.fullName}');
/// }
/// ```
const CurrentUserProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentUserProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentUserHash();
@$internal
@override
$ProviderElement<User?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
User? create(Ref ref) {
return currentUser(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(User? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<User?>(value),
);
}
}
String _$currentUserHash() => r'f3c1da551f4a4c2bf158782ea37a4749a718128a';
/// Convenience provider for user's loyalty tier
///
/// Returns the current user's loyalty tier or null if not logged in.
///
/// Usage:
/// ```dart
/// final tier = ref.watch(userLoyaltyTierProvider);
/// if (tier != null) {
/// Text('Tier: ${tier.displayName}');
/// }
/// ```
@ProviderFor(userLoyaltyTier)
const userLoyaltyTierProvider = UserLoyaltyTierProvider._();
/// Convenience provider for user's loyalty tier
///
/// Returns the current user's loyalty tier or null if not logged in.
///
/// Usage:
/// ```dart
/// final tier = ref.watch(userLoyaltyTierProvider);
/// if (tier != null) {
/// Text('Tier: ${tier.displayName}');
/// }
/// ```
final class UserLoyaltyTierProvider
extends $FunctionalProvider<LoyaltyTier?, LoyaltyTier?, LoyaltyTier?>
with $Provider<LoyaltyTier?> {
/// Convenience provider for user's loyalty tier
///
/// Returns the current user's loyalty tier or null if not logged in.
///
/// Usage:
/// ```dart
/// final tier = ref.watch(userLoyaltyTierProvider);
/// if (tier != null) {
/// Text('Tier: ${tier.displayName}');
/// }
/// ```
const UserLoyaltyTierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'userLoyaltyTierProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$userLoyaltyTierHash();
@$internal
@override
$ProviderElement<LoyaltyTier?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
LoyaltyTier? create(Ref ref) {
return userLoyaltyTier(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(LoyaltyTier? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<LoyaltyTier?>(value),
);
}
}
String _$userLoyaltyTierHash() => r'f1a157486b8bdd2cf64bc2201207f2ac71ea6a69';
/// Convenience provider for user's total points
///
/// Returns the current user's total loyalty points or 0 if not logged in.
///
/// Usage:
/// ```dart
/// final points = ref.watch(userTotalPointsProvider);
/// Text('Points: $points');
/// ```
@ProviderFor(userTotalPoints)
const userTotalPointsProvider = UserTotalPointsProvider._();
/// Convenience provider for user's total points
///
/// Returns the current user's total loyalty points or 0 if not logged in.
///
/// Usage:
/// ```dart
/// final points = ref.watch(userTotalPointsProvider);
/// Text('Points: $points');
/// ```
final class UserTotalPointsProvider extends $FunctionalProvider<int, int, int>
with $Provider<int> {
/// Convenience provider for user's total points
///
/// Returns the current user's total loyalty points or 0 if not logged in.
///
/// Usage:
/// ```dart
/// final points = ref.watch(userTotalPointsProvider);
/// Text('Points: $points');
/// ```
const UserTotalPointsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'userTotalPointsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$userTotalPointsHash();
@$internal
@override
$ProviderElement<int> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
int create(Ref ref) {
return userTotalPoints(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
}
String _$userTotalPointsHash() => r'9ccebb48a8641c3c0624b1649303b436e82602bd';

View File

@@ -0,0 +1,112 @@
/// Password Visibility Provider
///
/// Simple state provider for toggling password visibility in login/register forms.
///
/// Uses Riverpod 3.0 with code generation for type-safe state management.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'password_visibility_provider.g.dart';
/// Password Visibility State Provider
///
/// Manages the visibility state of password input fields.
/// Default state is false (password hidden).
///
/// Usage in login/register pages:
/// ```dart
/// class LoginPage extends ConsumerWidget {
/// @override
/// Widget build(BuildContext context, WidgetRef ref) {
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
///
/// return TextField(
/// obscureText: !isPasswordVisible,
/// decoration: InputDecoration(
/// suffixIcon: IconButton(
/// icon: Icon(
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
/// ),
/// onPressed: () {
/// ref.read(passwordVisibilityProvider.notifier).toggle();
/// },
/// ),
/// ),
/// );
/// }
/// }
/// ```
@riverpod
class PasswordVisibility extends _$PasswordVisibility {
/// Initialize with password hidden (false)
@override
bool build() => false;
/// Toggle password visibility
///
/// Switches between showing and hiding the password.
void toggle() {
state = !state;
}
/// Show password
///
/// Sets visibility to true (password visible).
void show() {
state = true;
}
/// Hide password
///
/// Sets visibility to false (password hidden).
void hide() {
state = false;
}
}
/// Confirm Password Visibility State Provider
///
/// Separate provider for confirm password field in registration forms.
/// This allows independent control of password and confirm password visibility.
///
/// Usage in registration page:
/// ```dart
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
///
/// TextField(
/// obscureText: !isConfirmPasswordVisible,
/// decoration: InputDecoration(
/// labelText: 'Xác nhận mật khẩu',
/// suffixIcon: IconButton(
/// icon: Icon(
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
/// ),
/// onPressed: () {
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
/// },
/// ),
/// ),
/// );
/// ```
@riverpod
class ConfirmPasswordVisibility extends _$ConfirmPasswordVisibility {
/// Initialize with password hidden (false)
@override
bool build() => false;
/// Toggle confirm password visibility
void toggle() {
state = !state;
}
/// Show confirm password
void show() {
state = true;
}
/// Hide confirm password
void hide() {
state = false;
}
}

View File

@@ -0,0 +1,329 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'password_visibility_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Password Visibility State Provider
///
/// Manages the visibility state of password input fields.
/// Default state is false (password hidden).
///
/// Usage in login/register pages:
/// ```dart
/// class LoginPage extends ConsumerWidget {
/// @override
/// Widget build(BuildContext context, WidgetRef ref) {
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
///
/// return TextField(
/// obscureText: !isPasswordVisible,
/// decoration: InputDecoration(
/// suffixIcon: IconButton(
/// icon: Icon(
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
/// ),
/// onPressed: () {
/// ref.read(passwordVisibilityProvider.notifier).toggle();
/// },
/// ),
/// ),
/// );
/// }
/// }
/// ```
@ProviderFor(PasswordVisibility)
const passwordVisibilityProvider = PasswordVisibilityProvider._();
/// Password Visibility State Provider
///
/// Manages the visibility state of password input fields.
/// Default state is false (password hidden).
///
/// Usage in login/register pages:
/// ```dart
/// class LoginPage extends ConsumerWidget {
/// @override
/// Widget build(BuildContext context, WidgetRef ref) {
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
///
/// return TextField(
/// obscureText: !isPasswordVisible,
/// decoration: InputDecoration(
/// suffixIcon: IconButton(
/// icon: Icon(
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
/// ),
/// onPressed: () {
/// ref.read(passwordVisibilityProvider.notifier).toggle();
/// },
/// ),
/// ),
/// );
/// }
/// }
/// ```
final class PasswordVisibilityProvider
extends $NotifierProvider<PasswordVisibility, bool> {
/// Password Visibility State Provider
///
/// Manages the visibility state of password input fields.
/// Default state is false (password hidden).
///
/// Usage in login/register pages:
/// ```dart
/// class LoginPage extends ConsumerWidget {
/// @override
/// Widget build(BuildContext context, WidgetRef ref) {
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
///
/// return TextField(
/// obscureText: !isPasswordVisible,
/// decoration: InputDecoration(
/// suffixIcon: IconButton(
/// icon: Icon(
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
/// ),
/// onPressed: () {
/// ref.read(passwordVisibilityProvider.notifier).toggle();
/// },
/// ),
/// ),
/// );
/// }
/// }
/// ```
const PasswordVisibilityProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'passwordVisibilityProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$passwordVisibilityHash();
@$internal
@override
PasswordVisibility create() => PasswordVisibility();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$passwordVisibilityHash() =>
r'25b6fa914e42dd83c8443aecbeb1d608cccd00ab';
/// Password Visibility State Provider
///
/// Manages the visibility state of password input fields.
/// Default state is false (password hidden).
///
/// Usage in login/register pages:
/// ```dart
/// class LoginPage extends ConsumerWidget {
/// @override
/// Widget build(BuildContext context, WidgetRef ref) {
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
///
/// return TextField(
/// obscureText: !isPasswordVisible,
/// decoration: InputDecoration(
/// suffixIcon: IconButton(
/// icon: Icon(
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
/// ),
/// onPressed: () {
/// ref.read(passwordVisibilityProvider.notifier).toggle();
/// },
/// ),
/// ),
/// );
/// }
/// }
/// ```
abstract class _$PasswordVisibility extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Confirm Password Visibility State Provider
///
/// Separate provider for confirm password field in registration forms.
/// This allows independent control of password and confirm password visibility.
///
/// Usage in registration page:
/// ```dart
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
///
/// TextField(
/// obscureText: !isConfirmPasswordVisible,
/// decoration: InputDecoration(
/// labelText: 'Xác nhận mật khẩu',
/// suffixIcon: IconButton(
/// icon: Icon(
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
/// ),
/// onPressed: () {
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
/// },
/// ),
/// ),
/// );
/// ```
@ProviderFor(ConfirmPasswordVisibility)
const confirmPasswordVisibilityProvider = ConfirmPasswordVisibilityProvider._();
/// Confirm Password Visibility State Provider
///
/// Separate provider for confirm password field in registration forms.
/// This allows independent control of password and confirm password visibility.
///
/// Usage in registration page:
/// ```dart
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
///
/// TextField(
/// obscureText: !isConfirmPasswordVisible,
/// decoration: InputDecoration(
/// labelText: 'Xác nhận mật khẩu',
/// suffixIcon: IconButton(
/// icon: Icon(
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
/// ),
/// onPressed: () {
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
/// },
/// ),
/// ),
/// );
/// ```
final class ConfirmPasswordVisibilityProvider
extends $NotifierProvider<ConfirmPasswordVisibility, bool> {
/// Confirm Password Visibility State Provider
///
/// Separate provider for confirm password field in registration forms.
/// This allows independent control of password and confirm password visibility.
///
/// Usage in registration page:
/// ```dart
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
///
/// TextField(
/// obscureText: !isConfirmPasswordVisible,
/// decoration: InputDecoration(
/// labelText: 'Xác nhận mật khẩu',
/// suffixIcon: IconButton(
/// icon: Icon(
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
/// ),
/// onPressed: () {
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
/// },
/// ),
/// ),
/// );
/// ```
const ConfirmPasswordVisibilityProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'confirmPasswordVisibilityProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$confirmPasswordVisibilityHash();
@$internal
@override
ConfirmPasswordVisibility create() => ConfirmPasswordVisibility();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$confirmPasswordVisibilityHash() =>
r'8408bba9db1e8deba425f98015a4e2fa76d75eb8';
/// Confirm Password Visibility State Provider
///
/// Separate provider for confirm password field in registration forms.
/// This allows independent control of password and confirm password visibility.
///
/// Usage in registration page:
/// ```dart
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
///
/// TextField(
/// obscureText: !isConfirmPasswordVisible,
/// decoration: InputDecoration(
/// labelText: 'Xác nhận mật khẩu',
/// suffixIcon: IconButton(
/// icon: Icon(
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
/// ),
/// onPressed: () {
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
/// },
/// ),
/// ),
/// );
/// ```
abstract class _$ConfirmPasswordVisibility extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,305 @@
/// Registration State Provider
///
/// Manages registration state for the Worker application.
/// Handles user registration with role-based validation and verification.
///
/// Uses Riverpod 3.0 with code generation for type-safe state management.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/features/auth/domain/entities/user.dart';
part 'register_provider.g.dart';
/// Registration Form Data
///
/// Contains all data needed for user registration.
/// Optional fields are used based on selected role.
class RegistrationData {
/// Required: Full name of the user
final String fullName;
/// Required: Phone number (Vietnamese format)
final String phoneNumber;
/// Required: Email address
final String email;
/// Required: Password (minimum 6 characters)
final String password;
/// Required: User role
final UserRole role;
/// Optional: CCCD/ID card number (required for dealer/worker roles)
final String? cccd;
/// Optional: Tax code (personal or company)
final String? taxCode;
/// Optional: Company/store name
final String? companyName;
/// Required: Province/city
final String? city;
/// Optional: Attachment file paths (ID card, certificate, license)
final List<String>? attachments;
const RegistrationData({
required this.fullName,
required this.phoneNumber,
required this.email,
required this.password,
required this.role,
this.cccd,
this.taxCode,
this.companyName,
this.city,
this.attachments,
});
/// Copy with method for immutability
RegistrationData copyWith({
String? fullName,
String? phoneNumber,
String? email,
String? password,
UserRole? role,
String? cccd,
String? taxCode,
String? companyName,
String? city,
List<String>? attachments,
}) {
return RegistrationData(
fullName: fullName ?? this.fullName,
phoneNumber: phoneNumber ?? this.phoneNumber,
email: email ?? this.email,
password: password ?? this.password,
role: role ?? this.role,
cccd: cccd ?? this.cccd,
taxCode: taxCode ?? this.taxCode,
companyName: companyName ?? this.companyName,
city: city ?? this.city,
attachments: attachments ?? this.attachments,
);
}
}
/// Registration State Provider
///
/// Main provider for user registration state management.
/// Handles registration process with role-based validation.
///
/// Usage in widgets:
/// ```dart
/// final registerState = ref.watch(registerProvider);
/// registerState.when(
/// data: (user) => SuccessScreen(user),
/// loading: () => LoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
@riverpod
class Register extends _$Register {
/// Initialize with no registration result
@override
Future<User?> build() async {
// No initial registration
return null;
}
/// Register a new user
///
/// Performs user registration with role-based validation.
/// For dealer/worker roles, requires additional verification documents.
///
/// Parameters:
/// - [data]: Registration form data containing all required fields
///
/// Returns: Newly created User object on success
///
/// Throws: Exception on validation failure or registration error
///
/// Error messages (Vietnamese):
/// - "Vui lòng điền đầy đủ thông tin bắt buộc"
/// - "Số điện thoại không hợp lệ"
/// - "Email không hợp lệ"
/// - "Mật khẩu phải có ít nhất 6 ký tự"
/// - "Vui lòng nhập số CCCD/CMND" (for dealer/worker)
/// - "Vui lòng tải lên ảnh CCCD/CMND" (for dealer/worker)
/// - "Vui lòng tải lên ảnh chứng chỉ hành nghề hoặc GPKD" (for dealer/worker)
/// - "Số điện thoại đã được đăng ký"
/// - "Email đã được đăng ký"
Future<void> register(RegistrationData data) async {
// Set loading state
state = const AsyncValue.loading();
// Perform registration with error handling
state = await AsyncValue.guard(() async {
// Validate required fields
if (data.fullName.isEmpty ||
data.phoneNumber.isEmpty ||
data.email.isEmpty ||
data.password.isEmpty ||
data.city == null ||
data.city!.isEmpty) {
throw Exception('Vui lòng điền đầy đủ thông tin bắt buộc');
}
// Validate phone number (Vietnamese format: 10 digits starting with 0)
final phoneRegex = RegExp(r'^0[0-9]{9}$');
if (!phoneRegex.hasMatch(data.phoneNumber)) {
throw Exception('Số điện thoại không hợp lệ');
}
// Validate email format
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(data.email)) {
throw Exception('Email không hợp lệ');
}
// Validate password length
if (data.password.length < 6) {
throw Exception('Mật khẩu phải có ít nhất 6 ký tự');
}
// Role-based validation for dealer/worker (requires verification)
if (data.role == UserRole.customer) {
// For dealer/worker roles, CCCD and attachments are required
if (data.cccd == null || data.cccd!.isEmpty) {
throw Exception('Vui lòng nhập số CCCD/CMND');
}
// Validate CCCD format (9 or 12 digits)
final cccdRegex = RegExp(r'^[0-9]{9}$|^[0-9]{12}$');
if (!cccdRegex.hasMatch(data.cccd!)) {
throw Exception('Số CCCD/CMND không hợp lệ (phải có 9 hoặc 12 số)');
}
// Validate attachments
if (data.attachments == null || data.attachments!.isEmpty) {
throw Exception('Vui lòng tải lên ảnh CCCD/CMND');
}
if (data.attachments!.length < 2) {
throw Exception('Vui lòng tải lên ảnh chứng chỉ hành nghề hoặc GPKD');
}
}
// Simulate API call delay (2 seconds)
await Future<void>.delayed(const Duration(seconds: 2));
// TODO: In production, call the registration API here
// final response = await ref.read(authRepositoryProvider).register(data);
// Mock: Simulate registration success
final now = DateTime.now();
// Determine initial status based on role
// Dealer/Worker require admin approval (pending status)
// Other roles are immediately active
final initialStatus = data.role == UserRole.customer
? UserStatus.pending
: UserStatus.active;
// Create new user entity
final newUser = User(
userId: 'user_${DateTime.now().millisecondsSinceEpoch}',
phoneNumber: data.phoneNumber,
fullName: data.fullName,
email: data.email,
role: data.role,
status: initialStatus,
loyaltyTier: LoyaltyTier.gold, // Default tier for new users
totalPoints: 0, // New users start with 0 points
companyInfo: data.companyName != null || data.taxCode != null
? CompanyInfo(
name: data.companyName,
taxId: data.taxCode,
businessType: _getBusinessType(data.role),
)
: null,
cccd: data.cccd,
attachments: data.attachments ?? [],
address: data.city,
avatarUrl: null,
referralCode: 'REF${data.phoneNumber.substring(0, 6)}',
referredBy: null,
erpnextCustomerId: null,
createdAt: now,
updatedAt: now,
lastLoginAt: null, // Not logged in yet
);
return newUser;
});
}
/// Reset registration state
///
/// Clears the registration result. Useful when navigating away
/// from success screen or starting a new registration.
Future<void> reset() async {
state = const AsyncValue.data(null);
}
/// Get business type based on user role
String _getBusinessType(UserRole role) {
switch (role) {
case UserRole.customer:
return 'Đại lý/Thầu thợ/Kiến trúc sư';
case UserRole.sales:
return 'Nhân viên kinh doanh';
case UserRole.admin:
return 'Quản trị viên';
case UserRole.accountant:
return 'Kế toán';
case UserRole.designer:
return 'Thiết kế';
}
}
/// Check if registration is in progress
bool get isLoading => state.isLoading;
/// Get registration error if any
Object? get error => state.error;
/// Get registered user if successful
User? get registeredUser => state.value;
/// Check if registration was successful
bool get isSuccess => state.hasValue && state.value != null;
}
/// Convenience provider for checking if registration is in progress
///
/// Usage:
/// ```dart
/// final isRegistering = ref.watch(isRegisteringProvider);
/// if (isRegistering) {
/// // Show loading indicator
/// }
/// ```
@riverpod
bool isRegistering(Ref ref) {
final registerState = ref.watch(registerProvider);
return registerState.isLoading;
}
/// Convenience provider for checking if registration was successful
///
/// Usage:
/// ```dart
/// final success = ref.watch(registrationSuccessProvider);
/// if (success) {
/// // Navigate to pending approval or OTP screen
/// }
/// ```
@riverpod
bool registrationSuccess(Ref ref) {
final registerState = ref.watch(registerProvider);
return registerState.hasValue && registerState.value != null;
}

View File

@@ -0,0 +1,251 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'register_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Registration State Provider
///
/// Main provider for user registration state management.
/// Handles registration process with role-based validation.
///
/// Usage in widgets:
/// ```dart
/// final registerState = ref.watch(registerProvider);
/// registerState.when(
/// data: (user) => SuccessScreen(user),
/// loading: () => LoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
@ProviderFor(Register)
const registerProvider = RegisterProvider._();
/// Registration State Provider
///
/// Main provider for user registration state management.
/// Handles registration process with role-based validation.
///
/// Usage in widgets:
/// ```dart
/// final registerState = ref.watch(registerProvider);
/// registerState.when(
/// data: (user) => SuccessScreen(user),
/// loading: () => LoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
final class RegisterProvider extends $AsyncNotifierProvider<Register, User?> {
/// Registration State Provider
///
/// Main provider for user registration state management.
/// Handles registration process with role-based validation.
///
/// Usage in widgets:
/// ```dart
/// final registerState = ref.watch(registerProvider);
/// registerState.when(
/// data: (user) => SuccessScreen(user),
/// loading: () => LoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
const RegisterProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'registerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$registerHash();
@$internal
@override
Register create() => Register();
}
String _$registerHash() => r'a073b5c5958b74c63a3cddfec7f6f018e14a5088';
/// Registration State Provider
///
/// Main provider for user registration state management.
/// Handles registration process with role-based validation.
///
/// Usage in widgets:
/// ```dart
/// final registerState = ref.watch(registerProvider);
/// registerState.when(
/// data: (user) => SuccessScreen(user),
/// loading: () => LoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
abstract class _$Register extends $AsyncNotifier<User?> {
FutureOr<User?> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<User?>, User?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<User?>, User?>,
AsyncValue<User?>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Convenience provider for checking if registration is in progress
///
/// Usage:
/// ```dart
/// final isRegistering = ref.watch(isRegisteringProvider);
/// if (isRegistering) {
/// // Show loading indicator
/// }
/// ```
@ProviderFor(isRegistering)
const isRegisteringProvider = IsRegisteringProvider._();
/// Convenience provider for checking if registration is in progress
///
/// Usage:
/// ```dart
/// final isRegistering = ref.watch(isRegisteringProvider);
/// if (isRegistering) {
/// // Show loading indicator
/// }
/// ```
final class IsRegisteringProvider extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
/// Convenience provider for checking if registration is in progress
///
/// Usage:
/// ```dart
/// final isRegistering = ref.watch(isRegisteringProvider);
/// if (isRegistering) {
/// // Show loading indicator
/// }
/// ```
const IsRegisteringProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'isRegisteringProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$isRegisteringHash();
@$internal
@override
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
bool create(Ref ref) {
return isRegistering(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$isRegisteringHash() => r'2108b87b37451de9aaf799f9b8b380924bed2c87';
/// Convenience provider for checking if registration was successful
///
/// Usage:
/// ```dart
/// final success = ref.watch(registrationSuccessProvider);
/// if (success) {
/// // Navigate to pending approval or OTP screen
/// }
/// ```
@ProviderFor(registrationSuccess)
const registrationSuccessProvider = RegistrationSuccessProvider._();
/// Convenience provider for checking if registration was successful
///
/// Usage:
/// ```dart
/// final success = ref.watch(registrationSuccessProvider);
/// if (success) {
/// // Navigate to pending approval or OTP screen
/// }
/// ```
final class RegistrationSuccessProvider
extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
/// Convenience provider for checking if registration was successful
///
/// Usage:
/// ```dart
/// final success = ref.watch(registrationSuccessProvider);
/// if (success) {
/// // Navigate to pending approval or OTP screen
/// }
/// ```
const RegistrationSuccessProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'registrationSuccessProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$registrationSuccessHash();
@$internal
@override
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
bool create(Ref ref) {
return registrationSuccess(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$registrationSuccessHash() =>
r'6435b9ca4bf4c287497a39077a5d4558e0515ddc';

View File

@@ -0,0 +1,175 @@
/// Selected Role State Provider
///
/// Manages the selected user role during registration.
/// Simple state provider for role selection in the registration form.
///
/// Uses Riverpod 3.0 with code generation for type-safe state management.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/features/auth/domain/entities/user.dart';
part 'selected_role_provider.g.dart';
/// Selected Role Provider
///
/// Manages the currently selected user role in the registration form.
/// Provides methods to select and clear role selection.
///
/// This provider is used to:
/// - Track which role the user has selected
/// - Conditionally show/hide verification fields based on role
/// - Validate required documents for dealer/worker roles
///
/// Usage in widgets:
/// ```dart
/// // Watch the selected role
/// final selectedRole = ref.watch(selectedRoleProvider);
///
/// // Select a role
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
///
/// // Clear selection
/// ref.read(selectedRoleProvider.notifier).clearRole();
///
/// // Show verification section conditionally
/// if (selectedRole == UserRole.customer) {
/// VerificationSection(),
/// }
/// ```
@riverpod
class SelectedRole extends _$SelectedRole {
/// Initialize with no role selected
@override
UserRole? build() {
return null;
}
/// Select a user role
///
/// Updates the state with the newly selected role.
/// This triggers UI updates that depend on role selection.
///
/// Parameters:
/// - [role]: The user role to select
///
/// Example:
/// ```dart
/// // User selects "Đại lý hệ thống" (dealer)
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
/// // This will show verification fields
/// ```
void selectRole(UserRole role) {
state = role;
}
/// Clear the role selection
///
/// Resets the state to null (no role selected).
/// Useful when resetting the form or canceling registration.
///
/// Example:
/// ```dart
/// // User clicks "Cancel" or goes back
/// ref.read(selectedRoleProvider.notifier).clearRole();
/// // This will hide verification fields
/// ```
void clearRole() {
state = null;
}
/// Check if a role is currently selected
///
/// Returns true if any role has been selected, false otherwise.
bool get hasSelection => state != null;
/// Check if verification is required for current role
///
/// Returns true if the selected role requires verification documents
/// (CCCD, certificates, etc.). Currently only customer role requires this.
///
/// This is used to conditionally show the verification section:
/// ```dart
/// if (ref.read(selectedRoleProvider.notifier).requiresVerification) {
/// // Show CCCD input, file uploads, etc.
/// }
/// ```
bool get requiresVerification => state == UserRole.customer;
/// Get the display name for the current role (Vietnamese)
///
/// Returns a user-friendly Vietnamese name for the selected role,
/// or null if no role is selected.
///
/// Example:
/// ```dart
/// final displayName = ref.read(selectedRoleProvider.notifier).displayName;
/// // Returns: "Đại lý hệ thống" for customer role
/// ```
String? get displayName {
if (state == null) return null;
switch (state!) {
case UserRole.customer:
return 'Đại lý/Thầu thợ/Kiến trúc sư';
case UserRole.sales:
return 'Nhân viên kinh doanh';
case UserRole.admin:
return 'Quản trị viên';
case UserRole.accountant:
return 'Kế toán';
case UserRole.designer:
return 'Thiết kế';
}
}
}
/// Convenience provider for checking if verification is required
///
/// Returns true if the currently selected role requires verification
/// documents (CCCD, certificates, etc.).
///
/// Usage:
/// ```dart
/// final needsVerification = ref.watch(requiresVerificationProvider);
/// if (needsVerification) {
/// // Show verification section with file uploads
/// }
/// ```
@riverpod
bool requiresVerification(Ref ref) {
final selectedRole = ref.watch(selectedRoleProvider);
return selectedRole == UserRole.customer;
}
/// Convenience provider for getting role display name
///
/// Returns a user-friendly Vietnamese name for the selected role,
/// or null if no role is selected.
///
/// Usage:
/// ```dart
/// final roleName = ref.watch(roleDisplayNameProvider);
/// if (roleName != null) {
/// Text('Bạn đang đăng ký với vai trò: $roleName');
/// }
/// ```
@riverpod
String? roleDisplayName(Ref ref) {
final selectedRole = ref.watch(selectedRoleProvider);
if (selectedRole == null) return null;
switch (selectedRole) {
case UserRole.customer:
return 'Đại lý/Thầu thợ/Kiến trúc sư';
case UserRole.sales:
return 'Nhân viên kinh doanh';
case UserRole.admin:
return 'Quản trị viên';
case UserRole.accountant:
return 'Kế toán';
case UserRole.designer:
return 'Thiết kế';
}
}

View File

@@ -0,0 +1,327 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'selected_role_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Selected Role Provider
///
/// Manages the currently selected user role in the registration form.
/// Provides methods to select and clear role selection.
///
/// This provider is used to:
/// - Track which role the user has selected
/// - Conditionally show/hide verification fields based on role
/// - Validate required documents for dealer/worker roles
///
/// Usage in widgets:
/// ```dart
/// // Watch the selected role
/// final selectedRole = ref.watch(selectedRoleProvider);
///
/// // Select a role
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
///
/// // Clear selection
/// ref.read(selectedRoleProvider.notifier).clearRole();
///
/// // Show verification section conditionally
/// if (selectedRole == UserRole.customer) {
/// VerificationSection(),
/// }
/// ```
@ProviderFor(SelectedRole)
const selectedRoleProvider = SelectedRoleProvider._();
/// Selected Role Provider
///
/// Manages the currently selected user role in the registration form.
/// Provides methods to select and clear role selection.
///
/// This provider is used to:
/// - Track which role the user has selected
/// - Conditionally show/hide verification fields based on role
/// - Validate required documents for dealer/worker roles
///
/// Usage in widgets:
/// ```dart
/// // Watch the selected role
/// final selectedRole = ref.watch(selectedRoleProvider);
///
/// // Select a role
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
///
/// // Clear selection
/// ref.read(selectedRoleProvider.notifier).clearRole();
///
/// // Show verification section conditionally
/// if (selectedRole == UserRole.customer) {
/// VerificationSection(),
/// }
/// ```
final class SelectedRoleProvider
extends $NotifierProvider<SelectedRole, UserRole?> {
/// Selected Role Provider
///
/// Manages the currently selected user role in the registration form.
/// Provides methods to select and clear role selection.
///
/// This provider is used to:
/// - Track which role the user has selected
/// - Conditionally show/hide verification fields based on role
/// - Validate required documents for dealer/worker roles
///
/// Usage in widgets:
/// ```dart
/// // Watch the selected role
/// final selectedRole = ref.watch(selectedRoleProvider);
///
/// // Select a role
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
///
/// // Clear selection
/// ref.read(selectedRoleProvider.notifier).clearRole();
///
/// // Show verification section conditionally
/// if (selectedRole == UserRole.customer) {
/// VerificationSection(),
/// }
/// ```
const SelectedRoleProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedRoleProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedRoleHash();
@$internal
@override
SelectedRole create() => SelectedRole();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(UserRole? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<UserRole?>(value),
);
}
}
String _$selectedRoleHash() => r'098c7fdaec4694d14a48c049556960eb6ed2dc06';
/// Selected Role Provider
///
/// Manages the currently selected user role in the registration form.
/// Provides methods to select and clear role selection.
///
/// This provider is used to:
/// - Track which role the user has selected
/// - Conditionally show/hide verification fields based on role
/// - Validate required documents for dealer/worker roles
///
/// Usage in widgets:
/// ```dart
/// // Watch the selected role
/// final selectedRole = ref.watch(selectedRoleProvider);
///
/// // Select a role
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
///
/// // Clear selection
/// ref.read(selectedRoleProvider.notifier).clearRole();
///
/// // Show verification section conditionally
/// if (selectedRole == UserRole.customer) {
/// VerificationSection(),
/// }
/// ```
abstract class _$SelectedRole extends $Notifier<UserRole?> {
UserRole? build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<UserRole?, UserRole?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<UserRole?, UserRole?>,
UserRole?,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Convenience provider for checking if verification is required
///
/// Returns true if the currently selected role requires verification
/// documents (CCCD, certificates, etc.).
///
/// Usage:
/// ```dart
/// final needsVerification = ref.watch(requiresVerificationProvider);
/// if (needsVerification) {
/// // Show verification section with file uploads
/// }
/// ```
@ProviderFor(requiresVerification)
const requiresVerificationProvider = RequiresVerificationProvider._();
/// Convenience provider for checking if verification is required
///
/// Returns true if the currently selected role requires verification
/// documents (CCCD, certificates, etc.).
///
/// Usage:
/// ```dart
/// final needsVerification = ref.watch(requiresVerificationProvider);
/// if (needsVerification) {
/// // Show verification section with file uploads
/// }
/// ```
final class RequiresVerificationProvider
extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
/// Convenience provider for checking if verification is required
///
/// Returns true if the currently selected role requires verification
/// documents (CCCD, certificates, etc.).
///
/// Usage:
/// ```dart
/// final needsVerification = ref.watch(requiresVerificationProvider);
/// if (needsVerification) {
/// // Show verification section with file uploads
/// }
/// ```
const RequiresVerificationProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'requiresVerificationProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$requiresVerificationHash();
@$internal
@override
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
bool create(Ref ref) {
return requiresVerification(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$requiresVerificationHash() =>
r'400b4242bca2defd14e46361d2b77dd94a4e3e5e';
/// Convenience provider for getting role display name
///
/// Returns a user-friendly Vietnamese name for the selected role,
/// or null if no role is selected.
///
/// Usage:
/// ```dart
/// final roleName = ref.watch(roleDisplayNameProvider);
/// if (roleName != null) {
/// Text('Bạn đang đăng ký với vai trò: $roleName');
/// }
/// ```
@ProviderFor(roleDisplayName)
const roleDisplayNameProvider = RoleDisplayNameProvider._();
/// Convenience provider for getting role display name
///
/// Returns a user-friendly Vietnamese name for the selected role,
/// or null if no role is selected.
///
/// Usage:
/// ```dart
/// final roleName = ref.watch(roleDisplayNameProvider);
/// if (roleName != null) {
/// Text('Bạn đang đăng ký với vai trò: $roleName');
/// }
/// ```
final class RoleDisplayNameProvider
extends $FunctionalProvider<String?, String?, String?>
with $Provider<String?> {
/// Convenience provider for getting role display name
///
/// Returns a user-friendly Vietnamese name for the selected role,
/// or null if no role is selected.
///
/// Usage:
/// ```dart
/// final roleName = ref.watch(roleDisplayNameProvider);
/// if (roleName != null) {
/// Text('Bạn đang đăng ký với vai trò: $roleName');
/// }
/// ```
const RoleDisplayNameProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'roleDisplayNameProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$roleDisplayNameHash();
@$internal
@override
$ProviderElement<String?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
String? create(Ref ref) {
return roleDisplayName(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$roleDisplayNameHash() => r'6cb4bfd9e76fb2f3ed52d4a249e5a2477bc6f39e';

View File

@@ -0,0 +1,216 @@
/// File Upload Card Widget
///
/// Reusable widget for uploading image files with preview.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
/// File Upload Card
///
/// A reusable widget for uploading files with preview functionality.
/// Features:
/// - Dashed border upload area
/// - Camera/file icon
/// - Title and subtitle
/// - Image preview after selection
/// - Remove button
///
/// Usage:
/// ```dart
/// FileUploadCard(
/// file: selectedFile,
/// onTap: () => pickImage(),
/// onRemove: () => removeImage(),
/// icon: Icons.camera_alt,
/// title: 'Chụp ảnh hoặc chọn file',
/// subtitle: 'JPG, PNG tối đa 5MB',
/// )
/// ```
class FileUploadCard extends StatelessWidget {
/// Creates a file upload card
const FileUploadCard({
super.key,
required this.file,
required this.onTap,
required this.onRemove,
required this.icon,
required this.title,
required this.subtitle,
});
/// Selected file (null if not selected)
final File? file;
/// Callback when upload area is tapped
final VoidCallback onTap;
/// Callback to remove selected file
final VoidCallback onRemove;
/// Icon to display in upload area
final IconData icon;
/// Title text
final String title;
/// Subtitle text
final String subtitle;
/// Format file size in bytes to human-readable string
String _formatFileSize(int bytes) {
if (bytes == 0) return '0 B';
const suffixes = ['B', 'KB', 'MB', 'GB'];
final i = (bytes.bitLength - 1) ~/ 10;
final size = bytes / (1 << (i * 10));
return '${size.toStringAsFixed(2)} ${suffixes[i]}';
}
/// Get file name from path
String _getFileName(String path) {
return path.split('/').last;
}
@override
Widget build(BuildContext context) {
if (file != null) {
// Show preview with remove option
return _buildPreview(context);
} else {
// Show upload area
return _buildUploadArea(context);
}
}
/// Build upload area
Widget _buildUploadArea(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Container(
decoration: BoxDecoration(
color: AppColors.white,
border: Border.all(
color: const Color(0xFFCBD5E1),
width: 2,
strokeAlign: BorderSide.strokeAlignInside,
),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
children: [
// Icon
Icon(icon, size: 32, color: AppColors.grey500),
const SizedBox(height: AppSpacing.sm),
// Title
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
const SizedBox(height: AppSpacing.xs),
// Subtitle
Text(
subtitle,
style: const TextStyle(fontSize: 12, color: AppColors.grey500),
),
],
),
),
);
}
/// Build preview with remove button
Widget _buildPreview(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.white,
border: Border.all(color: AppColors.grey100, width: 1),
borderRadius: BorderRadius.circular(AppRadius.md),
),
padding: const EdgeInsets.all(AppSpacing.sm),
child: Row(
children: [
// Thumbnail
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Image.file(
file!,
width: 50,
height: 50,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 50,
height: 50,
color: AppColors.grey100,
child: const Icon(
Icons.broken_image,
color: AppColors.grey500,
size: 24,
),
);
},
),
),
const SizedBox(width: AppSpacing.sm),
// File info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_getFileName(file!.path),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
FutureBuilder<int>(
future: file!.length(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Text(
_formatFileSize(snapshot.data!),
style: const TextStyle(
fontSize: 12,
color: AppColors.grey500,
),
);
}
return const SizedBox.shrink();
},
),
],
),
),
const SizedBox(width: AppSpacing.xs),
// Remove button
IconButton(
icon: const Icon(Icons.close, color: AppColors.danger, size: 20),
onPressed: onRemove,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
),
],
),
);
}
}

View File

@@ -0,0 +1,133 @@
/// Phone Input Field Widget
///
/// Custom text field for Vietnamese phone number input.
/// Supports formats: 0xxx xxx xxx, +84xxx xxx xxx, 84xxx xxx xxx
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
/// Phone Input Field
///
/// A custom text field widget specifically designed for Vietnamese phone number input.
/// Features:
/// - Phone icon prefix
/// - Numeric keyboard
/// - Phone number formatting
/// - Vietnamese phone validation support
///
/// Usage:
/// ```dart
/// PhoneInputField(
/// controller: phoneController,
/// validator: Validators.phone,
/// onChanged: (value) {
/// // Handle phone number change
/// },
/// )
/// ```
class PhoneInputField extends StatelessWidget {
/// Creates a phone input field
const PhoneInputField({
super.key,
required this.controller,
this.focusNode,
this.validator,
this.onChanged,
this.onFieldSubmitted,
this.enabled = true,
this.autofocus = false,
});
/// Text editing controller
final TextEditingController controller;
/// Focus node for keyboard focus management
final FocusNode? focusNode;
/// Form field validator
final String? Function(String?)? validator;
/// Callback when text changes
final void Function(String)? onChanged;
/// Callback when field is submitted
final void Function(String)? onFieldSubmitted;
/// Whether the field is enabled
final bool enabled;
/// Whether the field should auto-focus
final bool autofocus;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
focusNode: focusNode,
autofocus: autofocus,
enabled: enabled,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
inputFormatters: [
// Allow digits, spaces, +, and parentheses
FilteringTextInputFormatter.allow(RegExp(r'[0-9+\s()]')),
// Limit to reasonable phone length
LengthLimitingTextInputFormatter(15),
],
style: const TextStyle(
fontSize: InputFieldSpecs.fontSize,
color: AppColors.grey900,
),
decoration: InputDecoration(
labelText: 'Số điện thoại',
labelStyle: const TextStyle(
fontSize: InputFieldSpecs.labelFontSize,
color: AppColors.grey500,
),
hintText: 'Nhập số điện thoại',
hintStyle: const TextStyle(
fontSize: InputFieldSpecs.hintFontSize,
color: AppColors.grey500,
),
prefixIcon: const Icon(
Icons.phone,
color: AppColors.primaryBlue,
size: AppIconSize.md,
),
filled: true,
fillColor: AppColors.white,
contentPadding: InputFieldSpecs.contentPadding,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
width: 2.0,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.danger, width: 1.0),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.danger, width: 2.0),
),
errorStyle: const TextStyle(fontSize: 12.0, color: AppColors.danger),
),
validator: validator,
onChanged: onChanged,
onFieldSubmitted: onFieldSubmitted,
);
}
}

View File

@@ -0,0 +1,115 @@
/// Role Dropdown Widget
///
/// Dropdown for selecting user role during registration.
library;
import 'package:flutter/material.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
/// Role Dropdown
///
/// A custom dropdown widget for selecting user role.
/// Roles:
/// - dealer: Đại lý hệ thống
/// - worker: Kiến trúc sư/ Thầu thợ
/// - broker: Khách lẻ
/// - other: Khác
///
/// Usage:
/// ```dart
/// RoleDropdown(
/// value: selectedRole,
/// onChanged: (value) {
/// setState(() {
/// selectedRole = value;
/// });
/// },
/// validator: (value) {
/// if (value == null || value.isEmpty) {
/// return 'Vui lòng chọn vai trò';
/// }
/// return null;
/// },
/// )
/// ```
class RoleDropdown extends StatelessWidget {
/// Creates a role dropdown
const RoleDropdown({
super.key,
required this.value,
required this.onChanged,
this.validator,
});
/// Selected role value
final String? value;
/// Callback when role changes
final void Function(String?) onChanged;
/// Form field validator
final String? Function(String?)? validator;
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<String>(
value: value,
decoration: InputDecoration(
hintText: 'Chọn vai trò của bạn',
hintStyle: const TextStyle(
fontSize: InputFieldSpecs.hintFontSize,
color: AppColors.grey500,
),
prefixIcon: const Icon(
Icons.work,
color: AppColors.primaryBlue,
size: AppIconSize.md,
),
filled: true,
fillColor: AppColors.white,
contentPadding: InputFieldSpecs.contentPadding,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
width: 2.0,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.danger, width: 1.0),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.danger, width: 2.0),
),
),
items: const [
DropdownMenuItem(value: 'dealer', child: Text('Đại lý hệ thống')),
DropdownMenuItem(
value: 'worker',
child: Text('Kiến trúc sư/ Thầu thợ'),
),
DropdownMenuItem(value: 'broker', child: Text('Khách lẻ')),
DropdownMenuItem(value: 'other', child: Text('Khác')),
],
onChanged: onChanged,
validator: validator,
icon: const Icon(Icons.arrow_drop_down, color: AppColors.grey500),
dropdownColor: AppColors.white,
style: const TextStyle(
fontSize: InputFieldSpecs.fontSize,
color: AppColors.grey900,
),
);
}
}

View File

@@ -76,12 +76,7 @@ class Cart {
@override
int get hashCode {
return Object.hash(
cartId,
userId,
totalAmount,
isSynced,
);
return Object.hash(cartId, userId, totalAmount, isSynced);
}
@override

View File

@@ -48,19 +48,14 @@ class _CartPageState extends ConsumerState<CartPage> {
title: const Text('Xóa giỏ hàng'),
content: const Text('Bạn có chắc chắn muốn xóa toàn bộ giỏ hàng?'),
actions: [
TextButton(
onPressed: () => context.pop(),
child: const Text('Hủy'),
),
TextButton(onPressed: () => context.pop(), child: const Text('Hủy')),
ElevatedButton(
onPressed: () {
ref.read(cartProvider.notifier).clearCart();
context.pop();
context.pop(); // Also go back from cart page
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.danger,
),
style: ElevatedButton.styleFrom(backgroundColor: AppColors.danger),
child: const Text('Xóa'),
),
],
@@ -86,7 +81,10 @@ class _CartPageState extends ConsumerState<CartPage> {
icon: const Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => context.pop(),
),
title: Text('Giỏ hàng ($itemCount)', style: const TextStyle(color: Colors.black)),
title: Text(
'Giỏ hàng ($itemCount)',
style: const TextStyle(color: Colors.black),
),
elevation: AppBarSpecs.elevation,
backgroundColor: AppColors.white,
foregroundColor: AppColors.grey900,
@@ -155,9 +153,7 @@ class _CartPageState extends ConsumerState<CartPage> {
const SizedBox(height: 8),
Text(
'Hãy thêm sản phẩm vào giỏ hàng',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.grey500,
),
style: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
),
const SizedBox(height: 24),
ElevatedButton.icon(
@@ -283,9 +279,9 @@ class _CartPageState extends ConsumerState<CartPage> {
ElevatedButton(
onPressed: () {
if (_discountController.text.isNotEmpty) {
ref.read(cartProvider.notifier).applyDiscountCode(
_discountController.text,
);
ref
.read(cartProvider.notifier)
.applyDiscountCode(_discountController.text);
}
},
style: ElevatedButton.styleFrom(
@@ -326,7 +322,10 @@ class _CartPageState extends ConsumerState<CartPage> {
}
/// Build order summary section
Widget _buildOrderSummary(CartState cartState, NumberFormat currencyFormatter) {
Widget _buildOrderSummary(
CartState cartState,
NumberFormat currencyFormatter,
) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
@@ -394,10 +393,7 @@ class _CartPageState extends ConsumerState<CartPage> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Phí vận chuyển',
style: AppTypography.bodyMedium,
),
Text('Phí vận chuyển', style: AppTypography.bodyMedium),
Text(
cartState.shippingFee > 0
? currencyFormatter.format(cartState.shippingFee)
@@ -448,10 +444,7 @@ class _CartPageState extends ConsumerState<CartPage> {
: null,
child: const Text(
'Tiến hành đặt hàng',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
),

View File

@@ -142,11 +142,10 @@ class CheckoutPage extends HookConsumerWidget {
// Payment Method Section (hidden if negotiation is checked)
if (!needsNegotiation.value)
PaymentMethodSection(
paymentMethod: paymentMethod,
),
PaymentMethodSection(paymentMethod: paymentMethod),
if (!needsNegotiation.value) const SizedBox(height: AppSpacing.md),
if (!needsNegotiation.value)
const SizedBox(height: AppSpacing.md),
// Order Summary Section
OrderSummarySection(
@@ -160,9 +159,7 @@ class CheckoutPage extends HookConsumerWidget {
const SizedBox(height: AppSpacing.md),
// Price Negotiation Section
PriceNegotiationSection(
needsNegotiation: needsNegotiation,
),
PriceNegotiationSection(needsNegotiation: needsNegotiation),
const SizedBox(height: AppSpacing.md),

View File

@@ -44,14 +44,9 @@ class Cart extends _$Cart {
);
} else {
// Add new item
final newItem = CartItemData(
product: product,
quantity: quantity,
);
final newItem = CartItemData(product: product, quantity: quantity);
state = state.copyWith(
items: [...state.items, newItem],
);
state = state.copyWith(items: [...state.items, newItem]);
_recalculateTotal();
}
}
@@ -59,7 +54,9 @@ class Cart extends _$Cart {
/// Remove product from cart
void removeFromCart(String productId) {
state = state.copyWith(
items: state.items.where((item) => item.product.productId != productId).toList(),
items: state.items
.where((item) => item.product.productId != productId)
.toList(),
);
_recalculateTotal();
}
@@ -113,20 +110,14 @@ class Cart extends _$Cart {
// TODO: Validate with backend
// For now, simulate discount application
if (code.isNotEmpty) {
state = state.copyWith(
discountCode: code,
discountCodeApplied: true,
);
state = state.copyWith(discountCode: code, discountCodeApplied: true);
_recalculateTotal();
}
}
/// Remove discount code
void removeDiscountCode() {
state = state.copyWith(
discountCode: null,
discountCodeApplied: false,
);
state = state.copyWith(discountCode: null, discountCodeApplied: false);
_recalculateTotal();
}
@@ -157,10 +148,7 @@ class Cart extends _$Cart {
/// Get total quantity of all items
double get totalQuantity {
return state.items.fold<double>(
0.0,
(sum, item) => sum + item.quantity,
);
return state.items.fold<double>(0.0, (sum, item) => sum + item.quantity);
}
}

View File

@@ -12,18 +12,12 @@ class CartItemData {
final Product product;
final double quantity;
const CartItemData({
required this.product,
required this.quantity,
});
const CartItemData({required this.product, required this.quantity});
/// Calculate line total
double get lineTotal => product.basePrice * quantity;
CartItemData copyWith({
Product? product,
double? quantity,
}) {
CartItemData copyWith({Product? product, double? quantity}) {
return CartItemData(
product: product ?? this.product,
quantity: quantity ?? this.quantity,
@@ -101,7 +95,8 @@ class CartState {
discountCode: discountCode ?? this.discountCode,
discountCodeApplied: discountCodeApplied ?? this.discountCodeApplied,
memberTier: memberTier ?? this.memberTier,
memberDiscountPercent: memberDiscountPercent ?? this.memberDiscountPercent,
memberDiscountPercent:
memberDiscountPercent ?? this.memberDiscountPercent,
subtotal: subtotal ?? this.subtotal,
memberDiscount: memberDiscount ?? this.memberDiscount,
shippingFee: shippingFee ?? this.shippingFee,

View File

@@ -22,10 +22,7 @@ import 'package:worker/features/cart/presentation/providers/cart_state.dart';
class CartItemWidget extends ConsumerWidget {
final CartItemData item;
const CartItemWidget({
super.key,
required this.item,
});
const CartItemWidget({super.key, required this.item});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -65,9 +62,7 @@ class CartItemWidget extends ConsumerWidget {
height: 80,
color: AppColors.grey100,
child: const Center(
child: CircularProgressIndicator(
strokeWidth: 2,
),
child: CircularProgressIndicator(strokeWidth: 2),
),
),
errorWidget: (context, url, error) => Container(
@@ -129,9 +124,9 @@ class CartItemWidget extends ConsumerWidget {
_QuantityButton(
icon: Icons.remove,
onPressed: () {
ref.read(cartProvider.notifier).decrementQuantity(
item.product.productId,
);
ref
.read(cartProvider.notifier)
.decrementQuantity(item.product.productId);
},
),
@@ -151,9 +146,9 @@ class CartItemWidget extends ConsumerWidget {
_QuantityButton(
icon: Icons.add,
onPressed: () {
ref.read(cartProvider.notifier).incrementQuantity(
item.product.productId,
);
ref
.read(cartProvider.notifier)
.incrementQuantity(item.product.productId);
},
),
@@ -184,10 +179,7 @@ class _QuantityButton extends StatelessWidget {
final IconData icon;
final VoidCallback onPressed;
const _QuantityButton({
required this.icon,
required this.onPressed,
});
const _QuantityButton({required this.icon, required this.onPressed});
@override
Widget build(BuildContext context) {
@@ -201,11 +193,7 @@ class _QuantityButton extends StatelessWidget {
color: AppColors.grey100,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
icon,
size: 18,
color: AppColors.grey900,
),
child: Icon(icon, size: 18, color: AppColors.grey900),
),
);
}

View File

@@ -68,8 +68,11 @@ class CheckoutDatePickerField extends HookWidget {
: AppColors.grey500.withValues(alpha: 0.6),
),
),
const Icon(Icons.calendar_today,
size: 20, color: AppColors.grey500),
const Icon(
Icons.calendar_today,
size: 20,
color: AppColors.grey500,
),
],
),
),

View File

@@ -75,10 +75,7 @@ class CheckoutDropdownField extends StatelessWidget {
),
),
items: items.map((item) {
return DropdownMenuItem<String>(
value: item,
child: Text(item),
);
return DropdownMenuItem<String>(value: item, child: Text(item));
}).toList(),
onChanged: onChanged,
validator: (value) {

View File

@@ -114,7 +114,8 @@ class CheckoutSubmitButton extends StatelessWidget {
});
} else {
// Generate order ID (mock - replace with actual from backend)
final orderId = 'DH${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}';
final orderId =
'DH${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}';
// Show order success message
ScaffoldMessenger.of(context).showSnackBar(
@@ -130,10 +131,7 @@ class CheckoutSubmitButton extends StatelessWidget {
if (context.mounted) {
context.pushNamed(
RouteNames.paymentQr,
queryParameters: {
'orderId': orderId,
'amount': total.toString(),
},
queryParameters: {'orderId': orderId, 'amount': total.toString()},
);
}
});

View File

@@ -103,13 +103,7 @@ class DeliveryInformationSection extends HookWidget {
label: 'Tỉnh/Thành phố',
value: selectedProvince.value,
required: true,
items: const [
'TP.HCM',
'Hà Nội',
'Đà Nẵng',
'Cần Thơ',
'Biên Hòa',
],
items: const ['TP.HCM', 'Hà Nội', 'Đà Nẵng', 'Cần Thơ', 'Biên Hòa'],
onChanged: (value) {
selectedProvince.value = value;
},

View File

@@ -132,8 +132,9 @@ class InvoiceSection extends HookWidget {
return 'Vui lòng nhập email';
}
if (needsInvoice.value &&
!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(value!)) {
!RegExp(
r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$',
).hasMatch(value!)) {
return 'Email không hợp lệ';
}
return null;

View File

@@ -148,8 +148,10 @@ class OrderSummarySection extends StatelessWidget {
const SizedBox(height: 4),
Text(
'Mã: ${item['sku']}',
style:
const TextStyle(fontSize: 12, color: AppColors.grey500),
style: const TextStyle(
fontSize: 12,
color: AppColors.grey500,
),
),
],
),
@@ -168,8 +170,9 @@ class OrderSummarySection extends StatelessWidget {
const SizedBox(height: 4),
Text(
_formatCurrency(
((item['price'] as int) * (item['quantity'] as int))
.toDouble()),
((item['price'] as int) * (item['quantity'] as int))
.toDouble(),
),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -184,8 +187,11 @@ class OrderSummarySection extends StatelessWidget {
}
/// Build summary row
Widget _buildSummaryRow(String label, double amount,
{bool isDiscount = false}) {
Widget _buildSummaryRow(
String label,
double amount, {
bool isDiscount = false,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -207,9 +213,6 @@ class OrderSummarySection extends StatelessWidget {
/// Format currency
String _formatCurrency(double amount) {
return '${amount.toStringAsFixed(0).replaceAllMapped(
RegExp(r'(\d)(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
)}';
return '${amount.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
}
}

View File

@@ -14,10 +14,7 @@ import 'package:worker/core/theme/colors.dart';
class PaymentMethodSection extends HookWidget {
final ValueNotifier<String> paymentMethod;
const PaymentMethodSection({
super.key,
required this.paymentMethod,
});
const PaymentMethodSection({super.key, required this.paymentMethod});
@override
Widget build(BuildContext context) {
@@ -72,13 +69,17 @@ class PaymentMethodSection extends HookWidget {
Text(
'Chuyển khoản ngân hàng',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w500),
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 4),
Text(
'Thanh toán qua chuyển khoản',
style:
TextStyle(fontSize: 13, color: AppColors.grey500),
style: TextStyle(
fontSize: 13,
color: AppColors.grey500,
),
),
],
),
@@ -112,13 +113,17 @@ class PaymentMethodSection extends HookWidget {
Text(
'Thanh toán khi nhận hàng (COD)',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w500),
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 4),
Text(
'Thanh toán bằng tiền mặt khi nhận hàng',
style:
TextStyle(fontSize: 13, color: AppColors.grey500),
style: TextStyle(
fontSize: 13,
color: AppColors.grey500,
),
),
],
),

View File

@@ -14,10 +14,7 @@ import 'package:worker/core/theme/colors.dart';
class PriceNegotiationSection extends HookWidget {
final ValueNotifier<bool> needsNegotiation;
const PriceNegotiationSection({
super.key,
required this.needsNegotiation,
});
const PriceNegotiationSection({super.key, required this.needsNegotiation});
@override
Widget build(BuildContext context) {

View File

@@ -7,18 +7,39 @@ part 'chat_room_model.g.dart';
@HiveType(typeId: HiveTypeIds.chatRoomModel)
class ChatRoomModel extends HiveObject {
ChatRoomModel({required this.chatRoomId, required this.roomType, this.relatedQuoteId, this.relatedOrderId, required this.participants, this.roomName, required this.isActive, this.lastActivity, required this.createdAt, this.createdBy});
ChatRoomModel({
required this.chatRoomId,
required this.roomType,
this.relatedQuoteId,
this.relatedOrderId,
required this.participants,
this.roomName,
required this.isActive,
this.lastActivity,
required this.createdAt,
this.createdBy,
});
@HiveField(0) final String chatRoomId;
@HiveField(1) final RoomType roomType;
@HiveField(2) final String? relatedQuoteId;
@HiveField(3) final String? relatedOrderId;
@HiveField(4) final String participants;
@HiveField(5) final String? roomName;
@HiveField(6) final bool isActive;
@HiveField(7) final DateTime? lastActivity;
@HiveField(8) final DateTime createdAt;
@HiveField(9) final String? createdBy;
@HiveField(0)
final String chatRoomId;
@HiveField(1)
final RoomType roomType;
@HiveField(2)
final String? relatedQuoteId;
@HiveField(3)
final String? relatedOrderId;
@HiveField(4)
final String participants;
@HiveField(5)
final String? roomName;
@HiveField(6)
final bool isActive;
@HiveField(7)
final DateTime? lastActivity;
@HiveField(8)
final DateTime createdAt;
@HiveField(9)
final String? createdBy;
factory ChatRoomModel.fromJson(Map<String, dynamic> json) => ChatRoomModel(
chatRoomId: json['chat_room_id'] as String,
@@ -28,7 +49,9 @@ class ChatRoomModel extends HiveObject {
participants: jsonEncode(json['participants']),
roomName: json['room_name'] as String?,
isActive: json['is_active'] as bool? ?? true,
lastActivity: json['last_activity'] != null ? DateTime.parse(json['last_activity']?.toString() ?? '') : null,
lastActivity: json['last_activity'] != null
? DateTime.parse(json['last_activity']?.toString() ?? '')
: null,
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
createdBy: json['created_by'] as String?,
);

View File

@@ -7,27 +7,56 @@ part 'message_model.g.dart';
@HiveType(typeId: HiveTypeIds.messageModel)
class MessageModel extends HiveObject {
MessageModel({required this.messageId, required this.chatRoomId, required this.senderId, required this.contentType, required this.content, this.attachmentUrl, this.productReference, required this.isRead, required this.isEdited, required this.isDeleted, this.readBy, required this.timestamp, this.editedAt});
MessageModel({
required this.messageId,
required this.chatRoomId,
required this.senderId,
required this.contentType,
required this.content,
this.attachmentUrl,
this.productReference,
required this.isRead,
required this.isEdited,
required this.isDeleted,
this.readBy,
required this.timestamp,
this.editedAt,
});
@HiveField(0) final String messageId;
@HiveField(1) final String chatRoomId;
@HiveField(2) final String senderId;
@HiveField(3) final ContentType contentType;
@HiveField(4) final String content;
@HiveField(5) final String? attachmentUrl;
@HiveField(6) final String? productReference;
@HiveField(7) final bool isRead;
@HiveField(8) final bool isEdited;
@HiveField(9) final bool isDeleted;
@HiveField(10) final String? readBy;
@HiveField(11) final DateTime timestamp;
@HiveField(12) final DateTime? editedAt;
@HiveField(0)
final String messageId;
@HiveField(1)
final String chatRoomId;
@HiveField(2)
final String senderId;
@HiveField(3)
final ContentType contentType;
@HiveField(4)
final String content;
@HiveField(5)
final String? attachmentUrl;
@HiveField(6)
final String? productReference;
@HiveField(7)
final bool isRead;
@HiveField(8)
final bool isEdited;
@HiveField(9)
final bool isDeleted;
@HiveField(10)
final String? readBy;
@HiveField(11)
final DateTime timestamp;
@HiveField(12)
final DateTime? editedAt;
factory MessageModel.fromJson(Map<String, dynamic> json) => MessageModel(
messageId: json['message_id'] as String,
chatRoomId: json['chat_room_id'] as String,
senderId: json['sender_id'] as String,
contentType: ContentType.values.firstWhere((e) => e.name == json['content_type']),
contentType: ContentType.values.firstWhere(
(e) => e.name == json['content_type'],
),
content: json['content'] as String,
attachmentUrl: json['attachment_url'] as String?,
productReference: json['product_reference'] as String?,
@@ -36,7 +65,9 @@ class MessageModel extends HiveObject {
isDeleted: json['is_deleted'] as bool? ?? false,
readBy: json['read_by'] != null ? jsonEncode(json['read_by']) : null,
timestamp: DateTime.parse(json['timestamp']?.toString() ?? ''),
editedAt: json['edited_at'] != null ? DateTime.parse(json['edited_at']?.toString() ?? '') : null,
editedAt: json['edited_at'] != null
? DateTime.parse(json['edited_at']?.toString() ?? '')
: null,
);
Map<String, dynamic> toJson() => {

View File

@@ -117,8 +117,7 @@ class Message {
bool get isSystemMessage => contentType == ContentType.system;
/// Check if message has attachment
bool get hasAttachment =>
attachmentUrl != null && attachmentUrl!.isNotEmpty;
bool get hasAttachment => attachmentUrl != null && attachmentUrl!.isNotEmpty;
/// Check if message references a product
bool get hasProductReference =>

View File

@@ -112,10 +112,16 @@ class _ChatListPageState extends ConsumerState<ChatListPage> {
autofocus: true,
decoration: InputDecoration(
hintText: 'Tìm kiếm cuộc trò chuyện...',
prefixIcon: const Icon(Icons.search, color: AppColors.grey500),
prefixIcon: const Icon(
Icons.search,
color: AppColors.grey500,
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, color: AppColors.grey500),
icon: const Icon(
Icons.clear,
color: AppColors.grey500,
),
onPressed: () {
setState(() {
_searchController.clear();
@@ -148,7 +154,10 @@ class _ChatListPageState extends ConsumerState<ChatListPage> {
// Conversation 1 - Order Reference
_ConversationItem(
avatarIcon: Icons.inventory_2,
avatarGradient: const [AppColors.primaryBlue, AppColors.lightBlue],
avatarGradient: const [
AppColors.primaryBlue,
AppColors.lightBlue,
],
contactName: 'Đơn hàng #SO001234',
messageTime: '14:30',
lastMessage: 'Đơn hàng đang được giao - Dự kiến đến 16:00',
@@ -183,7 +192,10 @@ class _ChatListPageState extends ConsumerState<ChatListPage> {
// Conversation 3 - Support Team
_ConversationItem(
avatarIcon: Icons.headset_mic,
avatarGradient: const [AppColors.primaryBlue, AppColors.lightBlue],
avatarGradient: const [
AppColors.primaryBlue,
AppColors.lightBlue,
],
contactName: 'Tổng đài hỗ trợ',
messageTime: '13:45',
lastMessage: 'Thông tin về quy trình đổi trả sản phẩm',
@@ -319,8 +331,8 @@ class _ConversationItem extends StatelessWidget {
color: isOnline
? AppColors.success
: isAway
? AppColors.warning
: AppColors.grey500,
? AppColors.warning
: AppColors.grey500,
shape: BoxShape.circle,
border: Border.all(color: AppColors.white, width: 2),
),

View File

@@ -40,7 +40,9 @@ class FavoritesLocalDataSource {
Future<void> addFavorite(FavoriteModel favorite) async {
try {
await _box.put(favorite.favoriteId, favorite);
debugPrint('[FavoritesLocalDataSource] Added favorite: ${favorite.favoriteId} for user: ${favorite.userId}');
debugPrint(
'[FavoritesLocalDataSource] Added favorite: ${favorite.favoriteId} for user: ${favorite.userId}',
);
} catch (e) {
debugPrint('[FavoritesLocalDataSource] Error adding favorite: $e');
rethrow;
@@ -60,13 +62,17 @@ class FavoritesLocalDataSource {
.toList();
if (favorites.isEmpty) {
debugPrint('[FavoritesLocalDataSource] Favorite not found: productId=$productId, userId=$userId');
debugPrint(
'[FavoritesLocalDataSource] Favorite not found: productId=$productId, userId=$userId',
);
return false;
}
final favorite = favorites.first;
await _box.delete(favorite.favoriteId);
debugPrint('[FavoritesLocalDataSource] Removed favorite: ${favorite.favoriteId} for user: $userId');
debugPrint(
'[FavoritesLocalDataSource] Removed favorite: ${favorite.favoriteId} for user: $userId',
);
return true;
} catch (e) {
debugPrint('[FavoritesLocalDataSource] Error removing favorite: $e');
@@ -79,9 +85,9 @@ class FavoritesLocalDataSource {
/// Returns true if the product is in the user's favorites, false otherwise.
bool isFavorite(String productId, String userId) {
try {
return _box.values
.whereType<FavoriteModel>()
.any((fav) => fav.productId == productId && fav.userId == userId);
return _box.values.whereType<FavoriteModel>().any(
(fav) => fav.productId == productId && fav.userId == userId,
);
} catch (e) {
debugPrint('[FavoritesLocalDataSource] Error checking favorite: $e');
return false;
@@ -101,7 +107,9 @@ class FavoritesLocalDataSource {
.toList();
await _box.deleteAll(favoriteIds);
debugPrint('[FavoritesLocalDataSource] Cleared ${favoriteIds.length} favorites for user: $userId');
debugPrint(
'[FavoritesLocalDataSource] Cleared ${favoriteIds.length} favorites for user: $userId',
);
} catch (e) {
debugPrint('[FavoritesLocalDataSource] Error clearing favorites: $e');
rethrow;
@@ -140,7 +148,9 @@ class FavoritesLocalDataSource {
debugPrint('[FavoritesLocalDataSource] Favorites box compacted');
}
} catch (e) {
debugPrint('[FavoritesLocalDataSource] Error compacting favorites box: $e');
debugPrint(
'[FavoritesLocalDataSource] Error compacting favorites box: $e',
);
}
}
}

View File

@@ -62,11 +62,6 @@ class Favorite {
@override
int get hashCode {
return Object.hash(
favoriteId,
productId,
userId,
createdAt,
);
return Object.hash(favoriteId, productId, userId, createdAt);
}
}

View File

@@ -27,7 +27,11 @@ class FavoritesPage extends ConsumerWidget {
const FavoritesPage({super.key});
/// Show confirmation dialog before clearing all favorites
Future<void> _showClearAllDialog(BuildContext context, WidgetRef ref, int count) async {
Future<void> _showClearAllDialog(
BuildContext context,
WidgetRef ref,
int count,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@@ -185,10 +189,7 @@ class _EmptyState extends StatelessWidget {
// Subtext
Text(
'Thêm sản phẩm vào danh sách yêu thích để xem lại sau',
style: TextStyle(
fontSize: 14.0,
color: AppColors.grey500,
),
style: TextStyle(fontSize: 14.0, color: AppColors.grey500),
textAlign: TextAlign.center,
),
@@ -213,10 +214,7 @@ class _EmptyState extends StatelessWidget {
),
child: const Text(
'Khám phá sản phẩm',
style: TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.w600,
),
style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w600),
),
),
],
@@ -351,10 +349,7 @@ class _ErrorState extends StatelessWidget {
final Object error;
final VoidCallback onRetry;
const _ErrorState({
required this.error,
required this.onRetry,
});
const _ErrorState({required this.error, required this.onRetry});
@override
Widget build(BuildContext context) {
@@ -389,10 +384,7 @@ class _ErrorState extends StatelessWidget {
// Error message
Text(
error.toString(),
style: const TextStyle(
fontSize: 14.0,
color: AppColors.grey500,
),
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
@@ -417,10 +409,7 @@ class _ErrorState extends StatelessWidget {
icon: const Icon(Icons.refresh),
label: const Text(
'Thử lại',
style: TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.w600,
),
style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w600),
),
),
],
@@ -440,9 +429,7 @@ class _ErrorState extends StatelessWidget {
class _FavoritesGrid extends StatelessWidget {
final List<Product> products;
const _FavoritesGrid({
required this.products,
});
const _FavoritesGrid({required this.products});
@override
Widget build(BuildContext context) {
@@ -457,9 +444,7 @@ class _FavoritesGrid extends StatelessWidget {
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return RepaintBoundary(
child: FavoriteProductCard(product: product),
);
return RepaintBoundary(child: FavoriteProductCard(product: product));
},
);
}

View File

@@ -260,7 +260,9 @@ Future<List<Product>> favoriteProducts(Ref ref) async {
final allProducts = await getProductsUseCase();
// Filter to only include favorited products
return allProducts.where((product) => favoriteIds.contains(product.productId)).toList();
return allProducts
.where((product) => favoriteIds.contains(product.productId))
.toList();
}
// ============================================================================

View File

@@ -22,10 +22,7 @@ import 'package:worker/features/products/domain/entities/product.dart';
class FavoriteProductCard extends ConsumerWidget {
final Product product;
const FavoriteProductCard({
super.key,
required this.product,
});
const FavoriteProductCard({super.key, required this.product});
String _formatPrice(double price) {
final formatter = NumberFormat('#,###', 'vi_VN');
@@ -60,7 +57,9 @@ class FavoriteProductCard extends ConsumerWidget {
if (confirmed == true && context.mounted) {
// Remove from favorites
await ref.read(favoritesProvider.notifier).removeFavorite(product.productId);
await ref
.read(favoritesProvider.notifier)
.removeFavorite(product.productId);
// Show snackbar
if (context.mounted) {
@@ -103,9 +102,7 @@ class FavoriteProductCard extends ConsumerWidget {
placeholder: (context, url) => Shimmer.fromColors(
baseColor: AppColors.grey100,
highlightColor: AppColors.grey50,
child: Container(
color: AppColors.grey100,
),
child: Container(color: AppColors.grey100),
),
errorWidget: (context, url, error) => Container(
color: AppColors.grey100,

View File

@@ -82,7 +82,7 @@ class HomeLocalDataSourceImpl implements HomeLocalDataSource {
'tier': 'diamond',
'points': 9750,
'validUntil': '2025-12-31T23:59:59.000Z',
'qrData': '0983441099'
'qrData': '0983441099',
};
/// Mock JSON data for promotions
@@ -115,7 +115,7 @@ class HomeLocalDataSourceImpl implements HomeLocalDataSource {
'startDate': '2025-01-01T00:00:00.000Z',
'endDate': '2025-12-31T23:59:59.000Z',
'discountPercentage': 5,
}
},
];
/// Constructor

View File

@@ -15,7 +15,7 @@ enum PromotionStatus {
upcoming,
/// Expired promotion
expired;
expired,
}
/// Promotion Entity

View File

@@ -222,7 +222,6 @@ class HomePage extends ConsumerWidget {
),
],
),
);
}

View File

@@ -19,10 +19,7 @@ class MemberCardWidget extends StatelessWidget {
/// Member card data
final MemberCard memberCard;
const MemberCardWidget({
super.key,
required this.memberCard,
});
const MemberCardWidget({super.key, required this.memberCard});
@override
Widget build(BuildContext context) {
@@ -185,8 +182,8 @@ class MemberCardWidget extends StatelessWidget {
/// Format points with thousands separator
String _formatPoints(int points) {
return points.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]},',
);
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]},',
);
}
}

View File

@@ -16,12 +16,12 @@ import 'package:worker/features/home/domain/entities/promotion.dart';
/// Displays a horizontal scrollable list of promotion cards.
/// Each card shows an image, title, and brief description.
class PromotionSlider extends StatelessWidget {
const PromotionSlider({
super.key,
required this.promotions,
this.onPromotionTap,
});
/// List of promotions to display
final List<Promotion> promotions;
@@ -83,11 +83,7 @@ class PromotionSlider extends StatelessWidget {
/// Individual Promotion Card
class _PromotionCard extends StatelessWidget {
const _PromotionCard({
required this.promotion,
this.onTap,
});
const _PromotionCard({required this.promotion, this.onTap});
final Promotion promotion;
final VoidCallback? onTap;
@@ -115,8 +111,9 @@ class _PromotionCard extends StatelessWidget {
children: [
// Promotion Image
ClipRRect(
borderRadius:
const BorderRadius.vertical(top: Radius.circular(12)),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: CachedNetworkImage(
imageUrl: promotion.imageUrl,
height: 140,
@@ -125,9 +122,7 @@ class _PromotionCard extends StatelessWidget {
placeholder: (context, url) => Container(
height: 140,
color: AppColors.grey100,
child: const Center(
child: CircularProgressIndicator(),
),
child: const Center(child: CircularProgressIndicator()),
),
errorWidget: (context, url, error) => Container(
height: 140,

View File

@@ -63,11 +63,7 @@ class QuickActionItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Icon
Icon(
icon,
size: 32,
color: AppColors.primaryBlue,
),
Icon(icon, size: 32, color: AppColors.primaryBlue),
const SizedBox(height: 8),
// Label
Text(
@@ -97,9 +93,7 @@ class QuickActionItem extends StatelessWidget {
color: AppColors.danger,
borderRadius: BorderRadius.circular(12),
),
constraints: const BoxConstraints(
minWidth: 20,
),
constraints: const BoxConstraints(minWidth: 20),
child: Text(
badge!,
style: const TextStyle(

View File

@@ -31,9 +31,7 @@ class PointsHistoryLocalDataSource {
/// Get all points entries
Future<List<LoyaltyPointEntryModel>> getAllEntries() async {
final box = await entriesBox;
final entries = box.values
.whereType<LoyaltyPointEntryModel>()
.toList();
final entries = box.values.whereType<LoyaltyPointEntryModel>().toList();
entries.sort((a, b) => b.timestamp.compareTo(a.timestamp)); // Newest first
return entries;
}
@@ -42,9 +40,9 @@ class PointsHistoryLocalDataSource {
Future<LoyaltyPointEntryModel?> getEntryById(String entryId) async {
final box = await entriesBox;
try {
return box.values
.whereType<LoyaltyPointEntryModel>()
.firstWhere((entry) => entry.entryId == entryId);
return box.values.whereType<LoyaltyPointEntryModel>().firstWhere(
(entry) => entry.entryId == entryId,
);
} catch (e) {
throw Exception('Entry not found');
}

Some files were not shown because too many files have changed in this diff Show More