Compare commits
2 Commits
24a8508fce
...
ce7396f729
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce7396f729 | ||
|
|
3803bd26e0 |
@@ -13,7 +13,7 @@ You are a Riverpod 3.0 expert specializing in:
|
|||||||
|
|
||||||
- Modern code generation with `@riverpod` annotation
|
- 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
|
- Implementing proper state management patterns
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
522
HIVE_SETUP.md
522
HIVE_SETUP.md
@@ -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!
|
|
||||||
@@ -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
|
|
||||||
760
LOCALIZATION.md
760
LOCALIZATION.md
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
@@ -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`
|
|
||||||
@@ -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!
|
|
||||||
@@ -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
|
|
||||||
@@ -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! 🚀**
|
|
||||||
@@ -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.
|
|
||||||
@@ -182,13 +182,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ID ĐVKD -->
|
<!-- ID ĐVKD -->
|
||||||
<div class="form-group">
|
<!-- <div class="form-group">-->
|
||||||
<label class="form-label" for="DVKD">Mã ĐVKD *</label>
|
<!-- <label class="form-label" for="DVKD">Mã ĐVKD *</label>-->
|
||||||
<div class="form-input-icon">
|
<!-- <div class="form-input-icon">-->
|
||||||
<i class="fas fa-briefcase icon"></i>
|
<!-- <i class="fas fa-briefcase icon"></i>-->
|
||||||
<input type="text" id="DVKD" class="form-input" placeholder="Nhập mã ĐVKD" required>
|
<!-- <input type="text" id="DVKD" class="form-input" placeholder="Nhập mã ĐVKD" required>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
|
|
||||||
<!-- Role Selection -->
|
<!-- Role Selection -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
80
html/start.html
Normal file
80
html/start.html
Normal 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>
|
||||||
@@ -1,7 +1,43 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- connectivity_plus (0.0.1):
|
- connectivity_plus (0.0.1):
|
||||||
- Flutter
|
- 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 (1.0.0)
|
||||||
|
- flutter_secure_storage (6.0.0):
|
||||||
|
- Flutter
|
||||||
- GoogleDataTransport (9.4.1):
|
- GoogleDataTransport (9.4.1):
|
||||||
- GoogleUtilities/Environment (~> 7.7)
|
- GoogleUtilities/Environment (~> 7.7)
|
||||||
- nanopb (< 2.30911.0, >= 2.30908.0)
|
- nanopb (< 2.30911.0, >= 2.30908.0)
|
||||||
@@ -62,6 +98,9 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- PromisesObjC (2.4.0)
|
- PromisesObjC (2.4.0)
|
||||||
|
- SDWebImage (5.21.2):
|
||||||
|
- SDWebImage/Core (= 5.21.2)
|
||||||
|
- SDWebImage/Core (5.21.2)
|
||||||
- share_plus (0.0.1):
|
- share_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
@@ -70,10 +109,15 @@ PODS:
|
|||||||
- sqflite_darwin (0.0.4):
|
- sqflite_darwin (0.0.4):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- SwiftyGif (5.4.5)
|
||||||
|
- url_launcher_ios (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
|
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
|
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
|
||||||
@@ -81,9 +125,12 @@ DEPENDENCIES:
|
|||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
|
- DKImagePickerController
|
||||||
|
- DKPhotoGallery
|
||||||
- GoogleDataTransport
|
- GoogleDataTransport
|
||||||
- GoogleMLKit
|
- GoogleMLKit
|
||||||
- GoogleToolboxForMac
|
- GoogleToolboxForMac
|
||||||
@@ -96,12 +143,18 @@ SPEC REPOS:
|
|||||||
- MLKitVision
|
- MLKitVision
|
||||||
- nanopb
|
- nanopb
|
||||||
- PromisesObjC
|
- PromisesObjC
|
||||||
|
- SDWebImage
|
||||||
|
- SwiftyGif
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
connectivity_plus:
|
connectivity_plus:
|
||||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
|
file_picker:
|
||||||
|
:path: ".symlinks/plugins/file_picker/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
|
flutter_secure_storage:
|
||||||
|
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||||
image_picker_ios:
|
image_picker_ios:
|
||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
integration_test:
|
integration_test:
|
||||||
@@ -116,10 +169,16 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||||
|
url_launcher_ios:
|
||||||
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
|
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
|
||||||
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
|
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
|
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||||
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
||||||
GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065
|
GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065
|
||||||
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
||||||
@@ -136,9 +195,12 @@ SPEC CHECKSUMS:
|
|||||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
||||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
|
SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a
|
||||||
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
|
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
|
||||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||||
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
|
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||||
|
|
||||||
|
|||||||
16
lib/app.dart
16
lib/app.dart
@@ -33,7 +33,6 @@ class WorkerApp extends ConsumerWidget {
|
|||||||
theme: AppTheme.lightTheme(),
|
theme: AppTheme.lightTheme(),
|
||||||
darkTheme: AppTheme.darkTheme(),
|
darkTheme: AppTheme.darkTheme(),
|
||||||
themeMode: ThemeMode.light, // TODO: Make this configurable from settings
|
themeMode: ThemeMode.light, // TODO: Make this configurable from settings
|
||||||
|
|
||||||
// ==================== Localization Configuration ====================
|
// ==================== Localization Configuration ====================
|
||||||
// Support for Vietnamese (primary) and English (secondary)
|
// Support for Vietnamese (primary) and English (secondary)
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
@@ -53,8 +52,10 @@ class WorkerApp extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Default locale (Vietnamese)
|
// 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
|
// Locale resolution strategy
|
||||||
localeResolutionCallback: (locale, supportedLocales) {
|
localeResolutionCallback: (locale, supportedLocales) {
|
||||||
// Check if the device locale is supported
|
// Check if the device locale is supported
|
||||||
@@ -71,9 +72,7 @@ class WorkerApp extends ConsumerWidget {
|
|||||||
// ==================== Material App Configuration ====================
|
// ==================== Material App Configuration ====================
|
||||||
// Builder for additional context-dependent widgets
|
// Builder for additional context-dependent widgets
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return _AppBuilder(
|
return _AppBuilder(child: child ?? const SizedBox.shrink());
|
||||||
child: child ?? const SizedBox.shrink(),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,9 +85,7 @@ class WorkerApp extends ConsumerWidget {
|
|||||||
/// - Connectivity listener
|
/// - Connectivity listener
|
||||||
/// - Global overlays (loading, snackbars)
|
/// - Global overlays (loading, snackbars)
|
||||||
class _AppBuilder extends ConsumerWidget {
|
class _AppBuilder extends ConsumerWidget {
|
||||||
const _AppBuilder({
|
const _AppBuilder({required this.child});
|
||||||
required this.child,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
@@ -116,4 +113,3 @@ class _AppBuilder extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -398,7 +398,10 @@ class ApiConstants {
|
|||||||
/// final url = ApiConstants.buildUrlWithParams('/products/{id}', {'id': '123'});
|
/// final url = ApiConstants.buildUrlWithParams('/products/{id}', {'id': '123'});
|
||||||
/// // Returns: https://api.worker.example.com/v1/products/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;
|
String url = endpoint;
|
||||||
params.forEach((key, value) {
|
params.forEach((key, value) {
|
||||||
url = url.replaceAll('{$key}', value);
|
url = url.replaceAll('{$key}', value);
|
||||||
|
|||||||
@@ -440,7 +440,12 @@ class AppConstants {
|
|||||||
static const int maxProductImageSize = 3;
|
static const int maxProductImageSize = 3;
|
||||||
|
|
||||||
/// Supported image formats
|
/// 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)
|
/// Image quality for compression (0-100)
|
||||||
static const int imageQuality = 85;
|
static const int imageQuality = 85;
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ class HiveTypeIds {
|
|||||||
static const int promotionModel = 26;
|
static const int promotionModel = 26;
|
||||||
static const int categoryModel = 27;
|
static const int categoryModel = 27;
|
||||||
static const int favoriteModel = 28;
|
static const int favoriteModel = 28;
|
||||||
|
static const int businessUnitModel = 29;
|
||||||
|
|
||||||
// Enums (30-59)
|
// Enums (30-59)
|
||||||
static const int userRole = 30;
|
static const int userRole = 30;
|
||||||
@@ -152,7 +153,8 @@ class HiveTypeIds {
|
|||||||
// Aliases for backward compatibility and clarity
|
// Aliases for backward compatibility and clarity
|
||||||
static const int memberTier = loyaltyTier; // Alias for loyaltyTier
|
static const int memberTier = loyaltyTier; // Alias for loyaltyTier
|
||||||
static const int userType = userRole; // Alias for userRole
|
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
|
static const int transactionType = entryType; // Alias for entryType
|
||||||
|
|
||||||
// Cache & Sync Models (60-69)
|
// Cache & Sync Models (60-69)
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ class DatabaseManager {
|
|||||||
/// Get a box safely
|
/// Get a box safely
|
||||||
Box<T> _getBox<T>(String boxName) {
|
Box<T> _getBox<T>(String boxName) {
|
||||||
if (!_hiveService.isBoxOpen(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);
|
return _hiveService.getBox<T>(boxName);
|
||||||
}
|
}
|
||||||
@@ -49,11 +51,7 @@ class DatabaseManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get a value from a box
|
/// Get a value from a box
|
||||||
T? get<T>({
|
T? get<T>({required String boxName, required String key, T? defaultValue}) {
|
||||||
required String boxName,
|
|
||||||
required String key,
|
|
||||||
T? defaultValue,
|
|
||||||
}) {
|
|
||||||
try {
|
try {
|
||||||
final box = _getBox<T>(boxName);
|
final box = _getBox<T>(boxName);
|
||||||
return box.get(key, defaultValue: defaultValue);
|
return box.get(key, defaultValue: defaultValue);
|
||||||
@@ -65,10 +63,7 @@ class DatabaseManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a value from a box
|
/// Delete a value from a box
|
||||||
Future<void> delete({
|
Future<void> delete({required String boxName, required String key}) async {
|
||||||
required String boxName,
|
|
||||||
required String key,
|
|
||||||
}) async {
|
|
||||||
try {
|
try {
|
||||||
final box = _getBox<dynamic>(boxName);
|
final box = _getBox<dynamic>(boxName);
|
||||||
await box.delete(key);
|
await box.delete(key);
|
||||||
@@ -81,10 +76,7 @@ class DatabaseManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a key exists in a box
|
/// Check if a key exists in a box
|
||||||
bool exists({
|
bool exists({required String boxName, required String key}) {
|
||||||
required String boxName,
|
|
||||||
required String key,
|
|
||||||
}) {
|
|
||||||
try {
|
try {
|
||||||
final box = _getBox<dynamic>(boxName);
|
final box = _getBox<dynamic>(boxName);
|
||||||
return box.containsKey(key);
|
return box.containsKey(key);
|
||||||
@@ -138,10 +130,7 @@ class DatabaseManager {
|
|||||||
// ==================== Cache Operations ====================
|
// ==================== Cache Operations ====================
|
||||||
|
|
||||||
/// Save data to cache with timestamp
|
/// Save data to cache with timestamp
|
||||||
Future<void> saveToCache<T>({
|
Future<void> saveToCache<T>({required String key, required T data}) async {
|
||||||
required String key,
|
|
||||||
required T data,
|
|
||||||
}) async {
|
|
||||||
try {
|
try {
|
||||||
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
|
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
|
||||||
await cacheBox.put(key, {
|
await cacheBox.put(key, {
|
||||||
@@ -159,10 +148,7 @@ class DatabaseManager {
|
|||||||
/// Get data from cache
|
/// Get data from cache
|
||||||
///
|
///
|
||||||
/// Returns null if cache is expired or doesn't exist
|
/// Returns null if cache is expired or doesn't exist
|
||||||
T? getFromCache<T>({
|
T? getFromCache<T>({required String key, Duration? maxAge}) {
|
||||||
required String key,
|
|
||||||
Duration? maxAge,
|
|
||||||
}) {
|
|
||||||
try {
|
try {
|
||||||
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
|
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
|
||||||
final cachedData = cacheBox.get(key) as Map<dynamic, dynamic>?;
|
final cachedData = cacheBox.get(key) as Map<dynamic, dynamic>?;
|
||||||
@@ -193,10 +179,7 @@ class DatabaseManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if cache is valid (exists and not expired)
|
/// Check if cache is valid (exists and not expired)
|
||||||
bool isCacheValid({
|
bool isCacheValid({required String key, Duration? maxAge}) {
|
||||||
required String key,
|
|
||||||
Duration? maxAge,
|
|
||||||
}) {
|
|
||||||
try {
|
try {
|
||||||
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
|
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
|
||||||
final cachedData = cacheBox.get(key) as Map<dynamic, dynamic>?;
|
final cachedData = cacheBox.get(key) as Map<dynamic, dynamic>?;
|
||||||
@@ -244,7 +227,9 @@ class DatabaseManager {
|
|||||||
await cacheBox.delete(key);
|
await cacheBox.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('DatabaseManager: Cleared ${keysToDelete.length} expired cache entries');
|
debugPrint(
|
||||||
|
'DatabaseManager: Cleared ${keysToDelete.length} expired cache entries',
|
||||||
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('DatabaseManager: Error clearing expired cache: $e');
|
debugPrint('DatabaseManager: Error clearing expired cache: $e');
|
||||||
debugPrint('StackTrace: $stackTrace');
|
debugPrint('StackTrace: $stackTrace');
|
||||||
@@ -281,10 +266,7 @@ class DatabaseManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if data needs sync
|
/// Check if data needs sync
|
||||||
bool needsSync({
|
bool needsSync({required String dataType, required Duration syncInterval}) {
|
||||||
required String dataType,
|
|
||||||
required Duration syncInterval,
|
|
||||||
}) {
|
|
||||||
final lastSync = getLastSyncTime(dataType);
|
final lastSync = getLastSyncTime(dataType);
|
||||||
|
|
||||||
if (lastSync == null) return true;
|
if (lastSync == null) return true;
|
||||||
@@ -296,22 +278,12 @@ class DatabaseManager {
|
|||||||
// ==================== Settings Operations ====================
|
// ==================== Settings Operations ====================
|
||||||
|
|
||||||
/// Save a setting
|
/// Save a setting
|
||||||
Future<void> saveSetting<T>({
|
Future<void> saveSetting<T>({required String key, required T value}) async {
|
||||||
required String key,
|
await save(boxName: HiveBoxNames.settingsBox, key: key, value: value);
|
||||||
required T value,
|
|
||||||
}) async {
|
|
||||||
await save(
|
|
||||||
boxName: HiveBoxNames.settingsBox,
|
|
||||||
key: key,
|
|
||||||
value: value,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a setting
|
/// Get a setting
|
||||||
T? getSetting<T>({
|
T? getSetting<T>({required String key, T? defaultValue}) {
|
||||||
required String key,
|
|
||||||
T? defaultValue,
|
|
||||||
}) {
|
|
||||||
return get(
|
return get(
|
||||||
boxName: HiveBoxNames.settingsBox,
|
boxName: HiveBoxNames.settingsBox,
|
||||||
key: key,
|
key: key,
|
||||||
@@ -328,7 +300,9 @@ class DatabaseManager {
|
|||||||
|
|
||||||
// Check queue size limit
|
// Check queue size limit
|
||||||
if (queueBox.length >= HiveDatabaseConfig.maxOfflineQueueSize) {
|
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);
|
await queueBox.deleteAt(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,10 +360,7 @@ class DatabaseManager {
|
|||||||
try {
|
try {
|
||||||
if (_hiveService.isBoxOpen(boxName)) {
|
if (_hiveService.isBoxOpen(boxName)) {
|
||||||
final box = _getBox<dynamic>(boxName);
|
final box = _getBox<dynamic>(boxName);
|
||||||
stats[boxName] = {
|
stats[boxName] = {'count': box.length, 'keys': box.keys.length};
|
||||||
'count': box.length,
|
|
||||||
'keys': box.keys.length,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
stats[boxName] = {'error': e.toString()};
|
stats[boxName] = {'error': e.toString()};
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import 'package:hive_ce_flutter/hive_flutter.dart';
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import 'package:worker/core/constants/storage_constants.dart';
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
import 'package:worker/features/favorites/data/models/favorite_model.dart';
|
import 'package:worker/hive_registrar.g.dart';
|
||||||
// TODO: Re-enable when build_runner generates this file successfully
|
|
||||||
// import 'package:worker/hive_registrar.g.dart';
|
|
||||||
|
|
||||||
/// Hive CE (Community Edition) Database Service
|
/// Hive CE (Community Edition) Database Service
|
||||||
///
|
///
|
||||||
@@ -92,40 +90,45 @@ class HiveService {
|
|||||||
debugPrint('HiveService: Registering type adapters...');
|
debugPrint('HiveService: Registering type adapters...');
|
||||||
|
|
||||||
// Register all adapters using the auto-generated extension
|
// Register all adapters using the auto-generated extension
|
||||||
// This automatically registers:
|
// This automatically registers all model and enum adapters
|
||||||
// - CachedDataAdapter (typeId: 30)
|
Hive.registerAdapters();
|
||||||
// - All enum adapters (typeIds: 20-29)
|
|
||||||
// TODO: Re-enable when build_runner generates hive_registrar.g.dart successfully
|
|
||||||
// Hive.registerAdapters();
|
|
||||||
|
|
||||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.memberTier) ? "✓" : "✗"} MemberTier adapter');
|
debugPrint(
|
||||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.userType) ? "✓" : "✗"} UserType adapter');
|
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.loyaltyTier) ? "✓" : "✗"} LoyaltyTier adapter',
|
||||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.orderStatus) ? "✓" : "✗"} OrderStatus adapter');
|
);
|
||||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectStatus) ? "✓" : "✗"} ProjectStatus adapter');
|
debugPrint(
|
||||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectType) ? "✓" : "✗"} ProjectType adapter');
|
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.userRole) ? "✓" : "✗"} UserRole adapter',
|
||||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.transactionType) ? "✓" : "✗"} TransactionType adapter');
|
);
|
||||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.giftStatus) ? "✓" : "✗"} GiftStatus adapter');
|
debugPrint(
|
||||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentStatus) ? "✓" : "✗"} PaymentStatus adapter');
|
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.orderStatus) ? "✓" : "✗"} OrderStatus adapter',
|
||||||
// NotificationType adapter not needed - notification model uses String type
|
);
|
||||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentMethod) ? "✓" : "✗"} PaymentMethod adapter');
|
debugPrint(
|
||||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.cachedData) ? "✓" : "✗"} CachedData adapter');
|
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectType) ? "✓" : "✗"} ProjectType adapter',
|
||||||
|
);
|
||||||
// Register model type adapters manually
|
debugPrint(
|
||||||
// FavoriteModel adapter (typeId: 28)
|
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.entryType) ? "✓" : "✗"} EntryType adapter',
|
||||||
if (!Hive.isAdapterRegistered(HiveTypeIds.favoriteModel)) {
|
);
|
||||||
Hive.registerAdapter(FavoriteModelAdapter());
|
debugPrint(
|
||||||
debugPrint('HiveService: ✓ FavoriteModel adapter registered');
|
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.giftStatus) ? "✓" : "✗"} GiftStatus adapter',
|
||||||
}
|
);
|
||||||
|
debugPrint(
|
||||||
// TODO: Register other model type adapters when created
|
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentStatus) ? "✓" : "✗"} PaymentStatus adapter',
|
||||||
// Example:
|
);
|
||||||
// - UserModel (typeId: 0)
|
debugPrint(
|
||||||
// - ProductModel (typeId: 1)
|
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentMethod) ? "✓" : "✗"} PaymentMethod adapter',
|
||||||
// - CartItemModel (typeId: 2)
|
);
|
||||||
// - OrderModel (typeId: 3)
|
debugPrint(
|
||||||
// - ProjectModel (typeId: 4)
|
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.cachedData) ? "✓" : "✗"} CachedData adapter',
|
||||||
// - LoyaltyTransactionModel (typeId: 5)
|
);
|
||||||
// etc.
|
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');
|
debugPrint('HiveService: Type adapters registered successfully');
|
||||||
}
|
}
|
||||||
@@ -188,21 +191,23 @@ class HiveService {
|
|||||||
/// Handles schema version upgrades and data migrations.
|
/// Handles schema version upgrades and data migrations.
|
||||||
Future<void> _performMigrations() async {
|
Future<void> _performMigrations() async {
|
||||||
final settingsBox = Hive.box<dynamic>(HiveBoxNames.settingsBox);
|
final settingsBox = Hive.box<dynamic>(HiveBoxNames.settingsBox);
|
||||||
final currentVersion = settingsBox.get(
|
final currentVersion =
|
||||||
HiveKeys.schemaVersion,
|
settingsBox.get(HiveKeys.schemaVersion, defaultValue: 0) as int;
|
||||||
defaultValue: 0,
|
|
||||||
) as int;
|
|
||||||
|
|
||||||
debugPrint('HiveService: Current schema version: $currentVersion');
|
debugPrint('HiveService: Current schema version: $currentVersion');
|
||||||
debugPrint('HiveService: Target schema version: ${HiveDatabaseConfig.currentSchemaVersion}');
|
debugPrint(
|
||||||
|
'HiveService: Target schema version: ${HiveDatabaseConfig.currentSchemaVersion}',
|
||||||
|
);
|
||||||
|
|
||||||
if (currentVersion < HiveDatabaseConfig.currentSchemaVersion) {
|
if (currentVersion < HiveDatabaseConfig.currentSchemaVersion) {
|
||||||
debugPrint('HiveService: Performing migrations...');
|
debugPrint('HiveService: Performing migrations...');
|
||||||
|
|
||||||
// Perform migrations sequentially
|
// Perform migrations sequentially
|
||||||
for (int version = currentVersion + 1;
|
for (
|
||||||
|
int version = currentVersion + 1;
|
||||||
version <= HiveDatabaseConfig.currentSchemaVersion;
|
version <= HiveDatabaseConfig.currentSchemaVersion;
|
||||||
version++) {
|
version++
|
||||||
|
) {
|
||||||
await _migrateToVersion(version);
|
await _migrateToVersion(version);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,10 +283,9 @@ class HiveService {
|
|||||||
|
|
||||||
/// Clear expired cache entries
|
/// Clear expired cache entries
|
||||||
Future<void> _clearExpiredCache() async {
|
Future<void> _clearExpiredCache() async {
|
||||||
final cacheBox = Hive.box<dynamic>(HiveBoxNames.cacheBox);
|
|
||||||
|
|
||||||
// TODO: Implement cache expiration logic
|
// TODO: Implement cache expiration logic
|
||||||
// This will be implemented when cache models are created
|
// This will be implemented when cache models are created
|
||||||
|
// final cacheBox = Hive.box<dynamic>(HiveBoxNames.cacheBox);
|
||||||
|
|
||||||
debugPrint('HiveService: Cleared expired cache entries');
|
debugPrint('HiveService: Cleared expired cache entries');
|
||||||
}
|
}
|
||||||
@@ -291,14 +295,17 @@ class HiveService {
|
|||||||
final queueBox = Hive.box<dynamic>(HiveBoxNames.offlineQueueBox);
|
final queueBox = Hive.box<dynamic>(HiveBoxNames.offlineQueueBox);
|
||||||
|
|
||||||
if (queueBox.length > HiveDatabaseConfig.maxOfflineQueueSize) {
|
if (queueBox.length > HiveDatabaseConfig.maxOfflineQueueSize) {
|
||||||
final itemsToRemove = queueBox.length - HiveDatabaseConfig.maxOfflineQueueSize;
|
final itemsToRemove =
|
||||||
|
queueBox.length - HiveDatabaseConfig.maxOfflineQueueSize;
|
||||||
|
|
||||||
// Remove oldest items
|
// Remove oldest items
|
||||||
for (int i = 0; i < itemsToRemove; i++) {
|
for (int i = 0; i < itemsToRemove; i++) {
|
||||||
await queueBox.deleteAt(0);
|
await queueBox.deleteAt(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('HiveService: Removed $itemsToRemove old items from offline queue');
|
debugPrint(
|
||||||
|
'HiveService: Removed $itemsToRemove old items from offline queue',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,35 +10,27 @@ library;
|
|||||||
|
|
||||||
/// Base exception for all network-related errors
|
/// Base exception for all network-related errors
|
||||||
class NetworkException implements Exception {
|
class NetworkException implements Exception {
|
||||||
const NetworkException(
|
const NetworkException(this.message, {this.statusCode, this.data});
|
||||||
this.message, {
|
|
||||||
this.statusCode,
|
|
||||||
this.data,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String message;
|
final String message;
|
||||||
final int? statusCode;
|
final int? statusCode;
|
||||||
final dynamic data;
|
final dynamic data;
|
||||||
|
|
||||||
@override
|
@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
|
/// Exception thrown when there's no internet connection
|
||||||
class NoInternetException extends NetworkException {
|
class NoInternetException extends NetworkException {
|
||||||
const NoInternetException()
|
const NoInternetException()
|
||||||
: super(
|
: super('Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.');
|
||||||
'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
|
/// Exception thrown when connection times out
|
||||||
class TimeoutException extends NetworkException {
|
class TimeoutException extends NetworkException {
|
||||||
const TimeoutException()
|
const TimeoutException()
|
||||||
: super(
|
: super('Kết nối quá lâu. Vui lòng thử lại.', statusCode: 408);
|
||||||
'Kết nối quá lâu. Vui lòng thử lại.',
|
|
||||||
statusCode: 408,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exception thrown when server returns 500+ errors
|
/// Exception thrown when server returns 500+ errors
|
||||||
@@ -52,10 +44,7 @@ class ServerException extends NetworkException {
|
|||||||
/// Exception thrown when server is unreachable
|
/// Exception thrown when server is unreachable
|
||||||
class ServiceUnavailableException extends ServerException {
|
class ServiceUnavailableException extends ServerException {
|
||||||
const ServiceUnavailableException()
|
const ServiceUnavailableException()
|
||||||
: super(
|
: super('Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.', 503);
|
||||||
'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
|
/// Base exception for authentication-related errors
|
||||||
class AuthException implements Exception {
|
class AuthException implements Exception {
|
||||||
const AuthException(
|
const AuthException(this.message, {this.statusCode});
|
||||||
this.message, {
|
|
||||||
this.statusCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String message;
|
final String message;
|
||||||
final int? statusCode;
|
final int? statusCode;
|
||||||
@@ -79,10 +65,7 @@ class AuthException implements Exception {
|
|||||||
/// Exception thrown when authentication credentials are invalid
|
/// Exception thrown when authentication credentials are invalid
|
||||||
class InvalidCredentialsException extends AuthException {
|
class InvalidCredentialsException extends AuthException {
|
||||||
const InvalidCredentialsException()
|
const InvalidCredentialsException()
|
||||||
: super(
|
: super('Thông tin đăng nhập không hợp lệ.', statusCode: 401);
|
||||||
'Thông tin đăng nhập không hợp lệ.',
|
|
||||||
statusCode: 401,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exception thrown when user is not authenticated
|
/// Exception thrown when user is not authenticated
|
||||||
@@ -95,10 +78,7 @@ class UnauthorizedException extends AuthException {
|
|||||||
/// Exception thrown when user doesn't have permission
|
/// Exception thrown when user doesn't have permission
|
||||||
class ForbiddenException extends AuthException {
|
class ForbiddenException extends AuthException {
|
||||||
const ForbiddenException()
|
const ForbiddenException()
|
||||||
: super(
|
: super('Bạn không có quyền truy cập tài nguyên này.', statusCode: 403);
|
||||||
'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
|
/// Exception thrown when auth token is expired
|
||||||
@@ -122,19 +102,13 @@ class InvalidRefreshTokenException extends AuthException {
|
|||||||
/// Exception thrown when OTP is invalid
|
/// Exception thrown when OTP is invalid
|
||||||
class InvalidOTPException extends AuthException {
|
class InvalidOTPException extends AuthException {
|
||||||
const InvalidOTPException()
|
const InvalidOTPException()
|
||||||
: super(
|
: super('Mã OTP không hợp lệ. Vui lòng thử lại.', statusCode: 400);
|
||||||
'Mã OTP không hợp lệ. Vui lòng thử lại.',
|
|
||||||
statusCode: 400,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exception thrown when OTP is expired
|
/// Exception thrown when OTP is expired
|
||||||
class OTPExpiredException extends AuthException {
|
class OTPExpiredException extends AuthException {
|
||||||
const OTPExpiredException()
|
const OTPExpiredException()
|
||||||
: super(
|
: super('Mã OTP đã hết hạn. Vui lòng yêu cầu mã mới.', statusCode: 400);
|
||||||
'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
|
/// Exception thrown when request data is invalid
|
||||||
class ValidationException implements Exception {
|
class ValidationException implements Exception {
|
||||||
const ValidationException(
|
const ValidationException(this.message, {this.errors});
|
||||||
this.message, {
|
|
||||||
this.errors,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String message;
|
final String message;
|
||||||
final Map<String, List<String>>? errors;
|
final Map<String, List<String>>? errors;
|
||||||
@@ -198,9 +169,7 @@ class NotFoundException implements Exception {
|
|||||||
|
|
||||||
/// Exception thrown when trying to create a duplicate resource
|
/// Exception thrown when trying to create a duplicate resource
|
||||||
class ConflictException implements Exception {
|
class ConflictException implements Exception {
|
||||||
const ConflictException([
|
const ConflictException([this.message = 'Tài nguyên đã tồn tại.']);
|
||||||
this.message = 'Tài nguyên đã tồn tại.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
final String message;
|
final String message;
|
||||||
|
|
||||||
@@ -237,10 +206,7 @@ class RateLimitException implements Exception {
|
|||||||
|
|
||||||
/// Exception thrown for payment-related errors
|
/// Exception thrown for payment-related errors
|
||||||
class PaymentException implements Exception {
|
class PaymentException implements Exception {
|
||||||
const PaymentException(
|
const PaymentException(this.message, {this.transactionId});
|
||||||
this.message, {
|
|
||||||
this.transactionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String message;
|
final String message;
|
||||||
final String? transactionId;
|
final String? transactionId;
|
||||||
@@ -259,8 +225,7 @@ class PaymentFailedException extends PaymentException {
|
|||||||
|
|
||||||
/// Exception thrown when payment is cancelled
|
/// Exception thrown when payment is cancelled
|
||||||
class PaymentCancelledException extends PaymentException {
|
class PaymentCancelledException extends PaymentException {
|
||||||
const PaymentCancelledException()
|
const PaymentCancelledException() : super('Thanh toán đã bị hủy.');
|
||||||
: super('Thanh toán đã bị hủy.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -269,9 +234,7 @@ class PaymentCancelledException extends PaymentException {
|
|||||||
|
|
||||||
/// Exception thrown for cache-related errors
|
/// Exception thrown for cache-related errors
|
||||||
class CacheException implements Exception {
|
class CacheException implements Exception {
|
||||||
const CacheException([
|
const CacheException([this.message = 'Lỗi khi truy cập bộ nhớ đệm.']);
|
||||||
this.message = 'Lỗi khi truy cập bộ nhớ đệm.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
final String message;
|
final String message;
|
||||||
|
|
||||||
@@ -281,8 +244,7 @@ class CacheException implements Exception {
|
|||||||
|
|
||||||
/// Exception thrown when cache data is corrupted
|
/// Exception thrown when cache data is corrupted
|
||||||
class CacheCorruptedException extends CacheException {
|
class CacheCorruptedException extends CacheException {
|
||||||
const CacheCorruptedException()
|
const CacheCorruptedException() : super('Dữ liệu bộ nhớ đệm bị hỏng.');
|
||||||
: super('Dữ liệu bộ nhớ đệm bị hỏng.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -291,9 +253,7 @@ class CacheCorruptedException extends CacheException {
|
|||||||
|
|
||||||
/// Exception thrown for local storage errors
|
/// Exception thrown for local storage errors
|
||||||
class StorageException implements Exception {
|
class StorageException implements Exception {
|
||||||
const StorageException([
|
const StorageException([this.message = 'Lỗi khi truy cập bộ nhớ cục bộ.']);
|
||||||
this.message = 'Lỗi khi truy cập bộ nhớ cục bộ.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
final String message;
|
final String message;
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,12 @@ sealed class Failure {
|
|||||||
const Failure({required this.message});
|
const Failure({required this.message});
|
||||||
|
|
||||||
/// Network-related failure
|
/// Network-related failure
|
||||||
const factory Failure.network({
|
const factory Failure.network({required String message, int? statusCode}) =
|
||||||
required String message,
|
NetworkFailure;
|
||||||
int? statusCode,
|
|
||||||
}) = NetworkFailure;
|
|
||||||
|
|
||||||
/// Server error failure (5xx errors)
|
/// Server error failure (5xx errors)
|
||||||
const factory Failure.server({
|
const factory Failure.server({required String message, int? statusCode}) =
|
||||||
required String message,
|
ServerFailure;
|
||||||
int? statusCode,
|
|
||||||
}) = ServerFailure;
|
|
||||||
|
|
||||||
/// Authentication failure
|
/// Authentication failure
|
||||||
const factory Failure.authentication({
|
const factory Failure.authentication({
|
||||||
@@ -33,20 +29,14 @@ sealed class Failure {
|
|||||||
}) = ValidationFailure;
|
}) = ValidationFailure;
|
||||||
|
|
||||||
/// Not found failure (404)
|
/// Not found failure (404)
|
||||||
const factory Failure.notFound({
|
const factory Failure.notFound({required String message}) = NotFoundFailure;
|
||||||
required String message,
|
|
||||||
}) = NotFoundFailure;
|
|
||||||
|
|
||||||
/// Conflict failure (409)
|
/// Conflict failure (409)
|
||||||
const factory Failure.conflict({
|
const factory Failure.conflict({required String message}) = ConflictFailure;
|
||||||
required String message,
|
|
||||||
}) = ConflictFailure;
|
|
||||||
|
|
||||||
/// Rate limit exceeded failure (429)
|
/// Rate limit exceeded failure (429)
|
||||||
const factory Failure.rateLimit({
|
const factory Failure.rateLimit({required String message, int? retryAfter}) =
|
||||||
required String message,
|
RateLimitFailure;
|
||||||
int? retryAfter,
|
|
||||||
}) = RateLimitFailure;
|
|
||||||
|
|
||||||
/// Payment failure
|
/// Payment failure
|
||||||
const factory Failure.payment({
|
const factory Failure.payment({
|
||||||
@@ -55,19 +45,13 @@ sealed class Failure {
|
|||||||
}) = PaymentFailure;
|
}) = PaymentFailure;
|
||||||
|
|
||||||
/// Cache failure
|
/// Cache failure
|
||||||
const factory Failure.cache({
|
const factory Failure.cache({required String message}) = CacheFailure;
|
||||||
required String message,
|
|
||||||
}) = CacheFailure;
|
|
||||||
|
|
||||||
/// Storage failure
|
/// Storage failure
|
||||||
const factory Failure.storage({
|
const factory Failure.storage({required String message}) = StorageFailure;
|
||||||
required String message,
|
|
||||||
}) = StorageFailure;
|
|
||||||
|
|
||||||
/// Parse failure
|
/// Parse failure
|
||||||
const factory Failure.parse({
|
const factory Failure.parse({required String message}) = ParseFailure;
|
||||||
required String message,
|
|
||||||
}) = ParseFailure;
|
|
||||||
|
|
||||||
/// No internet connection failure
|
/// No internet connection failure
|
||||||
const factory Failure.noInternet() = NoInternetFailure;
|
const factory Failure.noInternet() = NoInternetFailure;
|
||||||
@@ -76,9 +60,7 @@ sealed class Failure {
|
|||||||
const factory Failure.timeout() = TimeoutFailure;
|
const factory Failure.timeout() = TimeoutFailure;
|
||||||
|
|
||||||
/// Unknown failure
|
/// Unknown failure
|
||||||
const factory Failure.unknown({
|
const factory Failure.unknown({required String message}) = UnknownFailure;
|
||||||
required String message,
|
|
||||||
}) = UnknownFailure;
|
|
||||||
|
|
||||||
final String message;
|
final String message;
|
||||||
|
|
||||||
@@ -120,15 +102,21 @@ sealed class Failure {
|
|||||||
/// Get user-friendly error message
|
/// Get user-friendly error message
|
||||||
String getUserMessage() {
|
String getUserMessage() {
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
ValidationFailure(:final message, :final errors) => _formatValidationMessage(message, errors),
|
ValidationFailure(:final message, :final errors) =>
|
||||||
RateLimitFailure(:final message, :final retryAfter) => _formatRateLimitMessage(message, retryAfter),
|
_formatValidationMessage(message, errors),
|
||||||
NoInternetFailure() => 'Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.',
|
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.',
|
TimeoutFailure() => 'Kết nối quá lâu. Vui lòng thử lại.',
|
||||||
_ => message,
|
_ => message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatValidationMessage(String message, Map<String, List<String>>? errors) {
|
String _formatValidationMessage(
|
||||||
|
String message,
|
||||||
|
Map<String, List<String>>? errors,
|
||||||
|
) {
|
||||||
if (errors != null && errors.isNotEmpty) {
|
if (errors != null && errors.isNotEmpty) {
|
||||||
final firstError = errors.values.first.first;
|
final firstError = errors.values.first.first;
|
||||||
return '$message: $firstError';
|
return '$message: $firstError';
|
||||||
@@ -146,10 +134,7 @@ sealed class Failure {
|
|||||||
|
|
||||||
/// Network-related failure
|
/// Network-related failure
|
||||||
final class NetworkFailure extends Failure {
|
final class NetworkFailure extends Failure {
|
||||||
const NetworkFailure({
|
const NetworkFailure({required super.message, this.statusCode});
|
||||||
required super.message,
|
|
||||||
this.statusCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final int? statusCode;
|
final int? statusCode;
|
||||||
@@ -157,10 +142,7 @@ final class NetworkFailure extends Failure {
|
|||||||
|
|
||||||
/// Server error failure (5xx errors)
|
/// Server error failure (5xx errors)
|
||||||
final class ServerFailure extends Failure {
|
final class ServerFailure extends Failure {
|
||||||
const ServerFailure({
|
const ServerFailure({required super.message, this.statusCode});
|
||||||
required super.message,
|
|
||||||
this.statusCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final int? statusCode;
|
final int? statusCode;
|
||||||
@@ -168,10 +150,7 @@ final class ServerFailure extends Failure {
|
|||||||
|
|
||||||
/// Authentication failure
|
/// Authentication failure
|
||||||
final class AuthenticationFailure extends Failure {
|
final class AuthenticationFailure extends Failure {
|
||||||
const AuthenticationFailure({
|
const AuthenticationFailure({required super.message, this.statusCode});
|
||||||
required super.message,
|
|
||||||
this.statusCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final int? statusCode;
|
final int? statusCode;
|
||||||
@@ -179,84 +158,61 @@ final class AuthenticationFailure extends Failure {
|
|||||||
|
|
||||||
/// Validation failure
|
/// Validation failure
|
||||||
final class ValidationFailure extends Failure {
|
final class ValidationFailure extends Failure {
|
||||||
const ValidationFailure({
|
const ValidationFailure({required super.message, this.errors});
|
||||||
required super.message,
|
|
||||||
this.errors,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Map<String, List<String>>? errors;
|
final Map<String, List<String>>? errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Not found failure (404)
|
/// Not found failure (404)
|
||||||
final class NotFoundFailure extends Failure {
|
final class NotFoundFailure extends Failure {
|
||||||
const NotFoundFailure({
|
const NotFoundFailure({required super.message});
|
||||||
required super.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Conflict failure (409)
|
/// Conflict failure (409)
|
||||||
final class ConflictFailure extends Failure {
|
final class ConflictFailure extends Failure {
|
||||||
const ConflictFailure({
|
const ConflictFailure({required super.message});
|
||||||
required super.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rate limit exceeded failure (429)
|
/// Rate limit exceeded failure (429)
|
||||||
final class RateLimitFailure extends Failure {
|
final class RateLimitFailure extends Failure {
|
||||||
const RateLimitFailure({
|
const RateLimitFailure({required super.message, this.retryAfter});
|
||||||
required super.message,
|
|
||||||
this.retryAfter,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int? retryAfter;
|
final int? retryAfter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Payment failure
|
/// Payment failure
|
||||||
final class PaymentFailure extends Failure {
|
final class PaymentFailure extends Failure {
|
||||||
const PaymentFailure({
|
const PaymentFailure({required super.message, this.transactionId});
|
||||||
required super.message,
|
|
||||||
this.transactionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String? transactionId;
|
final String? transactionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cache failure
|
/// Cache failure
|
||||||
final class CacheFailure extends Failure {
|
final class CacheFailure extends Failure {
|
||||||
const CacheFailure({
|
const CacheFailure({required super.message});
|
||||||
required super.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Storage failure
|
/// Storage failure
|
||||||
final class StorageFailure extends Failure {
|
final class StorageFailure extends Failure {
|
||||||
const StorageFailure({
|
const StorageFailure({required super.message});
|
||||||
required super.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse failure
|
/// Parse failure
|
||||||
final class ParseFailure extends Failure {
|
final class ParseFailure extends Failure {
|
||||||
const ParseFailure({
|
const ParseFailure({required super.message});
|
||||||
required super.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// No internet connection failure
|
/// No internet connection failure
|
||||||
final class NoInternetFailure extends Failure {
|
final class NoInternetFailure extends Failure {
|
||||||
const NoInternetFailure()
|
const NoInternetFailure() : super(message: 'Không có kết nối internet');
|
||||||
: super(message: 'Không có kết nối internet');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Timeout failure
|
/// Timeout failure
|
||||||
final class TimeoutFailure extends Failure {
|
final class TimeoutFailure extends Failure {
|
||||||
const TimeoutFailure()
|
const TimeoutFailure() : super(message: 'Kết nối quá lâu');
|
||||||
: super(message: 'Kết nối quá lâu');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unknown failure
|
/// Unknown failure
|
||||||
final class UnknownFailure extends Failure {
|
final class UnknownFailure extends Failure {
|
||||||
const UnknownFailure({
|
const UnknownFailure({required super.message});
|
||||||
required super.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ library;
|
|||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:worker/core/constants/api_constants.dart';
|
import 'package:worker/core/constants/api_constants.dart';
|
||||||
import 'package:worker/core/errors/exceptions.dart';
|
import 'package:worker/core/errors/exceptions.dart';
|
||||||
|
import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart';
|
||||||
|
|
||||||
part 'api_interceptor.g.dart';
|
part 'api_interceptor.g.dart';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ part 'api_interceptor.g.dart';
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Keys for storing auth tokens in SharedPreferences
|
/// Keys for storing auth tokens in SharedPreferences
|
||||||
|
/// @deprecated Use AuthLocalDataSource with Hive instead
|
||||||
class AuthStorageKeys {
|
class AuthStorageKeys {
|
||||||
static const String accessToken = 'auth_access_token';
|
static const String accessToken = 'auth_access_token';
|
||||||
static const String refreshToken = 'auth_refresh_token';
|
static const String refreshToken = 'auth_refresh_token';
|
||||||
@@ -33,12 +35,15 @@ class AuthStorageKeys {
|
|||||||
// Auth Interceptor
|
// 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 {
|
class AuthInterceptor extends Interceptor {
|
||||||
AuthInterceptor(this._prefs, this._dio);
|
AuthInterceptor(this._prefs, this._dio, this._authLocalDataSource);
|
||||||
|
|
||||||
final SharedPreferences _prefs;
|
final SharedPreferences _prefs;
|
||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
|
final AuthLocalDataSource _authLocalDataSource;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onRequest(
|
void onRequest(
|
||||||
@@ -47,10 +52,19 @@ class AuthInterceptor extends Interceptor {
|
|||||||
) async {
|
) async {
|
||||||
// Check if this endpoint requires authentication
|
// Check if this endpoint requires authentication
|
||||||
if (_requiresAuth(options.path)) {
|
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) {
|
if (token != null) {
|
||||||
// Add bearer token to headers
|
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,10 +80,7 @@ class AuthInterceptor extends Interceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onError(
|
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||||
DioException err,
|
|
||||||
ErrorInterceptorHandler handler,
|
|
||||||
) async {
|
|
||||||
// Check if error is 401 Unauthorized
|
// Check if error is 401 Unauthorized
|
||||||
if (err.response?.statusCode == 401) {
|
if (err.response?.statusCode == 401) {
|
||||||
// Try to refresh token
|
// Try to refresh token
|
||||||
@@ -113,15 +124,16 @@ class AuthInterceptor extends Interceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if token is expired
|
/// Check if token is expired
|
||||||
Future<bool> _isTokenExpired() async {
|
// TODO: Use this method when implementing token refresh logic
|
||||||
final expiryString = _prefs.getString(AuthStorageKeys.tokenExpiry);
|
// Future<bool> _isTokenExpired() async {
|
||||||
if (expiryString == null) return true;
|
// final expiryString = _prefs.getString(AuthStorageKeys.tokenExpiry);
|
||||||
|
// if (expiryString == null) return true;
|
||||||
final expiry = DateTime.tryParse(expiryString);
|
//
|
||||||
if (expiry == null) return true;
|
// final expiry = DateTime.tryParse(expiryString);
|
||||||
|
// if (expiry == null) return true;
|
||||||
return DateTime.now().isAfter(expiry);
|
//
|
||||||
}
|
// return DateTime.now().isAfter(expiry);
|
||||||
|
// }
|
||||||
|
|
||||||
/// Refresh access token using refresh token
|
/// Refresh access token using refresh token
|
||||||
Future<bool> _refreshAccessToken() async {
|
Future<bool> _refreshAccessToken() async {
|
||||||
@@ -135,11 +147,7 @@ class AuthInterceptor extends Interceptor {
|
|||||||
// Call refresh token endpoint
|
// Call refresh token endpoint
|
||||||
final response = await _dio.post<Map<String, dynamic>>(
|
final response = await _dio.post<Map<String, dynamic>>(
|
||||||
'${ApiConstants.apiBaseUrl}${ApiConstants.refreshToken}',
|
'${ApiConstants.apiBaseUrl}${ApiConstants.refreshToken}',
|
||||||
options: Options(
|
options: Options(headers: {'Authorization': 'Bearer $refreshToken'}),
|
||||||
headers: {
|
|
||||||
'Authorization': 'Bearer $refreshToken',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
@@ -185,10 +193,7 @@ class AuthInterceptor extends Interceptor {
|
|||||||
|
|
||||||
final options = Options(
|
final options = Options(
|
||||||
method: requestOptions.method,
|
method: requestOptions.method,
|
||||||
headers: {
|
headers: {...requestOptions.headers, 'Authorization': 'Bearer $token'},
|
||||||
...requestOptions.headers,
|
|
||||||
'Authorization': 'Bearer $token',
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return _dio.request(
|
return _dio.request(
|
||||||
@@ -217,19 +222,13 @@ class LoggingInterceptor extends Interceptor {
|
|||||||
final bool enableErrorLogging;
|
final bool enableErrorLogging;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onRequest(
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
RequestOptions options,
|
|
||||||
RequestInterceptorHandler handler,
|
|
||||||
) {
|
|
||||||
if (enableRequestLogging) {
|
if (enableRequestLogging) {
|
||||||
developer.log(
|
developer.log(
|
||||||
'╔══════════════════════════════════════════════════════════════',
|
'╔══════════════════════════════════════════════════════════════',
|
||||||
name: 'HTTP Request',
|
name: 'HTTP Request',
|
||||||
);
|
);
|
||||||
developer.log(
|
developer.log('║ ${options.method} ${options.uri}', name: 'HTTP Request');
|
||||||
'║ ${options.method} ${options.uri}',
|
|
||||||
name: 'HTTP Request',
|
|
||||||
);
|
|
||||||
developer.log(
|
developer.log(
|
||||||
'║ Headers: ${_sanitizeHeaders(options.headers)}',
|
'║ Headers: ${_sanitizeHeaders(options.headers)}',
|
||||||
name: 'HTTP Request',
|
name: 'HTTP Request',
|
||||||
@@ -290,10 +289,7 @@ class LoggingInterceptor extends Interceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onError(
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
DioException err,
|
|
||||||
ErrorInterceptorHandler handler,
|
|
||||||
) {
|
|
||||||
if (enableErrorLogging) {
|
if (enableErrorLogging) {
|
||||||
developer.log(
|
developer.log(
|
||||||
'╔══════════════════════════════════════════════════════════════',
|
'╔══════════════════════════════════════════════════════════════',
|
||||||
@@ -303,18 +299,12 @@ class LoggingInterceptor extends Interceptor {
|
|||||||
'║ ${err.requestOptions.method} ${err.requestOptions.uri}',
|
'║ ${err.requestOptions.method} ${err.requestOptions.uri}',
|
||||||
name: 'HTTP Error',
|
name: 'HTTP Error',
|
||||||
);
|
);
|
||||||
developer.log(
|
developer.log('║ Error Type: ${err.type}', name: 'HTTP Error');
|
||||||
'║ Error Type: ${err.type}',
|
|
||||||
name: 'HTTP Error',
|
|
||||||
);
|
|
||||||
developer.log(
|
developer.log(
|
||||||
'║ Status Code: ${err.response?.statusCode}',
|
'║ Status Code: ${err.response?.statusCode}',
|
||||||
name: 'HTTP Error',
|
name: 'HTTP Error',
|
||||||
);
|
);
|
||||||
developer.log(
|
developer.log('║ Message: ${err.message}', name: 'HTTP Error');
|
||||||
'║ Message: ${err.message}',
|
|
||||||
name: 'HTTP Error',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (err.response?.data != null) {
|
if (err.response?.data != null) {
|
||||||
developer.log(
|
developer.log(
|
||||||
@@ -389,10 +379,7 @@ class LoggingInterceptor extends Interceptor {
|
|||||||
/// Interceptor for transforming Dio errors into custom exceptions
|
/// Interceptor for transforming Dio errors into custom exceptions
|
||||||
class ErrorTransformerInterceptor extends Interceptor {
|
class ErrorTransformerInterceptor extends Interceptor {
|
||||||
@override
|
@override
|
||||||
void onError(
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
DioException err,
|
|
||||||
ErrorInterceptorHandler handler,
|
|
||||||
) {
|
|
||||||
Exception exception;
|
Exception exception;
|
||||||
|
|
||||||
switch (err.type) {
|
switch (err.type) {
|
||||||
@@ -415,9 +402,7 @@ class ErrorTransformerInterceptor extends Interceptor {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case DioExceptionType.unknown:
|
case DioExceptionType.unknown:
|
||||||
exception = NetworkException(
|
exception = NetworkException('Lỗi không xác định: ${err.message}');
|
||||||
'Lỗi không xác định: ${err.message}',
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -447,7 +432,8 @@ class ErrorTransformerInterceptor extends Interceptor {
|
|||||||
// Extract error message from response
|
// Extract error message from response
|
||||||
String? message;
|
String? message;
|
||||||
if (data is Map<String, dynamic>) {
|
if (data is Map<String, dynamic>) {
|
||||||
message = data['message'] as String? ??
|
message =
|
||||||
|
data['message'] as String? ??
|
||||||
data['error'] as String? ??
|
data['error'] as String? ??
|
||||||
data['msg'] as String?;
|
data['msg'] as String?;
|
||||||
}
|
}
|
||||||
@@ -460,9 +446,7 @@ class ErrorTransformerInterceptor extends Interceptor {
|
|||||||
final validationErrors = errors.map(
|
final validationErrors = errors.map(
|
||||||
(key, value) => MapEntry(
|
(key, value) => MapEntry(
|
||||||
key,
|
key,
|
||||||
value is List
|
value is List ? value.cast<String>() : [value.toString()],
|
||||||
? value.cast<String>()
|
|
||||||
: [value.toString()],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return ValidationException(
|
return ValidationException(
|
||||||
@@ -498,9 +482,7 @@ class ErrorTransformerInterceptor extends Interceptor {
|
|||||||
final validationErrors = errors.map(
|
final validationErrors = errors.map(
|
||||||
(key, value) => MapEntry(
|
(key, value) => MapEntry(
|
||||||
key,
|
key,
|
||||||
value is List
|
value is List ? value.cast<String>() : [value.toString()],
|
||||||
? value.cast<String>()
|
|
||||||
: [value.toString()],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return ValidationException(
|
return ValidationException(
|
||||||
@@ -513,7 +495,9 @@ class ErrorTransformerInterceptor extends Interceptor {
|
|||||||
|
|
||||||
case 429:
|
case 429:
|
||||||
final retryAfter = response.headers.value('retry-after');
|
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);
|
return RateLimitException(message ?? 'Quá nhiều yêu cầu', retrySeconds);
|
||||||
|
|
||||||
case 500:
|
case 500:
|
||||||
@@ -549,7 +533,15 @@ Future<SharedPreferences> sharedPreferences(Ref ref) async {
|
|||||||
@riverpod
|
@riverpod
|
||||||
Future<AuthInterceptor> authInterceptor(Ref ref, Dio dio) async {
|
Future<AuthInterceptor> authInterceptor(Ref ref, Dio dio) async {
|
||||||
final prefs = await ref.watch(sharedPreferencesProvider.future);
|
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
|
/// Provider for LoggingInterceptor
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ final class AuthInterceptorProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$authInterceptorHash() => r'b54ba9af62c3cd7b922ef4030a8e2debb0220e10';
|
String _$authInterceptorHash() => r'3f964536e03e204d09cc9120dd9d961b6d6d4b71';
|
||||||
|
|
||||||
/// Provider for AuthInterceptor
|
/// Provider for AuthInterceptor
|
||||||
|
|
||||||
|
|||||||
@@ -215,14 +215,14 @@ class DioClient {
|
|||||||
/// Clear all cached responses
|
/// Clear all cached responses
|
||||||
Future<void> clearCache() async {
|
Future<void> clearCache() async {
|
||||||
if (_cacheStore != null) {
|
if (_cacheStore != null) {
|
||||||
await _cacheStore!.clean();
|
await _cacheStore.clean();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear specific cached response by key
|
/// Clear specific cached response by key
|
||||||
Future<void> clearCacheByKey(String key) async {
|
Future<void> clearCacheByKey(String key) async {
|
||||||
if (_cacheStore != null) {
|
if (_cacheStore != null) {
|
||||||
await _cacheStore!.delete(key);
|
await _cacheStore.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ class DioClient {
|
|||||||
final key = CacheOptions.defaultCacheKeyBuilder(
|
final key = CacheOptions.defaultCacheKeyBuilder(
|
||||||
RequestOptions(path: path),
|
RequestOptions(path: path),
|
||||||
);
|
);
|
||||||
await _cacheStore!.delete(key);
|
await _cacheStore.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,10 +258,7 @@ class RetryInterceptor extends Interceptor {
|
|||||||
final double delayMultiplier;
|
final double delayMultiplier;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onError(
|
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||||
DioException err,
|
|
||||||
ErrorInterceptorHandler handler,
|
|
||||||
) async {
|
|
||||||
// Get retry count from request extra
|
// Get retry count from request extra
|
||||||
final retries = err.requestOptions.extra['retries'] as int? ?? 0;
|
final retries = err.requestOptions.extra['retries'] as int? ?? 0;
|
||||||
|
|
||||||
@@ -279,8 +276,9 @@ class RetryInterceptor extends Interceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate delay with exponential backoff
|
// Calculate delay with exponential backoff
|
||||||
final delayMs = (initialDelay.inMilliseconds *
|
final delayMs =
|
||||||
(delayMultiplier * (retries + 1))).toInt();
|
(initialDelay.inMilliseconds * (delayMultiplier * (retries + 1)))
|
||||||
|
.toInt();
|
||||||
final delay = Duration(
|
final delay = Duration(
|
||||||
milliseconds: delayMs.clamp(
|
milliseconds: delayMs.clamp(
|
||||||
initialDelay.inMilliseconds,
|
initialDelay.inMilliseconds,
|
||||||
@@ -341,10 +339,7 @@ class RetryInterceptor extends Interceptor {
|
|||||||
@riverpod
|
@riverpod
|
||||||
Future<CacheStore> cacheStore(Ref ref) async {
|
Future<CacheStore> cacheStore(Ref ref) async {
|
||||||
final directory = await getTemporaryDirectory();
|
final directory = await getTemporaryDirectory();
|
||||||
return HiveCacheStore(
|
return HiveCacheStore(directory.path, hiveBoxName: 'dio_cache');
|
||||||
directory.path,
|
|
||||||
hiveBoxName: 'dio_cache',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider for cache options
|
/// Provider for cache options
|
||||||
@@ -386,16 +381,17 @@ Future<Dio> dio(Ref ref) async {
|
|||||||
return status != null && status < 500;
|
return status != null && status < 500;
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add interceptors in order
|
// Add interceptors in order
|
||||||
|
|
||||||
// 1. Logging interceptor (first to log everything)
|
// 1. Logging interceptor (first to log everything)
|
||||||
..interceptors.add(ref.watch(loggingInterceptorProvider))
|
..interceptors.add(ref.watch(loggingInterceptorProvider))
|
||||||
|
|
||||||
// 2. Auth interceptor (add tokens to requests)
|
// 2. Auth interceptor (add tokens to requests)
|
||||||
..interceptors.add(await ref.watch(authInterceptorProvider(dio).future))
|
..interceptors.add(await ref.watch(authInterceptorProvider(dio).future))
|
||||||
// 3. Cache interceptor
|
// 3. Cache interceptor
|
||||||
..interceptors.add(DioCacheInterceptor(options: await ref.watch(cacheOptionsProvider.future)))
|
..interceptors.add(
|
||||||
|
DioCacheInterceptor(
|
||||||
|
options: await ref.watch(cacheOptionsProvider.future),
|
||||||
|
),
|
||||||
|
)
|
||||||
// 4. Retry interceptor
|
// 4. Retry interceptor
|
||||||
..interceptors.add(RetryInterceptor(ref.watch(networkInfoProvider)))
|
..interceptors.add(RetryInterceptor(ref.watch(networkInfoProvider)))
|
||||||
// 5. Error transformer (last to transform all errors)
|
// 5. Error transformer (last to transform all errors)
|
||||||
@@ -430,9 +426,7 @@ class ApiRequestOptions {
|
|||||||
final bool forceRefresh;
|
final bool forceRefresh;
|
||||||
|
|
||||||
/// Options with cache enabled
|
/// Options with cache enabled
|
||||||
static const cached = ApiRequestOptions(
|
static const cached = ApiRequestOptions(cachePolicy: CachePolicy.forceCache);
|
||||||
cachePolicy: CachePolicy.forceCache,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Options with network-first strategy
|
/// Options with network-first strategy
|
||||||
static const networkFirst = ApiRequestOptions(
|
static const networkFirst = ApiRequestOptions(
|
||||||
@@ -449,12 +443,9 @@ class ApiRequestOptions {
|
|||||||
Options toDioOptions() {
|
Options toDioOptions() {
|
||||||
return Options(
|
return Options(
|
||||||
extra: <String, dynamic>{
|
extra: <String, dynamic>{
|
||||||
if (cachePolicy != null)
|
if (cachePolicy != null) CacheResponse.cacheKey: cachePolicy!.index,
|
||||||
CacheResponse.cacheKey: cachePolicy!.index,
|
if (cacheDuration != null) 'maxStale': cacheDuration,
|
||||||
if (cacheDuration != null)
|
if (forceRefresh) 'policy': CachePolicy.refresh.index,
|
||||||
'maxStale': cacheDuration,
|
|
||||||
if (forceRefresh)
|
|
||||||
'policy': CachePolicy.refresh.index,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,9 @@ class NetworkInfoImpl implements NetworkInfo {
|
|||||||
return !results.contains(ConnectivityResult.none);
|
return !results.contains(ConnectivityResult.none);
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkConnectionType _mapConnectivityResult(List<ConnectivityResult> results) {
|
NetworkConnectionType _mapConnectivityResult(
|
||||||
|
List<ConnectivityResult> results,
|
||||||
|
) {
|
||||||
if (results.isEmpty || results.contains(ConnectivityResult.none)) {
|
if (results.isEmpty || results.contains(ConnectivityResult.none)) {
|
||||||
return NetworkConnectionType.none;
|
return NetworkConnectionType.none;
|
||||||
}
|
}
|
||||||
@@ -273,14 +275,11 @@ class NetworkStatusNotifier extends _$NetworkStatusNotifier {
|
|||||||
final status = await networkInfo.networkStatus;
|
final status = await networkInfo.networkStatus;
|
||||||
|
|
||||||
// Listen to network changes
|
// Listen to network changes
|
||||||
ref.listen(
|
ref.listen(networkStatusStreamProvider, (_, next) {
|
||||||
networkStatusStreamProvider,
|
|
||||||
(_, next) {
|
|
||||||
next.whenData((newStatus) {
|
next.whenData((newStatus) {
|
||||||
state = AsyncValue.data(newStatus);
|
state = AsyncValue.data(newStatus);
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ String appVersion(Ref ref) {
|
|||||||
int pointsMultiplier(Ref ref) {
|
int pointsMultiplier(Ref ref) {
|
||||||
// Can read other providers
|
// Can read other providers
|
||||||
final userTier = 'diamond'; // This would come from another provider
|
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
|
@riverpod
|
||||||
Future<String> userData(Ref ref) async {
|
Future<String> userData(Ref ref) async {
|
||||||
// Simulate API call
|
// Simulate API call
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
await Future<void>.delayed(const Duration(seconds: 1));
|
||||||
return 'User Data';
|
return 'User Data';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +59,7 @@ Future<String> userData(Ref ref) async {
|
|||||||
@riverpod
|
@riverpod
|
||||||
Future<String> userProfile(Ref ref, String userId) async {
|
Future<String> userProfile(Ref ref, String userId) async {
|
||||||
// Simulate API call with userId
|
// 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';
|
return 'Profile for user: $userId';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +74,7 @@ Future<List<String>> productList(
|
|||||||
String? searchQuery,
|
String? searchQuery,
|
||||||
}) async {
|
}) async {
|
||||||
// Simulate API call with parameters
|
// 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'];
|
return ['Product 1', 'Product 2', 'Product 3'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,10 +86,7 @@ Future<List<String>> productList(
|
|||||||
/// Use this for WebSocket connections, real-time updates, etc.
|
/// Use this for WebSocket connections, real-time updates, etc.
|
||||||
@riverpod
|
@riverpod
|
||||||
Stream<int> timer(Ref ref) {
|
Stream<int> timer(Ref ref) {
|
||||||
return Stream.periodic(
|
return Stream.periodic(const Duration(seconds: 1), (count) => count);
|
||||||
const Duration(seconds: 1),
|
|
||||||
(count) => count,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stream provider with parameters
|
/// Stream provider with parameters
|
||||||
@@ -176,7 +177,7 @@ class UserProfileNotifier extends _$UserProfileNotifier {
|
|||||||
@override
|
@override
|
||||||
Future<UserProfileData> build() async {
|
Future<UserProfileData> build() async {
|
||||||
// Fetch initial data
|
// 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');
|
return UserProfileData(name: 'John Doe', email: 'john@example.com');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,10 +226,7 @@ class UserProfileData {
|
|||||||
final String email;
|
final String email;
|
||||||
|
|
||||||
UserProfileData copyWith({String? name, String? email}) {
|
UserProfileData copyWith({String? name, String? email}) {
|
||||||
return UserProfileData(
|
return UserProfileData(name: name ?? this.name, email: email ?? this.email);
|
||||||
name: name ?? this.name,
|
|
||||||
email: email ?? this.email,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ final class UserDataProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$userDataHash() => r'3df905d6ea9f81ce7ca8205bd785ad4d4376b399';
|
String _$userDataHash() => r'1b754e931a5d4c202189fcdd3de54815f93aaba2';
|
||||||
|
|
||||||
/// Async provider with parameters (Family pattern)
|
/// Async provider with parameters (Family pattern)
|
||||||
/// Parameters are just function parameters - much simpler than before!
|
/// 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)
|
/// Async provider with parameters (Family pattern)
|
||||||
/// Parameters are just function parameters - much simpler than before!
|
/// 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
|
/// Async provider with multiple parameters
|
||||||
/// Named parameters, optional parameters, defaults - all supported!
|
/// Named parameters, optional parameters, defaults - all supported!
|
||||||
@@ -732,7 +732,7 @@ final class UserProfileNotifierProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$userProfileNotifierHash() =>
|
String _$userProfileNotifierHash() =>
|
||||||
r'87c9a9277552095a0ed0b768829e2930fa475c7f';
|
r'be7bcbe81f84be6ef50e94f52e0c65b00230291c';
|
||||||
|
|
||||||
/// AsyncNotifier for state that requires async initialization
|
/// AsyncNotifier for state that requires async initialization
|
||||||
/// Perfect for fetching data that can then be modified
|
/// Perfect for fetching data that can then be modified
|
||||||
|
|||||||
@@ -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/addresses_page.dart';
|
||||||
import 'package:worker/features/account/presentation/pages/change_password_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/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/cart_page.dart';
|
||||||
import 'package:worker/features/cart/presentation/pages/checkout_page.dart';
|
import 'package:worker/features/cart/presentation/pages/checkout_page.dart';
|
||||||
import 'package:worker/features/chat/presentation/pages/chat_list_page.dart';
|
import 'package:worker/features/chat/presentation/pages/chat_list_page.dart';
|
||||||
@@ -46,10 +50,46 @@ class AppRouter {
|
|||||||
/// Router configuration
|
/// Router configuration
|
||||||
static final GoRouter router = GoRouter(
|
static final GoRouter router = GoRouter(
|
||||||
// Initial route
|
// Initial route
|
||||||
initialLocation: RouteNames.home,
|
initialLocation: RouteNames.login,
|
||||||
|
|
||||||
// Route definitions
|
// Route definitions
|
||||||
routes: [
|
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)
|
// Main Route (with bottom navigation)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.home,
|
path: RouteNames.home,
|
||||||
@@ -278,8 +318,10 @@ class AppRouter {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.designRequestCreate,
|
path: RouteNames.designRequestCreate,
|
||||||
name: RouteNames.designRequestCreate,
|
name: RouteNames.designRequestCreate,
|
||||||
pageBuilder: (context, state) =>
|
pageBuilder: (context, state) => MaterialPage(
|
||||||
MaterialPage(key: state.pageKey, child: const DesignRequestCreatePage()),
|
key: state.pageKey,
|
||||||
|
child: const DesignRequestCreatePage(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Design Request Detail Route
|
// Design Request Detail Route
|
||||||
@@ -421,19 +463,27 @@ class RouteNames {
|
|||||||
|
|
||||||
// Model Houses & Design Requests Routes
|
// Model Houses & Design Requests Routes
|
||||||
static const String modelHouses = '/model-houses';
|
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';
|
static const String designRequestDetail = '/model-houses/design-request/:id';
|
||||||
|
|
||||||
// Authentication Routes (TODO: implement when auth feature is ready)
|
// Authentication Routes (TODO: implement when auth feature is ready)
|
||||||
static const String login = '/login';
|
static const String login = '/login';
|
||||||
static const String otpVerification = '/otp-verification';
|
static const String otpVerification = '/otp-verification';
|
||||||
static const String register = '/register';
|
static const String register = '/register';
|
||||||
|
static const String businessUnitSelection = '/business-unit-selection';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Route Extensions
|
/// Route Extensions
|
||||||
///
|
///
|
||||||
/// Helper extensions for common navigation patterns.
|
/// Helper extensions for common navigation patterns.
|
||||||
extension GoRouterExtension on BuildContext {
|
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
|
/// Navigate to home page
|
||||||
void goHome() => go(RouteNames.home);
|
void goHome() => go(RouteNames.home);
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,7 @@ class AppTheme {
|
|||||||
color: AppColors.white,
|
color: AppColors.white,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
iconTheme: const IconThemeData(
|
iconTheme: const IconThemeData(color: AppColors.white, size: 24),
|
||||||
color: AppColors.white,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -65,9 +62,7 @@ class AppTheme {
|
|||||||
foregroundColor: AppColors.white,
|
foregroundColor: AppColors.white,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
textStyle: AppTypography.buttonText,
|
textStyle: AppTypography.buttonText,
|
||||||
minimumSize: const Size(64, 48),
|
minimumSize: const Size(64, 48),
|
||||||
),
|
),
|
||||||
@@ -78,9 +73,7 @@ class AppTheme {
|
|||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: AppColors.primaryBlue,
|
foregroundColor: AppColors.primaryBlue,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
textStyle: AppTypography.buttonText,
|
textStyle: AppTypography.buttonText,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -91,9 +84,7 @@ class AppTheme {
|
|||||||
foregroundColor: AppColors.primaryBlue,
|
foregroundColor: AppColors.primaryBlue,
|
||||||
side: const BorderSide(color: AppColors.primaryBlue, width: 1.5),
|
side: const BorderSide(color: AppColors.primaryBlue, width: 1.5),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
textStyle: AppTypography.buttonText,
|
textStyle: AppTypography.buttonText,
|
||||||
minimumSize: const Size(64, 48),
|
minimumSize: const Size(64, 48),
|
||||||
),
|
),
|
||||||
@@ -103,7 +94,10 @@ class AppTheme {
|
|||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: AppColors.white,
|
fillColor: AppColors.white,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1),
|
borderSide: const BorderSide(color: AppColors.grey100, width: 1),
|
||||||
@@ -124,15 +118,9 @@ class AppTheme {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: const BorderSide(color: AppColors.danger, width: 2),
|
borderSide: const BorderSide(color: AppColors.danger, width: 2),
|
||||||
),
|
),
|
||||||
labelStyle: AppTypography.bodyMedium.copyWith(
|
labelStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
|
||||||
color: AppColors.grey500,
|
hintStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
|
||||||
),
|
errorStyle: AppTypography.bodySmall.copyWith(color: AppColors.danger),
|
||||||
hintStyle: AppTypography.bodyMedium.copyWith(
|
|
||||||
color: AppColors.grey500,
|
|
||||||
),
|
|
||||||
errorStyle: AppTypography.bodySmall.copyWith(
|
|
||||||
color: AppColors.danger,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// ==================== Bottom Navigation Bar Theme ====================
|
// ==================== Bottom Navigation Bar Theme ====================
|
||||||
@@ -144,10 +132,7 @@ class AppTheme {
|
|||||||
size: 28,
|
size: 28,
|
||||||
color: AppColors.primaryBlue,
|
color: AppColors.primaryBlue,
|
||||||
),
|
),
|
||||||
unselectedIconTheme: IconThemeData(
|
unselectedIconTheme: IconThemeData(size: 24, color: AppColors.grey500),
|
||||||
size: 24,
|
|
||||||
color: AppColors.grey500,
|
|
||||||
),
|
|
||||||
selectedLabelStyle: TextStyle(
|
selectedLabelStyle: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -182,13 +167,12 @@ class AppTheme {
|
|||||||
color: AppColors.white,
|
color: AppColors.white,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// ==================== Dialog Theme ====================
|
// ==================== Dialog Theme ====================
|
||||||
dialogTheme: const DialogThemeData(
|
dialogTheme:
|
||||||
|
const DialogThemeData(
|
||||||
backgroundColor: AppColors.white,
|
backgroundColor: AppColors.white,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -209,9 +193,7 @@ class AppTheme {
|
|||||||
contentTextStyle: AppTypography.bodyMedium.copyWith(
|
contentTextStyle: AppTypography.bodyMedium.copyWith(
|
||||||
color: AppColors.white,
|
color: AppColors.white,
|
||||||
),
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
),
|
),
|
||||||
@@ -224,10 +206,7 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// ==================== Icon Theme ====================
|
// ==================== Icon Theme ====================
|
||||||
iconTheme: const IconThemeData(
|
iconTheme: const IconThemeData(color: AppColors.grey900, size: 24),
|
||||||
color: AppColors.grey900,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
|
|
||||||
// ==================== List Tile Theme ====================
|
// ==================== List Tile Theme ====================
|
||||||
listTileTheme: ListTileThemeData(
|
listTileTheme: ListTileThemeData(
|
||||||
@@ -266,9 +245,7 @@ class AppTheme {
|
|||||||
return AppColors.white;
|
return AppColors.white;
|
||||||
}),
|
}),
|
||||||
checkColor: MaterialStateProperty.all(AppColors.white),
|
checkColor: MaterialStateProperty.all(AppColors.white),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// ==================== Radio Theme ====================
|
// ==================== Radio Theme ====================
|
||||||
@@ -297,7 +274,8 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// ==================== Tab Bar Theme ====================
|
// ==================== Tab Bar Theme ====================
|
||||||
tabBarTheme: const TabBarThemeData(
|
tabBarTheme:
|
||||||
|
const TabBarThemeData(
|
||||||
labelColor: AppColors.primaryBlue,
|
labelColor: AppColors.primaryBlue,
|
||||||
unselectedLabelColor: AppColors.grey500,
|
unselectedLabelColor: AppColors.grey500,
|
||||||
indicatorColor: AppColors.primaryBlue,
|
indicatorColor: AppColors.primaryBlue,
|
||||||
@@ -338,10 +316,7 @@ class AppTheme {
|
|||||||
color: AppColors.white,
|
color: AppColors.white,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
iconTheme: const IconThemeData(
|
iconTheme: const IconThemeData(color: AppColors.white, size: 24),
|
||||||
color: AppColors.white,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -363,9 +338,7 @@ class AppTheme {
|
|||||||
foregroundColor: AppColors.white,
|
foregroundColor: AppColors.white,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
textStyle: AppTypography.buttonText,
|
textStyle: AppTypography.buttonText,
|
||||||
minimumSize: const Size(64, 48),
|
minimumSize: const Size(64, 48),
|
||||||
),
|
),
|
||||||
@@ -375,7 +348,10 @@ class AppTheme {
|
|||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFF2A2A2A),
|
fillColor: const Color(0xFF2A2A2A),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: const BorderSide(color: Color(0xFF3A3A3A), width: 1),
|
borderSide: const BorderSide(color: Color(0xFF3A3A3A), width: 1),
|
||||||
@@ -396,15 +372,9 @@ class AppTheme {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: const BorderSide(color: AppColors.danger, width: 2),
|
borderSide: const BorderSide(color: AppColors.danger, width: 2),
|
||||||
),
|
),
|
||||||
labelStyle: AppTypography.bodyMedium.copyWith(
|
labelStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
|
||||||
color: AppColors.grey500,
|
hintStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
|
||||||
),
|
errorStyle: AppTypography.bodySmall.copyWith(color: AppColors.danger),
|
||||||
hintStyle: AppTypography.bodyMedium.copyWith(
|
|
||||||
color: AppColors.grey500,
|
|
||||||
),
|
|
||||||
errorStyle: AppTypography.bodySmall.copyWith(
|
|
||||||
color: AppColors.danger,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// ==================== Bottom Navigation Bar Theme ====================
|
// ==================== Bottom Navigation Bar Theme ====================
|
||||||
@@ -412,14 +382,8 @@ class AppTheme {
|
|||||||
backgroundColor: Color(0xFF1E1E1E),
|
backgroundColor: Color(0xFF1E1E1E),
|
||||||
selectedItemColor: AppColors.lightBlue,
|
selectedItemColor: AppColors.lightBlue,
|
||||||
unselectedItemColor: AppColors.grey500,
|
unselectedItemColor: AppColors.grey500,
|
||||||
selectedIconTheme: IconThemeData(
|
selectedIconTheme: IconThemeData(size: 28, color: AppColors.lightBlue),
|
||||||
size: 28,
|
unselectedIconTheme: IconThemeData(size: 24, color: AppColors.grey500),
|
||||||
color: AppColors.lightBlue,
|
|
||||||
),
|
|
||||||
unselectedIconTheme: IconThemeData(
|
|
||||||
size: 24,
|
|
||||||
color: AppColors.grey500,
|
|
||||||
),
|
|
||||||
selectedLabelStyle: TextStyle(
|
selectedLabelStyle: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -449,9 +413,7 @@ class AppTheme {
|
|||||||
contentTextStyle: AppTypography.bodyMedium.copyWith(
|
contentTextStyle: AppTypography.bodyMedium.copyWith(
|
||||||
color: AppColors.white,
|
color: AppColors.white,
|
||||||
),
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -423,16 +423,14 @@ extension BuildContextExtensions on BuildContext {
|
|||||||
|
|
||||||
/// Navigate to route
|
/// Navigate to route
|
||||||
Future<T?> push<T>(Widget page) {
|
Future<T?> push<T>(Widget page) {
|
||||||
return Navigator.of(this).push<T>(
|
return Navigator.of(this).push<T>(MaterialPageRoute(builder: (_) => page));
|
||||||
MaterialPageRoute(builder: (_) => page),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigate and replace current route
|
/// Navigate and replace current route
|
||||||
Future<T?> pushReplacement<T>(Widget page) {
|
Future<T?> pushReplacement<T>(Widget page) {
|
||||||
return Navigator.of(this).pushReplacement<T, void>(
|
return Navigator.of(
|
||||||
MaterialPageRoute(builder: (_) => page),
|
this,
|
||||||
);
|
).pushReplacement<T, void>(MaterialPageRoute(builder: (_) => page));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pop current route
|
/// Pop current route
|
||||||
|
|||||||
@@ -313,15 +313,21 @@ class TextFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Truncate text with ellipsis
|
/// 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;
|
if (text.length <= maxLength) return text;
|
||||||
return text.substring(0, maxLength - ellipsis.length) + ellipsis;
|
return text.substring(0, maxLength - ellipsis.length) + ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove diacritics from Vietnamese text
|
/// Remove diacritics from Vietnamese text
|
||||||
static String removeDiacritics(String text) {
|
static String removeDiacritics(String text) {
|
||||||
const withDiacritics = 'àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ';
|
const withDiacritics =
|
||||||
const withoutDiacritics = 'aaaaaaaaaaaaaaaaaeeeeeeeeeeeiiiiioooooooooooooooooouuuuuuuuuuuyyyyyd';
|
'àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ';
|
||||||
|
const withoutDiacritics =
|
||||||
|
'aaaaaaaaaaaaaaaaaeeeeeeeeeeeiiiiioooooooooooooooooouuuuuuuuuuuyyyyyd';
|
||||||
|
|
||||||
var result = text.toLowerCase();
|
var result = text.toLowerCase();
|
||||||
for (var i = 0; i < withDiacritics.length; i++) {
|
for (var i = 0; i < withDiacritics.length; i++) {
|
||||||
|
|||||||
@@ -241,8 +241,11 @@ class L10nHelper {
|
|||||||
/// final pointsText = L10nHelper.formatPoints(context, 100);
|
/// final pointsText = L10nHelper.formatPoints(context, 100);
|
||||||
/// // Returns: "+100 điểm" (Vietnamese) or "+100 points" (English)
|
/// // Returns: "+100 điểm" (Vietnamese) or "+100 points" (English)
|
||||||
/// ```
|
/// ```
|
||||||
static String formatPoints(BuildContext context, int points,
|
static String formatPoints(
|
||||||
{bool showSign = true}) {
|
BuildContext context,
|
||||||
|
int points, {
|
||||||
|
bool showSign = true,
|
||||||
|
}) {
|
||||||
if (showSign && points > 0) {
|
if (showSign && points > 0) {
|
||||||
return context.l10n.earnedPoints(points);
|
return context.l10n.earnedPoints(points);
|
||||||
} else if (showSign && points < 0) {
|
} else if (showSign && points < 0) {
|
||||||
|
|||||||
@@ -66,9 +66,7 @@ class QRGenerator {
|
|||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
gapless: true,
|
gapless: true,
|
||||||
embeddedImageStyle: const QrEmbeddedImageStyle(
|
embeddedImageStyle: const QrEmbeddedImageStyle(size: Size(48, 48)),
|
||||||
size: Size(48, 48),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,9 +187,7 @@ class QRGenerator {
|
|||||||
embeddedImage: embeddedImage is AssetImage
|
embeddedImage: embeddedImage is AssetImage
|
||||||
? (embeddedImage as AssetImage).assetName as ImageProvider
|
? (embeddedImage as AssetImage).assetName as ImageProvider
|
||||||
: null,
|
: null,
|
||||||
embeddedImageStyle: QrEmbeddedImageStyle(
|
embeddedImageStyle: QrEmbeddedImageStyle(size: embeddedImageSize),
|
||||||
size: embeddedImageSize,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,18 +199,12 @@ class QRGenerator {
|
|||||||
if (data.contains(':')) {
|
if (data.contains(':')) {
|
||||||
final parts = data.split(':');
|
final parts = data.split(':');
|
||||||
if (parts.length == 2) {
|
if (parts.length == 2) {
|
||||||
return {
|
return {'type': parts[0].toUpperCase(), 'value': parts[1]};
|
||||||
'type': parts[0].toUpperCase(),
|
|
||||||
'value': parts[1],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no type prefix, return as generic data
|
// If no type prefix, return as generic data
|
||||||
return {
|
return {'type': 'GENERIC', 'value': data};
|
||||||
'type': 'GENERIC',
|
|
||||||
'value': data,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,9 +244,7 @@ class Validators {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (double.tryParse(value) == null) {
|
if (double.tryParse(value) == null) {
|
||||||
return fieldName != null
|
return fieldName != null ? '$fieldName phải là số' : 'Giá trị phải là số';
|
||||||
? '$fieldName phải là số'
|
|
||||||
: 'Giá trị phải là số';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -351,7 +349,8 @@ class Validators {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final today = DateTime.now();
|
final today = DateTime.now();
|
||||||
final age = today.year -
|
final age =
|
||||||
|
today.year -
|
||||||
birthDate.year -
|
birthDate.year -
|
||||||
(today.month > birthDate.month ||
|
(today.month > birthDate.month ||
|
||||||
(today.month == birthDate.month && today.day >= birthDate.day)
|
(today.month == birthDate.month && today.day >= birthDate.day)
|
||||||
@@ -456,11 +455,7 @@ class Validators {
|
|||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
/// Validate against custom regex pattern
|
/// Validate against custom regex pattern
|
||||||
static String? pattern(
|
static String? pattern(String? value, RegExp pattern, String errorMessage) {
|
||||||
String? value,
|
|
||||||
RegExp pattern,
|
|
||||||
String errorMessage,
|
|
||||||
) {
|
|
||||||
if (value == null || value.trim().isEmpty) {
|
if (value == null || value.trim().isEmpty) {
|
||||||
return 'Trường này là bắt buộc';
|
return 'Trường này là bắt buộc';
|
||||||
}
|
}
|
||||||
@@ -491,12 +486,7 @@ class Validators {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Password strength enum
|
/// Password strength enum
|
||||||
enum PasswordStrength {
|
enum PasswordStrength { weak, medium, strong, veryStrong }
|
||||||
weak,
|
|
||||||
medium,
|
|
||||||
strong,
|
|
||||||
veryStrong,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Password strength calculator
|
/// Password strength calculator
|
||||||
class PasswordStrengthCalculator {
|
class PasswordStrengthCalculator {
|
||||||
|
|||||||
@@ -58,10 +58,7 @@ class CustomBottomNavBar extends StatelessWidget {
|
|||||||
selectedFontSize: 12,
|
selectedFontSize: 12,
|
||||||
unselectedFontSize: 12,
|
unselectedFontSize: 12,
|
||||||
items: const [
|
items: const [
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
|
||||||
icon: Icon(Icons.home),
|
|
||||||
label: 'Home',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.shopping_bag),
|
icon: Icon(Icons.shopping_bag),
|
||||||
label: 'Products',
|
label: 'Products',
|
||||||
@@ -70,14 +67,8 @@ class CustomBottomNavBar extends StatelessWidget {
|
|||||||
icon: Icon(Icons.card_membership),
|
icon: Icon(Icons.card_membership),
|
||||||
label: 'Loyalty',
|
label: 'Loyalty',
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Account'),
|
||||||
icon: Icon(Icons.person),
|
BottomNavigationBarItem(icon: Icon(Icons.menu), label: 'More'),
|
||||||
label: 'Account',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.menu),
|
|
||||||
label: 'More',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,10 +124,7 @@ class CustomButton extends StatelessWidget {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
text,
|
text,
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -135,10 +132,7 @@ class CustomButton extends StatelessWidget {
|
|||||||
|
|
||||||
return Text(
|
return Text(
|
||||||
text,
|
text,
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,11 +54,7 @@ class EmptyState extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(icon, size: iconSize, color: AppColors.grey500),
|
||||||
icon,
|
|
||||||
size: iconSize,
|
|
||||||
color: AppColors.grey500,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
@@ -73,10 +69,7 @@ class EmptyState extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
subtitle!,
|
subtitle!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||||
fontSize: 14,
|
|
||||||
color: AppColors.grey500,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -58,15 +58,9 @@ class ChatFloatingButton extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.danger,
|
color: AppColors.danger,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
color: Colors.white,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
constraints: const BoxConstraints(
|
|
||||||
minWidth: 20,
|
|
||||||
minHeight: 20,
|
|
||||||
),
|
),
|
||||||
|
constraints: const BoxConstraints(minWidth: 20, minHeight: 20),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
unreadCount! > 99 ? '99+' : unreadCount.toString(),
|
unreadCount! > 99 ? '99+' : unreadCount.toString(),
|
||||||
|
|||||||
@@ -50,10 +50,7 @@ class CustomLoadingIndicator extends StatelessWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
message!,
|
message!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||||
fontSize: 14,
|
|
||||||
color: AppColors.grey500,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,18 +6,39 @@ part 'audit_log_model.g.dart';
|
|||||||
|
|
||||||
@HiveType(typeId: HiveTypeIds.auditLogModel)
|
@HiveType(typeId: HiveTypeIds.auditLogModel)
|
||||||
class AuditLogModel extends HiveObject {
|
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(0)
|
||||||
@HiveField(1) final String userId;
|
final int logId;
|
||||||
@HiveField(2) final String action;
|
@HiveField(1)
|
||||||
@HiveField(3) final String entityType;
|
final String userId;
|
||||||
@HiveField(4) final String entityId;
|
@HiveField(2)
|
||||||
@HiveField(5) final String? oldValue;
|
final String action;
|
||||||
@HiveField(6) final String? newValue;
|
@HiveField(3)
|
||||||
@HiveField(7) final String? ipAddress;
|
final String entityType;
|
||||||
@HiveField(8) final String? userAgent;
|
@HiveField(4)
|
||||||
@HiveField(9) final DateTime timestamp;
|
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(
|
factory AuditLogModel.fromJson(Map<String, dynamic> json) => AuditLogModel(
|
||||||
logId: json['log_id'] as int,
|
logId: json['log_id'] as int,
|
||||||
|
|||||||
@@ -6,32 +6,64 @@ part 'payment_reminder_model.g.dart';
|
|||||||
|
|
||||||
@HiveType(typeId: HiveTypeIds.paymentReminderModel)
|
@HiveType(typeId: HiveTypeIds.paymentReminderModel)
|
||||||
class PaymentReminderModel extends HiveObject {
|
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(0)
|
||||||
@HiveField(1) final String invoiceId;
|
final String reminderId;
|
||||||
@HiveField(2) final String userId;
|
@HiveField(1)
|
||||||
@HiveField(3) final ReminderType reminderType;
|
final String invoiceId;
|
||||||
@HiveField(4) final String subject;
|
@HiveField(2)
|
||||||
@HiveField(5) final String message;
|
final String userId;
|
||||||
@HiveField(6) final bool isRead;
|
@HiveField(3)
|
||||||
@HiveField(7) final bool isSent;
|
final ReminderType reminderType;
|
||||||
@HiveField(8) final DateTime? scheduledAt;
|
@HiveField(4)
|
||||||
@HiveField(9) final DateTime? sentAt;
|
final String subject;
|
||||||
@HiveField(10) final DateTime? readAt;
|
@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(
|
factory PaymentReminderModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
PaymentReminderModel(
|
||||||
reminderId: json['reminder_id'] as String,
|
reminderId: json['reminder_id'] as String,
|
||||||
invoiceId: json['invoice_id'] as String,
|
invoiceId: json['invoice_id'] as String,
|
||||||
userId: json['user_id'] as String,
|
userId: json['user_id'] as String,
|
||||||
reminderType: ReminderType.values.firstWhere((e) => e.name == json['reminder_type']),
|
reminderType: ReminderType.values.firstWhere(
|
||||||
|
(e) => e.name == json['reminder_type'],
|
||||||
|
),
|
||||||
subject: json['subject'] as String,
|
subject: json['subject'] as String,
|
||||||
message: json['message'] as String,
|
message: json['message'] as String,
|
||||||
isRead: json['is_read'] as bool? ?? false,
|
isRead: json['is_read'] as bool? ?? false,
|
||||||
isSent: json['is_sent'] as bool? ?? false,
|
isSent: json['is_sent'] as bool? ?? false,
|
||||||
scheduledAt: json['scheduled_at'] != null ? DateTime.parse(json['scheduled_at']?.toString() ?? '') : null,
|
scheduledAt: json['scheduled_at'] != null
|
||||||
sentAt: json['sent_at'] != null ? DateTime.parse(json['sent_at']?.toString() ?? '') : null,
|
? DateTime.parse(json['scheduled_at']?.toString() ?? '')
|
||||||
readAt: json['read_at'] != null ? DateTime.parse(json['read_at']?.toString() ?? '') : null,
|
: 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() => {
|
Map<String, dynamic> toJson() => {
|
||||||
|
|||||||
@@ -134,13 +134,7 @@ class AuditLog {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode {
|
int get hashCode {
|
||||||
return Object.hash(
|
return Object.hash(logId, userId, action, entityType, entityId);
|
||||||
logId,
|
|
||||||
userId,
|
|
||||||
action,
|
|
||||||
entityType,
|
|
||||||
entityId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -59,10 +59,7 @@ class AccountMenuItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(color: AppColors.grey100, width: 1.0),
|
||||||
color: AppColors.grey100,
|
|
||||||
width: 1.0,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -72,7 +69,9 @@ class AccountMenuItem extends StatelessWidget {
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: iconBackgroundColor ?? AppColors.lightBlue.withValues(alpha: 0.1),
|
color:
|
||||||
|
iconBackgroundColor ??
|
||||||
|
AppColors.lightBlue.withValues(alpha: 0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
|
|||||||
122
lib/features/auth/data/datasources/auth_local_datasource.dart
Normal file
122
lib/features/auth/data/datasources/auth_local_datasource.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
86
lib/features/auth/data/models/auth_session_model.dart
Normal file
86
lib/features/auth/data/models/auth_session_model.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1113
lib/features/auth/data/models/auth_session_model.freezed.dart
Normal file
1113
lib/features/auth/data/models/auth_session_model.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
131
lib/features/auth/data/models/auth_session_model.g.dart
Normal file
131
lib/features/auth/data/models/auth_session_model.g.dart
Normal 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(),
|
||||||
|
};
|
||||||
91
lib/features/auth/data/models/business_unit_model.dart
Normal file
91
lib/features/auth/data/models/business_unit_model.dart
Normal 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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
53
lib/features/auth/data/models/business_unit_model.g.dart
Normal file
53
lib/features/auth/data/models/business_unit_model.g.dart
Normal 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;
|
||||||
|
}
|
||||||
93
lib/features/auth/domain/entities/business_unit.dart
Normal file
93
lib/features/auth/domain/entities/business_unit.dart
Normal 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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ enum UserRole {
|
|||||||
accountant,
|
accountant,
|
||||||
|
|
||||||
/// Designer
|
/// Designer
|
||||||
designer;
|
designer,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User status enum
|
/// User status enum
|
||||||
@@ -34,7 +34,7 @@ enum UserStatus {
|
|||||||
suspended,
|
suspended,
|
||||||
|
|
||||||
/// Rejected account
|
/// Rejected account
|
||||||
rejected;
|
rejected,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loyalty tier enum
|
/// Loyalty tier enum
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
489
lib/features/auth/presentation/pages/login_page.dart
Normal file
489
lib/features/auth/presentation/pages/login_page.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
806
lib/features/auth/presentation/pages/register_page.dart
Normal file
806
lib/features/auth/presentation/pages/register_page.dart
Normal 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: ' và '),
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
279
lib/features/auth/presentation/providers/auth_provider.dart
Normal file
279
lib/features/auth/presentation/providers/auth_provider.dart
Normal 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;
|
||||||
|
}
|
||||||
500
lib/features/auth/presentation/providers/auth_provider.g.dart
Normal file
500
lib/features/auth/presentation/providers/auth_provider.g.dart
Normal 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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
305
lib/features/auth/presentation/providers/register_provider.dart
Normal file
305
lib/features/auth/presentation/providers/register_provider.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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ế';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
216
lib/features/auth/presentation/widgets/file_upload_card.dart
Normal file
216
lib/features/auth/presentation/widgets/file_upload_card.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
lib/features/auth/presentation/widgets/phone_input_field.dart
Normal file
133
lib/features/auth/presentation/widgets/phone_input_field.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
lib/features/auth/presentation/widgets/role_dropdown.dart
Normal file
115
lib/features/auth/presentation/widgets/role_dropdown.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,12 +76,7 @@ class Cart {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode {
|
int get hashCode {
|
||||||
return Object.hash(
|
return Object.hash(cartId, userId, totalAmount, isSynced);
|
||||||
cartId,
|
|
||||||
userId,
|
|
||||||
totalAmount,
|
|
||||||
isSynced,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -48,19 +48,14 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
title: const Text('Xóa giỏ hàng'),
|
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?'),
|
content: const Text('Bạn có chắc chắn muốn xóa toàn bộ giỏ hàng?'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(onPressed: () => context.pop(), child: const Text('Hủy')),
|
||||||
onPressed: () => context.pop(),
|
|
||||||
child: const Text('Hủy'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(cartProvider.notifier).clearCart();
|
ref.read(cartProvider.notifier).clearCart();
|
||||||
context.pop();
|
context.pop();
|
||||||
context.pop(); // Also go back from cart page
|
context.pop(); // Also go back from cart page
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(backgroundColor: AppColors.danger),
|
||||||
backgroundColor: AppColors.danger,
|
|
||||||
),
|
|
||||||
child: const Text('Xóa'),
|
child: const Text('Xóa'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -86,7 +81,10 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
icon: const Icon(Icons.arrow_back, color: Colors.black),
|
icon: const Icon(Icons.arrow_back, color: Colors.black),
|
||||||
onPressed: () => context.pop(),
|
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,
|
elevation: AppBarSpecs.elevation,
|
||||||
backgroundColor: AppColors.white,
|
backgroundColor: AppColors.white,
|
||||||
foregroundColor: AppColors.grey900,
|
foregroundColor: AppColors.grey900,
|
||||||
@@ -155,9 +153,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Hãy thêm sản phẩm vào giỏ hàng',
|
'Hãy thêm sản phẩm vào giỏ hàng',
|
||||||
style: AppTypography.bodyMedium.copyWith(
|
style: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
|
||||||
color: AppColors.grey500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
@@ -283,9 +279,9 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_discountController.text.isNotEmpty) {
|
if (_discountController.text.isNotEmpty) {
|
||||||
ref.read(cartProvider.notifier).applyDiscountCode(
|
ref
|
||||||
_discountController.text,
|
.read(cartProvider.notifier)
|
||||||
);
|
.applyDiscountCode(_discountController.text);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
@@ -326,7 +322,10 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build order summary section
|
/// Build order summary section
|
||||||
Widget _buildOrderSummary(CartState cartState, NumberFormat currencyFormatter) {
|
Widget _buildOrderSummary(
|
||||||
|
CartState cartState,
|
||||||
|
NumberFormat currencyFormatter,
|
||||||
|
) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -394,10 +393,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text('Phí vận chuyển', style: AppTypography.bodyMedium),
|
||||||
'Phí vận chuyển',
|
|
||||||
style: AppTypography.bodyMedium,
|
|
||||||
),
|
|
||||||
Text(
|
Text(
|
||||||
cartState.shippingFee > 0
|
cartState.shippingFee > 0
|
||||||
? currencyFormatter.format(cartState.shippingFee)
|
? currencyFormatter.format(cartState.shippingFee)
|
||||||
@@ -448,10 +444,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
: null,
|
: null,
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Tiến hành đặt hàng',
|
'Tiến hành đặt hàng',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -142,11 +142,10 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
// Payment Method Section (hidden if negotiation is checked)
|
// Payment Method Section (hidden if negotiation is checked)
|
||||||
if (!needsNegotiation.value)
|
if (!needsNegotiation.value)
|
||||||
PaymentMethodSection(
|
PaymentMethodSection(paymentMethod: paymentMethod),
|
||||||
paymentMethod: paymentMethod,
|
|
||||||
),
|
|
||||||
|
|
||||||
if (!needsNegotiation.value) const SizedBox(height: AppSpacing.md),
|
if (!needsNegotiation.value)
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Order Summary Section
|
// Order Summary Section
|
||||||
OrderSummarySection(
|
OrderSummarySection(
|
||||||
@@ -160,9 +159,7 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Price Negotiation Section
|
// Price Negotiation Section
|
||||||
PriceNegotiationSection(
|
PriceNegotiationSection(needsNegotiation: needsNegotiation),
|
||||||
needsNegotiation: needsNegotiation,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
|
|||||||
@@ -44,14 +44,9 @@ class Cart extends _$Cart {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Add new item
|
// Add new item
|
||||||
final newItem = CartItemData(
|
final newItem = CartItemData(product: product, quantity: quantity);
|
||||||
product: product,
|
|
||||||
quantity: quantity,
|
|
||||||
);
|
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(items: [...state.items, newItem]);
|
||||||
items: [...state.items, newItem],
|
|
||||||
);
|
|
||||||
_recalculateTotal();
|
_recalculateTotal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +54,9 @@ class Cart extends _$Cart {
|
|||||||
/// Remove product from cart
|
/// Remove product from cart
|
||||||
void removeFromCart(String productId) {
|
void removeFromCart(String productId) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
items: state.items.where((item) => item.product.productId != productId).toList(),
|
items: state.items
|
||||||
|
.where((item) => item.product.productId != productId)
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
_recalculateTotal();
|
_recalculateTotal();
|
||||||
}
|
}
|
||||||
@@ -113,20 +110,14 @@ class Cart extends _$Cart {
|
|||||||
// TODO: Validate with backend
|
// TODO: Validate with backend
|
||||||
// For now, simulate discount application
|
// For now, simulate discount application
|
||||||
if (code.isNotEmpty) {
|
if (code.isNotEmpty) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(discountCode: code, discountCodeApplied: true);
|
||||||
discountCode: code,
|
|
||||||
discountCodeApplied: true,
|
|
||||||
);
|
|
||||||
_recalculateTotal();
|
_recalculateTotal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove discount code
|
/// Remove discount code
|
||||||
void removeDiscountCode() {
|
void removeDiscountCode() {
|
||||||
state = state.copyWith(
|
state = state.copyWith(discountCode: null, discountCodeApplied: false);
|
||||||
discountCode: null,
|
|
||||||
discountCodeApplied: false,
|
|
||||||
);
|
|
||||||
_recalculateTotal();
|
_recalculateTotal();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,10 +148,7 @@ class Cart extends _$Cart {
|
|||||||
|
|
||||||
/// Get total quantity of all items
|
/// Get total quantity of all items
|
||||||
double get totalQuantity {
|
double get totalQuantity {
|
||||||
return state.items.fold<double>(
|
return state.items.fold<double>(0.0, (sum, item) => sum + item.quantity);
|
||||||
0.0,
|
|
||||||
(sum, item) => sum + item.quantity,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,18 +12,12 @@ class CartItemData {
|
|||||||
final Product product;
|
final Product product;
|
||||||
final double quantity;
|
final double quantity;
|
||||||
|
|
||||||
const CartItemData({
|
const CartItemData({required this.product, required this.quantity});
|
||||||
required this.product,
|
|
||||||
required this.quantity,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Calculate line total
|
/// Calculate line total
|
||||||
double get lineTotal => product.basePrice * quantity;
|
double get lineTotal => product.basePrice * quantity;
|
||||||
|
|
||||||
CartItemData copyWith({
|
CartItemData copyWith({Product? product, double? quantity}) {
|
||||||
Product? product,
|
|
||||||
double? quantity,
|
|
||||||
}) {
|
|
||||||
return CartItemData(
|
return CartItemData(
|
||||||
product: product ?? this.product,
|
product: product ?? this.product,
|
||||||
quantity: quantity ?? this.quantity,
|
quantity: quantity ?? this.quantity,
|
||||||
@@ -101,7 +95,8 @@ class CartState {
|
|||||||
discountCode: discountCode ?? this.discountCode,
|
discountCode: discountCode ?? this.discountCode,
|
||||||
discountCodeApplied: discountCodeApplied ?? this.discountCodeApplied,
|
discountCodeApplied: discountCodeApplied ?? this.discountCodeApplied,
|
||||||
memberTier: memberTier ?? this.memberTier,
|
memberTier: memberTier ?? this.memberTier,
|
||||||
memberDiscountPercent: memberDiscountPercent ?? this.memberDiscountPercent,
|
memberDiscountPercent:
|
||||||
|
memberDiscountPercent ?? this.memberDiscountPercent,
|
||||||
subtotal: subtotal ?? this.subtotal,
|
subtotal: subtotal ?? this.subtotal,
|
||||||
memberDiscount: memberDiscount ?? this.memberDiscount,
|
memberDiscount: memberDiscount ?? this.memberDiscount,
|
||||||
shippingFee: shippingFee ?? this.shippingFee,
|
shippingFee: shippingFee ?? this.shippingFee,
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ import 'package:worker/features/cart/presentation/providers/cart_state.dart';
|
|||||||
class CartItemWidget extends ConsumerWidget {
|
class CartItemWidget extends ConsumerWidget {
|
||||||
final CartItemData item;
|
final CartItemData item;
|
||||||
|
|
||||||
const CartItemWidget({
|
const CartItemWidget({super.key, required this.item});
|
||||||
super.key,
|
|
||||||
required this.item,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -65,9 +62,7 @@ class CartItemWidget extends ConsumerWidget {
|
|||||||
height: 80,
|
height: 80,
|
||||||
color: AppColors.grey100,
|
color: AppColors.grey100,
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) => Container(
|
errorWidget: (context, url, error) => Container(
|
||||||
@@ -129,9 +124,9 @@ class CartItemWidget extends ConsumerWidget {
|
|||||||
_QuantityButton(
|
_QuantityButton(
|
||||||
icon: Icons.remove,
|
icon: Icons.remove,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(cartProvider.notifier).decrementQuantity(
|
ref
|
||||||
item.product.productId,
|
.read(cartProvider.notifier)
|
||||||
);
|
.decrementQuantity(item.product.productId);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -151,9 +146,9 @@ class CartItemWidget extends ConsumerWidget {
|
|||||||
_QuantityButton(
|
_QuantityButton(
|
||||||
icon: Icons.add,
|
icon: Icons.add,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(cartProvider.notifier).incrementQuantity(
|
ref
|
||||||
item.product.productId,
|
.read(cartProvider.notifier)
|
||||||
);
|
.incrementQuantity(item.product.productId);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -184,10 +179,7 @@ class _QuantityButton extends StatelessWidget {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
const _QuantityButton({
|
const _QuantityButton({required this.icon, required this.onPressed});
|
||||||
required this.icon,
|
|
||||||
required this.onPressed,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -201,11 +193,7 @@ class _QuantityButton extends StatelessWidget {
|
|||||||
color: AppColors.grey100,
|
color: AppColors.grey100,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(icon, size: 18, color: AppColors.grey900),
|
||||||
icon,
|
|
||||||
size: 18,
|
|
||||||
color: AppColors.grey900,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,8 +68,11 @@ class CheckoutDatePickerField extends HookWidget {
|
|||||||
: AppColors.grey500.withValues(alpha: 0.6),
|
: AppColors.grey500.withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(Icons.calendar_today,
|
const Icon(
|
||||||
size: 20, color: AppColors.grey500),
|
Icons.calendar_today,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.grey500,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -75,10 +75,7 @@ class CheckoutDropdownField extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
items: items.map((item) {
|
items: items.map((item) {
|
||||||
return DropdownMenuItem<String>(
|
return DropdownMenuItem<String>(value: item, child: Text(item));
|
||||||
value: item,
|
|
||||||
child: Text(item),
|
|
||||||
);
|
|
||||||
}).toList(),
|
}).toList(),
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ class CheckoutSubmitButton extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Generate order ID (mock - replace with actual from backend)
|
// 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
|
// Show order success message
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -130,10 +131,7 @@ class CheckoutSubmitButton extends StatelessWidget {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.pushNamed(
|
context.pushNamed(
|
||||||
RouteNames.paymentQr,
|
RouteNames.paymentQr,
|
||||||
queryParameters: {
|
queryParameters: {'orderId': orderId, 'amount': total.toString()},
|
||||||
'orderId': orderId,
|
|
||||||
'amount': total.toString(),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,13 +103,7 @@ class DeliveryInformationSection extends HookWidget {
|
|||||||
label: 'Tỉnh/Thành phố',
|
label: 'Tỉnh/Thành phố',
|
||||||
value: selectedProvince.value,
|
value: selectedProvince.value,
|
||||||
required: true,
|
required: true,
|
||||||
items: const [
|
items: const ['TP.HCM', 'Hà Nội', 'Đà Nẵng', 'Cần Thơ', 'Biên Hòa'],
|
||||||
'TP.HCM',
|
|
||||||
'Hà Nội',
|
|
||||||
'Đà Nẵng',
|
|
||||||
'Cần Thơ',
|
|
||||||
'Biên Hòa',
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
selectedProvince.value = value;
|
selectedProvince.value = value;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -132,8 +132,9 @@ class InvoiceSection extends HookWidget {
|
|||||||
return 'Vui lòng nhập email';
|
return 'Vui lòng nhập email';
|
||||||
}
|
}
|
||||||
if (needsInvoice.value &&
|
if (needsInvoice.value &&
|
||||||
!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
|
!RegExp(
|
||||||
.hasMatch(value!)) {
|
r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$',
|
||||||
|
).hasMatch(value!)) {
|
||||||
return 'Email không hợp lệ';
|
return 'Email không hợp lệ';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -148,8 +148,10 @@ class OrderSummarySection extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Mã: ${item['sku']}',
|
'Mã: ${item['sku']}',
|
||||||
style:
|
style: const TextStyle(
|
||||||
const TextStyle(fontSize: 12, color: AppColors.grey500),
|
fontSize: 12,
|
||||||
|
color: AppColors.grey500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -169,7 +171,8 @@ class OrderSummarySection extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
_formatCurrency(
|
_formatCurrency(
|
||||||
((item['price'] as int) * (item['quantity'] as int))
|
((item['price'] as int) * (item['quantity'] as int))
|
||||||
.toDouble()),
|
.toDouble(),
|
||||||
|
),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -184,8 +187,11 @@ class OrderSummarySection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build summary row
|
/// Build summary row
|
||||||
Widget _buildSummaryRow(String label, double amount,
|
Widget _buildSummaryRow(
|
||||||
{bool isDiscount = false}) {
|
String label,
|
||||||
|
double amount, {
|
||||||
|
bool isDiscount = false,
|
||||||
|
}) {
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@@ -207,9 +213,6 @@ class OrderSummarySection extends StatelessWidget {
|
|||||||
|
|
||||||
/// Format currency
|
/// Format currency
|
||||||
String _formatCurrency(double amount) {
|
String _formatCurrency(double amount) {
|
||||||
return '${amount.toStringAsFixed(0).replaceAllMapped(
|
return '${amount.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}₫';
|
||||||
RegExp(r'(\d)(?=(\d{3})+(?!\d))'),
|
|
||||||
(Match m) => '${m[1]}.',
|
|
||||||
)}₫';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ import 'package:worker/core/theme/colors.dart';
|
|||||||
class PaymentMethodSection extends HookWidget {
|
class PaymentMethodSection extends HookWidget {
|
||||||
final ValueNotifier<String> paymentMethod;
|
final ValueNotifier<String> paymentMethod;
|
||||||
|
|
||||||
const PaymentMethodSection({
|
const PaymentMethodSection({super.key, required this.paymentMethod});
|
||||||
super.key,
|
|
||||||
required this.paymentMethod,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -72,13 +69,17 @@ class PaymentMethodSection extends HookWidget {
|
|||||||
Text(
|
Text(
|
||||||
'Chuyển khoản ngân hàng',
|
'Chuyển khoản ngân hàng',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15, fontWeight: FontWeight.w500),
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 4),
|
SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Thanh toán qua chuyển khoản',
|
'Thanh toán qua chuyển khoản',
|
||||||
style:
|
style: TextStyle(
|
||||||
TextStyle(fontSize: 13, color: AppColors.grey500),
|
fontSize: 13,
|
||||||
|
color: AppColors.grey500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -112,13 +113,17 @@ class PaymentMethodSection extends HookWidget {
|
|||||||
Text(
|
Text(
|
||||||
'Thanh toán khi nhận hàng (COD)',
|
'Thanh toán khi nhận hàng (COD)',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15, fontWeight: FontWeight.w500),
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 4),
|
SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Thanh toán bằng tiền mặt khi nhận hàng',
|
'Thanh toán bằng tiền mặt khi nhận hàng',
|
||||||
style:
|
style: TextStyle(
|
||||||
TextStyle(fontSize: 13, color: AppColors.grey500),
|
fontSize: 13,
|
||||||
|
color: AppColors.grey500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ import 'package:worker/core/theme/colors.dart';
|
|||||||
class PriceNegotiationSection extends HookWidget {
|
class PriceNegotiationSection extends HookWidget {
|
||||||
final ValueNotifier<bool> needsNegotiation;
|
final ValueNotifier<bool> needsNegotiation;
|
||||||
|
|
||||||
const PriceNegotiationSection({
|
const PriceNegotiationSection({super.key, required this.needsNegotiation});
|
||||||
super.key,
|
|
||||||
required this.needsNegotiation,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@@ -7,18 +7,39 @@ part 'chat_room_model.g.dart';
|
|||||||
|
|
||||||
@HiveType(typeId: HiveTypeIds.chatRoomModel)
|
@HiveType(typeId: HiveTypeIds.chatRoomModel)
|
||||||
class ChatRoomModel extends HiveObject {
|
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(0)
|
||||||
@HiveField(1) final RoomType roomType;
|
final String chatRoomId;
|
||||||
@HiveField(2) final String? relatedQuoteId;
|
@HiveField(1)
|
||||||
@HiveField(3) final String? relatedOrderId;
|
final RoomType roomType;
|
||||||
@HiveField(4) final String participants;
|
@HiveField(2)
|
||||||
@HiveField(5) final String? roomName;
|
final String? relatedQuoteId;
|
||||||
@HiveField(6) final bool isActive;
|
@HiveField(3)
|
||||||
@HiveField(7) final DateTime? lastActivity;
|
final String? relatedOrderId;
|
||||||
@HiveField(8) final DateTime createdAt;
|
@HiveField(4)
|
||||||
@HiveField(9) final String? createdBy;
|
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(
|
factory ChatRoomModel.fromJson(Map<String, dynamic> json) => ChatRoomModel(
|
||||||
chatRoomId: json['chat_room_id'] as String,
|
chatRoomId: json['chat_room_id'] as String,
|
||||||
@@ -28,7 +49,9 @@ class ChatRoomModel extends HiveObject {
|
|||||||
participants: jsonEncode(json['participants']),
|
participants: jsonEncode(json['participants']),
|
||||||
roomName: json['room_name'] as String?,
|
roomName: json['room_name'] as String?,
|
||||||
isActive: json['is_active'] as bool? ?? true,
|
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() ?? ''),
|
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||||
createdBy: json['created_by'] as String?,
|
createdBy: json['created_by'] as String?,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,27 +7,56 @@ part 'message_model.g.dart';
|
|||||||
|
|
||||||
@HiveType(typeId: HiveTypeIds.messageModel)
|
@HiveType(typeId: HiveTypeIds.messageModel)
|
||||||
class MessageModel extends HiveObject {
|
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(0)
|
||||||
@HiveField(1) final String chatRoomId;
|
final String messageId;
|
||||||
@HiveField(2) final String senderId;
|
@HiveField(1)
|
||||||
@HiveField(3) final ContentType contentType;
|
final String chatRoomId;
|
||||||
@HiveField(4) final String content;
|
@HiveField(2)
|
||||||
@HiveField(5) final String? attachmentUrl;
|
final String senderId;
|
||||||
@HiveField(6) final String? productReference;
|
@HiveField(3)
|
||||||
@HiveField(7) final bool isRead;
|
final ContentType contentType;
|
||||||
@HiveField(8) final bool isEdited;
|
@HiveField(4)
|
||||||
@HiveField(9) final bool isDeleted;
|
final String content;
|
||||||
@HiveField(10) final String? readBy;
|
@HiveField(5)
|
||||||
@HiveField(11) final DateTime timestamp;
|
final String? attachmentUrl;
|
||||||
@HiveField(12) final DateTime? editedAt;
|
@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(
|
factory MessageModel.fromJson(Map<String, dynamic> json) => MessageModel(
|
||||||
messageId: json['message_id'] as String,
|
messageId: json['message_id'] as String,
|
||||||
chatRoomId: json['chat_room_id'] as String,
|
chatRoomId: json['chat_room_id'] as String,
|
||||||
senderId: json['sender_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,
|
content: json['content'] as String,
|
||||||
attachmentUrl: json['attachment_url'] as String?,
|
attachmentUrl: json['attachment_url'] as String?,
|
||||||
productReference: json['product_reference'] as String?,
|
productReference: json['product_reference'] as String?,
|
||||||
@@ -36,7 +65,9 @@ class MessageModel extends HiveObject {
|
|||||||
isDeleted: json['is_deleted'] as bool? ?? false,
|
isDeleted: json['is_deleted'] as bool? ?? false,
|
||||||
readBy: json['read_by'] != null ? jsonEncode(json['read_by']) : null,
|
readBy: json['read_by'] != null ? jsonEncode(json['read_by']) : null,
|
||||||
timestamp: DateTime.parse(json['timestamp']?.toString() ?? ''),
|
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() => {
|
Map<String, dynamic> toJson() => {
|
||||||
|
|||||||
@@ -117,8 +117,7 @@ class Message {
|
|||||||
bool get isSystemMessage => contentType == ContentType.system;
|
bool get isSystemMessage => contentType == ContentType.system;
|
||||||
|
|
||||||
/// Check if message has attachment
|
/// Check if message has attachment
|
||||||
bool get hasAttachment =>
|
bool get hasAttachment => attachmentUrl != null && attachmentUrl!.isNotEmpty;
|
||||||
attachmentUrl != null && attachmentUrl!.isNotEmpty;
|
|
||||||
|
|
||||||
/// Check if message references a product
|
/// Check if message references a product
|
||||||
bool get hasProductReference =>
|
bool get hasProductReference =>
|
||||||
|
|||||||
@@ -112,10 +112,16 @@ class _ChatListPageState extends ConsumerState<ChatListPage> {
|
|||||||
autofocus: true,
|
autofocus: true,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Tìm kiếm cuộc trò chuyện...',
|
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
|
suffixIcon: _searchController.text.isNotEmpty
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(Icons.clear, color: AppColors.grey500),
|
icon: const Icon(
|
||||||
|
Icons.clear,
|
||||||
|
color: AppColors.grey500,
|
||||||
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
@@ -148,7 +154,10 @@ class _ChatListPageState extends ConsumerState<ChatListPage> {
|
|||||||
// Conversation 1 - Order Reference
|
// Conversation 1 - Order Reference
|
||||||
_ConversationItem(
|
_ConversationItem(
|
||||||
avatarIcon: Icons.inventory_2,
|
avatarIcon: Icons.inventory_2,
|
||||||
avatarGradient: const [AppColors.primaryBlue, AppColors.lightBlue],
|
avatarGradient: const [
|
||||||
|
AppColors.primaryBlue,
|
||||||
|
AppColors.lightBlue,
|
||||||
|
],
|
||||||
contactName: 'Đơn hàng #SO001234',
|
contactName: 'Đơn hàng #SO001234',
|
||||||
messageTime: '14:30',
|
messageTime: '14:30',
|
||||||
lastMessage: 'Đơn hàng đang được giao - Dự kiến đến 16:00',
|
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
|
// Conversation 3 - Support Team
|
||||||
_ConversationItem(
|
_ConversationItem(
|
||||||
avatarIcon: Icons.headset_mic,
|
avatarIcon: Icons.headset_mic,
|
||||||
avatarGradient: const [AppColors.primaryBlue, AppColors.lightBlue],
|
avatarGradient: const [
|
||||||
|
AppColors.primaryBlue,
|
||||||
|
AppColors.lightBlue,
|
||||||
|
],
|
||||||
contactName: 'Tổng đài hỗ trợ',
|
contactName: 'Tổng đài hỗ trợ',
|
||||||
messageTime: '13:45',
|
messageTime: '13:45',
|
||||||
lastMessage: 'Thông tin về quy trình đổi trả sản phẩm',
|
lastMessage: 'Thông tin về quy trình đổi trả sản phẩm',
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ class FavoritesLocalDataSource {
|
|||||||
Future<void> addFavorite(FavoriteModel favorite) async {
|
Future<void> addFavorite(FavoriteModel favorite) async {
|
||||||
try {
|
try {
|
||||||
await _box.put(favorite.favoriteId, favorite);
|
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) {
|
} catch (e) {
|
||||||
debugPrint('[FavoritesLocalDataSource] Error adding favorite: $e');
|
debugPrint('[FavoritesLocalDataSource] Error adding favorite: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -60,13 +62,17 @@ class FavoritesLocalDataSource {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (favorites.isEmpty) {
|
if (favorites.isEmpty) {
|
||||||
debugPrint('[FavoritesLocalDataSource] Favorite not found: productId=$productId, userId=$userId');
|
debugPrint(
|
||||||
|
'[FavoritesLocalDataSource] Favorite not found: productId=$productId, userId=$userId',
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final favorite = favorites.first;
|
final favorite = favorites.first;
|
||||||
await _box.delete(favorite.favoriteId);
|
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;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[FavoritesLocalDataSource] Error removing favorite: $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.
|
/// Returns true if the product is in the user's favorites, false otherwise.
|
||||||
bool isFavorite(String productId, String userId) {
|
bool isFavorite(String productId, String userId) {
|
||||||
try {
|
try {
|
||||||
return _box.values
|
return _box.values.whereType<FavoriteModel>().any(
|
||||||
.whereType<FavoriteModel>()
|
(fav) => fav.productId == productId && fav.userId == userId,
|
||||||
.any((fav) => fav.productId == productId && fav.userId == userId);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[FavoritesLocalDataSource] Error checking favorite: $e');
|
debugPrint('[FavoritesLocalDataSource] Error checking favorite: $e');
|
||||||
return false;
|
return false;
|
||||||
@@ -101,7 +107,9 @@ class FavoritesLocalDataSource {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
await _box.deleteAll(favoriteIds);
|
await _box.deleteAll(favoriteIds);
|
||||||
debugPrint('[FavoritesLocalDataSource] Cleared ${favoriteIds.length} favorites for user: $userId');
|
debugPrint(
|
||||||
|
'[FavoritesLocalDataSource] Cleared ${favoriteIds.length} favorites for user: $userId',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[FavoritesLocalDataSource] Error clearing favorites: $e');
|
debugPrint('[FavoritesLocalDataSource] Error clearing favorites: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -140,7 +148,9 @@ class FavoritesLocalDataSource {
|
|||||||
debugPrint('[FavoritesLocalDataSource] Favorites box compacted');
|
debugPrint('[FavoritesLocalDataSource] Favorites box compacted');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[FavoritesLocalDataSource] Error compacting favorites box: $e');
|
debugPrint(
|
||||||
|
'[FavoritesLocalDataSource] Error compacting favorites box: $e',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,11 +62,6 @@ class Favorite {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode {
|
int get hashCode {
|
||||||
return Object.hash(
|
return Object.hash(favoriteId, productId, userId, createdAt);
|
||||||
favoriteId,
|
|
||||||
productId,
|
|
||||||
userId,
|
|
||||||
createdAt,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ class FavoritesPage extends ConsumerWidget {
|
|||||||
const FavoritesPage({super.key});
|
const FavoritesPage({super.key});
|
||||||
|
|
||||||
/// Show confirmation dialog before clearing all favorites
|
/// 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>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
@@ -185,10 +189,7 @@ class _EmptyState extends StatelessWidget {
|
|||||||
// Subtext
|
// Subtext
|
||||||
Text(
|
Text(
|
||||||
'Thêm sản phẩm vào danh sách yêu thích để xem lại sau',
|
'Thêm sản phẩm vào danh sách yêu thích để xem lại sau',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 14.0, color: AppColors.grey500),
|
||||||
fontSize: 14.0,
|
|
||||||
color: AppColors.grey500,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -213,10 +214,7 @@ class _EmptyState extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Khám phá sản phẩm',
|
'Khám phá sản phẩm',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w600),
|
||||||
fontSize: 16.0,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -351,10 +349,7 @@ class _ErrorState extends StatelessWidget {
|
|||||||
final Object error;
|
final Object error;
|
||||||
final VoidCallback onRetry;
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
const _ErrorState({
|
const _ErrorState({required this.error, required this.onRetry});
|
||||||
required this.error,
|
|
||||||
required this.onRetry,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -389,10 +384,7 @@ class _ErrorState extends StatelessWidget {
|
|||||||
// Error message
|
// Error message
|
||||||
Text(
|
Text(
|
||||||
error.toString(),
|
error.toString(),
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
|
||||||
fontSize: 14.0,
|
|
||||||
color: AppColors.grey500,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -417,10 +409,7 @@ class _ErrorState extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text(
|
label: const Text(
|
||||||
'Thử lại',
|
'Thử lại',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w600),
|
||||||
fontSize: 16.0,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -440,9 +429,7 @@ class _ErrorState extends StatelessWidget {
|
|||||||
class _FavoritesGrid extends StatelessWidget {
|
class _FavoritesGrid extends StatelessWidget {
|
||||||
final List<Product> products;
|
final List<Product> products;
|
||||||
|
|
||||||
const _FavoritesGrid({
|
const _FavoritesGrid({required this.products});
|
||||||
required this.products,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -457,9 +444,7 @@ class _FavoritesGrid extends StatelessWidget {
|
|||||||
itemCount: products.length,
|
itemCount: products.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final product = products[index];
|
final product = products[index];
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(child: FavoriteProductCard(product: product));
|
||||||
child: FavoriteProductCard(product: product),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,7 +260,9 @@ Future<List<Product>> favoriteProducts(Ref ref) async {
|
|||||||
final allProducts = await getProductsUseCase();
|
final allProducts = await getProductsUseCase();
|
||||||
|
|
||||||
// Filter to only include favorited products
|
// Filter to only include favorited products
|
||||||
return allProducts.where((product) => favoriteIds.contains(product.productId)).toList();
|
return allProducts
|
||||||
|
.where((product) => favoriteIds.contains(product.productId))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ import 'package:worker/features/products/domain/entities/product.dart';
|
|||||||
class FavoriteProductCard extends ConsumerWidget {
|
class FavoriteProductCard extends ConsumerWidget {
|
||||||
final Product product;
|
final Product product;
|
||||||
|
|
||||||
const FavoriteProductCard({
|
const FavoriteProductCard({super.key, required this.product});
|
||||||
super.key,
|
|
||||||
required this.product,
|
|
||||||
});
|
|
||||||
|
|
||||||
String _formatPrice(double price) {
|
String _formatPrice(double price) {
|
||||||
final formatter = NumberFormat('#,###', 'vi_VN');
|
final formatter = NumberFormat('#,###', 'vi_VN');
|
||||||
@@ -60,7 +57,9 @@ class FavoriteProductCard extends ConsumerWidget {
|
|||||||
|
|
||||||
if (confirmed == true && context.mounted) {
|
if (confirmed == true && context.mounted) {
|
||||||
// Remove from favorites
|
// Remove from favorites
|
||||||
await ref.read(favoritesProvider.notifier).removeFavorite(product.productId);
|
await ref
|
||||||
|
.read(favoritesProvider.notifier)
|
||||||
|
.removeFavorite(product.productId);
|
||||||
|
|
||||||
// Show snackbar
|
// Show snackbar
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -103,9 +102,7 @@ class FavoriteProductCard extends ConsumerWidget {
|
|||||||
placeholder: (context, url) => Shimmer.fromColors(
|
placeholder: (context, url) => Shimmer.fromColors(
|
||||||
baseColor: AppColors.grey100,
|
baseColor: AppColors.grey100,
|
||||||
highlightColor: AppColors.grey50,
|
highlightColor: AppColors.grey50,
|
||||||
child: Container(
|
child: Container(color: AppColors.grey100),
|
||||||
color: AppColors.grey100,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) => Container(
|
errorWidget: (context, url, error) => Container(
|
||||||
color: AppColors.grey100,
|
color: AppColors.grey100,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class HomeLocalDataSourceImpl implements HomeLocalDataSource {
|
|||||||
'tier': 'diamond',
|
'tier': 'diamond',
|
||||||
'points': 9750,
|
'points': 9750,
|
||||||
'validUntil': '2025-12-31T23:59:59.000Z',
|
'validUntil': '2025-12-31T23:59:59.000Z',
|
||||||
'qrData': '0983441099'
|
'qrData': '0983441099',
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Mock JSON data for promotions
|
/// Mock JSON data for promotions
|
||||||
@@ -115,7 +115,7 @@ class HomeLocalDataSourceImpl implements HomeLocalDataSource {
|
|||||||
'startDate': '2025-01-01T00:00:00.000Z',
|
'startDate': '2025-01-01T00:00:00.000Z',
|
||||||
'endDate': '2025-12-31T23:59:59.000Z',
|
'endDate': '2025-12-31T23:59:59.000Z',
|
||||||
'discountPercentage': 5,
|
'discountPercentage': 5,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Constructor
|
/// Constructor
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ enum PromotionStatus {
|
|||||||
upcoming,
|
upcoming,
|
||||||
|
|
||||||
/// Expired promotion
|
/// Expired promotion
|
||||||
expired;
|
expired,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Promotion Entity
|
/// Promotion Entity
|
||||||
|
|||||||
@@ -222,7 +222,6 @@ class HomePage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ class MemberCardWidget extends StatelessWidget {
|
|||||||
/// Member card data
|
/// Member card data
|
||||||
final MemberCard memberCard;
|
final MemberCard memberCard;
|
||||||
|
|
||||||
const MemberCardWidget({
|
const MemberCardWidget({super.key, required this.memberCard});
|
||||||
super.key,
|
|
||||||
required this.memberCard,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ import 'package:worker/features/home/domain/entities/promotion.dart';
|
|||||||
/// Displays a horizontal scrollable list of promotion cards.
|
/// Displays a horizontal scrollable list of promotion cards.
|
||||||
/// Each card shows an image, title, and brief description.
|
/// Each card shows an image, title, and brief description.
|
||||||
class PromotionSlider extends StatelessWidget {
|
class PromotionSlider extends StatelessWidget {
|
||||||
|
|
||||||
const PromotionSlider({
|
const PromotionSlider({
|
||||||
super.key,
|
super.key,
|
||||||
required this.promotions,
|
required this.promotions,
|
||||||
this.onPromotionTap,
|
this.onPromotionTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// List of promotions to display
|
/// List of promotions to display
|
||||||
final List<Promotion> promotions;
|
final List<Promotion> promotions;
|
||||||
|
|
||||||
@@ -83,11 +83,7 @@ class PromotionSlider extends StatelessWidget {
|
|||||||
|
|
||||||
/// Individual Promotion Card
|
/// Individual Promotion Card
|
||||||
class _PromotionCard extends StatelessWidget {
|
class _PromotionCard extends StatelessWidget {
|
||||||
|
const _PromotionCard({required this.promotion, this.onTap});
|
||||||
const _PromotionCard({
|
|
||||||
required this.promotion,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
final Promotion promotion;
|
final Promotion promotion;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
@@ -115,8 +111,9 @@ class _PromotionCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Promotion Image
|
// Promotion Image
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius:
|
borderRadius: const BorderRadius.vertical(
|
||||||
const BorderRadius.vertical(top: Radius.circular(12)),
|
top: Radius.circular(12),
|
||||||
|
),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
imageUrl: promotion.imageUrl,
|
imageUrl: promotion.imageUrl,
|
||||||
height: 140,
|
height: 140,
|
||||||
@@ -125,9 +122,7 @@ class _PromotionCard extends StatelessWidget {
|
|||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
height: 140,
|
height: 140,
|
||||||
color: AppColors.grey100,
|
color: AppColors.grey100,
|
||||||
child: const Center(
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) => Container(
|
errorWidget: (context, url, error) => Container(
|
||||||
height: 140,
|
height: 140,
|
||||||
|
|||||||
@@ -63,11 +63,7 @@ class QuickActionItem extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Icon
|
// Icon
|
||||||
Icon(
|
Icon(icon, size: 32, color: AppColors.primaryBlue),
|
||||||
icon,
|
|
||||||
size: 32,
|
|
||||||
color: AppColors.primaryBlue,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Label
|
// Label
|
||||||
Text(
|
Text(
|
||||||
@@ -97,9 +93,7 @@ class QuickActionItem extends StatelessWidget {
|
|||||||
color: AppColors.danger,
|
color: AppColors.danger,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(minWidth: 20),
|
||||||
minWidth: 20,
|
|
||||||
),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
badge!,
|
badge!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ class PointsHistoryLocalDataSource {
|
|||||||
/// Get all points entries
|
/// Get all points entries
|
||||||
Future<List<LoyaltyPointEntryModel>> getAllEntries() async {
|
Future<List<LoyaltyPointEntryModel>> getAllEntries() async {
|
||||||
final box = await entriesBox;
|
final box = await entriesBox;
|
||||||
final entries = box.values
|
final entries = box.values.whereType<LoyaltyPointEntryModel>().toList();
|
||||||
.whereType<LoyaltyPointEntryModel>()
|
|
||||||
.toList();
|
|
||||||
entries.sort((a, b) => b.timestamp.compareTo(a.timestamp)); // Newest first
|
entries.sort((a, b) => b.timestamp.compareTo(a.timestamp)); // Newest first
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
@@ -42,9 +40,9 @@ class PointsHistoryLocalDataSource {
|
|||||||
Future<LoyaltyPointEntryModel?> getEntryById(String entryId) async {
|
Future<LoyaltyPointEntryModel?> getEntryById(String entryId) async {
|
||||||
final box = await entriesBox;
|
final box = await entriesBox;
|
||||||
try {
|
try {
|
||||||
return box.values
|
return box.values.whereType<LoyaltyPointEntryModel>().firstWhere(
|
||||||
.whereType<LoyaltyPointEntryModel>()
|
(entry) => entry.entryId == entryId,
|
||||||
.firstWhere((entry) => entry.entryId == entryId);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Entry not found');
|
throw Exception('Entry not found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,40 +6,80 @@ part 'gift_catalog_model.g.dart';
|
|||||||
|
|
||||||
@HiveType(typeId: HiveTypeIds.giftCatalogModel)
|
@HiveType(typeId: HiveTypeIds.giftCatalogModel)
|
||||||
class GiftCatalogModel extends HiveObject {
|
class GiftCatalogModel extends HiveObject {
|
||||||
GiftCatalogModel({required this.catalogId, required this.name, required this.description, this.imageUrl, required this.category, required this.pointsCost, required this.cashValue, required this.quantityAvailable, required this.quantityRedeemed, this.termsConditions, required this.isActive, this.validFrom, this.validUntil, required this.createdAt, this.updatedAt});
|
GiftCatalogModel({
|
||||||
|
required this.catalogId,
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
this.imageUrl,
|
||||||
|
required this.category,
|
||||||
|
required this.pointsCost,
|
||||||
|
required this.cashValue,
|
||||||
|
required this.quantityAvailable,
|
||||||
|
required this.quantityRedeemed,
|
||||||
|
this.termsConditions,
|
||||||
|
required this.isActive,
|
||||||
|
this.validFrom,
|
||||||
|
this.validUntil,
|
||||||
|
required this.createdAt,
|
||||||
|
this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
@HiveField(0) final String catalogId;
|
@HiveField(0)
|
||||||
@HiveField(1) final String name;
|
final String catalogId;
|
||||||
@HiveField(2) final String description;
|
@HiveField(1)
|
||||||
@HiveField(3) final String? imageUrl;
|
final String name;
|
||||||
@HiveField(4) final GiftCategory category;
|
@HiveField(2)
|
||||||
@HiveField(5) final int pointsCost;
|
final String description;
|
||||||
@HiveField(6) final double cashValue;
|
@HiveField(3)
|
||||||
@HiveField(7) final int quantityAvailable;
|
final String? imageUrl;
|
||||||
@HiveField(8) final int quantityRedeemed;
|
@HiveField(4)
|
||||||
@HiveField(9) final String? termsConditions;
|
final GiftCategory category;
|
||||||
@HiveField(10) final bool isActive;
|
@HiveField(5)
|
||||||
@HiveField(11) final DateTime? validFrom;
|
final int pointsCost;
|
||||||
@HiveField(12) final DateTime? validUntil;
|
@HiveField(6)
|
||||||
@HiveField(13) final DateTime createdAt;
|
final double cashValue;
|
||||||
@HiveField(14) final DateTime? updatedAt;
|
@HiveField(7)
|
||||||
|
final int quantityAvailable;
|
||||||
|
@HiveField(8)
|
||||||
|
final int quantityRedeemed;
|
||||||
|
@HiveField(9)
|
||||||
|
final String? termsConditions;
|
||||||
|
@HiveField(10)
|
||||||
|
final bool isActive;
|
||||||
|
@HiveField(11)
|
||||||
|
final DateTime? validFrom;
|
||||||
|
@HiveField(12)
|
||||||
|
final DateTime? validUntil;
|
||||||
|
@HiveField(13)
|
||||||
|
final DateTime createdAt;
|
||||||
|
@HiveField(14)
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
factory GiftCatalogModel.fromJson(Map<String, dynamic> json) => GiftCatalogModel(
|
factory GiftCatalogModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
GiftCatalogModel(
|
||||||
catalogId: json['catalog_id'] as String,
|
catalogId: json['catalog_id'] as String,
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
description: json['description'] as String,
|
description: json['description'] as String,
|
||||||
imageUrl: json['image_url'] as String?,
|
imageUrl: json['image_url'] as String?,
|
||||||
category: GiftCategory.values.firstWhere((e) => e.name == json['category']),
|
category: GiftCategory.values.firstWhere(
|
||||||
|
(e) => e.name == json['category'],
|
||||||
|
),
|
||||||
pointsCost: json['points_cost'] as int,
|
pointsCost: json['points_cost'] as int,
|
||||||
cashValue: (json['cash_value'] as num).toDouble(),
|
cashValue: (json['cash_value'] as num).toDouble(),
|
||||||
quantityAvailable: json['quantity_available'] as int,
|
quantityAvailable: json['quantity_available'] as int,
|
||||||
quantityRedeemed: json['quantity_redeemed'] as int? ?? 0,
|
quantityRedeemed: json['quantity_redeemed'] as int? ?? 0,
|
||||||
termsConditions: json['terms_conditions'] as String?,
|
termsConditions: json['terms_conditions'] as String?,
|
||||||
isActive: json['is_active'] as bool? ?? true,
|
isActive: json['is_active'] as bool? ?? true,
|
||||||
validFrom: json['valid_from'] != null ? DateTime.parse(json['valid_from']?.toString() ?? '') : null,
|
validFrom: json['valid_from'] != null
|
||||||
validUntil: json['valid_until'] != null ? DateTime.parse(json['valid_until']?.toString() ?? '') : null,
|
? DateTime.parse(json['valid_from']?.toString() ?? '')
|
||||||
|
: null,
|
||||||
|
validUntil: json['valid_until'] != null
|
||||||
|
? DateTime.parse(json['valid_until']?.toString() ?? '')
|
||||||
|
: null,
|
||||||
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||||
updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']?.toString() ?? '') : null,
|
updatedAt: json['updated_at'] != null
|
||||||
|
? DateTime.parse(json['updated_at']?.toString() ?? '')
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user