diff --git a/.claude/agents/riverpod-expert.md b/.claude/agents/riverpod-expert.md index 48fbb5f..da440a5 100644 --- a/.claude/agents/riverpod-expert.md +++ b/.claude/agents/riverpod-expert.md @@ -13,7 +13,7 @@ You are a Riverpod 3.0 expert specializing in: - Modern code generation with `@riverpod` annotation -- Creating providers with Notifier, AsyncNotifier, and StreamNotifier +- Creating providers with NotifierProvider, AsyncNotifierProvider, and StreamNotifier - Implementing proper state management patterns diff --git a/DOMAIN_ENTITIES_SUMMARY.md b/DOMAIN_ENTITIES_SUMMARY.md deleted file mode 100644 index 08131b4..0000000 --- a/DOMAIN_ENTITIES_SUMMARY.md +++ /dev/null @@ -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` or typed classes -- `inet` → `String?` -- `text` → `String?` -- Arrays → `List` - -## 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. diff --git a/FINAL_PROVIDER_FIX.md b/FINAL_PROVIDER_FIX.md deleted file mode 100644 index 30c53b4..0000000 --- a/FINAL_PROVIDER_FIX.md +++ /dev/null @@ -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 priceDocuments(PriceDocumentsRef ref) { - return _mockDocuments; -} - -@riverpod -List 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 priceDocuments(Ref ref) { - return _mockDocuments; -} - -@riverpod -List 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, ...> - with $Provider> { - // ... -} -``` - -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 priceDocuments(Ref ref) { - return _mockDocuments; -} - -// Usage in widget -final documents = ref.watch(priceDocumentsProvider); -``` - -### Family Provider (with parameter) -```dart -// Provider definition -@riverpod -List 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. diff --git a/HIVE_MODELS_COMPLETED.md b/HIVE_MODELS_COMPLETED.md deleted file mode 100644 index d385e8e..0000000 --- a/HIVE_MODELS_COMPLETED.md +++ /dev/null @@ -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 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 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> 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 diff --git a/HIVE_MODELS_REFERENCE.md b/HIVE_MODELS_REFERENCE.md deleted file mode 100644 index 066e26b..0000000 --- a/HIVE_MODELS_REFERENCE.md +++ /dev/null @@ -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 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 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? get jsonbFieldMap { - if (jsonbField == null) return null; - try { - return jsonDecode(jsonbField!) as Map; - } 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? 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 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 diff --git a/HIVE_SETUP.md b/HIVE_SETUP.md deleted file mode 100644 index 8c39724..0000000 --- a/HIVE_SETUP.md +++ /dev/null @@ -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>( - 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( - 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 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! diff --git a/HIVE_TYPEID_SUMMARY.md b/HIVE_TYPEID_SUMMARY.md deleted file mode 100644 index 6783a5a..0000000 --- a/HIVE_TYPEID_SUMMARY.md +++ /dev/null @@ -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 diff --git a/LOCALIZATION.md b/LOCALIZATION.md deleted file mode 100644 index 2dcc066..0000000 --- a/LOCALIZATION.md +++ /dev/null @@ -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((ref) { - return LanguageNotifier(); -}); - -class LanguageNotifier extends StateNotifier { - LanguageNotifier() : super(const Locale('vi', 'VN')) { - _loadSavedLanguage(); - } - - Future _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 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( - 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 diff --git a/NEWS_DETAIL_IMPLEMENTATION.md b/NEWS_DETAIL_IMPLEMENTATION.md deleted file mode 100644 index b187202..0000000 --- a/NEWS_DETAIL_IMPLEMENTATION.md +++ /dev/null @@ -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: `

`, `

`, `

`, `

    `, `
  • `, `
      `, `
      `, `` -- Custom `` 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` - 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` - -**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**: -- `

      ...

      ` → Section heading with blue underline -- `

      ...

      ` → Subsection heading -- `

      ...

      ` → Paragraph text -- `
      • ...
      ` → Bullet list -- `
      1. ...
      ` → Numbered list -- `
      ...
      ` → Quote box -- `...` → Highlight box - -**Example**: -```html -

      1. Gạch men họa tiết đá tự nhiên

      -

      Xu hướng bắt chước kết cấu và màu sắc...

      - -Chọn gạch men vân đá... - -

      Các loại texture phổ biến:

      -
        -
      1. Matt finish: Bề mặt nhám
      2. -
      3. Structured surface: Có kết cấu
      4. -
      - -
      "Việc sử dụng gạch men..." - KTS Nguyễn Minh Tuấn
      -``` - ---- - -## 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. diff --git a/PRICE_POLICY_IMPLEMENTATION.md b/PRICE_POLICY_IMPLEMENTATION.md deleted file mode 100644 index cce23a5..0000000 --- a/PRICE_POLICY_IMPLEMENTATION.md +++ /dev/null @@ -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 priceDocuments(PriceDocumentsRef ref) { - return _mockDocuments; -} - -@riverpod -List 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` diff --git a/PROVIDER_FIX_SUMMARY.md b/PROVIDER_FIX_SUMMARY.md deleted file mode 100644 index 65eb193..0000000 --- a/PROVIDER_FIX_SUMMARY.md +++ /dev/null @@ -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 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 priceDocuments(PriceDocumentsRef ref) { - return _mockDocuments; -} - -@riverpod -List 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! diff --git a/RIVERPOD_SETUP.md b/RIVERPOD_SETUP.md deleted file mode 100644 index 5335bd3..0000000 --- a/RIVERPOD_SETUP.md +++ /dev/null @@ -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(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 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(UserRef ref, String id) async { - return await fetchUser(id); -} - -// Multiple parameters with named, optional, defaults -@riverpod -Future> 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 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 build() async => await fetchUser(); - - Future 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 currentUser(CurrentUserRef ref) async { - final token = await ref.watch(authTokenProvider.future); - return await fetchUser(token); -} -``` - -### 3. StreamProvider (Real-time Data) - -```dart -@riverpod -Stream> chatMessages(ChatMessagesRef ref, String roomId) { - return ref.watch(webSocketProvider).messages(roomId); -} -``` - -### 4. Notifier (Mutable State) - -```dart -@riverpod -class Cart extends _$Cart { - @override - List 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 build() async { - return await ref.read(userRepositoryProvider).getCurrentUser(); - } - - Future updateName(String name) async { - state = const AsyncValue.loading(); - - state = await AsyncValue.guard(() async { - final updated = await ref.read(userRepositoryProvider).updateName(name); - return updated; - }); - } - - Future refresh() async { - ref.invalidateSelf(); - } -} -``` - -### 6. StreamNotifier (Stream Mutable State) - -```dart -@riverpod -class LiveChat extends _$LiveChat { - @override - Stream> build(String roomId) { - return ref.watch(chatServiceProvider).messagesStream(roomId); - } - - Future 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 createState() => _OrderPageState(); -} - -class _OrderPageState extends ConsumerState { - @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(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 { - Counter() : super(0); - void increment() => state++; -} -final counterProvider = StateNotifierProvider(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((ref, id) async { - return fetchUser(id); -}); - -// New (3.0) -@riverpod -Future 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 diff --git a/RIVERPOD_SUMMARY.md b/RIVERPOD_SUMMARY.md deleted file mode 100644 index 970d735..0000000 --- a/RIVERPOD_SUMMARY.md +++ /dev/null @@ -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(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 build() async => await fetchUser(); - - Future 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 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 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 createState() => _MyWidgetState(); -} - -class _MyWidgetState extends ConsumerState { - @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! 🚀** diff --git a/TYPEID_VERIFICATION.md b/TYPEID_VERIFICATION.md deleted file mode 100644 index e1edcd5..0000000 --- a/TYPEID_VERIFICATION.md +++ /dev/null @@ -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. diff --git a/html/register.html b/html/register.html index 0ace520..f3050fe 100644 --- a/html/register.html +++ b/html/register.html @@ -182,13 +182,13 @@ -
      - -
      - - -
      -
      + + + + + + +
      diff --git a/html/start.html b/html/start.html new file mode 100644 index 0000000..431ee7b --- /dev/null +++ b/html/start.html @@ -0,0 +1,80 @@ + + + + + + Truy cập - EuroTile Worker + + + + + +
      + +
      + + + +

      Đơn vị kinh doanh

      + + +
      +
      + +
      +
      +

      DBIZ

      +

      Worker App

      +
      +
      + + +
      +

      Chọn đơn vị kinh doanh để tiếp tục

      +
      + + +
      +
      + +
      + + +
      +
      + +
      + + + + + + + +
      +
      + + \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ced8076..8805236 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,7 +1,43 @@ PODS: - connectivity_plus (0.0.1): - Flutter + - DKImagePickerController/Core (4.3.9): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.19): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.19): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter - Flutter (1.0.0) + - flutter_secure_storage (6.0.0): + - Flutter - GoogleDataTransport (9.4.1): - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30911.0, >= 2.30908.0) @@ -62,6 +98,9 @@ PODS: - Flutter - FlutterMacOS - PromisesObjC (2.4.0) + - SDWebImage (5.21.2): + - SDWebImage/Core (= 5.21.2) + - SDWebImage/Core (5.21.2) - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -70,10 +109,15 @@ PODS: - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS + - SwiftyGif (5.4.5) + - url_launcher_ios (0.0.1): + - Flutter DEPENDENCIES: - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`) @@ -81,9 +125,12 @@ DEPENDENCIES: - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) SPEC REPOS: trunk: + - DKImagePickerController + - DKPhotoGallery - GoogleDataTransport - GoogleMLKit - GoogleToolboxForMac @@ -96,12 +143,18 @@ SPEC REPOS: - MLKitVision - nanopb - PromisesObjC + - SDWebImage + - SwiftyGif EXTERNAL SOURCES: connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" integration_test: @@ -116,10 +169,16 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite_darwin: :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065 GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 @@ -136,9 +195,12 @@ SPEC CHECKSUMS: nanopb: 438bc412db1928dac798aa6fd75726007be04262 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/lib/app.dart b/lib/app.dart index 385a3a8..5f7f01a 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -33,7 +33,6 @@ class WorkerApp extends ConsumerWidget { theme: AppTheme.lightTheme(), darkTheme: AppTheme.darkTheme(), themeMode: ThemeMode.light, // TODO: Make this configurable from settings - // ==================== Localization Configuration ==================== // Support for Vietnamese (primary) and English (secondary) localizationsDelegates: const [ @@ -53,8 +52,10 @@ class WorkerApp extends ConsumerWidget { ], // Default locale (Vietnamese) - locale: const Locale('vi', 'VN'), // TODO: Make this configurable from settings - + locale: const Locale( + 'vi', + 'VN', + ), // TODO: Make this configurable from settings // Locale resolution strategy localeResolutionCallback: (locale, supportedLocales) { // Check if the device locale is supported @@ -71,9 +72,7 @@ class WorkerApp extends ConsumerWidget { // ==================== Material App Configuration ==================== // Builder for additional context-dependent widgets builder: (context, child) { - return _AppBuilder( - child: child ?? const SizedBox.shrink(), - ); + return _AppBuilder(child: child ?? const SizedBox.shrink()); }, ); } @@ -86,9 +85,7 @@ class WorkerApp extends ConsumerWidget { /// - Connectivity listener /// - Global overlays (loading, snackbars) class _AppBuilder extends ConsumerWidget { - const _AppBuilder({ - required this.child, - }); + const _AppBuilder({required this.child}); final Widget child; @@ -116,4 +113,3 @@ class _AppBuilder extends ConsumerWidget { ); } } - diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index 5bdb58a..2366652 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -398,7 +398,10 @@ class ApiConstants { /// final url = ApiConstants.buildUrlWithParams('/products/{id}', {'id': '123'}); /// // Returns: https://api.worker.example.com/v1/products/123 /// ``` - static String buildUrlWithParams(String endpoint, Map params) { + static String buildUrlWithParams( + String endpoint, + Map params, + ) { String url = endpoint; params.forEach((key, value) { url = url.replaceAll('{$key}', value); diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 05c7efc..709e5ab 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -440,7 +440,12 @@ class AppConstants { static const int maxProductImageSize = 3; /// Supported image formats - static const List supportedImageFormats = ['jpg', 'jpeg', 'png', 'webp']; + static const List supportedImageFormats = [ + 'jpg', + 'jpeg', + 'png', + 'webp', + ]; /// Image quality for compression (0-100) static const int imageQuality = 85; diff --git a/lib/core/constants/storage_constants.dart b/lib/core/constants/storage_constants.dart index d1fcf03..2552c3a 100644 --- a/lib/core/constants/storage_constants.dart +++ b/lib/core/constants/storage_constants.dart @@ -59,22 +59,22 @@ class HiveBoxNames { /// Get all box names for initialization static List get allBoxes => [ - userBox, - productBox, - cartBox, - orderBox, - projectBox, - quotes, - loyaltyBox, - rewardsBox, - settingsBox, - cacheBox, - syncStateBox, - notificationBox, - addressBox, - favoriteBox, - offlineQueueBox, - ]; + userBox, + productBox, + cartBox, + orderBox, + projectBox, + quotes, + loyaltyBox, + rewardsBox, + settingsBox, + cacheBox, + syncStateBox, + notificationBox, + addressBox, + favoriteBox, + offlineQueueBox, + ]; } /// Hive Type Adapter IDs @@ -152,7 +152,8 @@ class HiveTypeIds { // Aliases for backward compatibility and clarity static const int memberTier = loyaltyTier; // Alias for loyaltyTier static const int userType = userRole; // Alias for userRole - static const int projectStatus = submissionStatus; // Alias for submissionStatus + static const int projectStatus = + submissionStatus; // Alias for submissionStatus static const int transactionType = entryType; // Alias for entryType // Cache & Sync Models (60-69) diff --git a/lib/core/database/database_manager.dart b/lib/core/database/database_manager.dart index fd10462..5e615e4 100644 --- a/lib/core/database/database_manager.dart +++ b/lib/core/database/database_manager.dart @@ -17,14 +17,16 @@ import 'package:worker/core/database/hive_service.dart'; /// - Sync state tracking class DatabaseManager { DatabaseManager({HiveService? hiveService}) - : _hiveService = hiveService ?? HiveService(); + : _hiveService = hiveService ?? HiveService(); final HiveService _hiveService; /// Get a box safely Box _getBox(String boxName) { if (!_hiveService.isBoxOpen(boxName)) { - throw HiveError('Box $boxName is not open. Initialize HiveService first.'); + throw HiveError( + 'Box $boxName is not open. Initialize HiveService first.', + ); } return _hiveService.getBox(boxName); } @@ -49,11 +51,7 @@ class DatabaseManager { } /// Get a value from a box - T? get({ - required String boxName, - required String key, - T? defaultValue, - }) { + T? get({required String boxName, required String key, T? defaultValue}) { try { final box = _getBox(boxName); return box.get(key, defaultValue: defaultValue); @@ -65,10 +63,7 @@ class DatabaseManager { } /// Delete a value from a box - Future delete({ - required String boxName, - required String key, - }) async { + Future delete({required String boxName, required String key}) async { try { final box = _getBox(boxName); await box.delete(key); @@ -81,10 +76,7 @@ class DatabaseManager { } /// Check if a key exists in a box - bool exists({ - required String boxName, - required String key, - }) { + bool exists({required String boxName, required String key}) { try { final box = _getBox(boxName); return box.containsKey(key); @@ -138,10 +130,7 @@ class DatabaseManager { // ==================== Cache Operations ==================== /// Save data to cache with timestamp - Future saveToCache({ - required String key, - required T data, - }) async { + Future saveToCache({required String key, required T data}) async { try { final cacheBox = _getBox(HiveBoxNames.cacheBox); await cacheBox.put(key, { @@ -159,10 +148,7 @@ class DatabaseManager { /// Get data from cache /// /// Returns null if cache is expired or doesn't exist - T? getFromCache({ - required String key, - Duration? maxAge, - }) { + T? getFromCache({required String key, Duration? maxAge}) { try { final cacheBox = _getBox(HiveBoxNames.cacheBox); final cachedData = cacheBox.get(key) as Map?; @@ -193,10 +179,7 @@ class DatabaseManager { } /// Check if cache is valid (exists and not expired) - bool isCacheValid({ - required String key, - Duration? maxAge, - }) { + bool isCacheValid({required String key, Duration? maxAge}) { try { final cacheBox = _getBox(HiveBoxNames.cacheBox); final cachedData = cacheBox.get(key) as Map?; @@ -244,7 +227,9 @@ class DatabaseManager { await cacheBox.delete(key); } - debugPrint('DatabaseManager: Cleared ${keysToDelete.length} expired cache entries'); + debugPrint( + 'DatabaseManager: Cleared ${keysToDelete.length} expired cache entries', + ); } catch (e, stackTrace) { debugPrint('DatabaseManager: Error clearing expired cache: $e'); debugPrint('StackTrace: $stackTrace'); @@ -281,10 +266,7 @@ class DatabaseManager { } /// Check if data needs sync - bool needsSync({ - required String dataType, - required Duration syncInterval, - }) { + bool needsSync({required String dataType, required Duration syncInterval}) { final lastSync = getLastSyncTime(dataType); if (lastSync == null) return true; @@ -296,22 +278,12 @@ class DatabaseManager { // ==================== Settings Operations ==================== /// Save a setting - Future saveSetting({ - required String key, - required T value, - }) async { - await save( - boxName: HiveBoxNames.settingsBox, - key: key, - value: value, - ); + Future saveSetting({required String key, required T value}) async { + await save(boxName: HiveBoxNames.settingsBox, key: key, value: value); } /// Get a setting - T? getSetting({ - required String key, - T? defaultValue, - }) { + T? getSetting({required String key, T? defaultValue}) { return get( boxName: HiveBoxNames.settingsBox, key: key, @@ -328,7 +300,9 @@ class DatabaseManager { // Check queue size limit if (queueBox.length >= HiveDatabaseConfig.maxOfflineQueueSize) { - debugPrint('DatabaseManager: Offline queue is full, removing oldest item'); + debugPrint( + 'DatabaseManager: Offline queue is full, removing oldest item', + ); await queueBox.deleteAt(0); } @@ -386,10 +360,7 @@ class DatabaseManager { try { if (_hiveService.isBoxOpen(boxName)) { final box = _getBox(boxName); - stats[boxName] = { - 'count': box.length, - 'keys': box.keys.length, - }; + stats[boxName] = {'count': box.length, 'keys': box.keys.length}; } } catch (e) { stats[boxName] = {'error': e.toString()}; diff --git a/lib/core/database/hive_service.dart b/lib/core/database/hive_service.dart index 826908f..ab24db8 100644 --- a/lib/core/database/hive_service.dart +++ b/lib/core/database/hive_service.dart @@ -5,9 +5,7 @@ import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:path_provider/path_provider.dart'; import 'package:worker/core/constants/storage_constants.dart'; -import 'package:worker/features/favorites/data/models/favorite_model.dart'; -// TODO: Re-enable when build_runner generates this file successfully -// import 'package:worker/hive_registrar.g.dart'; +import 'package:worker/hive_registrar.g.dart'; /// Hive CE (Community Edition) Database Service /// @@ -92,40 +90,45 @@ class HiveService { debugPrint('HiveService: Registering type adapters...'); // Register all adapters using the auto-generated extension - // This automatically registers: - // - CachedDataAdapter (typeId: 30) - // - All enum adapters (typeIds: 20-29) - // TODO: Re-enable when build_runner generates hive_registrar.g.dart successfully - // Hive.registerAdapters(); + // This automatically registers all model and enum adapters + Hive.registerAdapters(); - debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.memberTier) ? "✓" : "✗"} MemberTier adapter'); - debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.userType) ? "✓" : "✗"} UserType adapter'); - debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.orderStatus) ? "✓" : "✗"} OrderStatus adapter'); - debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectStatus) ? "✓" : "✗"} ProjectStatus adapter'); - debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectType) ? "✓" : "✗"} ProjectType adapter'); - debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.transactionType) ? "✓" : "✗"} TransactionType adapter'); - debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.giftStatus) ? "✓" : "✗"} GiftStatus adapter'); - debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentStatus) ? "✓" : "✗"} PaymentStatus adapter'); - // NotificationType adapter not needed - notification model uses String type - debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentMethod) ? "✓" : "✗"} PaymentMethod adapter'); - debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.cachedData) ? "✓" : "✗"} CachedData adapter'); - - // Register model type adapters manually - // FavoriteModel adapter (typeId: 28) - if (!Hive.isAdapterRegistered(HiveTypeIds.favoriteModel)) { - Hive.registerAdapter(FavoriteModelAdapter()); - debugPrint('HiveService: ✓ FavoriteModel adapter registered'); - } - - // TODO: Register other model type adapters when created - // Example: - // - UserModel (typeId: 0) - // - ProductModel (typeId: 1) - // - CartItemModel (typeId: 2) - // - OrderModel (typeId: 3) - // - ProjectModel (typeId: 4) - // - LoyaltyTransactionModel (typeId: 5) - // etc. + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.loyaltyTier) ? "✓" : "✗"} LoyaltyTier adapter', + ); + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.userRole) ? "✓" : "✗"} UserRole adapter', + ); + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.orderStatus) ? "✓" : "✗"} OrderStatus adapter', + ); + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectType) ? "✓" : "✗"} ProjectType adapter', + ); + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.entryType) ? "✓" : "✗"} EntryType adapter', + ); + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.giftStatus) ? "✓" : "✗"} GiftStatus adapter', + ); + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentStatus) ? "✓" : "✗"} PaymentStatus adapter', + ); + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentMethod) ? "✓" : "✗"} PaymentMethod adapter', + ); + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.cachedData) ? "✓" : "✗"} CachedData adapter', + ); + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.favoriteModel) ? "✓" : "✗"} FavoriteModel adapter', + ); + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.productModel) ? "✓" : "✗"} ProductModel adapter', + ); + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.userModel) ? "✓" : "✗"} UserModel adapter', + ); debugPrint('HiveService: Type adapters registered successfully'); } @@ -188,21 +191,23 @@ class HiveService { /// Handles schema version upgrades and data migrations. Future _performMigrations() async { final settingsBox = Hive.box(HiveBoxNames.settingsBox); - final currentVersion = settingsBox.get( - HiveKeys.schemaVersion, - defaultValue: 0, - ) as int; + final currentVersion = + settingsBox.get(HiveKeys.schemaVersion, defaultValue: 0) as int; debugPrint('HiveService: Current schema version: $currentVersion'); - debugPrint('HiveService: Target schema version: ${HiveDatabaseConfig.currentSchemaVersion}'); + debugPrint( + 'HiveService: Target schema version: ${HiveDatabaseConfig.currentSchemaVersion}', + ); if (currentVersion < HiveDatabaseConfig.currentSchemaVersion) { debugPrint('HiveService: Performing migrations...'); // Perform migrations sequentially - for (int version = currentVersion + 1; - version <= HiveDatabaseConfig.currentSchemaVersion; - version++) { + for ( + int version = currentVersion + 1; + version <= HiveDatabaseConfig.currentSchemaVersion; + version++ + ) { await _migrateToVersion(version); } @@ -278,10 +283,9 @@ class HiveService { /// Clear expired cache entries Future _clearExpiredCache() async { - final cacheBox = Hive.box(HiveBoxNames.cacheBox); - // TODO: Implement cache expiration logic // This will be implemented when cache models are created + // final cacheBox = Hive.box(HiveBoxNames.cacheBox); debugPrint('HiveService: Cleared expired cache entries'); } @@ -291,14 +295,17 @@ class HiveService { final queueBox = Hive.box(HiveBoxNames.offlineQueueBox); if (queueBox.length > HiveDatabaseConfig.maxOfflineQueueSize) { - final itemsToRemove = queueBox.length - HiveDatabaseConfig.maxOfflineQueueSize; + final itemsToRemove = + queueBox.length - HiveDatabaseConfig.maxOfflineQueueSize; // Remove oldest items for (int i = 0; i < itemsToRemove; i++) { await queueBox.deleteAt(0); } - debugPrint('HiveService: Removed $itemsToRemove old items from offline queue'); + debugPrint( + 'HiveService: Removed $itemsToRemove old items from offline queue', + ); } } diff --git a/lib/core/errors/exceptions.dart b/lib/core/errors/exceptions.dart index e747b1a..ac510b4 100644 --- a/lib/core/errors/exceptions.dart +++ b/lib/core/errors/exceptions.dart @@ -10,35 +10,27 @@ library; /// Base exception for all network-related errors class NetworkException implements Exception { - const NetworkException( - this.message, { - this.statusCode, - this.data, - }); + const NetworkException(this.message, {this.statusCode, this.data}); final String message; final int? statusCode; final dynamic data; @override - String toString() => 'NetworkException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}'; + String toString() => + 'NetworkException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}'; } /// Exception thrown when there's no internet connection class NoInternetException extends NetworkException { const NoInternetException() - : super( - 'Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.', - ); + : super('Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.'); } /// Exception thrown when connection times out class TimeoutException extends NetworkException { const TimeoutException() - : super( - 'Kết nối quá lâu. Vui lòng thử lại.', - statusCode: 408, - ); + : super('Kết nối quá lâu. Vui lòng thử lại.', statusCode: 408); } /// Exception thrown when server returns 500+ errors @@ -52,10 +44,7 @@ class ServerException extends NetworkException { /// Exception thrown when server is unreachable class ServiceUnavailableException extends ServerException { const ServiceUnavailableException() - : super( - 'Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.', - 503, - ); + : super('Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.', 503); } // ============================================================================ @@ -64,10 +53,7 @@ class ServiceUnavailableException extends ServerException { /// Base exception for authentication-related errors class AuthException implements Exception { - const AuthException( - this.message, { - this.statusCode, - }); + const AuthException(this.message, {this.statusCode}); final String message; final int? statusCode; @@ -79,10 +65,7 @@ class AuthException implements Exception { /// Exception thrown when authentication credentials are invalid class InvalidCredentialsException extends AuthException { const InvalidCredentialsException() - : super( - 'Thông tin đăng nhập không hợp lệ.', - statusCode: 401, - ); + : super('Thông tin đăng nhập không hợp lệ.', statusCode: 401); } /// Exception thrown when user is not authenticated @@ -95,46 +78,37 @@ class UnauthorizedException extends AuthException { /// Exception thrown when user doesn't have permission class ForbiddenException extends AuthException { const ForbiddenException() - : super( - 'Bạn không có quyền truy cập tài nguyên này.', - statusCode: 403, - ); + : super('Bạn không có quyền truy cập tài nguyên này.', statusCode: 403); } /// Exception thrown when auth token is expired class TokenExpiredException extends AuthException { const TokenExpiredException() - : super( - 'Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.', - statusCode: 401, - ); + : super( + 'Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.', + statusCode: 401, + ); } /// Exception thrown when refresh token is invalid class InvalidRefreshTokenException extends AuthException { const InvalidRefreshTokenException() - : super( - 'Không thể làm mới phiên đăng nhập. Vui lòng đăng nhập lại.', - statusCode: 401, - ); + : super( + 'Không thể làm mới phiên đăng nhập. Vui lòng đăng nhập lại.', + statusCode: 401, + ); } /// Exception thrown when OTP is invalid class InvalidOTPException extends AuthException { const InvalidOTPException() - : super( - 'Mã OTP không hợp lệ. Vui lòng thử lại.', - statusCode: 400, - ); + : super('Mã OTP không hợp lệ. Vui lòng thử lại.', statusCode: 400); } /// Exception thrown when OTP is expired class OTPExpiredException extends AuthException { const OTPExpiredException() - : super( - 'Mã OTP đã hết hạn. Vui lòng yêu cầu mã mới.', - statusCode: 400, - ); + : super('Mã OTP đã hết hạn. Vui lòng yêu cầu mã mới.', statusCode: 400); } // ============================================================================ @@ -143,10 +117,7 @@ class OTPExpiredException extends AuthException { /// Exception thrown when request data is invalid class ValidationException implements Exception { - const ValidationException( - this.message, { - this.errors, - }); + const ValidationException(this.message, {this.errors}); final String message; final Map>? errors; @@ -198,9 +169,7 @@ class NotFoundException implements Exception { /// Exception thrown when trying to create a duplicate resource class ConflictException implements Exception { - const ConflictException([ - this.message = 'Tài nguyên đã tồn tại.', - ]); + const ConflictException([this.message = 'Tài nguyên đã tồn tại.']); final String message; @@ -237,10 +206,7 @@ class RateLimitException implements Exception { /// Exception thrown for payment-related errors class PaymentException implements Exception { - const PaymentException( - this.message, { - this.transactionId, - }); + const PaymentException(this.message, {this.transactionId}); final String message; final String? transactionId; @@ -259,8 +225,7 @@ class PaymentFailedException extends PaymentException { /// Exception thrown when payment is cancelled class PaymentCancelledException extends PaymentException { - const PaymentCancelledException() - : super('Thanh toán đã bị hủy.'); + const PaymentCancelledException() : super('Thanh toán đã bị hủy.'); } // ============================================================================ @@ -269,9 +234,7 @@ class PaymentCancelledException extends PaymentException { /// Exception thrown for cache-related errors class CacheException implements Exception { - const CacheException([ - this.message = 'Lỗi khi truy cập bộ nhớ đệm.', - ]); + const CacheException([this.message = 'Lỗi khi truy cập bộ nhớ đệm.']); final String message; @@ -281,8 +244,7 @@ class CacheException implements Exception { /// Exception thrown when cache data is corrupted class CacheCorruptedException extends CacheException { - const CacheCorruptedException() - : super('Dữ liệu bộ nhớ đệm bị hỏng.'); + const CacheCorruptedException() : super('Dữ liệu bộ nhớ đệm bị hỏng.'); } // ============================================================================ @@ -291,9 +253,7 @@ class CacheCorruptedException extends CacheException { /// Exception thrown for local storage errors class StorageException implements Exception { - const StorageException([ - this.message = 'Lỗi khi truy cập bộ nhớ cục bộ.', - ]); + const StorageException([this.message = 'Lỗi khi truy cập bộ nhớ cục bộ.']); final String message; @@ -304,7 +264,7 @@ class StorageException implements Exception { /// Exception thrown when storage is full class StorageFullException extends StorageException { const StorageFullException() - : super('Bộ nhớ đã đầy. Vui lòng giải phóng không gian.'); + : super('Bộ nhớ đã đầy. Vui lòng giải phóng không gian.'); } // ============================================================================ diff --git a/lib/core/errors/failures.dart b/lib/core/errors/failures.dart index 0b66268..fbc795d 100644 --- a/lib/core/errors/failures.dart +++ b/lib/core/errors/failures.dart @@ -9,16 +9,12 @@ sealed class Failure { const Failure({required this.message}); /// Network-related failure - const factory Failure.network({ - required String message, - int? statusCode, - }) = NetworkFailure; + const factory Failure.network({required String message, int? statusCode}) = + NetworkFailure; /// Server error failure (5xx errors) - const factory Failure.server({ - required String message, - int? statusCode, - }) = ServerFailure; + const factory Failure.server({required String message, int? statusCode}) = + ServerFailure; /// Authentication failure const factory Failure.authentication({ @@ -33,20 +29,14 @@ sealed class Failure { }) = ValidationFailure; /// Not found failure (404) - const factory Failure.notFound({ - required String message, - }) = NotFoundFailure; + const factory Failure.notFound({required String message}) = NotFoundFailure; /// Conflict failure (409) - const factory Failure.conflict({ - required String message, - }) = ConflictFailure; + const factory Failure.conflict({required String message}) = ConflictFailure; /// Rate limit exceeded failure (429) - const factory Failure.rateLimit({ - required String message, - int? retryAfter, - }) = RateLimitFailure; + const factory Failure.rateLimit({required String message, int? retryAfter}) = + RateLimitFailure; /// Payment failure const factory Failure.payment({ @@ -55,19 +45,13 @@ sealed class Failure { }) = PaymentFailure; /// Cache failure - const factory Failure.cache({ - required String message, - }) = CacheFailure; + const factory Failure.cache({required String message}) = CacheFailure; /// Storage failure - const factory Failure.storage({ - required String message, - }) = StorageFailure; + const factory Failure.storage({required String message}) = StorageFailure; /// Parse failure - const factory Failure.parse({ - required String message, - }) = ParseFailure; + const factory Failure.parse({required String message}) = ParseFailure; /// No internet connection failure const factory Failure.noInternet() = NoInternetFailure; @@ -76,9 +60,7 @@ sealed class Failure { const factory Failure.timeout() = TimeoutFailure; /// Unknown failure - const factory Failure.unknown({ - required String message, - }) = UnknownFailure; + const factory Failure.unknown({required String message}) = UnknownFailure; final String message; @@ -120,15 +102,21 @@ sealed class Failure { /// Get user-friendly error message String getUserMessage() { return switch (this) { - ValidationFailure(:final message, :final errors) => _formatValidationMessage(message, errors), - RateLimitFailure(:final message, :final retryAfter) => _formatRateLimitMessage(message, retryAfter), - NoInternetFailure() => 'Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.', + ValidationFailure(:final message, :final errors) => + _formatValidationMessage(message, errors), + RateLimitFailure(:final message, :final retryAfter) => + _formatRateLimitMessage(message, retryAfter), + NoInternetFailure() => + 'Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.', TimeoutFailure() => 'Kết nối quá lâu. Vui lòng thử lại.', _ => message, }; } - String _formatValidationMessage(String message, Map>? errors) { + String _formatValidationMessage( + String message, + Map>? errors, + ) { if (errors != null && errors.isNotEmpty) { final firstError = errors.values.first.first; return '$message: $firstError'; @@ -146,10 +134,7 @@ sealed class Failure { /// Network-related failure final class NetworkFailure extends Failure { - const NetworkFailure({ - required super.message, - this.statusCode, - }); + const NetworkFailure({required super.message, this.statusCode}); @override final int? statusCode; @@ -157,10 +142,7 @@ final class NetworkFailure extends Failure { /// Server error failure (5xx errors) final class ServerFailure extends Failure { - const ServerFailure({ - required super.message, - this.statusCode, - }); + const ServerFailure({required super.message, this.statusCode}); @override final int? statusCode; @@ -168,10 +150,7 @@ final class ServerFailure extends Failure { /// Authentication failure final class AuthenticationFailure extends Failure { - const AuthenticationFailure({ - required super.message, - this.statusCode, - }); + const AuthenticationFailure({required super.message, this.statusCode}); @override final int? statusCode; @@ -179,84 +158,61 @@ final class AuthenticationFailure extends Failure { /// Validation failure final class ValidationFailure extends Failure { - const ValidationFailure({ - required super.message, - this.errors, - }); + const ValidationFailure({required super.message, this.errors}); final Map>? errors; } /// Not found failure (404) final class NotFoundFailure extends Failure { - const NotFoundFailure({ - required super.message, - }); + const NotFoundFailure({required super.message}); } /// Conflict failure (409) final class ConflictFailure extends Failure { - const ConflictFailure({ - required super.message, - }); + const ConflictFailure({required super.message}); } /// Rate limit exceeded failure (429) final class RateLimitFailure extends Failure { - const RateLimitFailure({ - required super.message, - this.retryAfter, - }); + const RateLimitFailure({required super.message, this.retryAfter}); final int? retryAfter; } /// Payment failure final class PaymentFailure extends Failure { - const PaymentFailure({ - required super.message, - this.transactionId, - }); + const PaymentFailure({required super.message, this.transactionId}); final String? transactionId; } /// Cache failure final class CacheFailure extends Failure { - const CacheFailure({ - required super.message, - }); + const CacheFailure({required super.message}); } /// Storage failure final class StorageFailure extends Failure { - const StorageFailure({ - required super.message, - }); + const StorageFailure({required super.message}); } /// Parse failure final class ParseFailure extends Failure { - const ParseFailure({ - required super.message, - }); + const ParseFailure({required super.message}); } /// No internet connection failure final class NoInternetFailure extends Failure { - const NoInternetFailure() - : super(message: 'Không có kết nối internet'); + const NoInternetFailure() : super(message: 'Không có kết nối internet'); } /// Timeout failure final class TimeoutFailure extends Failure { - const TimeoutFailure() - : super(message: 'Kết nối quá lâu'); + const TimeoutFailure() : super(message: 'Kết nối quá lâu'); } /// Unknown failure final class UnknownFailure extends Failure { - const UnknownFailure({ - required super.message, - }); + const UnknownFailure({required super.message}); } diff --git a/lib/core/network/api_interceptor.dart b/lib/core/network/api_interceptor.dart index 60cc2f5..830f32e 100644 --- a/lib/core/network/api_interceptor.dart +++ b/lib/core/network/api_interceptor.dart @@ -10,11 +10,12 @@ library; import 'dart:developer' as developer; import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shared_preferences/shared_preferences.dart'; - import 'package:worker/core/constants/api_constants.dart'; import 'package:worker/core/errors/exceptions.dart'; +import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart'; part 'api_interceptor.g.dart'; @@ -23,6 +24,7 @@ part 'api_interceptor.g.dart'; // ============================================================================ /// Keys for storing auth tokens in SharedPreferences +/// @deprecated Use AuthLocalDataSource with Hive instead class AuthStorageKeys { static const String accessToken = 'auth_access_token'; static const String refreshToken = 'auth_refresh_token'; @@ -33,12 +35,15 @@ class AuthStorageKeys { // Auth Interceptor // ============================================================================ -/// Interceptor for adding authentication tokens to requests +/// Interceptor for adding ERPNext session tokens to requests +/// +/// Adds SID (Session ID) and CSRF token from Hive storage to request headers. class AuthInterceptor extends Interceptor { - AuthInterceptor(this._prefs, this._dio); + AuthInterceptor(this._prefs, this._dio, this._authLocalDataSource); final SharedPreferences _prefs; final Dio _dio; + final AuthLocalDataSource _authLocalDataSource; @override void onRequest( @@ -47,10 +52,19 @@ class AuthInterceptor extends Interceptor { ) async { // Check if this endpoint requires authentication if (_requiresAuth(options.path)) { - final token = await _getAccessToken(); + // Get session data from secure storage (async) + final sid = await _authLocalDataSource.getSid(); + final csrfToken = await _authLocalDataSource.getCsrfToken(); + if (sid != null && csrfToken != null) { + // Add ERPNext session headers + options.headers['Cookie'] = 'sid=$sid'; + options.headers['X-Frappe-CSRF-Token'] = csrfToken; + } + + // Legacy: Also check for access token (for backward compatibility) + final token = await _getAccessToken(); if (token != null) { - // Add bearer token to headers options.headers['Authorization'] = 'Bearer $token'; } } @@ -66,10 +80,7 @@ class AuthInterceptor extends Interceptor { } @override - void onError( - DioException err, - ErrorInterceptorHandler handler, - ) async { + void onError(DioException err, ErrorInterceptorHandler handler) async { // Check if error is 401 Unauthorized if (err.response?.statusCode == 401) { // Try to refresh token @@ -113,15 +124,16 @@ class AuthInterceptor extends Interceptor { } /// Check if token is expired - Future _isTokenExpired() async { - final expiryString = _prefs.getString(AuthStorageKeys.tokenExpiry); - if (expiryString == null) return true; - - final expiry = DateTime.tryParse(expiryString); - if (expiry == null) return true; - - return DateTime.now().isAfter(expiry); - } + // TODO: Use this method when implementing token refresh logic + // Future _isTokenExpired() async { + // final expiryString = _prefs.getString(AuthStorageKeys.tokenExpiry); + // if (expiryString == null) return true; + // + // final expiry = DateTime.tryParse(expiryString); + // if (expiry == null) return true; + // + // return DateTime.now().isAfter(expiry); + // } /// Refresh access token using refresh token Future _refreshAccessToken() async { @@ -135,11 +147,7 @@ class AuthInterceptor extends Interceptor { // Call refresh token endpoint final response = await _dio.post>( '${ApiConstants.apiBaseUrl}${ApiConstants.refreshToken}', - options: Options( - headers: { - 'Authorization': 'Bearer $refreshToken', - }, - ), + options: Options(headers: {'Authorization': 'Bearer $refreshToken'}), ); if (response.statusCode == 200) { @@ -185,10 +193,7 @@ class AuthInterceptor extends Interceptor { final options = Options( method: requestOptions.method, - headers: { - ...requestOptions.headers, - 'Authorization': 'Bearer $token', - }, + headers: {...requestOptions.headers, 'Authorization': 'Bearer $token'}, ); return _dio.request( @@ -217,19 +222,13 @@ class LoggingInterceptor extends Interceptor { final bool enableErrorLogging; @override - void onRequest( - RequestOptions options, - RequestInterceptorHandler handler, - ) { + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { if (enableRequestLogging) { developer.log( '╔══════════════════════════════════════════════════════════════', name: 'HTTP Request', ); - developer.log( - '║ ${options.method} ${options.uri}', - name: 'HTTP Request', - ); + developer.log('║ ${options.method} ${options.uri}', name: 'HTTP Request'); developer.log( '║ Headers: ${_sanitizeHeaders(options.headers)}', name: 'HTTP Request', @@ -290,10 +289,7 @@ class LoggingInterceptor extends Interceptor { } @override - void onError( - DioException err, - ErrorInterceptorHandler handler, - ) { + void onError(DioException err, ErrorInterceptorHandler handler) { if (enableErrorLogging) { developer.log( '╔══════════════════════════════════════════════════════════════', @@ -303,18 +299,12 @@ class LoggingInterceptor extends Interceptor { '║ ${err.requestOptions.method} ${err.requestOptions.uri}', name: 'HTTP Error', ); - developer.log( - '║ Error Type: ${err.type}', - name: 'HTTP Error', - ); + developer.log('║ Error Type: ${err.type}', name: 'HTTP Error'); developer.log( '║ Status Code: ${err.response?.statusCode}', name: 'HTTP Error', ); - developer.log( - '║ Message: ${err.message}', - name: 'HTTP Error', - ); + developer.log('║ Message: ${err.message}', name: 'HTTP Error'); if (err.response?.data != null) { developer.log( @@ -389,10 +379,7 @@ class LoggingInterceptor extends Interceptor { /// Interceptor for transforming Dio errors into custom exceptions class ErrorTransformerInterceptor extends Interceptor { @override - void onError( - DioException err, - ErrorInterceptorHandler handler, - ) { + void onError(DioException err, ErrorInterceptorHandler handler) { Exception exception; switch (err.type) { @@ -415,9 +402,7 @@ class ErrorTransformerInterceptor extends Interceptor { break; case DioExceptionType.unknown: - exception = NetworkException( - 'Lỗi không xác định: ${err.message}', - ); + exception = NetworkException('Lỗi không xác định: ${err.message}'); break; default: @@ -447,7 +432,8 @@ class ErrorTransformerInterceptor extends Interceptor { // Extract error message from response String? message; if (data is Map) { - message = data['message'] as String? ?? + message = + data['message'] as String? ?? data['error'] as String? ?? data['msg'] as String?; } @@ -460,9 +446,7 @@ class ErrorTransformerInterceptor extends Interceptor { final validationErrors = errors.map( (key, value) => MapEntry( key, - value is List - ? value.cast() - : [value.toString()], + value is List ? value.cast() : [value.toString()], ), ); return ValidationException( @@ -498,9 +482,7 @@ class ErrorTransformerInterceptor extends Interceptor { final validationErrors = errors.map( (key, value) => MapEntry( key, - value is List - ? value.cast() - : [value.toString()], + value is List ? value.cast() : [value.toString()], ), ); return ValidationException( @@ -513,7 +495,9 @@ class ErrorTransformerInterceptor extends Interceptor { case 429: final retryAfter = response.headers.value('retry-after'); - final retrySeconds = retryAfter != null ? int.tryParse(retryAfter) : null; + final retrySeconds = retryAfter != null + ? int.tryParse(retryAfter) + : null; return RateLimitException(message ?? 'Quá nhiều yêu cầu', retrySeconds); case 500: @@ -549,7 +533,15 @@ Future sharedPreferences(Ref ref) async { @riverpod Future authInterceptor(Ref ref, Dio dio) async { final prefs = await ref.watch(sharedPreferencesProvider.future); - return AuthInterceptor(prefs, dio); + + // Create AuthLocalDataSource with FlutterSecureStorage + const secureStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + final authLocalDataSource = AuthLocalDataSource(secureStorage); + + return AuthInterceptor(prefs, dio, authLocalDataSource); } /// Provider for LoggingInterceptor diff --git a/lib/core/network/api_interceptor.g.dart b/lib/core/network/api_interceptor.g.dart index 3789396..17d916a 100644 --- a/lib/core/network/api_interceptor.g.dart +++ b/lib/core/network/api_interceptor.g.dart @@ -114,7 +114,7 @@ final class AuthInterceptorProvider } } -String _$authInterceptorHash() => r'b54ba9af62c3cd7b922ef4030a8e2debb0220e10'; +String _$authInterceptorHash() => r'3f964536e03e204d09cc9120dd9d961b6d6d4b71'; /// Provider for AuthInterceptor diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index 54c5f77..0d16354 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -215,14 +215,14 @@ class DioClient { /// Clear all cached responses Future clearCache() async { if (_cacheStore != null) { - await _cacheStore!.clean(); + await _cacheStore.clean(); } } /// Clear specific cached response by key Future clearCacheByKey(String key) async { if (_cacheStore != null) { - await _cacheStore!.delete(key); + await _cacheStore.delete(key); } } @@ -232,7 +232,7 @@ class DioClient { final key = CacheOptions.defaultCacheKeyBuilder( RequestOptions(path: path), ); - await _cacheStore!.delete(key); + await _cacheStore.delete(key); } } } @@ -258,10 +258,7 @@ class RetryInterceptor extends Interceptor { final double delayMultiplier; @override - void onError( - DioException err, - ErrorInterceptorHandler handler, - ) async { + void onError(DioException err, ErrorInterceptorHandler handler) async { // Get retry count from request extra final retries = err.requestOptions.extra['retries'] as int? ?? 0; @@ -279,8 +276,9 @@ class RetryInterceptor extends Interceptor { } // Calculate delay with exponential backoff - final delayMs = (initialDelay.inMilliseconds * - (delayMultiplier * (retries + 1))).toInt(); + final delayMs = + (initialDelay.inMilliseconds * (delayMultiplier * (retries + 1))) + .toInt(); final delay = Duration( milliseconds: delayMs.clamp( initialDelay.inMilliseconds, @@ -341,10 +339,7 @@ class RetryInterceptor extends Interceptor { @riverpod Future cacheStore(Ref ref) async { final directory = await getTemporaryDirectory(); - return HiveCacheStore( - directory.path, - hiveBoxName: 'dio_cache', - ); + return HiveCacheStore(directory.path, hiveBoxName: 'dio_cache'); } /// Provider for cache options @@ -371,31 +366,32 @@ Future dio(Ref ref) async { // Base configuration dio ..options = BaseOptions( - baseUrl: ApiConstants.apiBaseUrl, - connectTimeout: ApiConstants.connectionTimeout, - receiveTimeout: ApiConstants.receiveTimeout, - sendTimeout: ApiConstants.sendTimeout, - headers: { - 'Content-Type': ApiConstants.contentTypeJson, - 'Accept': ApiConstants.acceptJson, - 'Accept-Language': ApiConstants.acceptLanguageVi, - }, - responseType: ResponseType.json, - validateStatus: (status) { - // Accept all status codes and handle errors in interceptor - return status != null && status < 500; - }, - ) - - // Add interceptors in order - + baseUrl: ApiConstants.apiBaseUrl, + connectTimeout: ApiConstants.connectionTimeout, + receiveTimeout: ApiConstants.receiveTimeout, + sendTimeout: ApiConstants.sendTimeout, + headers: { + 'Content-Type': ApiConstants.contentTypeJson, + 'Accept': ApiConstants.acceptJson, + 'Accept-Language': ApiConstants.acceptLanguageVi, + }, + responseType: ResponseType.json, + validateStatus: (status) { + // Accept all status codes and handle errors in interceptor + return status != null && status < 500; + }, + ) + // Add interceptors in order // 1. Logging interceptor (first to log everything) ..interceptors.add(ref.watch(loggingInterceptorProvider)) - // 2. Auth interceptor (add tokens to requests) ..interceptors.add(await ref.watch(authInterceptorProvider(dio).future)) // 3. Cache interceptor - ..interceptors.add(DioCacheInterceptor(options: await ref.watch(cacheOptionsProvider.future))) + ..interceptors.add( + DioCacheInterceptor( + options: await ref.watch(cacheOptionsProvider.future), + ), + ) // 4. Retry interceptor ..interceptors.add(RetryInterceptor(ref.watch(networkInfoProvider))) // 5. Error transformer (last to transform all errors) @@ -430,9 +426,7 @@ class ApiRequestOptions { final bool forceRefresh; /// Options with cache enabled - static const cached = ApiRequestOptions( - cachePolicy: CachePolicy.forceCache, - ); + static const cached = ApiRequestOptions(cachePolicy: CachePolicy.forceCache); /// Options with network-first strategy static const networkFirst = ApiRequestOptions( @@ -449,12 +443,9 @@ class ApiRequestOptions { Options toDioOptions() { return Options( extra: { - if (cachePolicy != null) - CacheResponse.cacheKey: cachePolicy!.index, - if (cacheDuration != null) - 'maxStale': cacheDuration, - if (forceRefresh) - 'policy': CachePolicy.refresh.index, + if (cachePolicy != null) CacheResponse.cacheKey: cachePolicy!.index, + if (cacheDuration != null) 'maxStale': cacheDuration, + if (forceRefresh) 'policy': CachePolicy.refresh.index, }, ); } @@ -487,10 +478,10 @@ class QueuedRequest { final DateTime timestamp; Map toJson() => { - 'method': method, - 'path': path, - 'data': data, - 'queryParameters': queryParameters, - 'timestamp': timestamp.toIso8601String(), - }; + 'method': method, + 'path': path, + 'data': data, + 'queryParameters': queryParameters, + 'timestamp': timestamp.toIso8601String(), + }; } diff --git a/lib/core/network/network_info.dart b/lib/core/network/network_info.dart index 61273eb..6c7b6d2 100644 --- a/lib/core/network/network_info.dart +++ b/lib/core/network/network_info.dart @@ -191,7 +191,9 @@ class NetworkInfoImpl implements NetworkInfo { return !results.contains(ConnectivityResult.none); } - NetworkConnectionType _mapConnectivityResult(List results) { + NetworkConnectionType _mapConnectivityResult( + List results, + ) { if (results.isEmpty || results.contains(ConnectivityResult.none)) { return NetworkConnectionType.none; } @@ -273,14 +275,11 @@ class NetworkStatusNotifier extends _$NetworkStatusNotifier { final status = await networkInfo.networkStatus; // Listen to network changes - ref.listen( - networkStatusStreamProvider, - (_, next) { - next.whenData((newStatus) { - state = AsyncValue.data(newStatus); - }); - }, - ); + ref.listen(networkStatusStreamProvider, (_, next) { + next.whenData((newStatus) { + state = AsyncValue.data(newStatus); + }); + }); return status; } diff --git a/lib/core/providers/connectivity_provider.dart b/lib/core/providers/connectivity_provider.dart index bd909a4..29f5740 100644 --- a/lib/core/providers/connectivity_provider.dart +++ b/lib/core/providers/connectivity_provider.dart @@ -95,7 +95,7 @@ Stream isOnline(Ref ref) { return connectivity.onConnectivityChanged.map((result) { // Online if connected to WiFi or mobile return result.contains(ConnectivityResult.wifi) || - result.contains(ConnectivityResult.mobile); + result.contains(ConnectivityResult.mobile); }); } diff --git a/lib/core/providers/provider_examples.dart b/lib/core/providers/provider_examples.dart index 5c7acdd..4bebb02 100644 --- a/lib/core/providers/provider_examples.dart +++ b/lib/core/providers/provider_examples.dart @@ -34,7 +34,11 @@ String appVersion(Ref ref) { int pointsMultiplier(Ref ref) { // Can read other providers final userTier = 'diamond'; // This would come from another provider - return userTier == 'diamond' ? 3 : userTier == 'platinum' ? 2 : 1; + return userTier == 'diamond' + ? 3 + : userTier == 'platinum' + ? 2 + : 1; } // ============================================================================ @@ -46,7 +50,7 @@ int pointsMultiplier(Ref ref) { @riverpod Future userData(Ref ref) async { // Simulate API call - await Future.delayed(const Duration(seconds: 1)); + await Future.delayed(const Duration(seconds: 1)); return 'User Data'; } @@ -55,7 +59,7 @@ Future userData(Ref ref) async { @riverpod Future userProfile(Ref ref, String userId) async { // Simulate API call with userId - await Future.delayed(const Duration(seconds: 1)); + await Future.delayed(const Duration(seconds: 1)); return 'Profile for user: $userId'; } @@ -70,7 +74,7 @@ Future> productList( String? searchQuery, }) async { // Simulate API call with parameters - await Future.delayed(const Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 500)); return ['Product 1', 'Product 2', 'Product 3']; } @@ -82,10 +86,7 @@ Future> productList( /// Use this for WebSocket connections, real-time updates, etc. @riverpod Stream timer(Ref ref) { - return Stream.periodic( - const Duration(seconds: 1), - (count) => count, - ); + return Stream.periodic(const Duration(seconds: 1), (count) => count); } /// Stream provider with parameters @@ -176,7 +177,7 @@ class UserProfileNotifier extends _$UserProfileNotifier { @override Future build() async { // Fetch initial data - await Future.delayed(const Duration(seconds: 1)); + await Future.delayed(const Duration(seconds: 1)); return UserProfileData(name: 'John Doe', email: 'john@example.com'); } @@ -225,10 +226,7 @@ class UserProfileData { final String email; UserProfileData copyWith({String? name, String? email}) { - return UserProfileData( - name: name ?? this.name, - email: email ?? this.email, - ); + return UserProfileData(name: name ?? this.name, email: email ?? this.email); } } diff --git a/lib/core/providers/provider_examples.g.dart b/lib/core/providers/provider_examples.g.dart index 5fe0d67..4235e83 100644 --- a/lib/core/providers/provider_examples.g.dart +++ b/lib/core/providers/provider_examples.g.dart @@ -189,7 +189,7 @@ final class UserDataProvider } } -String _$userDataHash() => r'3df905d6ea9f81ce7ca8205bd785ad4d4376b399'; +String _$userDataHash() => r'1b754e931a5d4c202189fcdd3de54815f93aaba2'; /// Async provider with parameters (Family pattern) /// Parameters are just function parameters - much simpler than before! @@ -248,7 +248,7 @@ final class UserProfileProvider } } -String _$userProfileHash() => r'd42ed517f41ce0dfde58d74b2beb3d8415b81a22'; +String _$userProfileHash() => r'35cdc3f9117e81c0150399d7015840ed034661d3'; /// Async provider with parameters (Family pattern) /// Parameters are just function parameters - much simpler than before! @@ -346,7 +346,7 @@ final class ProductListProvider } } -String _$productListHash() => r'aacee7761543692ccd59f0ecd2f290e1a7de203a'; +String _$productListHash() => r'db1568fb33a3615db0a31265c953d6db62b6535b'; /// Async provider with multiple parameters /// Named parameters, optional parameters, defaults - all supported! @@ -732,7 +732,7 @@ final class UserProfileNotifierProvider } String _$userProfileNotifierHash() => - r'87c9a9277552095a0ed0b768829e2930fa475c7f'; + r'be7bcbe81f84be6ef50e94f52e0c65b00230291c'; /// AsyncNotifier for state that requires async initialization /// Perfect for fetching data that can then be modified diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 8aca77e..c2195d8 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -10,6 +10,8 @@ import 'package:go_router/go_router.dart'; import 'package:worker/features/account/presentation/pages/addresses_page.dart'; import 'package:worker/features/account/presentation/pages/change_password_page.dart'; import 'package:worker/features/account/presentation/pages/profile_edit_page.dart'; +import 'package:worker/features/auth/presentation/pages/login_page.dart'; +import 'package:worker/features/auth/presentation/pages/register_page.dart'; import 'package:worker/features/cart/presentation/pages/cart_page.dart'; import 'package:worker/features/cart/presentation/pages/checkout_page.dart'; import 'package:worker/features/chat/presentation/pages/chat_list_page.dart'; @@ -46,10 +48,24 @@ class AppRouter { /// Router configuration static final GoRouter router = GoRouter( // Initial route - initialLocation: RouteNames.home, + initialLocation: RouteNames.login, // Route definitions routes: [ + // Authentication Routes + GoRoute( + path: RouteNames.login, + name: RouteNames.login, + pageBuilder: (context, state) => + MaterialPage(key: state.pageKey, child: const LoginPage()), + ), + GoRoute( + path: RouteNames.register, + name: RouteNames.register, + pageBuilder: (context, state) => + MaterialPage(key: state.pageKey, child: const RegisterPage()), + ), + // Main Route (with bottom navigation) GoRoute( path: RouteNames.home, @@ -278,8 +294,10 @@ class AppRouter { GoRoute( path: RouteNames.designRequestCreate, name: RouteNames.designRequestCreate, - pageBuilder: (context, state) => - MaterialPage(key: state.pageKey, child: const DesignRequestCreatePage()), + pageBuilder: (context, state) => MaterialPage( + key: state.pageKey, + child: const DesignRequestCreatePage(), + ), ), // Design Request Detail Route @@ -421,7 +439,8 @@ class RouteNames { // Model Houses & Design Requests Routes static const String modelHouses = '/model-houses'; - static const String designRequestCreate = '/model-houses/design-request/create'; + static const String designRequestCreate = + '/model-houses/design-request/create'; static const String designRequestDetail = '/model-houses/design-request/:id'; // Authentication Routes (TODO: implement when auth feature is ready) @@ -434,6 +453,12 @@ class RouteNames { /// /// Helper extensions for common navigation patterns. extension GoRouterExtension on BuildContext { + /// Navigate to login page + void goLogin() => go(RouteNames.login); + + /// Navigate to register page + void goRegister() => go(RouteNames.register); + /// Navigate to home page void goHome() => go(RouteNames.home); diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index f7f3fbe..1f6d85b 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -40,10 +40,7 @@ class AppTheme { color: AppColors.white, fontWeight: FontWeight.w600, ), - iconTheme: const IconThemeData( - color: AppColors.white, - size: 24, - ), + iconTheme: const IconThemeData(color: AppColors.white, size: 24), systemOverlayStyle: SystemUiOverlayStyle.light, ), @@ -65,9 +62,7 @@ class AppTheme { foregroundColor: AppColors.white, elevation: 2, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), textStyle: AppTypography.buttonText, minimumSize: const Size(64, 48), ), @@ -78,9 +73,7 @@ class AppTheme { style: TextButton.styleFrom( foregroundColor: AppColors.primaryBlue, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), textStyle: AppTypography.buttonText, ), ), @@ -91,9 +84,7 @@ class AppTheme { foregroundColor: AppColors.primaryBlue, side: const BorderSide(color: AppColors.primaryBlue, width: 1.5), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), textStyle: AppTypography.buttonText, minimumSize: const Size(64, 48), ), @@ -103,7 +94,10 @@ class AppTheme { inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: AppColors.white, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: AppColors.grey100, width: 1), @@ -124,15 +118,9 @@ class AppTheme { borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: AppColors.danger, width: 2), ), - labelStyle: AppTypography.bodyMedium.copyWith( - color: AppColors.grey500, - ), - hintStyle: AppTypography.bodyMedium.copyWith( - color: AppColors.grey500, - ), - errorStyle: AppTypography.bodySmall.copyWith( - color: AppColors.danger, - ), + labelStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500), + hintStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500), + errorStyle: AppTypography.bodySmall.copyWith(color: AppColors.danger), ), // ==================== Bottom Navigation Bar Theme ==================== @@ -144,10 +132,7 @@ class AppTheme { size: 28, color: AppColors.primaryBlue, ), - unselectedIconTheme: IconThemeData( - size: 24, - color: AppColors.grey500, - ), + unselectedIconTheme: IconThemeData(size: 24, color: AppColors.grey500), selectedLabelStyle: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, @@ -182,26 +167,25 @@ class AppTheme { color: AppColors.white, ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), ), // ==================== Dialog Theme ==================== - dialogTheme: const DialogThemeData( - backgroundColor: AppColors.white, - elevation: 8, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(16)), - ), - ).copyWith( - titleTextStyle: AppTypography.headlineMedium.copyWith( - color: AppColors.grey900, - ), - contentTextStyle: AppTypography.bodyLarge.copyWith( - color: AppColors.grey900, - ), - ), + dialogTheme: + const DialogThemeData( + backgroundColor: AppColors.white, + elevation: 8, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + ).copyWith( + titleTextStyle: AppTypography.headlineMedium.copyWith( + color: AppColors.grey900, + ), + contentTextStyle: AppTypography.bodyLarge.copyWith( + color: AppColors.grey900, + ), + ), // ==================== Snackbar Theme ==================== snackBarTheme: SnackBarThemeData( @@ -209,9 +193,7 @@ class AppTheme { contentTextStyle: AppTypography.bodyMedium.copyWith( color: AppColors.white, ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), behavior: SnackBarBehavior.floating, elevation: 4, ), @@ -224,10 +206,7 @@ class AppTheme { ), // ==================== Icon Theme ==================== - iconTheme: const IconThemeData( - color: AppColors.grey900, - size: 24, - ), + iconTheme: const IconThemeData(color: AppColors.grey900, size: 24), // ==================== List Tile Theme ==================== listTileTheme: ListTileThemeData( @@ -266,9 +245,7 @@ class AppTheme { return AppColors.white; }), checkColor: MaterialStateProperty.all(AppColors.white), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), ), // ==================== Radio Theme ==================== @@ -297,14 +274,15 @@ class AppTheme { ), // ==================== Tab Bar Theme ==================== - tabBarTheme: const TabBarThemeData( - labelColor: AppColors.primaryBlue, - unselectedLabelColor: AppColors.grey500, - indicatorColor: AppColors.primaryBlue, - ).copyWith( - labelStyle: AppTypography.labelLarge, - unselectedLabelStyle: AppTypography.labelLarge, - ), + tabBarTheme: + const TabBarThemeData( + labelColor: AppColors.primaryBlue, + unselectedLabelColor: AppColors.grey500, + indicatorColor: AppColors.primaryBlue, + ).copyWith( + labelStyle: AppTypography.labelLarge, + unselectedLabelStyle: AppTypography.labelLarge, + ), ); } @@ -338,10 +316,7 @@ class AppTheme { color: AppColors.white, fontWeight: FontWeight.w600, ), - iconTheme: const IconThemeData( - color: AppColors.white, - size: 24, - ), + iconTheme: const IconThemeData(color: AppColors.white, size: 24), systemOverlayStyle: SystemUiOverlayStyle.light, ), @@ -363,9 +338,7 @@ class AppTheme { foregroundColor: AppColors.white, elevation: 2, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), textStyle: AppTypography.buttonText, minimumSize: const Size(64, 48), ), @@ -375,7 +348,10 @@ class AppTheme { inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: const Color(0xFF2A2A2A), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF3A3A3A), width: 1), @@ -396,15 +372,9 @@ class AppTheme { borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: AppColors.danger, width: 2), ), - labelStyle: AppTypography.bodyMedium.copyWith( - color: AppColors.grey500, - ), - hintStyle: AppTypography.bodyMedium.copyWith( - color: AppColors.grey500, - ), - errorStyle: AppTypography.bodySmall.copyWith( - color: AppColors.danger, - ), + labelStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500), + hintStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500), + errorStyle: AppTypography.bodySmall.copyWith(color: AppColors.danger), ), // ==================== Bottom Navigation Bar Theme ==================== @@ -412,14 +382,8 @@ class AppTheme { backgroundColor: Color(0xFF1E1E1E), selectedItemColor: AppColors.lightBlue, unselectedItemColor: AppColors.grey500, - selectedIconTheme: IconThemeData( - size: 28, - color: AppColors.lightBlue, - ), - unselectedIconTheme: IconThemeData( - size: 24, - color: AppColors.grey500, - ), + selectedIconTheme: IconThemeData(size: 28, color: AppColors.lightBlue), + unselectedIconTheme: IconThemeData(size: 24, color: AppColors.grey500), selectedLabelStyle: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, @@ -449,9 +413,7 @@ class AppTheme { contentTextStyle: AppTypography.bodyMedium.copyWith( color: AppColors.white, ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), behavior: SnackBarBehavior.floating, elevation: 4, ), diff --git a/lib/core/utils/extensions.dart b/lib/core/utils/extensions.dart index 02f70b4..b7bb529 100644 --- a/lib/core/utils/extensions.dart +++ b/lib/core/utils/extensions.dart @@ -423,16 +423,14 @@ extension BuildContextExtensions on BuildContext { /// Navigate to route Future push(Widget page) { - return Navigator.of(this).push( - MaterialPageRoute(builder: (_) => page), - ); + return Navigator.of(this).push(MaterialPageRoute(builder: (_) => page)); } /// Navigate and replace current route Future pushReplacement(Widget page) { - return Navigator.of(this).pushReplacement( - MaterialPageRoute(builder: (_) => page), - ); + return Navigator.of( + this, + ).pushReplacement(MaterialPageRoute(builder: (_) => page)); } /// Pop current route diff --git a/lib/core/utils/formatters.dart b/lib/core/utils/formatters.dart index 3281a63..212057a 100644 --- a/lib/core/utils/formatters.dart +++ b/lib/core/utils/formatters.dart @@ -313,15 +313,21 @@ class TextFormatter { } /// Truncate text with ellipsis - static String truncate(String text, int maxLength, {String ellipsis = '...'}) { + static String truncate( + String text, + int maxLength, { + String ellipsis = '...', + }) { if (text.length <= maxLength) return text; return text.substring(0, maxLength - ellipsis.length) + ellipsis; } /// Remove diacritics from Vietnamese text static String removeDiacritics(String text) { - const withDiacritics = 'àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ'; - const withoutDiacritics = 'aaaaaaaaaaaaaaaaaeeeeeeeeeeeiiiiioooooooooooooooooouuuuuuuuuuuyyyyyd'; + const withDiacritics = + 'àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ'; + const withoutDiacritics = + 'aaaaaaaaaaaaaaaaaeeeeeeeeeeeiiiiioooooooooooooooooouuuuuuuuuuuyyyyyd'; var result = text.toLowerCase(); for (var i = 0; i < withDiacritics.length; i++) { diff --git a/lib/core/utils/l10n_extensions.dart b/lib/core/utils/l10n_extensions.dart index 866870b..553cb11 100644 --- a/lib/core/utils/l10n_extensions.dart +++ b/lib/core/utils/l10n_extensions.dart @@ -241,8 +241,11 @@ class L10nHelper { /// final pointsText = L10nHelper.formatPoints(context, 100); /// // Returns: "+100 điểm" (Vietnamese) or "+100 points" (English) /// ``` - static String formatPoints(BuildContext context, int points, - {bool showSign = true}) { + static String formatPoints( + BuildContext context, + int points, { + bool showSign = true, + }) { if (showSign && points > 0) { return context.l10n.earnedPoints(points); } else if (showSign && points < 0) { diff --git a/lib/core/utils/qr_generator.dart b/lib/core/utils/qr_generator.dart index ede4d36..a320a00 100644 --- a/lib/core/utils/qr_generator.dart +++ b/lib/core/utils/qr_generator.dart @@ -66,9 +66,7 @@ class QRGenerator { ), padding: const EdgeInsets.all(16), gapless: true, - embeddedImageStyle: const QrEmbeddedImageStyle( - size: Size(48, 48), - ), + embeddedImageStyle: const QrEmbeddedImageStyle(size: Size(48, 48)), ); } @@ -189,9 +187,7 @@ class QRGenerator { embeddedImage: embeddedImage is AssetImage ? (embeddedImage as AssetImage).assetName as ImageProvider : null, - embeddedImageStyle: QrEmbeddedImageStyle( - size: embeddedImageSize, - ), + embeddedImageStyle: QrEmbeddedImageStyle(size: embeddedImageSize), ); } @@ -203,18 +199,12 @@ class QRGenerator { if (data.contains(':')) { final parts = data.split(':'); if (parts.length == 2) { - return { - 'type': parts[0].toUpperCase(), - 'value': parts[1], - }; + return {'type': parts[0].toUpperCase(), 'value': parts[1]}; } } // If no type prefix, return as generic data - return { - 'type': 'GENERIC', - 'value': data, - }; + return {'type': 'GENERIC', 'value': data}; } catch (e) { return null; } diff --git a/lib/core/utils/validators.dart b/lib/core/utils/validators.dart index 16c710e..83c5f75 100644 --- a/lib/core/utils/validators.dart +++ b/lib/core/utils/validators.dart @@ -244,9 +244,7 @@ class Validators { } if (double.tryParse(value) == null) { - return fieldName != null - ? '$fieldName phải là số' - : 'Giá trị phải là số'; + return fieldName != null ? '$fieldName phải là số' : 'Giá trị phải là số'; } return null; @@ -351,7 +349,8 @@ class Validators { ); final today = DateTime.now(); - final age = today.year - + final age = + today.year - birthDate.year - (today.month > birthDate.month || (today.month == birthDate.month && today.day >= birthDate.day) @@ -456,11 +455,7 @@ class Validators { // ======================================================================== /// Validate against custom regex pattern - static String? pattern( - String? value, - RegExp pattern, - String errorMessage, - ) { + static String? pattern(String? value, RegExp pattern, String errorMessage) { if (value == null || value.trim().isEmpty) { return 'Trường này là bắt buộc'; } @@ -491,12 +486,7 @@ class Validators { } /// Password strength enum -enum PasswordStrength { - weak, - medium, - strong, - veryStrong, -} +enum PasswordStrength { weak, medium, strong, veryStrong } /// Password strength calculator class PasswordStrengthCalculator { diff --git a/lib/core/widgets/bottom_nav_bar.dart b/lib/core/widgets/bottom_nav_bar.dart index 8898c23..d6afd63 100644 --- a/lib/core/widgets/bottom_nav_bar.dart +++ b/lib/core/widgets/bottom_nav_bar.dart @@ -58,10 +58,7 @@ class CustomBottomNavBar extends StatelessWidget { selectedFontSize: 12, unselectedFontSize: 12, items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home), - label: 'Home', - ), + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), BottomNavigationBarItem( icon: Icon(Icons.shopping_bag), label: 'Products', @@ -70,14 +67,8 @@ class CustomBottomNavBar extends StatelessWidget { icon: Icon(Icons.card_membership), label: 'Loyalty', ), - BottomNavigationBarItem( - icon: Icon(Icons.person), - label: 'Account', - ), - BottomNavigationBarItem( - icon: Icon(Icons.menu), - label: 'More', - ), + BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Account'), + BottomNavigationBarItem(icon: Icon(Icons.menu), label: 'More'), ], ); } diff --git a/lib/core/widgets/custom_button.dart b/lib/core/widgets/custom_button.dart index 19baa3c..fe865aa 100644 --- a/lib/core/widgets/custom_button.dart +++ b/lib/core/widgets/custom_button.dart @@ -124,10 +124,7 @@ class CustomButton extends StatelessWidget { const SizedBox(width: 8), Text( text, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), ), ], ); @@ -135,10 +132,7 @@ class CustomButton extends StatelessWidget { return Text( text, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), ); } } diff --git a/lib/core/widgets/empty_state.dart b/lib/core/widgets/empty_state.dart index 707160b..0502b9c 100644 --- a/lib/core/widgets/empty_state.dart +++ b/lib/core/widgets/empty_state.dart @@ -54,11 +54,7 @@ class EmptyState extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - icon, - size: iconSize, - color: AppColors.grey500, - ), + Icon(icon, size: iconSize, color: AppColors.grey500), const SizedBox(height: 16), Text( title, @@ -73,10 +69,7 @@ class EmptyState extends StatelessWidget { const SizedBox(height: 8), Text( subtitle!, - style: const TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14, color: AppColors.grey500), textAlign: TextAlign.center, ), ], diff --git a/lib/core/widgets/floating_chat_button.dart b/lib/core/widgets/floating_chat_button.dart index 2c56502..1e3132e 100644 --- a/lib/core/widgets/floating_chat_button.dart +++ b/lib/core/widgets/floating_chat_button.dart @@ -58,15 +58,9 @@ class ChatFloatingButton extends StatelessWidget { decoration: BoxDecoration( color: AppColors.danger, shape: BoxShape.circle, - border: Border.all( - color: Colors.white, - width: 2, - ), - ), - constraints: const BoxConstraints( - minWidth: 20, - minHeight: 20, + border: Border.all(color: Colors.white, width: 2), ), + constraints: const BoxConstraints(minWidth: 20, minHeight: 20), child: Center( child: Text( unreadCount! > 99 ? '99+' : unreadCount.toString(), diff --git a/lib/core/widgets/loading_indicator.dart b/lib/core/widgets/loading_indicator.dart index 6dcb282..7c32fec 100644 --- a/lib/core/widgets/loading_indicator.dart +++ b/lib/core/widgets/loading_indicator.dart @@ -50,10 +50,7 @@ class CustomLoadingIndicator extends StatelessWidget { const SizedBox(height: 16), Text( message!, - style: const TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14, color: AppColors.grey500), textAlign: TextAlign.center, ), ], diff --git a/lib/features/account/data/models/audit_log_model.dart b/lib/features/account/data/models/audit_log_model.dart index f4f0fa7..c424c85 100644 --- a/lib/features/account/data/models/audit_log_model.dart +++ b/lib/features/account/data/models/audit_log_model.dart @@ -6,18 +6,39 @@ part 'audit_log_model.g.dart'; @HiveType(typeId: HiveTypeIds.auditLogModel) class AuditLogModel extends HiveObject { - AuditLogModel({required this.logId, required this.userId, required this.action, required this.entityType, required this.entityId, this.oldValue, this.newValue, this.ipAddress, this.userAgent, required this.timestamp}); - - @HiveField(0) final int logId; - @HiveField(1) final String userId; - @HiveField(2) final String action; - @HiveField(3) final String entityType; - @HiveField(4) final String entityId; - @HiveField(5) final String? oldValue; - @HiveField(6) final String? newValue; - @HiveField(7) final String? ipAddress; - @HiveField(8) final String? userAgent; - @HiveField(9) final DateTime timestamp; + AuditLogModel({ + required this.logId, + required this.userId, + required this.action, + required this.entityType, + required this.entityId, + this.oldValue, + this.newValue, + this.ipAddress, + this.userAgent, + required this.timestamp, + }); + + @HiveField(0) + final int logId; + @HiveField(1) + final String userId; + @HiveField(2) + final String action; + @HiveField(3) + final String entityType; + @HiveField(4) + final String entityId; + @HiveField(5) + final String? oldValue; + @HiveField(6) + final String? newValue; + @HiveField(7) + final String? ipAddress; + @HiveField(8) + final String? userAgent; + @HiveField(9) + final DateTime timestamp; factory AuditLogModel.fromJson(Map json) => AuditLogModel( logId: json['log_id'] as int, diff --git a/lib/features/account/data/models/payment_reminder_model.dart b/lib/features/account/data/models/payment_reminder_model.dart index 2abe480..f16c76e 100644 --- a/lib/features/account/data/models/payment_reminder_model.dart +++ b/lib/features/account/data/models/payment_reminder_model.dart @@ -6,33 +6,65 @@ part 'payment_reminder_model.g.dart'; @HiveType(typeId: HiveTypeIds.paymentReminderModel) class PaymentReminderModel extends HiveObject { - PaymentReminderModel({required this.reminderId, required this.invoiceId, required this.userId, required this.reminderType, required this.subject, required this.message, required this.isRead, required this.isSent, this.scheduledAt, this.sentAt, this.readAt}); - - @HiveField(0) final String reminderId; - @HiveField(1) final String invoiceId; - @HiveField(2) final String userId; - @HiveField(3) final ReminderType reminderType; - @HiveField(4) final String subject; - @HiveField(5) final String message; - @HiveField(6) final bool isRead; - @HiveField(7) final bool isSent; - @HiveField(8) final DateTime? scheduledAt; - @HiveField(9) final DateTime? sentAt; - @HiveField(10) final DateTime? readAt; + 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, + }); - factory PaymentReminderModel.fromJson(Map json) => PaymentReminderModel( - reminderId: json['reminder_id'] as String, - invoiceId: json['invoice_id'] as String, - userId: json['user_id'] as String, - reminderType: ReminderType.values.firstWhere((e) => e.name == json['reminder_type']), - subject: json['subject'] as String, - message: json['message'] as String, - isRead: json['is_read'] as bool? ?? false, - isSent: json['is_sent'] as bool? ?? false, - scheduledAt: json['scheduled_at'] != null ? DateTime.parse(json['scheduled_at']?.toString() ?? '') : null, - sentAt: json['sent_at'] != null ? DateTime.parse(json['sent_at']?.toString() ?? '') : null, - readAt: json['read_at'] != null ? DateTime.parse(json['read_at']?.toString() ?? '') : null, - ); + @HiveField(0) + final String reminderId; + @HiveField(1) + final String invoiceId; + @HiveField(2) + final String userId; + @HiveField(3) + final ReminderType reminderType; + @HiveField(4) + final String subject; + @HiveField(5) + final String message; + @HiveField(6) + final bool isRead; + @HiveField(7) + final bool isSent; + @HiveField(8) + final DateTime? scheduledAt; + @HiveField(9) + final DateTime? sentAt; + @HiveField(10) + final DateTime? readAt; + + factory PaymentReminderModel.fromJson(Map json) => + PaymentReminderModel( + reminderId: json['reminder_id'] as String, + invoiceId: json['invoice_id'] as String, + userId: json['user_id'] as String, + reminderType: ReminderType.values.firstWhere( + (e) => e.name == json['reminder_type'], + ), + subject: json['subject'] as String, + message: json['message'] as String, + isRead: json['is_read'] as bool? ?? false, + isSent: json['is_sent'] as bool? ?? false, + scheduledAt: json['scheduled_at'] != null + ? DateTime.parse(json['scheduled_at']?.toString() ?? '') + : null, + sentAt: json['sent_at'] != null + ? DateTime.parse(json['sent_at']?.toString() ?? '') + : null, + readAt: json['read_at'] != null + ? DateTime.parse(json['read_at']?.toString() ?? '') + : null, + ); Map toJson() => { 'reminder_id': reminderId, diff --git a/lib/features/account/domain/entities/audit_log.dart b/lib/features/account/domain/entities/audit_log.dart index 25086de..c8e386f 100644 --- a/lib/features/account/domain/entities/audit_log.dart +++ b/lib/features/account/domain/entities/audit_log.dart @@ -134,13 +134,7 @@ class AuditLog { @override int get hashCode { - return Object.hash( - logId, - userId, - action, - entityType, - entityId, - ); + return Object.hash(logId, userId, action, entityType, entityId); } @override diff --git a/lib/features/account/presentation/widgets/account_menu_item.dart b/lib/features/account/presentation/widgets/account_menu_item.dart index 01b9bfe..b35b79e 100644 --- a/lib/features/account/presentation/widgets/account_menu_item.dart +++ b/lib/features/account/presentation/widgets/account_menu_item.dart @@ -59,10 +59,7 @@ class AccountMenuItem extends StatelessWidget { ), decoration: BoxDecoration( border: Border( - bottom: BorderSide( - color: AppColors.grey100, - width: 1.0, - ), + bottom: BorderSide(color: AppColors.grey100, width: 1.0), ), ), child: Row( @@ -72,7 +69,9 @@ class AccountMenuItem extends StatelessWidget { width: 40, height: 40, decoration: BoxDecoration( - color: iconBackgroundColor ?? AppColors.lightBlue.withValues(alpha: 0.1), + color: + iconBackgroundColor ?? + AppColors.lightBlue.withValues(alpha: 0.1), shape: BoxShape.circle, ), child: Icon( diff --git a/lib/features/auth/data/datasources/auth_local_datasource.dart b/lib/features/auth/data/datasources/auth_local_datasource.dart new file mode 100644 index 0000000..3705458 --- /dev/null +++ b/lib/features/auth/data/datasources/auth_local_datasource.dart @@ -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 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 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 getSid() async { + return await _secureStorage.read(key: _sidKey); + } + + /// Get CSRF Token + /// + /// Returns null if not logged in. + Future getCsrfToken() async { + return await _secureStorage.read(key: _csrfTokenKey); + } + + /// Get Full Name + /// + /// Returns null if not logged in. + Future getFullName() async { + return await _secureStorage.read(key: _fullNameKey); + } + + /// Check if user has valid session + /// + /// Returns true if SID and CSRF token are present. + Future 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 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 clearAll() async { + await _secureStorage.deleteAll(); + } +} diff --git a/lib/features/auth/data/models/auth_session_model.dart b/lib/features/auth/data/models/auth_session_model.dart new file mode 100644 index 0000000..6b6ee05 --- /dev/null +++ b/lib/features/auth/data/models/auth_session_model.dart @@ -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 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 apps, + }) = _LoginMessage; + + factory LoginMessage.fromJson(Map 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 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? apps, + }) = _SessionData; + + factory SessionData.fromJson(Map 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, + ); + } +} diff --git a/lib/features/auth/data/models/auth_session_model.freezed.dart b/lib/features/auth/data/models/auth_session_model.freezed.dart new file mode 100644 index 0000000..3fb1040 --- /dev/null +++ b/lib/features/auth/data/models/auth_session_model.freezed.dart @@ -0,0 +1,1113 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'auth_session_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$AppInfo { + +@JsonKey(name: 'app_title') String get appTitle;@JsonKey(name: 'app_endpoint') String get appEndpoint;@JsonKey(name: 'app_logo') String get appLogo; +/// Create a copy of AppInfo +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AppInfoCopyWith get copyWith => _$AppInfoCopyWithImpl(this as AppInfo, _$identity); + + /// Serializes this AppInfo to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AppInfo&&(identical(other.appTitle, appTitle) || other.appTitle == appTitle)&&(identical(other.appEndpoint, appEndpoint) || other.appEndpoint == appEndpoint)&&(identical(other.appLogo, appLogo) || other.appLogo == appLogo)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,appTitle,appEndpoint,appLogo); + +@override +String toString() { + return 'AppInfo(appTitle: $appTitle, appEndpoint: $appEndpoint, appLogo: $appLogo)'; +} + + +} + +/// @nodoc +abstract mixin class $AppInfoCopyWith<$Res> { + factory $AppInfoCopyWith(AppInfo value, $Res Function(AppInfo) _then) = _$AppInfoCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'app_title') String appTitle,@JsonKey(name: 'app_endpoint') String appEndpoint,@JsonKey(name: 'app_logo') String appLogo +}); + + + + +} +/// @nodoc +class _$AppInfoCopyWithImpl<$Res> + implements $AppInfoCopyWith<$Res> { + _$AppInfoCopyWithImpl(this._self, this._then); + + final AppInfo _self; + final $Res Function(AppInfo) _then; + +/// Create a copy of AppInfo +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? appTitle = null,Object? appEndpoint = null,Object? appLogo = null,}) { + return _then(_self.copyWith( +appTitle: null == appTitle ? _self.appTitle : appTitle // ignore: cast_nullable_to_non_nullable +as String,appEndpoint: null == appEndpoint ? _self.appEndpoint : appEndpoint // ignore: cast_nullable_to_non_nullable +as String,appLogo: null == appLogo ? _self.appLogo : appLogo // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AppInfo]. +extension AppInfoPatterns on AppInfo { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AppInfo value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AppInfo() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AppInfo value) $default,){ +final _that = this; +switch (_that) { +case _AppInfo(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AppInfo value)? $default,){ +final _that = this; +switch (_that) { +case _AppInfo() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'app_title') String appTitle, @JsonKey(name: 'app_endpoint') String appEndpoint, @JsonKey(name: 'app_logo') String appLogo)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AppInfo() when $default != null: +return $default(_that.appTitle,_that.appEndpoint,_that.appLogo);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'app_title') String appTitle, @JsonKey(name: 'app_endpoint') String appEndpoint, @JsonKey(name: 'app_logo') String appLogo) $default,) {final _that = this; +switch (_that) { +case _AppInfo(): +return $default(_that.appTitle,_that.appEndpoint,_that.appLogo);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'app_title') String appTitle, @JsonKey(name: 'app_endpoint') String appEndpoint, @JsonKey(name: 'app_logo') String appLogo)? $default,) {final _that = this; +switch (_that) { +case _AppInfo() when $default != null: +return $default(_that.appTitle,_that.appEndpoint,_that.appLogo);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _AppInfo implements AppInfo { + const _AppInfo({@JsonKey(name: 'app_title') required this.appTitle, @JsonKey(name: 'app_endpoint') required this.appEndpoint, @JsonKey(name: 'app_logo') required this.appLogo}); + factory _AppInfo.fromJson(Map json) => _$AppInfoFromJson(json); + +@override@JsonKey(name: 'app_title') final String appTitle; +@override@JsonKey(name: 'app_endpoint') final String appEndpoint; +@override@JsonKey(name: 'app_logo') final String appLogo; + +/// Create a copy of AppInfo +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AppInfoCopyWith<_AppInfo> get copyWith => __$AppInfoCopyWithImpl<_AppInfo>(this, _$identity); + +@override +Map toJson() { + return _$AppInfoToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppInfo&&(identical(other.appTitle, appTitle) || other.appTitle == appTitle)&&(identical(other.appEndpoint, appEndpoint) || other.appEndpoint == appEndpoint)&&(identical(other.appLogo, appLogo) || other.appLogo == appLogo)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,appTitle,appEndpoint,appLogo); + +@override +String toString() { + return 'AppInfo(appTitle: $appTitle, appEndpoint: $appEndpoint, appLogo: $appLogo)'; +} + + +} + +/// @nodoc +abstract mixin class _$AppInfoCopyWith<$Res> implements $AppInfoCopyWith<$Res> { + factory _$AppInfoCopyWith(_AppInfo value, $Res Function(_AppInfo) _then) = __$AppInfoCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'app_title') String appTitle,@JsonKey(name: 'app_endpoint') String appEndpoint,@JsonKey(name: 'app_logo') String appLogo +}); + + + + +} +/// @nodoc +class __$AppInfoCopyWithImpl<$Res> + implements _$AppInfoCopyWith<$Res> { + __$AppInfoCopyWithImpl(this._self, this._then); + + final _AppInfo _self; + final $Res Function(_AppInfo) _then; + +/// Create a copy of AppInfo +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? appTitle = null,Object? appEndpoint = null,Object? appLogo = null,}) { + return _then(_AppInfo( +appTitle: null == appTitle ? _self.appTitle : appTitle // ignore: cast_nullable_to_non_nullable +as String,appEndpoint: null == appEndpoint ? _self.appEndpoint : appEndpoint // ignore: cast_nullable_to_non_nullable +as String,appLogo: null == appLogo ? _self.appLogo : appLogo // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$LoginMessage { + + bool get success; String get message; String get sid;@JsonKey(name: 'csrf_token') String get csrfToken; List get apps; +/// Create a copy of LoginMessage +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LoginMessageCopyWith get copyWith => _$LoginMessageCopyWithImpl(this as LoginMessage, _$identity); + + /// Serializes this LoginMessage to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LoginMessage&&(identical(other.success, success) || other.success == success)&&(identical(other.message, message) || other.message == message)&&(identical(other.sid, sid) || other.sid == sid)&&(identical(other.csrfToken, csrfToken) || other.csrfToken == csrfToken)&&const DeepCollectionEquality().equals(other.apps, apps)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,success,message,sid,csrfToken,const DeepCollectionEquality().hash(apps)); + +@override +String toString() { + return 'LoginMessage(success: $success, message: $message, sid: $sid, csrfToken: $csrfToken, apps: $apps)'; +} + + +} + +/// @nodoc +abstract mixin class $LoginMessageCopyWith<$Res> { + factory $LoginMessageCopyWith(LoginMessage value, $Res Function(LoginMessage) _then) = _$LoginMessageCopyWithImpl; +@useResult +$Res call({ + bool success, String message, String sid,@JsonKey(name: 'csrf_token') String csrfToken, List apps +}); + + + + +} +/// @nodoc +class _$LoginMessageCopyWithImpl<$Res> + implements $LoginMessageCopyWith<$Res> { + _$LoginMessageCopyWithImpl(this._self, this._then); + + final LoginMessage _self; + final $Res Function(LoginMessage) _then; + +/// Create a copy of LoginMessage +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? success = null,Object? message = null,Object? sid = null,Object? csrfToken = null,Object? apps = null,}) { + return _then(_self.copyWith( +success: null == success ? _self.success : success // ignore: cast_nullable_to_non_nullable +as bool,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String,sid: null == sid ? _self.sid : sid // ignore: cast_nullable_to_non_nullable +as String,csrfToken: null == csrfToken ? _self.csrfToken : csrfToken // ignore: cast_nullable_to_non_nullable +as String,apps: null == apps ? _self.apps : apps // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LoginMessage]. +extension LoginMessagePatterns on LoginMessage { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LoginMessage value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LoginMessage() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LoginMessage value) $default,){ +final _that = this; +switch (_that) { +case _LoginMessage(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LoginMessage value)? $default,){ +final _that = this; +switch (_that) { +case _LoginMessage() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool success, String message, String sid, @JsonKey(name: 'csrf_token') String csrfToken, List apps)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LoginMessage() when $default != null: +return $default(_that.success,_that.message,_that.sid,_that.csrfToken,_that.apps);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool success, String message, String sid, @JsonKey(name: 'csrf_token') String csrfToken, List apps) $default,) {final _that = this; +switch (_that) { +case _LoginMessage(): +return $default(_that.success,_that.message,_that.sid,_that.csrfToken,_that.apps);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool success, String message, String sid, @JsonKey(name: 'csrf_token') String csrfToken, List apps)? $default,) {final _that = this; +switch (_that) { +case _LoginMessage() when $default != null: +return $default(_that.success,_that.message,_that.sid,_that.csrfToken,_that.apps);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _LoginMessage implements LoginMessage { + const _LoginMessage({required this.success, required this.message, required this.sid, @JsonKey(name: 'csrf_token') required this.csrfToken, final List apps = const []}): _apps = apps; + factory _LoginMessage.fromJson(Map json) => _$LoginMessageFromJson(json); + +@override final bool success; +@override final String message; +@override final String sid; +@override@JsonKey(name: 'csrf_token') final String csrfToken; + final List _apps; +@override@JsonKey() List get apps { + if (_apps is EqualUnmodifiableListView) return _apps; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_apps); +} + + +/// Create a copy of LoginMessage +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LoginMessageCopyWith<_LoginMessage> get copyWith => __$LoginMessageCopyWithImpl<_LoginMessage>(this, _$identity); + +@override +Map toJson() { + return _$LoginMessageToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoginMessage&&(identical(other.success, success) || other.success == success)&&(identical(other.message, message) || other.message == message)&&(identical(other.sid, sid) || other.sid == sid)&&(identical(other.csrfToken, csrfToken) || other.csrfToken == csrfToken)&&const DeepCollectionEquality().equals(other._apps, _apps)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,success,message,sid,csrfToken,const DeepCollectionEquality().hash(_apps)); + +@override +String toString() { + return 'LoginMessage(success: $success, message: $message, sid: $sid, csrfToken: $csrfToken, apps: $apps)'; +} + + +} + +/// @nodoc +abstract mixin class _$LoginMessageCopyWith<$Res> implements $LoginMessageCopyWith<$Res> { + factory _$LoginMessageCopyWith(_LoginMessage value, $Res Function(_LoginMessage) _then) = __$LoginMessageCopyWithImpl; +@override @useResult +$Res call({ + bool success, String message, String sid,@JsonKey(name: 'csrf_token') String csrfToken, List apps +}); + + + + +} +/// @nodoc +class __$LoginMessageCopyWithImpl<$Res> + implements _$LoginMessageCopyWith<$Res> { + __$LoginMessageCopyWithImpl(this._self, this._then); + + final _LoginMessage _self; + final $Res Function(_LoginMessage) _then; + +/// Create a copy of LoginMessage +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? success = null,Object? message = null,Object? sid = null,Object? csrfToken = null,Object? apps = null,}) { + return _then(_LoginMessage( +success: null == success ? _self.success : success // ignore: cast_nullable_to_non_nullable +as bool,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String,sid: null == sid ? _self.sid : sid // ignore: cast_nullable_to_non_nullable +as String,csrfToken: null == csrfToken ? _self.csrfToken : csrfToken // ignore: cast_nullable_to_non_nullable +as String,apps: null == apps ? _self._apps : apps // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$AuthSessionResponse { + +@JsonKey(name: 'session_expired') int get sessionExpired; LoginMessage get message;@JsonKey(name: 'home_page') String get homePage;@JsonKey(name: 'full_name') String get fullName; +/// Create a copy of AuthSessionResponse +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AuthSessionResponseCopyWith get copyWith => _$AuthSessionResponseCopyWithImpl(this as AuthSessionResponse, _$identity); + + /// Serializes this AuthSessionResponse to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthSessionResponse&&(identical(other.sessionExpired, sessionExpired) || other.sessionExpired == sessionExpired)&&(identical(other.message, message) || other.message == message)&&(identical(other.homePage, homePage) || other.homePage == homePage)&&(identical(other.fullName, fullName) || other.fullName == fullName)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,sessionExpired,message,homePage,fullName); + +@override +String toString() { + return 'AuthSessionResponse(sessionExpired: $sessionExpired, message: $message, homePage: $homePage, fullName: $fullName)'; +} + + +} + +/// @nodoc +abstract mixin class $AuthSessionResponseCopyWith<$Res> { + factory $AuthSessionResponseCopyWith(AuthSessionResponse value, $Res Function(AuthSessionResponse) _then) = _$AuthSessionResponseCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'session_expired') int sessionExpired, LoginMessage message,@JsonKey(name: 'home_page') String homePage,@JsonKey(name: 'full_name') String fullName +}); + + +$LoginMessageCopyWith<$Res> get message; + +} +/// @nodoc +class _$AuthSessionResponseCopyWithImpl<$Res> + implements $AuthSessionResponseCopyWith<$Res> { + _$AuthSessionResponseCopyWithImpl(this._self, this._then); + + final AuthSessionResponse _self; + final $Res Function(AuthSessionResponse) _then; + +/// Create a copy of AuthSessionResponse +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? sessionExpired = null,Object? message = null,Object? homePage = null,Object? fullName = null,}) { + return _then(_self.copyWith( +sessionExpired: null == sessionExpired ? _self.sessionExpired : sessionExpired // ignore: cast_nullable_to_non_nullable +as int,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as LoginMessage,homePage: null == homePage ? _self.homePage : homePage // ignore: cast_nullable_to_non_nullable +as String,fullName: null == fullName ? _self.fullName : fullName // ignore: cast_nullable_to_non_nullable +as String, + )); +} +/// Create a copy of AuthSessionResponse +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LoginMessageCopyWith<$Res> get message { + + return $LoginMessageCopyWith<$Res>(_self.message, (value) { + return _then(_self.copyWith(message: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [AuthSessionResponse]. +extension AuthSessionResponsePatterns on AuthSessionResponse { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AuthSessionResponse value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AuthSessionResponse() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AuthSessionResponse value) $default,){ +final _that = this; +switch (_that) { +case _AuthSessionResponse(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AuthSessionResponse value)? $default,){ +final _that = this; +switch (_that) { +case _AuthSessionResponse() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'session_expired') int sessionExpired, LoginMessage message, @JsonKey(name: 'home_page') String homePage, @JsonKey(name: 'full_name') String fullName)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AuthSessionResponse() when $default != null: +return $default(_that.sessionExpired,_that.message,_that.homePage,_that.fullName);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'session_expired') int sessionExpired, LoginMessage message, @JsonKey(name: 'home_page') String homePage, @JsonKey(name: 'full_name') String fullName) $default,) {final _that = this; +switch (_that) { +case _AuthSessionResponse(): +return $default(_that.sessionExpired,_that.message,_that.homePage,_that.fullName);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'session_expired') int sessionExpired, LoginMessage message, @JsonKey(name: 'home_page') String homePage, @JsonKey(name: 'full_name') String fullName)? $default,) {final _that = this; +switch (_that) { +case _AuthSessionResponse() when $default != null: +return $default(_that.sessionExpired,_that.message,_that.homePage,_that.fullName);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _AuthSessionResponse implements AuthSessionResponse { + const _AuthSessionResponse({@JsonKey(name: 'session_expired') required this.sessionExpired, required this.message, @JsonKey(name: 'home_page') required this.homePage, @JsonKey(name: 'full_name') required this.fullName}); + factory _AuthSessionResponse.fromJson(Map json) => _$AuthSessionResponseFromJson(json); + +@override@JsonKey(name: 'session_expired') final int sessionExpired; +@override final LoginMessage message; +@override@JsonKey(name: 'home_page') final String homePage; +@override@JsonKey(name: 'full_name') final String fullName; + +/// Create a copy of AuthSessionResponse +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AuthSessionResponseCopyWith<_AuthSessionResponse> get copyWith => __$AuthSessionResponseCopyWithImpl<_AuthSessionResponse>(this, _$identity); + +@override +Map toJson() { + return _$AuthSessionResponseToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AuthSessionResponse&&(identical(other.sessionExpired, sessionExpired) || other.sessionExpired == sessionExpired)&&(identical(other.message, message) || other.message == message)&&(identical(other.homePage, homePage) || other.homePage == homePage)&&(identical(other.fullName, fullName) || other.fullName == fullName)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,sessionExpired,message,homePage,fullName); + +@override +String toString() { + return 'AuthSessionResponse(sessionExpired: $sessionExpired, message: $message, homePage: $homePage, fullName: $fullName)'; +} + + +} + +/// @nodoc +abstract mixin class _$AuthSessionResponseCopyWith<$Res> implements $AuthSessionResponseCopyWith<$Res> { + factory _$AuthSessionResponseCopyWith(_AuthSessionResponse value, $Res Function(_AuthSessionResponse) _then) = __$AuthSessionResponseCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'session_expired') int sessionExpired, LoginMessage message,@JsonKey(name: 'home_page') String homePage,@JsonKey(name: 'full_name') String fullName +}); + + +@override $LoginMessageCopyWith<$Res> get message; + +} +/// @nodoc +class __$AuthSessionResponseCopyWithImpl<$Res> + implements _$AuthSessionResponseCopyWith<$Res> { + __$AuthSessionResponseCopyWithImpl(this._self, this._then); + + final _AuthSessionResponse _self; + final $Res Function(_AuthSessionResponse) _then; + +/// Create a copy of AuthSessionResponse +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? sessionExpired = null,Object? message = null,Object? homePage = null,Object? fullName = null,}) { + return _then(_AuthSessionResponse( +sessionExpired: null == sessionExpired ? _self.sessionExpired : sessionExpired // ignore: cast_nullable_to_non_nullable +as int,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as LoginMessage,homePage: null == homePage ? _self.homePage : homePage // ignore: cast_nullable_to_non_nullable +as String,fullName: null == fullName ? _self.fullName : fullName // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +/// Create a copy of AuthSessionResponse +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LoginMessageCopyWith<$Res> get message { + + return $LoginMessageCopyWith<$Res>(_self.message, (value) { + return _then(_self.copyWith(message: value)); + }); +} +} + + +/// @nodoc +mixin _$SessionData { + + String get sid; String get csrfToken; String get fullName; DateTime get createdAt; List? get apps; +/// Create a copy of SessionData +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SessionDataCopyWith get copyWith => _$SessionDataCopyWithImpl(this as SessionData, _$identity); + + /// Serializes this SessionData to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SessionData&&(identical(other.sid, sid) || other.sid == sid)&&(identical(other.csrfToken, csrfToken) || other.csrfToken == csrfToken)&&(identical(other.fullName, fullName) || other.fullName == fullName)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&const DeepCollectionEquality().equals(other.apps, apps)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,sid,csrfToken,fullName,createdAt,const DeepCollectionEquality().hash(apps)); + +@override +String toString() { + return 'SessionData(sid: $sid, csrfToken: $csrfToken, fullName: $fullName, createdAt: $createdAt, apps: $apps)'; +} + + +} + +/// @nodoc +abstract mixin class $SessionDataCopyWith<$Res> { + factory $SessionDataCopyWith(SessionData value, $Res Function(SessionData) _then) = _$SessionDataCopyWithImpl; +@useResult +$Res call({ + String sid, String csrfToken, String fullName, DateTime createdAt, List? apps +}); + + + + +} +/// @nodoc +class _$SessionDataCopyWithImpl<$Res> + implements $SessionDataCopyWith<$Res> { + _$SessionDataCopyWithImpl(this._self, this._then); + + final SessionData _self; + final $Res Function(SessionData) _then; + +/// Create a copy of SessionData +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? sid = null,Object? csrfToken = null,Object? fullName = null,Object? createdAt = null,Object? apps = freezed,}) { + return _then(_self.copyWith( +sid: null == sid ? _self.sid : sid // ignore: cast_nullable_to_non_nullable +as String,csrfToken: null == csrfToken ? _self.csrfToken : csrfToken // ignore: cast_nullable_to_non_nullable +as String,fullName: null == fullName ? _self.fullName : fullName // ignore: cast_nullable_to_non_nullable +as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,apps: freezed == apps ? _self.apps : apps // ignore: cast_nullable_to_non_nullable +as List?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SessionData]. +extension SessionDataPatterns on SessionData { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SessionData value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SessionData() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SessionData value) $default,){ +final _that = this; +switch (_that) { +case _SessionData(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SessionData value)? $default,){ +final _that = this; +switch (_that) { +case _SessionData() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String sid, String csrfToken, String fullName, DateTime createdAt, List? apps)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SessionData() when $default != null: +return $default(_that.sid,_that.csrfToken,_that.fullName,_that.createdAt,_that.apps);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String sid, String csrfToken, String fullName, DateTime createdAt, List? apps) $default,) {final _that = this; +switch (_that) { +case _SessionData(): +return $default(_that.sid,_that.csrfToken,_that.fullName,_that.createdAt,_that.apps);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String sid, String csrfToken, String fullName, DateTime createdAt, List? apps)? $default,) {final _that = this; +switch (_that) { +case _SessionData() when $default != null: +return $default(_that.sid,_that.csrfToken,_that.fullName,_that.createdAt,_that.apps);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SessionData implements SessionData { + const _SessionData({required this.sid, required this.csrfToken, required this.fullName, required this.createdAt, final List? apps}): _apps = apps; + factory _SessionData.fromJson(Map json) => _$SessionDataFromJson(json); + +@override final String sid; +@override final String csrfToken; +@override final String fullName; +@override final DateTime createdAt; + final List? _apps; +@override List? get apps { + final value = _apps; + if (value == null) return null; + if (_apps is EqualUnmodifiableListView) return _apps; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + + +/// Create a copy of SessionData +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SessionDataCopyWith<_SessionData> get copyWith => __$SessionDataCopyWithImpl<_SessionData>(this, _$identity); + +@override +Map toJson() { + return _$SessionDataToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SessionData&&(identical(other.sid, sid) || other.sid == sid)&&(identical(other.csrfToken, csrfToken) || other.csrfToken == csrfToken)&&(identical(other.fullName, fullName) || other.fullName == fullName)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&const DeepCollectionEquality().equals(other._apps, _apps)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,sid,csrfToken,fullName,createdAt,const DeepCollectionEquality().hash(_apps)); + +@override +String toString() { + return 'SessionData(sid: $sid, csrfToken: $csrfToken, fullName: $fullName, createdAt: $createdAt, apps: $apps)'; +} + + +} + +/// @nodoc +abstract mixin class _$SessionDataCopyWith<$Res> implements $SessionDataCopyWith<$Res> { + factory _$SessionDataCopyWith(_SessionData value, $Res Function(_SessionData) _then) = __$SessionDataCopyWithImpl; +@override @useResult +$Res call({ + String sid, String csrfToken, String fullName, DateTime createdAt, List? apps +}); + + + + +} +/// @nodoc +class __$SessionDataCopyWithImpl<$Res> + implements _$SessionDataCopyWith<$Res> { + __$SessionDataCopyWithImpl(this._self, this._then); + + final _SessionData _self; + final $Res Function(_SessionData) _then; + +/// Create a copy of SessionData +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? sid = null,Object? csrfToken = null,Object? fullName = null,Object? createdAt = null,Object? apps = freezed,}) { + return _then(_SessionData( +sid: null == sid ? _self.sid : sid // ignore: cast_nullable_to_non_nullable +as String,csrfToken: null == csrfToken ? _self.csrfToken : csrfToken // ignore: cast_nullable_to_non_nullable +as String,fullName: null == fullName ? _self.fullName : fullName // ignore: cast_nullable_to_non_nullable +as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,apps: freezed == apps ? _self._apps : apps // ignore: cast_nullable_to_non_nullable +as List?, + )); +} + + +} + +// dart format on diff --git a/lib/features/auth/data/models/auth_session_model.g.dart b/lib/features/auth/data/models/auth_session_model.g.dart new file mode 100644 index 0000000..f5c4868 --- /dev/null +++ b/lib/features/auth/data/models/auth_session_model.g.dart @@ -0,0 +1,131 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_session_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_AppInfo _$AppInfoFromJson(Map 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 _$AppInfoToJson(_AppInfo instance) => { + 'app_title': instance.appTitle, + 'app_endpoint': instance.appEndpoint, + 'app_logo': instance.appLogo, +}; + +_LoginMessage _$LoginMessageFromJson(Map 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?) + ?.map((e) => AppInfo.fromJson(e as Map)) + .toList() ?? + const [], + ), + ); + return val; + }, fieldKeyMap: const {'csrfToken': 'csrf_token'}); + +Map _$LoginMessageToJson(_LoginMessage instance) => + { + 'success': instance.success, + 'message': instance.message, + 'sid': instance.sid, + 'csrf_token': instance.csrfToken, + 'apps': instance.apps.map((e) => e.toJson()).toList(), + }; + +_AuthSessionResponse _$AuthSessionResponseFromJson(Map 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), + ), + 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 _$AuthSessionResponseToJson( + _AuthSessionResponse instance, +) => { + 'session_expired': instance.sessionExpired, + 'message': instance.message.toJson(), + 'home_page': instance.homePage, + 'full_name': instance.fullName, +}; + +_SessionData _$SessionDataFromJson(Map 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?) + ?.map((e) => AppInfo.fromJson(e as Map)) + .toList(), + ), + ); + return val; + }, + fieldKeyMap: const { + 'csrfToken': 'csrf_token', + 'fullName': 'full_name', + 'createdAt': 'created_at', + }, +); + +Map _$SessionDataToJson(_SessionData instance) => + { + 'sid': instance.sid, + 'csrf_token': instance.csrfToken, + 'full_name': instance.fullName, + 'created_at': instance.createdAt.toIso8601String(), + 'apps': ?instance.apps?.map((e) => e.toJson()).toList(), + }; diff --git a/lib/features/auth/domain/entities/user.dart b/lib/features/auth/domain/entities/user.dart index 2d82549..21a6bd8 100644 --- a/lib/features/auth/domain/entities/user.dart +++ b/lib/features/auth/domain/entities/user.dart @@ -19,7 +19,7 @@ enum UserRole { accountant, /// Designer - designer; + designer, } /// User status enum @@ -34,7 +34,7 @@ enum UserStatus { suspended, /// Rejected account - rejected; + rejected, } /// Loyalty tier enum diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart new file mode 100644 index 0000000..cca6cc2 --- /dev/null +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -0,0 +1,492 @@ +/// 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 createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + // Form key for validation + final _formKey = GlobalKey(); + + // 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 _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 register page + void _navigateToRegister() { + // TODO: Navigate to register page when route is set up + // context.go('/register'); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Chức năng đăng ký đang được phát triển'), + behavior: SnackBarBehavior.floating, + ), + ); + } + + /// Show support dialog + void _showSupport() { + showDialog( + 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 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( + 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, + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/pages/register_page.dart b/lib/features/auth/presentation/pages/register_page.dart new file mode 100644 index 0000000..48b423a --- /dev/null +++ b/lib/features/auth/presentation/pages/register_page.dart @@ -0,0 +1,790 @@ +/// 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/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: Login page +/// - To: OTP verification (broker/other) or pending approval (worker/dealer) +class RegisterPage extends ConsumerStatefulWidget { + const RegisterPage({super.key}); + + @override + ConsumerState createState() => _RegisterPageState(); +} + +class _RegisterPageState extends ConsumerState { + // Form key + final _formKey = GlobalKey(); + + // 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 _pickImage(bool isIdCard) async { + try { + // Show bottom sheet to select source + final source = await showModalBottomSheet( + 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 _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 + // 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( + 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( + 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', + ), + ], + ), + ); + } +} diff --git a/lib/features/auth/presentation/providers/auth_provider.dart b/lib/features/auth/presentation/providers/auth_provider.dart new file mode 100644 index 0000000..7937910 --- /dev/null +++ b/lib/features/auth/presentation/providers/auth_provider.dart @@ -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; + +/// 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 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 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.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 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.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; +} diff --git a/lib/features/auth/presentation/providers/auth_provider.g.dart b/lib/features/auth/presentation/providers/auth_provider.g.dart new file mode 100644 index 0000000..8b0568a --- /dev/null +++ b/lib/features/auth/presentation/providers/auth_provider.g.dart @@ -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 { + /// 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 $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(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 { + /// 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 $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(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 { + /// 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 { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref, User?>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, User?>, + AsyncValue, + 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 + with $Provider { + /// 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 $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(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 + with $Provider { + /// 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 $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(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 + with $Provider { + /// 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 $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(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 + with $Provider { + /// 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 $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(value), + ); + } +} + +String _$userTotalPointsHash() => r'9ccebb48a8641c3c0624b1649303b436e82602bd'; diff --git a/lib/features/auth/presentation/providers/password_visibility_provider.dart b/lib/features/auth/presentation/providers/password_visibility_provider.dart new file mode 100644 index 0000000..d641cdf --- /dev/null +++ b/lib/features/auth/presentation/providers/password_visibility_provider.dart @@ -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; + } +} diff --git a/lib/features/auth/presentation/providers/password_visibility_provider.g.dart b/lib/features/auth/presentation/providers/password_visibility_provider.g.dart new file mode 100644 index 0000000..dad8d09 --- /dev/null +++ b/lib/features/auth/presentation/providers/password_visibility_provider.g.dart @@ -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 { + /// 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(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 build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + 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 { + /// 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(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 build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + bool, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/features/auth/presentation/providers/register_provider.dart b/lib/features/auth/presentation/providers/register_provider.dart new file mode 100644 index 0000000..abcfa91 --- /dev/null +++ b/lib/features/auth/presentation/providers/register_provider.dart @@ -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? 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? 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 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 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.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 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; +} diff --git a/lib/features/auth/presentation/providers/register_provider.g.dart b/lib/features/auth/presentation/providers/register_provider.g.dart new file mode 100644 index 0000000..49e3677 --- /dev/null +++ b/lib/features/auth/presentation/providers/register_provider.g.dart @@ -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 { + /// 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 { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref, User?>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, User?>, + AsyncValue, + 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 + with $Provider { + /// 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 $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(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 + with $Provider { + /// 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 $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(value), + ); + } +} + +String _$registrationSuccessHash() => + r'6435b9ca4bf4c287497a39077a5d4558e0515ddc'; diff --git a/lib/features/auth/presentation/providers/selected_role_provider.dart b/lib/features/auth/presentation/providers/selected_role_provider.dart new file mode 100644 index 0000000..403f852 --- /dev/null +++ b/lib/features/auth/presentation/providers/selected_role_provider.dart @@ -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ế'; + } +} diff --git a/lib/features/auth/presentation/providers/selected_role_provider.g.dart b/lib/features/auth/presentation/providers/selected_role_provider.g.dart new file mode 100644 index 0000000..5e2b9ae --- /dev/null +++ b/lib/features/auth/presentation/providers/selected_role_provider.g.dart @@ -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 { + /// 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(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? build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + 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 + with $Provider { + /// 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 $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(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 + with $Provider { + /// 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 $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(value), + ); + } +} + +String _$roleDisplayNameHash() => r'6cb4bfd9e76fb2f3ed52d4a249e5a2477bc6f39e'; diff --git a/lib/features/auth/presentation/widgets/file_upload_card.dart b/lib/features/auth/presentation/widgets/file_upload_card.dart new file mode 100644 index 0000000..2c2212f --- /dev/null +++ b/lib/features/auth/presentation/widgets/file_upload_card.dart @@ -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( + 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, + ), + ], + ), + ); + } +} diff --git a/lib/features/auth/presentation/widgets/phone_input_field.dart b/lib/features/auth/presentation/widgets/phone_input_field.dart new file mode 100644 index 0000000..583069d --- /dev/null +++ b/lib/features/auth/presentation/widgets/phone_input_field.dart @@ -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, + ); + } +} diff --git a/lib/features/auth/presentation/widgets/role_dropdown.dart b/lib/features/auth/presentation/widgets/role_dropdown.dart new file mode 100644 index 0000000..f7d3c8b --- /dev/null +++ b/lib/features/auth/presentation/widgets/role_dropdown.dart @@ -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( + 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, + ), + ); + } +} diff --git a/lib/features/cart/domain/entities/cart.dart b/lib/features/cart/domain/entities/cart.dart index cc5fbe1..fe436d9 100644 --- a/lib/features/cart/domain/entities/cart.dart +++ b/lib/features/cart/domain/entities/cart.dart @@ -76,12 +76,7 @@ class Cart { @override int get hashCode { - return Object.hash( - cartId, - userId, - totalAmount, - isSynced, - ); + return Object.hash(cartId, userId, totalAmount, isSynced); } @override diff --git a/lib/features/cart/presentation/pages/cart_page.dart b/lib/features/cart/presentation/pages/cart_page.dart index 889f2ad..f7a93ab 100644 --- a/lib/features/cart/presentation/pages/cart_page.dart +++ b/lib/features/cart/presentation/pages/cart_page.dart @@ -48,19 +48,14 @@ class _CartPageState extends ConsumerState { title: const Text('Xóa giỏ hàng'), content: const Text('Bạn có chắc chắn muốn xóa toàn bộ giỏ hàng?'), actions: [ - TextButton( - onPressed: () => context.pop(), - child: const Text('Hủy'), - ), + TextButton(onPressed: () => context.pop(), child: const Text('Hủy')), ElevatedButton( onPressed: () { ref.read(cartProvider.notifier).clearCart(); context.pop(); context.pop(); // Also go back from cart page }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.danger, - ), + style: ElevatedButton.styleFrom(backgroundColor: AppColors.danger), child: const Text('Xóa'), ), ], @@ -86,7 +81,10 @@ class _CartPageState extends ConsumerState { icon: const Icon(Icons.arrow_back, color: Colors.black), onPressed: () => context.pop(), ), - title: Text('Giỏ hàng ($itemCount)', style: const TextStyle(color: Colors.black)), + title: Text( + 'Giỏ hàng ($itemCount)', + style: const TextStyle(color: Colors.black), + ), elevation: AppBarSpecs.elevation, backgroundColor: AppColors.white, foregroundColor: AppColors.grey900, @@ -155,9 +153,7 @@ class _CartPageState extends ConsumerState { const SizedBox(height: 8), Text( 'Hãy thêm sản phẩm vào giỏ hàng', - style: AppTypography.bodyMedium.copyWith( - color: AppColors.grey500, - ), + style: AppTypography.bodyMedium.copyWith(color: AppColors.grey500), ), const SizedBox(height: 24), ElevatedButton.icon( @@ -283,9 +279,9 @@ class _CartPageState extends ConsumerState { ElevatedButton( onPressed: () { if (_discountController.text.isNotEmpty) { - ref.read(cartProvider.notifier).applyDiscountCode( - _discountController.text, - ); + ref + .read(cartProvider.notifier) + .applyDiscountCode(_discountController.text); } }, style: ElevatedButton.styleFrom( @@ -326,7 +322,10 @@ class _CartPageState extends ConsumerState { } /// Build order summary section - Widget _buildOrderSummary(CartState cartState, NumberFormat currencyFormatter) { + Widget _buildOrderSummary( + CartState cartState, + NumberFormat currencyFormatter, + ) { return Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.all(16), @@ -394,10 +393,7 @@ class _CartPageState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - 'Phí vận chuyển', - style: AppTypography.bodyMedium, - ), + Text('Phí vận chuyển', style: AppTypography.bodyMedium), Text( cartState.shippingFee > 0 ? currencyFormatter.format(cartState.shippingFee) @@ -448,10 +444,7 @@ class _CartPageState extends ConsumerState { : null, child: const Text( 'Tiến hành đặt hàng', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), ), ), diff --git a/lib/features/cart/presentation/pages/checkout_page.dart b/lib/features/cart/presentation/pages/checkout_page.dart index d9ffacf..ad3560b 100644 --- a/lib/features/cart/presentation/pages/checkout_page.dart +++ b/lib/features/cart/presentation/pages/checkout_page.dart @@ -142,11 +142,10 @@ class CheckoutPage extends HookConsumerWidget { // Payment Method Section (hidden if negotiation is checked) if (!needsNegotiation.value) - PaymentMethodSection( - paymentMethod: paymentMethod, - ), + PaymentMethodSection(paymentMethod: paymentMethod), - if (!needsNegotiation.value) const SizedBox(height: AppSpacing.md), + if (!needsNegotiation.value) + const SizedBox(height: AppSpacing.md), // Order Summary Section OrderSummarySection( @@ -160,9 +159,7 @@ class CheckoutPage extends HookConsumerWidget { const SizedBox(height: AppSpacing.md), // Price Negotiation Section - PriceNegotiationSection( - needsNegotiation: needsNegotiation, - ), + PriceNegotiationSection(needsNegotiation: needsNegotiation), const SizedBox(height: AppSpacing.md), diff --git a/lib/features/cart/presentation/providers/cart_provider.dart b/lib/features/cart/presentation/providers/cart_provider.dart index 165210b..62d1456 100644 --- a/lib/features/cart/presentation/providers/cart_provider.dart +++ b/lib/features/cart/presentation/providers/cart_provider.dart @@ -44,14 +44,9 @@ class Cart extends _$Cart { ); } else { // Add new item - final newItem = CartItemData( - product: product, - quantity: quantity, - ); + final newItem = CartItemData(product: product, quantity: quantity); - state = state.copyWith( - items: [...state.items, newItem], - ); + state = state.copyWith(items: [...state.items, newItem]); _recalculateTotal(); } } @@ -59,7 +54,9 @@ class Cart extends _$Cart { /// Remove product from cart void removeFromCart(String productId) { state = state.copyWith( - items: state.items.where((item) => item.product.productId != productId).toList(), + items: state.items + .where((item) => item.product.productId != productId) + .toList(), ); _recalculateTotal(); } @@ -113,20 +110,14 @@ class Cart extends _$Cart { // TODO: Validate with backend // For now, simulate discount application if (code.isNotEmpty) { - state = state.copyWith( - discountCode: code, - discountCodeApplied: true, - ); + state = state.copyWith(discountCode: code, discountCodeApplied: true); _recalculateTotal(); } } /// Remove discount code void removeDiscountCode() { - state = state.copyWith( - discountCode: null, - discountCodeApplied: false, - ); + state = state.copyWith(discountCode: null, discountCodeApplied: false); _recalculateTotal(); } @@ -157,10 +148,7 @@ class Cart extends _$Cart { /// Get total quantity of all items double get totalQuantity { - return state.items.fold( - 0.0, - (sum, item) => sum + item.quantity, - ); + return state.items.fold(0.0, (sum, item) => sum + item.quantity); } } diff --git a/lib/features/cart/presentation/providers/cart_state.dart b/lib/features/cart/presentation/providers/cart_state.dart index c9f24b4..49aed82 100644 --- a/lib/features/cart/presentation/providers/cart_state.dart +++ b/lib/features/cart/presentation/providers/cart_state.dart @@ -12,18 +12,12 @@ class CartItemData { final Product product; final double quantity; - const CartItemData({ - required this.product, - required this.quantity, - }); + const CartItemData({required this.product, required this.quantity}); /// Calculate line total double get lineTotal => product.basePrice * quantity; - CartItemData copyWith({ - Product? product, - double? quantity, - }) { + CartItemData copyWith({Product? product, double? quantity}) { return CartItemData( product: product ?? this.product, quantity: quantity ?? this.quantity, @@ -101,7 +95,8 @@ class CartState { discountCode: discountCode ?? this.discountCode, discountCodeApplied: discountCodeApplied ?? this.discountCodeApplied, memberTier: memberTier ?? this.memberTier, - memberDiscountPercent: memberDiscountPercent ?? this.memberDiscountPercent, + memberDiscountPercent: + memberDiscountPercent ?? this.memberDiscountPercent, subtotal: subtotal ?? this.subtotal, memberDiscount: memberDiscount ?? this.memberDiscount, shippingFee: shippingFee ?? this.shippingFee, diff --git a/lib/features/cart/presentation/widgets/cart_item_widget.dart b/lib/features/cart/presentation/widgets/cart_item_widget.dart index 8d30eaf..f6f99f0 100644 --- a/lib/features/cart/presentation/widgets/cart_item_widget.dart +++ b/lib/features/cart/presentation/widgets/cart_item_widget.dart @@ -22,10 +22,7 @@ import 'package:worker/features/cart/presentation/providers/cart_state.dart'; class CartItemWidget extends ConsumerWidget { final CartItemData item; - const CartItemWidget({ - super.key, - required this.item, - }); + const CartItemWidget({super.key, required this.item}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -65,9 +62,7 @@ class CartItemWidget extends ConsumerWidget { height: 80, color: AppColors.grey100, child: const Center( - child: CircularProgressIndicator( - strokeWidth: 2, - ), + child: CircularProgressIndicator(strokeWidth: 2), ), ), errorWidget: (context, url, error) => Container( @@ -129,9 +124,9 @@ class CartItemWidget extends ConsumerWidget { _QuantityButton( icon: Icons.remove, onPressed: () { - ref.read(cartProvider.notifier).decrementQuantity( - item.product.productId, - ); + ref + .read(cartProvider.notifier) + .decrementQuantity(item.product.productId); }, ), @@ -151,9 +146,9 @@ class CartItemWidget extends ConsumerWidget { _QuantityButton( icon: Icons.add, onPressed: () { - ref.read(cartProvider.notifier).incrementQuantity( - item.product.productId, - ); + ref + .read(cartProvider.notifier) + .incrementQuantity(item.product.productId); }, ), @@ -184,10 +179,7 @@ class _QuantityButton extends StatelessWidget { final IconData icon; final VoidCallback onPressed; - const _QuantityButton({ - required this.icon, - required this.onPressed, - }); + const _QuantityButton({required this.icon, required this.onPressed}); @override Widget build(BuildContext context) { @@ -201,11 +193,7 @@ class _QuantityButton extends StatelessWidget { color: AppColors.grey100, borderRadius: BorderRadius.circular(20), ), - child: Icon( - icon, - size: 18, - color: AppColors.grey900, - ), + child: Icon(icon, size: 18, color: AppColors.grey900), ), ); } diff --git a/lib/features/cart/presentation/widgets/checkout_date_picker_field.dart b/lib/features/cart/presentation/widgets/checkout_date_picker_field.dart index 820c354..0f992b3 100644 --- a/lib/features/cart/presentation/widgets/checkout_date_picker_field.dart +++ b/lib/features/cart/presentation/widgets/checkout_date_picker_field.dart @@ -68,8 +68,11 @@ class CheckoutDatePickerField extends HookWidget { : AppColors.grey500.withValues(alpha: 0.6), ), ), - const Icon(Icons.calendar_today, - size: 20, color: AppColors.grey500), + const Icon( + Icons.calendar_today, + size: 20, + color: AppColors.grey500, + ), ], ), ), diff --git a/lib/features/cart/presentation/widgets/checkout_dropdown_field.dart b/lib/features/cart/presentation/widgets/checkout_dropdown_field.dart index 3d06dc8..6a3b932 100644 --- a/lib/features/cart/presentation/widgets/checkout_dropdown_field.dart +++ b/lib/features/cart/presentation/widgets/checkout_dropdown_field.dart @@ -75,10 +75,7 @@ class CheckoutDropdownField extends StatelessWidget { ), ), items: items.map((item) { - return DropdownMenuItem( - value: item, - child: Text(item), - ); + return DropdownMenuItem(value: item, child: Text(item)); }).toList(), onChanged: onChanged, validator: (value) { diff --git a/lib/features/cart/presentation/widgets/checkout_submit_button.dart b/lib/features/cart/presentation/widgets/checkout_submit_button.dart index 120c928..45607f9 100644 --- a/lib/features/cart/presentation/widgets/checkout_submit_button.dart +++ b/lib/features/cart/presentation/widgets/checkout_submit_button.dart @@ -114,7 +114,8 @@ class CheckoutSubmitButton extends StatelessWidget { }); } else { // Generate order ID (mock - replace with actual from backend) - final orderId = 'DH${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}'; + final orderId = + 'DH${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}'; // Show order success message ScaffoldMessenger.of(context).showSnackBar( @@ -130,10 +131,7 @@ class CheckoutSubmitButton extends StatelessWidget { if (context.mounted) { context.pushNamed( RouteNames.paymentQr, - queryParameters: { - 'orderId': orderId, - 'amount': total.toString(), - }, + queryParameters: {'orderId': orderId, 'amount': total.toString()}, ); } }); diff --git a/lib/features/cart/presentation/widgets/delivery_information_section.dart b/lib/features/cart/presentation/widgets/delivery_information_section.dart index 62a15b9..56b2736 100644 --- a/lib/features/cart/presentation/widgets/delivery_information_section.dart +++ b/lib/features/cart/presentation/widgets/delivery_information_section.dart @@ -103,13 +103,7 @@ class DeliveryInformationSection extends HookWidget { label: 'Tỉnh/Thành phố', value: selectedProvince.value, required: true, - items: const [ - 'TP.HCM', - 'Hà Nội', - 'Đà Nẵng', - 'Cần Thơ', - 'Biên Hòa', - ], + items: const ['TP.HCM', 'Hà Nội', 'Đà Nẵng', 'Cần Thơ', 'Biên Hòa'], onChanged: (value) { selectedProvince.value = value; }, diff --git a/lib/features/cart/presentation/widgets/invoice_section.dart b/lib/features/cart/presentation/widgets/invoice_section.dart index ea6a4f2..074315c 100644 --- a/lib/features/cart/presentation/widgets/invoice_section.dart +++ b/lib/features/cart/presentation/widgets/invoice_section.dart @@ -132,8 +132,9 @@ class InvoiceSection extends HookWidget { return 'Vui lòng nhập email'; } if (needsInvoice.value && - !RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$') - .hasMatch(value!)) { + !RegExp( + r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$', + ).hasMatch(value!)) { return 'Email không hợp lệ'; } return null; diff --git a/lib/features/cart/presentation/widgets/order_summary_section.dart b/lib/features/cart/presentation/widgets/order_summary_section.dart index 8449427..4f95de2 100644 --- a/lib/features/cart/presentation/widgets/order_summary_section.dart +++ b/lib/features/cart/presentation/widgets/order_summary_section.dart @@ -148,8 +148,10 @@ class OrderSummarySection extends StatelessWidget { const SizedBox(height: 4), Text( 'Mã: ${item['sku']}', - style: - const TextStyle(fontSize: 12, color: AppColors.grey500), + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), ), ], ), @@ -168,8 +170,9 @@ class OrderSummarySection extends StatelessWidget { const SizedBox(height: 4), Text( _formatCurrency( - ((item['price'] as int) * (item['quantity'] as int)) - .toDouble()), + ((item['price'] as int) * (item['quantity'] as int)) + .toDouble(), + ), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, @@ -184,8 +187,11 @@ class OrderSummarySection extends StatelessWidget { } /// Build summary row - Widget _buildSummaryRow(String label, double amount, - {bool isDiscount = false}) { + Widget _buildSummaryRow( + String label, + double amount, { + bool isDiscount = false, + }) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -207,9 +213,6 @@ class OrderSummarySection extends StatelessWidget { /// Format currency String _formatCurrency(double amount) { - return '${amount.toStringAsFixed(0).replaceAllMapped( - RegExp(r'(\d)(?=(\d{3})+(?!\d))'), - (Match m) => '${m[1]}.', - )}₫'; + return '${amount.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}₫'; } } diff --git a/lib/features/cart/presentation/widgets/payment_method_section.dart b/lib/features/cart/presentation/widgets/payment_method_section.dart index cfaa012..73e6854 100644 --- a/lib/features/cart/presentation/widgets/payment_method_section.dart +++ b/lib/features/cart/presentation/widgets/payment_method_section.dart @@ -14,10 +14,7 @@ import 'package:worker/core/theme/colors.dart'; class PaymentMethodSection extends HookWidget { final ValueNotifier paymentMethod; - const PaymentMethodSection({ - super.key, - required this.paymentMethod, - }); + const PaymentMethodSection({super.key, required this.paymentMethod}); @override Widget build(BuildContext context) { @@ -72,13 +69,17 @@ class PaymentMethodSection extends HookWidget { Text( 'Chuyển khoản ngân hàng', style: TextStyle( - fontSize: 15, fontWeight: FontWeight.w500), + fontSize: 15, + fontWeight: FontWeight.w500, + ), ), SizedBox(height: 4), Text( 'Thanh toán qua chuyển khoản', - style: - TextStyle(fontSize: 13, color: AppColors.grey500), + style: TextStyle( + fontSize: 13, + color: AppColors.grey500, + ), ), ], ), @@ -112,13 +113,17 @@ class PaymentMethodSection extends HookWidget { Text( 'Thanh toán khi nhận hàng (COD)', style: TextStyle( - fontSize: 15, fontWeight: FontWeight.w500), + fontSize: 15, + fontWeight: FontWeight.w500, + ), ), SizedBox(height: 4), Text( 'Thanh toán bằng tiền mặt khi nhận hàng', - style: - TextStyle(fontSize: 13, color: AppColors.grey500), + style: TextStyle( + fontSize: 13, + color: AppColors.grey500, + ), ), ], ), diff --git a/lib/features/cart/presentation/widgets/price_negotiation_section.dart b/lib/features/cart/presentation/widgets/price_negotiation_section.dart index 6895644..c097083 100644 --- a/lib/features/cart/presentation/widgets/price_negotiation_section.dart +++ b/lib/features/cart/presentation/widgets/price_negotiation_section.dart @@ -14,10 +14,7 @@ import 'package:worker/core/theme/colors.dart'; class PriceNegotiationSection extends HookWidget { final ValueNotifier needsNegotiation; - const PriceNegotiationSection({ - super.key, - required this.needsNegotiation, - }); + const PriceNegotiationSection({super.key, required this.needsNegotiation}); @override Widget build(BuildContext context) { diff --git a/lib/features/chat/data/models/chat_room_model.dart b/lib/features/chat/data/models/chat_room_model.dart index 0e62e52..492e48a 100644 --- a/lib/features/chat/data/models/chat_room_model.dart +++ b/lib/features/chat/data/models/chat_room_model.dart @@ -7,18 +7,39 @@ part 'chat_room_model.g.dart'; @HiveType(typeId: HiveTypeIds.chatRoomModel) class ChatRoomModel extends HiveObject { - ChatRoomModel({required this.chatRoomId, required this.roomType, this.relatedQuoteId, this.relatedOrderId, required this.participants, this.roomName, required this.isActive, this.lastActivity, required this.createdAt, this.createdBy}); - - @HiveField(0) final String chatRoomId; - @HiveField(1) final RoomType roomType; - @HiveField(2) final String? relatedQuoteId; - @HiveField(3) final String? relatedOrderId; - @HiveField(4) final String participants; - @HiveField(5) final String? roomName; - @HiveField(6) final bool isActive; - @HiveField(7) final DateTime? lastActivity; - @HiveField(8) final DateTime createdAt; - @HiveField(9) final String? createdBy; + ChatRoomModel({ + required this.chatRoomId, + required this.roomType, + this.relatedQuoteId, + this.relatedOrderId, + required this.participants, + this.roomName, + required this.isActive, + this.lastActivity, + required this.createdAt, + this.createdBy, + }); + + @HiveField(0) + final String chatRoomId; + @HiveField(1) + final RoomType roomType; + @HiveField(2) + final String? relatedQuoteId; + @HiveField(3) + final String? relatedOrderId; + @HiveField(4) + final String participants; + @HiveField(5) + final String? roomName; + @HiveField(6) + final bool isActive; + @HiveField(7) + final DateTime? lastActivity; + @HiveField(8) + final DateTime createdAt; + @HiveField(9) + final String? createdBy; factory ChatRoomModel.fromJson(Map json) => ChatRoomModel( chatRoomId: json['chat_room_id'] as String, @@ -28,7 +49,9 @@ class ChatRoomModel extends HiveObject { participants: jsonEncode(json['participants']), roomName: json['room_name'] as String?, isActive: json['is_active'] as bool? ?? true, - lastActivity: json['last_activity'] != null ? DateTime.parse(json['last_activity']?.toString() ?? '') : null, + lastActivity: json['last_activity'] != null + ? DateTime.parse(json['last_activity']?.toString() ?? '') + : null, createdAt: DateTime.parse(json['created_at']?.toString() ?? ''), createdBy: json['created_by'] as String?, ); diff --git a/lib/features/chat/data/models/message_model.dart b/lib/features/chat/data/models/message_model.dart index 6a50dda..074ecde 100644 --- a/lib/features/chat/data/models/message_model.dart +++ b/lib/features/chat/data/models/message_model.dart @@ -7,27 +7,56 @@ part 'message_model.g.dart'; @HiveType(typeId: HiveTypeIds.messageModel) class MessageModel extends HiveObject { - MessageModel({required this.messageId, required this.chatRoomId, required this.senderId, required this.contentType, required this.content, this.attachmentUrl, this.productReference, required this.isRead, required this.isEdited, required this.isDeleted, this.readBy, required this.timestamp, this.editedAt}); - - @HiveField(0) final String messageId; - @HiveField(1) final String chatRoomId; - @HiveField(2) final String senderId; - @HiveField(3) final ContentType contentType; - @HiveField(4) final String content; - @HiveField(5) final String? attachmentUrl; - @HiveField(6) final String? productReference; - @HiveField(7) final bool isRead; - @HiveField(8) final bool isEdited; - @HiveField(9) final bool isDeleted; - @HiveField(10) final String? readBy; - @HiveField(11) final DateTime timestamp; - @HiveField(12) final DateTime? editedAt; + MessageModel({ + required this.messageId, + required this.chatRoomId, + required this.senderId, + required this.contentType, + required this.content, + this.attachmentUrl, + this.productReference, + required this.isRead, + required this.isEdited, + required this.isDeleted, + this.readBy, + required this.timestamp, + this.editedAt, + }); + + @HiveField(0) + final String messageId; + @HiveField(1) + final String chatRoomId; + @HiveField(2) + final String senderId; + @HiveField(3) + final ContentType contentType; + @HiveField(4) + final String content; + @HiveField(5) + final String? attachmentUrl; + @HiveField(6) + final String? productReference; + @HiveField(7) + final bool isRead; + @HiveField(8) + final bool isEdited; + @HiveField(9) + final bool isDeleted; + @HiveField(10) + final String? readBy; + @HiveField(11) + final DateTime timestamp; + @HiveField(12) + final DateTime? editedAt; factory MessageModel.fromJson(Map json) => MessageModel( messageId: json['message_id'] as String, chatRoomId: json['chat_room_id'] as String, senderId: json['sender_id'] as String, - contentType: ContentType.values.firstWhere((e) => e.name == json['content_type']), + contentType: ContentType.values.firstWhere( + (e) => e.name == json['content_type'], + ), content: json['content'] as String, attachmentUrl: json['attachment_url'] as String?, productReference: json['product_reference'] as String?, @@ -36,7 +65,9 @@ class MessageModel extends HiveObject { isDeleted: json['is_deleted'] as bool? ?? false, readBy: json['read_by'] != null ? jsonEncode(json['read_by']) : null, timestamp: DateTime.parse(json['timestamp']?.toString() ?? ''), - editedAt: json['edited_at'] != null ? DateTime.parse(json['edited_at']?.toString() ?? '') : null, + editedAt: json['edited_at'] != null + ? DateTime.parse(json['edited_at']?.toString() ?? '') + : null, ); Map toJson() => { diff --git a/lib/features/chat/domain/entities/message.dart b/lib/features/chat/domain/entities/message.dart index 6e8d972..411ba4d 100644 --- a/lib/features/chat/domain/entities/message.dart +++ b/lib/features/chat/domain/entities/message.dart @@ -117,8 +117,7 @@ class Message { bool get isSystemMessage => contentType == ContentType.system; /// Check if message has attachment - bool get hasAttachment => - attachmentUrl != null && attachmentUrl!.isNotEmpty; + bool get hasAttachment => attachmentUrl != null && attachmentUrl!.isNotEmpty; /// Check if message references a product bool get hasProductReference => diff --git a/lib/features/chat/presentation/pages/chat_list_page.dart b/lib/features/chat/presentation/pages/chat_list_page.dart index 9c863a5..319fbc3 100644 --- a/lib/features/chat/presentation/pages/chat_list_page.dart +++ b/lib/features/chat/presentation/pages/chat_list_page.dart @@ -112,10 +112,16 @@ class _ChatListPageState extends ConsumerState { autofocus: true, decoration: InputDecoration( hintText: 'Tìm kiếm cuộc trò chuyện...', - prefixIcon: const Icon(Icons.search, color: AppColors.grey500), + prefixIcon: const Icon( + Icons.search, + color: AppColors.grey500, + ), suffixIcon: _searchController.text.isNotEmpty ? IconButton( - icon: const Icon(Icons.clear, color: AppColors.grey500), + icon: const Icon( + Icons.clear, + color: AppColors.grey500, + ), onPressed: () { setState(() { _searchController.clear(); @@ -148,7 +154,10 @@ class _ChatListPageState extends ConsumerState { // Conversation 1 - Order Reference _ConversationItem( avatarIcon: Icons.inventory_2, - avatarGradient: const [AppColors.primaryBlue, AppColors.lightBlue], + avatarGradient: const [ + AppColors.primaryBlue, + AppColors.lightBlue, + ], contactName: 'Đơn hàng #SO001234', messageTime: '14:30', lastMessage: 'Đơn hàng đang được giao - Dự kiến đến 16:00', @@ -183,7 +192,10 @@ class _ChatListPageState extends ConsumerState { // Conversation 3 - Support Team _ConversationItem( avatarIcon: Icons.headset_mic, - avatarGradient: const [AppColors.primaryBlue, AppColors.lightBlue], + avatarGradient: const [ + AppColors.primaryBlue, + AppColors.lightBlue, + ], contactName: 'Tổng đài hỗ trợ', messageTime: '13:45', lastMessage: 'Thông tin về quy trình đổi trả sản phẩm', @@ -319,8 +331,8 @@ class _ConversationItem extends StatelessWidget { color: isOnline ? AppColors.success : isAway - ? AppColors.warning - : AppColors.grey500, + ? AppColors.warning + : AppColors.grey500, shape: BoxShape.circle, border: Border.all(color: AppColors.white, width: 2), ), diff --git a/lib/features/favorites/data/datasources/favorites_local_datasource.dart b/lib/features/favorites/data/datasources/favorites_local_datasource.dart index f92ec90..83c5a3a 100644 --- a/lib/features/favorites/data/datasources/favorites_local_datasource.dart +++ b/lib/features/favorites/data/datasources/favorites_local_datasource.dart @@ -40,7 +40,9 @@ class FavoritesLocalDataSource { Future addFavorite(FavoriteModel favorite) async { try { await _box.put(favorite.favoriteId, favorite); - debugPrint('[FavoritesLocalDataSource] Added favorite: ${favorite.favoriteId} for user: ${favorite.userId}'); + debugPrint( + '[FavoritesLocalDataSource] Added favorite: ${favorite.favoriteId} for user: ${favorite.userId}', + ); } catch (e) { debugPrint('[FavoritesLocalDataSource] Error adding favorite: $e'); rethrow; @@ -60,13 +62,17 @@ class FavoritesLocalDataSource { .toList(); if (favorites.isEmpty) { - debugPrint('[FavoritesLocalDataSource] Favorite not found: productId=$productId, userId=$userId'); + debugPrint( + '[FavoritesLocalDataSource] Favorite not found: productId=$productId, userId=$userId', + ); return false; } final favorite = favorites.first; await _box.delete(favorite.favoriteId); - debugPrint('[FavoritesLocalDataSource] Removed favorite: ${favorite.favoriteId} for user: $userId'); + debugPrint( + '[FavoritesLocalDataSource] Removed favorite: ${favorite.favoriteId} for user: $userId', + ); return true; } catch (e) { debugPrint('[FavoritesLocalDataSource] Error removing favorite: $e'); @@ -79,9 +85,9 @@ class FavoritesLocalDataSource { /// Returns true if the product is in the user's favorites, false otherwise. bool isFavorite(String productId, String userId) { try { - return _box.values - .whereType() - .any((fav) => fav.productId == productId && fav.userId == userId); + return _box.values.whereType().any( + (fav) => fav.productId == productId && fav.userId == userId, + ); } catch (e) { debugPrint('[FavoritesLocalDataSource] Error checking favorite: $e'); return false; @@ -101,7 +107,9 @@ class FavoritesLocalDataSource { .toList(); await _box.deleteAll(favoriteIds); - debugPrint('[FavoritesLocalDataSource] Cleared ${favoriteIds.length} favorites for user: $userId'); + debugPrint( + '[FavoritesLocalDataSource] Cleared ${favoriteIds.length} favorites for user: $userId', + ); } catch (e) { debugPrint('[FavoritesLocalDataSource] Error clearing favorites: $e'); rethrow; @@ -140,7 +148,9 @@ class FavoritesLocalDataSource { debugPrint('[FavoritesLocalDataSource] Favorites box compacted'); } } catch (e) { - debugPrint('[FavoritesLocalDataSource] Error compacting favorites box: $e'); + debugPrint( + '[FavoritesLocalDataSource] Error compacting favorites box: $e', + ); } } } diff --git a/lib/features/favorites/domain/entities/favorite.dart b/lib/features/favorites/domain/entities/favorite.dart index 21a0afe..8e6d61e 100644 --- a/lib/features/favorites/domain/entities/favorite.dart +++ b/lib/features/favorites/domain/entities/favorite.dart @@ -62,11 +62,6 @@ class Favorite { @override int get hashCode { - return Object.hash( - favoriteId, - productId, - userId, - createdAt, - ); + return Object.hash(favoriteId, productId, userId, createdAt); } } diff --git a/lib/features/favorites/presentation/pages/favorites_page.dart b/lib/features/favorites/presentation/pages/favorites_page.dart index 5a2802a..aa81371 100644 --- a/lib/features/favorites/presentation/pages/favorites_page.dart +++ b/lib/features/favorites/presentation/pages/favorites_page.dart @@ -27,7 +27,11 @@ class FavoritesPage extends ConsumerWidget { const FavoritesPage({super.key}); /// Show confirmation dialog before clearing all favorites - Future _showClearAllDialog(BuildContext context, WidgetRef ref, int count) async { + Future _showClearAllDialog( + BuildContext context, + WidgetRef ref, + int count, + ) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( @@ -185,10 +189,7 @@ class _EmptyState extends StatelessWidget { // Subtext Text( 'Thêm sản phẩm vào danh sách yêu thích để xem lại sau', - style: TextStyle( - fontSize: 14.0, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 14.0, color: AppColors.grey500), textAlign: TextAlign.center, ), @@ -213,10 +214,7 @@ class _EmptyState extends StatelessWidget { ), child: const Text( 'Khám phá sản phẩm', - style: TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.w600, - ), + style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w600), ), ), ], @@ -351,10 +349,7 @@ class _ErrorState extends StatelessWidget { final Object error; final VoidCallback onRetry; - const _ErrorState({ - required this.error, - required this.onRetry, - }); + const _ErrorState({required this.error, required this.onRetry}); @override Widget build(BuildContext context) { @@ -389,10 +384,7 @@ class _ErrorState extends StatelessWidget { // Error message Text( error.toString(), - style: const TextStyle( - fontSize: 14.0, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14.0, color: AppColors.grey500), textAlign: TextAlign.center, maxLines: 3, overflow: TextOverflow.ellipsis, @@ -417,10 +409,7 @@ class _ErrorState extends StatelessWidget { icon: const Icon(Icons.refresh), label: const Text( 'Thử lại', - style: TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.w600, - ), + style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w600), ), ), ], @@ -440,9 +429,7 @@ class _ErrorState extends StatelessWidget { class _FavoritesGrid extends StatelessWidget { final List products; - const _FavoritesGrid({ - required this.products, - }); + const _FavoritesGrid({required this.products}); @override Widget build(BuildContext context) { @@ -457,9 +444,7 @@ class _FavoritesGrid extends StatelessWidget { itemCount: products.length, itemBuilder: (context, index) { final product = products[index]; - return RepaintBoundary( - child: FavoriteProductCard(product: product), - ); + return RepaintBoundary(child: FavoriteProductCard(product: product)); }, ); } diff --git a/lib/features/favorites/presentation/providers/favorites_provider.dart b/lib/features/favorites/presentation/providers/favorites_provider.dart index 7bcabe8..bc345a5 100644 --- a/lib/features/favorites/presentation/providers/favorites_provider.dart +++ b/lib/features/favorites/presentation/providers/favorites_provider.dart @@ -260,7 +260,9 @@ Future> favoriteProducts(Ref ref) async { final allProducts = await getProductsUseCase(); // Filter to only include favorited products - return allProducts.where((product) => favoriteIds.contains(product.productId)).toList(); + return allProducts + .where((product) => favoriteIds.contains(product.productId)) + .toList(); } // ============================================================================ diff --git a/lib/features/favorites/presentation/widgets/favorite_product_card.dart b/lib/features/favorites/presentation/widgets/favorite_product_card.dart index 1ddef89..bc350a4 100644 --- a/lib/features/favorites/presentation/widgets/favorite_product_card.dart +++ b/lib/features/favorites/presentation/widgets/favorite_product_card.dart @@ -22,10 +22,7 @@ import 'package:worker/features/products/domain/entities/product.dart'; class FavoriteProductCard extends ConsumerWidget { final Product product; - const FavoriteProductCard({ - super.key, - required this.product, - }); + const FavoriteProductCard({super.key, required this.product}); String _formatPrice(double price) { final formatter = NumberFormat('#,###', 'vi_VN'); @@ -60,7 +57,9 @@ class FavoriteProductCard extends ConsumerWidget { if (confirmed == true && context.mounted) { // Remove from favorites - await ref.read(favoritesProvider.notifier).removeFavorite(product.productId); + await ref + .read(favoritesProvider.notifier) + .removeFavorite(product.productId); // Show snackbar if (context.mounted) { @@ -103,9 +102,7 @@ class FavoriteProductCard extends ConsumerWidget { placeholder: (context, url) => Shimmer.fromColors( baseColor: AppColors.grey100, highlightColor: AppColors.grey50, - child: Container( - color: AppColors.grey100, - ), + child: Container(color: AppColors.grey100), ), errorWidget: (context, url, error) => Container( color: AppColors.grey100, diff --git a/lib/features/home/data/datasources/home_local_datasource.dart b/lib/features/home/data/datasources/home_local_datasource.dart index 1ea3c12..a2c0a4a 100644 --- a/lib/features/home/data/datasources/home_local_datasource.dart +++ b/lib/features/home/data/datasources/home_local_datasource.dart @@ -82,7 +82,7 @@ class HomeLocalDataSourceImpl implements HomeLocalDataSource { 'tier': 'diamond', 'points': 9750, 'validUntil': '2025-12-31T23:59:59.000Z', - 'qrData': '0983441099' + 'qrData': '0983441099', }; /// Mock JSON data for promotions @@ -115,7 +115,7 @@ class HomeLocalDataSourceImpl implements HomeLocalDataSource { 'startDate': '2025-01-01T00:00:00.000Z', 'endDate': '2025-12-31T23:59:59.000Z', 'discountPercentage': 5, - } + }, ]; /// Constructor diff --git a/lib/features/home/domain/entities/promotion.dart b/lib/features/home/domain/entities/promotion.dart index 615162a..61c228f 100644 --- a/lib/features/home/domain/entities/promotion.dart +++ b/lib/features/home/domain/entities/promotion.dart @@ -15,7 +15,7 @@ enum PromotionStatus { upcoming, /// Expired promotion - expired; + expired, } /// Promotion Entity diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index d4eb10a..f30aee5 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -222,7 +222,6 @@ class HomePage extends ConsumerWidget { ), ], ), - ); } diff --git a/lib/features/home/presentation/widgets/member_card_widget.dart b/lib/features/home/presentation/widgets/member_card_widget.dart index 32e737b..5e0a940 100644 --- a/lib/features/home/presentation/widgets/member_card_widget.dart +++ b/lib/features/home/presentation/widgets/member_card_widget.dart @@ -19,10 +19,7 @@ class MemberCardWidget extends StatelessWidget { /// Member card data final MemberCard memberCard; - const MemberCardWidget({ - super.key, - required this.memberCard, - }); + const MemberCardWidget({super.key, required this.memberCard}); @override Widget build(BuildContext context) { @@ -185,8 +182,8 @@ class MemberCardWidget extends StatelessWidget { /// Format points with thousands separator String _formatPoints(int points) { return points.toString().replaceAllMapped( - RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), - (Match m) => '${m[1]},', - ); + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]},', + ); } } diff --git a/lib/features/home/presentation/widgets/promotion_slider.dart b/lib/features/home/presentation/widgets/promotion_slider.dart index d61cc9b..511833e 100644 --- a/lib/features/home/presentation/widgets/promotion_slider.dart +++ b/lib/features/home/presentation/widgets/promotion_slider.dart @@ -16,12 +16,12 @@ import 'package:worker/features/home/domain/entities/promotion.dart'; /// Displays a horizontal scrollable list of promotion cards. /// Each card shows an image, title, and brief description. class PromotionSlider extends StatelessWidget { - const PromotionSlider({ super.key, required this.promotions, this.onPromotionTap, }); + /// List of promotions to display final List promotions; @@ -83,11 +83,7 @@ class PromotionSlider extends StatelessWidget { /// Individual Promotion Card class _PromotionCard extends StatelessWidget { - - const _PromotionCard({ - required this.promotion, - this.onTap, - }); + const _PromotionCard({required this.promotion, this.onTap}); final Promotion promotion; final VoidCallback? onTap; @@ -115,8 +111,9 @@ class _PromotionCard extends StatelessWidget { children: [ // Promotion Image ClipRRect( - borderRadius: - const BorderRadius.vertical(top: Radius.circular(12)), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(12), + ), child: CachedNetworkImage( imageUrl: promotion.imageUrl, height: 140, @@ -125,9 +122,7 @@ class _PromotionCard extends StatelessWidget { placeholder: (context, url) => Container( height: 140, color: AppColors.grey100, - child: const Center( - child: CircularProgressIndicator(), - ), + child: const Center(child: CircularProgressIndicator()), ), errorWidget: (context, url, error) => Container( height: 140, diff --git a/lib/features/home/presentation/widgets/quick_action_item.dart b/lib/features/home/presentation/widgets/quick_action_item.dart index 2c40579..226526e 100644 --- a/lib/features/home/presentation/widgets/quick_action_item.dart +++ b/lib/features/home/presentation/widgets/quick_action_item.dart @@ -63,11 +63,7 @@ class QuickActionItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ // Icon - Icon( - icon, - size: 32, - color: AppColors.primaryBlue, - ), + Icon(icon, size: 32, color: AppColors.primaryBlue), const SizedBox(height: 8), // Label Text( @@ -97,9 +93,7 @@ class QuickActionItem extends StatelessWidget { color: AppColors.danger, borderRadius: BorderRadius.circular(12), ), - constraints: const BoxConstraints( - minWidth: 20, - ), + constraints: const BoxConstraints(minWidth: 20), child: Text( badge!, style: const TextStyle( diff --git a/lib/features/loyalty/data/datasources/points_history_local_datasource.dart b/lib/features/loyalty/data/datasources/points_history_local_datasource.dart index 8611e2e..d7a4afa 100644 --- a/lib/features/loyalty/data/datasources/points_history_local_datasource.dart +++ b/lib/features/loyalty/data/datasources/points_history_local_datasource.dart @@ -31,9 +31,7 @@ class PointsHistoryLocalDataSource { /// Get all points entries Future> getAllEntries() async { final box = await entriesBox; - final entries = box.values - .whereType() - .toList(); + final entries = box.values.whereType().toList(); entries.sort((a, b) => b.timestamp.compareTo(a.timestamp)); // Newest first return entries; } @@ -42,9 +40,9 @@ class PointsHistoryLocalDataSource { Future getEntryById(String entryId) async { final box = await entriesBox; try { - return box.values - .whereType() - .firstWhere((entry) => entry.entryId == entryId); + return box.values.whereType().firstWhere( + (entry) => entry.entryId == entryId, + ); } catch (e) { throw Exception('Entry not found'); } diff --git a/lib/features/loyalty/data/models/gift_catalog_model.dart b/lib/features/loyalty/data/models/gift_catalog_model.dart index 2f096dd..c09d4e5 100644 --- a/lib/features/loyalty/data/models/gift_catalog_model.dart +++ b/lib/features/loyalty/data/models/gift_catalog_model.dart @@ -6,41 +6,81 @@ part 'gift_catalog_model.g.dart'; @HiveType(typeId: HiveTypeIds.giftCatalogModel) 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}); - - @HiveField(0) final String catalogId; - @HiveField(1) final String name; - @HiveField(2) final String description; - @HiveField(3) final String? imageUrl; - @HiveField(4) final GiftCategory category; - @HiveField(5) final int pointsCost; - @HiveField(6) final double cashValue; - @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; + 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, + }); - factory GiftCatalogModel.fromJson(Map json) => GiftCatalogModel( - catalogId: json['catalog_id'] as String, - name: json['name'] as String, - description: json['description'] as String, - imageUrl: json['image_url'] as String?, - category: GiftCategory.values.firstWhere((e) => e.name == json['category']), - pointsCost: json['points_cost'] as int, - cashValue: (json['cash_value'] as num).toDouble(), - quantityAvailable: json['quantity_available'] as int, - quantityRedeemed: json['quantity_redeemed'] as int? ?? 0, - termsConditions: json['terms_conditions'] as String?, - isActive: json['is_active'] as bool? ?? true, - validFrom: json['valid_from'] != 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() ?? ''), - updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']?.toString() ?? '') : null, - ); + @HiveField(0) + final String catalogId; + @HiveField(1) + final String name; + @HiveField(2) + final String description; + @HiveField(3) + final String? imageUrl; + @HiveField(4) + final GiftCategory category; + @HiveField(5) + final int pointsCost; + @HiveField(6) + final double cashValue; + @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 json) => + GiftCatalogModel( + catalogId: json['catalog_id'] as String, + name: json['name'] as String, + description: json['description'] as String, + imageUrl: json['image_url'] as String?, + category: GiftCategory.values.firstWhere( + (e) => e.name == json['category'], + ), + pointsCost: json['points_cost'] as int, + cashValue: (json['cash_value'] as num).toDouble(), + quantityAvailable: json['quantity_available'] as int, + quantityRedeemed: json['quantity_redeemed'] as int? ?? 0, + termsConditions: json['terms_conditions'] as String?, + isActive: json['is_active'] as bool? ?? true, + validFrom: json['valid_from'] != 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() ?? ''), + updatedAt: json['updated_at'] != null + ? DateTime.parse(json['updated_at']?.toString() ?? '') + : null, + ); Map toJson() => { 'catalog_id': catalogId, diff --git a/lib/features/loyalty/data/models/loyalty_point_entry_model.dart b/lib/features/loyalty/data/models/loyalty_point_entry_model.dart index 946901b..8b692a4 100644 --- a/lib/features/loyalty/data/models/loyalty_point_entry_model.dart +++ b/lib/features/loyalty/data/models/loyalty_point_entry_model.dart @@ -7,24 +7,55 @@ part 'loyalty_point_entry_model.g.dart'; @HiveType(typeId: HiveTypeIds.loyaltyPointEntryModel) class LoyaltyPointEntryModel extends HiveObject { - LoyaltyPointEntryModel({required this.entryId, required this.userId, required this.points, required this.entryType, required this.source, required this.description, this.referenceId, this.referenceType, this.complaint, required this.complaintStatus, required this.balanceAfter, this.expiryDate, required this.timestamp, this.erpnextEntryId}); - - @HiveField(0) final String entryId; - @HiveField(1) final String userId; - @HiveField(2) final int points; - @HiveField(3) final EntryType entryType; - @HiveField(4) final EntrySource source; - @HiveField(5) final String description; - @HiveField(6) final String? referenceId; - @HiveField(7) final String? referenceType; - @HiveField(8) final String? complaint; - @HiveField(9) final ComplaintStatus complaintStatus; - @HiveField(10) final int balanceAfter; - @HiveField(11) final DateTime? expiryDate; - @HiveField(12) final DateTime timestamp; - @HiveField(13) final String? erpnextEntryId; + LoyaltyPointEntryModel({ + required this.entryId, + required this.userId, + required this.points, + required this.entryType, + required this.source, + required this.description, + this.referenceId, + this.referenceType, + this.complaint, + required this.complaintStatus, + required this.balanceAfter, + this.expiryDate, + required this.timestamp, + this.erpnextEntryId, + }); - factory LoyaltyPointEntryModel.fromJson(Map json) => LoyaltyPointEntryModel( + @HiveField(0) + final String entryId; + @HiveField(1) + final String userId; + @HiveField(2) + final int points; + @HiveField(3) + final EntryType entryType; + @HiveField(4) + final EntrySource source; + @HiveField(5) + final String description; + @HiveField(6) + final String? referenceId; + @HiveField(7) + final String? referenceType; + @HiveField(8) + final String? complaint; + @HiveField(9) + final ComplaintStatus complaintStatus; + @HiveField(10) + final int balanceAfter; + @HiveField(11) + final DateTime? expiryDate; + @HiveField(12) + final DateTime timestamp; + @HiveField(13) + final String? erpnextEntryId; + + factory LoyaltyPointEntryModel.fromJson( + Map json, + ) => LoyaltyPointEntryModel( entryId: json['entry_id'] as String, userId: json['user_id'] as String, points: json['points'] as int, @@ -34,9 +65,13 @@ class LoyaltyPointEntryModel extends HiveObject { referenceId: json['reference_id'] as String?, referenceType: json['reference_type'] as String?, complaint: json['complaint'] != null ? jsonEncode(json['complaint']) : null, - complaintStatus: ComplaintStatus.values.firstWhere((e) => e.name == (json['complaint_status'] ?? 'none')), + complaintStatus: ComplaintStatus.values.firstWhere( + (e) => e.name == (json['complaint_status'] ?? 'none'), + ), balanceAfter: json['balance_after'] as int, - expiryDate: json['expiry_date'] != null ? DateTime.parse(json['expiry_date']?.toString() ?? '') : null, + expiryDate: json['expiry_date'] != null + ? DateTime.parse(json['expiry_date']?.toString() ?? '') + : null, timestamp: DateTime.parse(json['timestamp']?.toString() ?? ''), erpnextEntryId: json['erpnext_entry_id'] as String?, ); @@ -67,6 +102,7 @@ class LoyaltyPointEntryModel extends HiveObject { } } - bool get isExpired => expiryDate != null && DateTime.now().isAfter(expiryDate!); + bool get isExpired => + expiryDate != null && DateTime.now().isAfter(expiryDate!); bool get hasComplaint => complaintStatus != ComplaintStatus.none; } diff --git a/lib/features/loyalty/data/models/points_record_model.dart b/lib/features/loyalty/data/models/points_record_model.dart index 99053e8..728aaac 100644 --- a/lib/features/loyalty/data/models/points_record_model.dart +++ b/lib/features/loyalty/data/models/points_record_model.dart @@ -7,39 +7,75 @@ part 'points_record_model.g.dart'; @HiveType(typeId: HiveTypeIds.pointsRecordModel) class PointsRecordModel extends HiveObject { - PointsRecordModel({required this.recordId, required this.userId, required this.invoiceNumber, required this.storeName, required this.transactionDate, required this.invoiceAmount, this.notes, this.attachments, required this.status, this.rejectReason, this.pointsEarned, required this.submittedAt, this.processedAt, this.processedBy}); - - @HiveField(0) final String recordId; - @HiveField(1) final String userId; - @HiveField(2) final String invoiceNumber; - @HiveField(3) final String storeName; - @HiveField(4) final DateTime transactionDate; - @HiveField(5) final double invoiceAmount; - @HiveField(6) final String? notes; - @HiveField(7) final String? attachments; - @HiveField(8) final PointsStatus status; - @HiveField(9) final String? rejectReason; - @HiveField(10) final int? pointsEarned; - @HiveField(11) final DateTime submittedAt; - @HiveField(12) final DateTime? processedAt; - @HiveField(13) final String? processedBy; + PointsRecordModel({ + required this.recordId, + required this.userId, + required this.invoiceNumber, + required this.storeName, + required this.transactionDate, + required this.invoiceAmount, + this.notes, + this.attachments, + required this.status, + this.rejectReason, + this.pointsEarned, + required this.submittedAt, + this.processedAt, + this.processedBy, + }); - factory PointsRecordModel.fromJson(Map json) => PointsRecordModel( - recordId: json['record_id'] as String, - userId: json['user_id'] as String, - invoiceNumber: json['invoice_number'] as String, - storeName: json['store_name'] as String, - transactionDate: DateTime.parse(json['transaction_date']?.toString() ?? ''), - invoiceAmount: (json['invoice_amount'] as num).toDouble(), - notes: json['notes'] as String?, - attachments: json['attachments'] != null ? jsonEncode(json['attachments']) : null, - status: PointsStatus.values.firstWhere((e) => e.name == json['status']), - rejectReason: json['reject_reason'] as String?, - pointsEarned: json['points_earned'] as int?, - submittedAt: DateTime.parse(json['submitted_at']?.toString() ?? ''), - processedAt: json['processed_at'] != null ? DateTime.parse(json['processed_at']?.toString() ?? '') : null, - processedBy: json['processed_by'] as String?, - ); + @HiveField(0) + final String recordId; + @HiveField(1) + final String userId; + @HiveField(2) + final String invoiceNumber; + @HiveField(3) + final String storeName; + @HiveField(4) + final DateTime transactionDate; + @HiveField(5) + final double invoiceAmount; + @HiveField(6) + final String? notes; + @HiveField(7) + final String? attachments; + @HiveField(8) + final PointsStatus status; + @HiveField(9) + final String? rejectReason; + @HiveField(10) + final int? pointsEarned; + @HiveField(11) + final DateTime submittedAt; + @HiveField(12) + final DateTime? processedAt; + @HiveField(13) + final String? processedBy; + + factory PointsRecordModel.fromJson(Map json) => + PointsRecordModel( + recordId: json['record_id'] as String, + userId: json['user_id'] as String, + invoiceNumber: json['invoice_number'] as String, + storeName: json['store_name'] as String, + transactionDate: DateTime.parse( + json['transaction_date']?.toString() ?? '', + ), + invoiceAmount: (json['invoice_amount'] as num).toDouble(), + notes: json['notes'] as String?, + attachments: json['attachments'] != null + ? jsonEncode(json['attachments']) + : null, + status: PointsStatus.values.firstWhere((e) => e.name == json['status']), + rejectReason: json['reject_reason'] as String?, + pointsEarned: json['points_earned'] as int?, + submittedAt: DateTime.parse(json['submitted_at']?.toString() ?? ''), + processedAt: json['processed_at'] != null + ? DateTime.parse(json['processed_at']?.toString() ?? '') + : null, + processedBy: json['processed_by'] as String?, + ); Map toJson() => { 'record_id': recordId, diff --git a/lib/features/loyalty/data/models/redeemed_gift_model.dart b/lib/features/loyalty/data/models/redeemed_gift_model.dart index e7cfa8d..c46deee 100644 --- a/lib/features/loyalty/data/models/redeemed_gift_model.dart +++ b/lib/features/loyalty/data/models/redeemed_gift_model.dart @@ -6,43 +6,83 @@ part 'redeemed_gift_model.g.dart'; @HiveType(typeId: HiveTypeIds.redeemedGiftModel) class RedeemedGiftModel extends HiveObject { - RedeemedGiftModel({required this.giftId, required this.userId, required this.catalogId, required this.name, required this.description, this.voucherCode, this.qrCodeImage, required this.giftType, required this.pointsCost, required this.cashValue, this.expiryDate, required this.status, required this.redeemedAt, this.usedAt, this.usedLocation, this.usedReference}); - - @HiveField(0) final String giftId; - @HiveField(1) final String userId; - @HiveField(2) final String catalogId; - @HiveField(3) final String name; - @HiveField(4) final String description; - @HiveField(5) final String? voucherCode; - @HiveField(6) final String? qrCodeImage; - @HiveField(7) final GiftCategory giftType; - @HiveField(8) final int pointsCost; - @HiveField(9) final double cashValue; - @HiveField(10) final DateTime? expiryDate; - @HiveField(11) final GiftStatus status; - @HiveField(12) final DateTime redeemedAt; - @HiveField(13) final DateTime? usedAt; - @HiveField(14) final String? usedLocation; - @HiveField(15) final String? usedReference; + RedeemedGiftModel({ + required this.giftId, + required this.userId, + required this.catalogId, + required this.name, + required this.description, + this.voucherCode, + this.qrCodeImage, + required this.giftType, + required this.pointsCost, + required this.cashValue, + this.expiryDate, + required this.status, + required this.redeemedAt, + this.usedAt, + this.usedLocation, + this.usedReference, + }); - factory RedeemedGiftModel.fromJson(Map json) => RedeemedGiftModel( - giftId: json['gift_id'] as String, - userId: json['user_id'] as String, - catalogId: json['catalog_id'] as String, - name: json['name'] as String, - description: json['description'] as String, - voucherCode: json['voucher_code'] as String?, - qrCodeImage: json['qr_code_image'] as String?, - giftType: GiftCategory.values.firstWhere((e) => e.name == json['gift_type']), - pointsCost: json['points_cost'] as int, - cashValue: (json['cash_value'] as num).toDouble(), - expiryDate: json['expiry_date'] != null ? DateTime.parse(json['expiry_date']?.toString() ?? '') : null, - status: GiftStatus.values.firstWhere((e) => e.name == json['status']), - redeemedAt: DateTime.parse(json['redeemed_at']?.toString() ?? ''), - usedAt: json['used_at'] != null ? DateTime.parse(json['used_at']?.toString() ?? '') : null, - usedLocation: json['used_location'] as String?, - usedReference: json['used_reference'] as String?, - ); + @HiveField(0) + final String giftId; + @HiveField(1) + final String userId; + @HiveField(2) + final String catalogId; + @HiveField(3) + final String name; + @HiveField(4) + final String description; + @HiveField(5) + final String? voucherCode; + @HiveField(6) + final String? qrCodeImage; + @HiveField(7) + final GiftCategory giftType; + @HiveField(8) + final int pointsCost; + @HiveField(9) + final double cashValue; + @HiveField(10) + final DateTime? expiryDate; + @HiveField(11) + final GiftStatus status; + @HiveField(12) + final DateTime redeemedAt; + @HiveField(13) + final DateTime? usedAt; + @HiveField(14) + final String? usedLocation; + @HiveField(15) + final String? usedReference; + + factory RedeemedGiftModel.fromJson(Map json) => + RedeemedGiftModel( + giftId: json['gift_id'] as String, + userId: json['user_id'] as String, + catalogId: json['catalog_id'] as String, + name: json['name'] as String, + description: json['description'] as String, + voucherCode: json['voucher_code'] as String?, + qrCodeImage: json['qr_code_image'] as String?, + giftType: GiftCategory.values.firstWhere( + (e) => e.name == json['gift_type'], + ), + pointsCost: json['points_cost'] as int, + cashValue: (json['cash_value'] as num).toDouble(), + expiryDate: json['expiry_date'] != null + ? DateTime.parse(json['expiry_date']?.toString() ?? '') + : null, + status: GiftStatus.values.firstWhere((e) => e.name == json['status']), + redeemedAt: DateTime.parse(json['redeemed_at']?.toString() ?? ''), + usedAt: json['used_at'] != null + ? DateTime.parse(json['used_at']?.toString() ?? '') + : null, + usedLocation: json['used_location'] as String?, + usedReference: json['used_reference'] as String?, + ); Map toJson() => { 'gift_id': giftId, @@ -63,7 +103,8 @@ class RedeemedGiftModel extends HiveObject { 'used_reference': usedReference, }; - bool get isExpired => expiryDate != null && DateTime.now().isAfter(expiryDate!); + bool get isExpired => + expiryDate != null && DateTime.now().isAfter(expiryDate!); bool get isUsed => status == GiftStatus.used; bool get isActive => status == GiftStatus.active && !isExpired; } diff --git a/lib/features/loyalty/domain/entities/gift_catalog.dart b/lib/features/loyalty/domain/entities/gift_catalog.dart index 7c1b2d0..5c6edd0 100644 --- a/lib/features/loyalty/domain/entities/gift_catalog.dart +++ b/lib/features/loyalty/domain/entities/gift_catalog.dart @@ -194,13 +194,7 @@ class GiftCatalog { @override int get hashCode { - return Object.hash( - catalogId, - name, - category, - pointsCost, - isActive, - ); + return Object.hash(catalogId, name, category, pointsCost, isActive); } @override diff --git a/lib/features/loyalty/domain/entities/loyalty_point_entry.dart b/lib/features/loyalty/domain/entities/loyalty_point_entry.dart index 842f422..bc5bcf0 100644 --- a/lib/features/loyalty/domain/entities/loyalty_point_entry.dart +++ b/lib/features/loyalty/domain/entities/loyalty_point_entry.dart @@ -15,7 +15,7 @@ enum EntryType { adjustment, /// Points expired - expiry; + expiry, } /// Entry source enum @@ -45,7 +45,7 @@ enum EntrySource { welcome, /// Other source - other; + other, } /// Complaint status enum @@ -63,7 +63,7 @@ enum ComplaintStatus { approved, /// Complaint rejected - rejected; + rejected, } /// Loyalty Point Entry Entity diff --git a/lib/features/loyalty/domain/entities/points_record.dart b/lib/features/loyalty/domain/entities/points_record.dart index 351c28c..ff92d9e 100644 --- a/lib/features/loyalty/domain/entities/points_record.dart +++ b/lib/features/loyalty/domain/entities/points_record.dart @@ -164,13 +164,7 @@ class PointsRecord { @override int get hashCode { - return Object.hash( - recordId, - userId, - invoiceNumber, - invoiceAmount, - status, - ); + return Object.hash(recordId, userId, invoiceNumber, invoiceAmount, status); } @override diff --git a/lib/features/loyalty/domain/entities/redeemed_gift.dart b/lib/features/loyalty/domain/entities/redeemed_gift.dart index 5dd5aab..acd5c59 100644 --- a/lib/features/loyalty/domain/entities/redeemed_gift.dart +++ b/lib/features/loyalty/domain/entities/redeemed_gift.dart @@ -190,13 +190,7 @@ class RedeemedGift { @override int get hashCode { - return Object.hash( - giftId, - userId, - catalogId, - voucherCode, - status, - ); + return Object.hash(giftId, userId, catalogId, voucherCode, status); } @override diff --git a/lib/features/loyalty/presentation/pages/loyalty_page.dart b/lib/features/loyalty/presentation/pages/loyalty_page.dart index b59f82c..a74e220 100644 --- a/lib/features/loyalty/presentation/pages/loyalty_page.dart +++ b/lib/features/loyalty/presentation/pages/loyalty_page.dart @@ -204,9 +204,7 @@ class LoyaltyPage extends ConsumerWidget { return Card( elevation: 5, margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -228,17 +226,11 @@ class LoyaltyPage extends ConsumerWidget { children: [ Text( 'Hạng hiện tại: DIAMOND', - style: TextStyle( - fontSize: 13, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 13, color: AppColors.grey500), ), Text( 'Hạng kế tiếp: PLATINUM', - style: TextStyle( - fontSize: 13, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 13, color: AppColors.grey500), ), ], ), @@ -265,10 +257,7 @@ class LoyaltyPage extends ConsumerWidget { child: RichText( textAlign: TextAlign.center, text: const TextSpan( - style: TextStyle( - fontSize: 13, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 13, color: AppColors.grey500), children: [ TextSpan(text: 'Còn '), TextSpan( @@ -414,9 +403,7 @@ class LoyaltyPage extends ConsumerWidget { return Card( elevation: 1, margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -436,7 +423,10 @@ class LoyaltyPage extends ConsumerWidget { _buildBenefitItem('Ưu tiên xử lý đơn hàng'), _buildBenefitItem('Tặng 500 điểm vào ngày sinh nhật'), _buildBenefitItem('Tư vấn thiết kế miễn phí'), - _buildBenefitItem('Mời tham gia sự kiện VIP độc quyền', isLast: true), + _buildBenefitItem( + 'Mời tham gia sự kiện VIP độc quyền', + isLast: true, + ), ], ), ), @@ -450,11 +440,7 @@ class LoyaltyPage extends ConsumerWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon( - Icons.check_circle, - size: 20, - color: Color(0xFF4A00E0), - ), + const Icon(Icons.check_circle, size: 20, color: Color(0xFF4A00E0)), const SizedBox(width: 12), Expanded( child: Text( diff --git a/lib/features/loyalty/presentation/pages/points_history_page.dart b/lib/features/loyalty/presentation/pages/points_history_page.dart index 897a1a5..f4f8c0d 100644 --- a/lib/features/loyalty/presentation/pages/points_history_page.dart +++ b/lib/features/loyalty/presentation/pages/points_history_page.dart @@ -65,7 +65,9 @@ class PointsHistoryPage extends ConsumerWidget { const SizedBox(height: 16), // Transaction List - ...entries.map((entry) => _buildTransactionCard(context, ref, entry)), + ...entries.map( + (entry) => _buildTransactionCard(context, ref, entry), + ), ], ), ); @@ -82,9 +84,7 @@ class PointsHistoryPage extends ConsumerWidget { return Card( elevation: 1, margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -101,20 +101,13 @@ class PointsHistoryPage extends ConsumerWidget { color: AppColors.grey900, ), ), - Icon( - Icons.filter_list, - color: AppColors.primaryBlue, - size: 20, - ), + Icon(Icons.filter_list, color: AppColors.primaryBlue, size: 20), ], ), const SizedBox(height: 8), const Text( 'Thời gian hiệu lực: 01/01/2023 - 31/12/2023', - style: TextStyle( - fontSize: 12, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 12, color: AppColors.grey500), ), ], ), @@ -137,14 +130,14 @@ class PointsHistoryPage extends ConsumerWidget { // Get transaction amount if it's a purchase final datasource = ref.read(pointsHistoryLocalDataSourceProvider); - final transactionAmount = datasource.getTransactionAmount(entry.description); + final transactionAmount = datasource.getTransactionAmount( + entry.description, + ); return Card( elevation: 1, margin: const EdgeInsets.only(bottom: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -199,11 +192,16 @@ class PointsHistoryPage extends ConsumerWidget { OutlinedButton( onPressed: () { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Chức năng khiếu nại đang phát triển')), + const SnackBar( + content: Text('Chức năng khiếu nại đang phát triển'), + ), ); }, style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), side: const BorderSide(color: AppColors.grey500), foregroundColor: AppColors.grey900, textStyle: const TextStyle(fontSize: 12), @@ -235,8 +233,8 @@ class PointsHistoryPage extends ConsumerWidget { color: entry.points > 0 ? AppColors.success : entry.points < 0 - ? AppColors.danger - : AppColors.grey900, + ? AppColors.danger + : AppColors.grey900, ), ), const SizedBox(height: 2), @@ -282,10 +280,7 @@ class PointsHistoryPage extends ConsumerWidget { const SizedBox(height: 8), const Text( 'Kéo xuống để làm mới', - style: TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 14, color: AppColors.grey500), ), ], ), @@ -316,10 +311,7 @@ class PointsHistoryPage extends ConsumerWidget { const SizedBox(height: 8), Text( error.toString(), - style: const TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14, color: AppColors.grey500), textAlign: TextAlign.center, ), ], diff --git a/lib/features/loyalty/presentation/pages/rewards_page.dart b/lib/features/loyalty/presentation/pages/rewards_page.dart index fed49ca..3841bd5 100644 --- a/lib/features/loyalty/presentation/pages/rewards_page.dart +++ b/lib/features/loyalty/presentation/pages/rewards_page.dart @@ -37,13 +37,20 @@ class RewardsPage extends ConsumerWidget { icon: const Icon(Icons.arrow_back, color: Colors.black), onPressed: () => context.pop(), ), - title: const Text('Đổi quà tặng', style: TextStyle(color: Colors.black)), + title: const Text( + 'Đổi quà tặng', + style: TextStyle(color: Colors.black), + ), elevation: AppBarSpecs.elevation, backgroundColor: AppColors.white, foregroundColor: AppColors.grey900, centerTitle: false, - actions: const [ - SizedBox(width: AppSpacing.sm), + actions: [ + IconButton( + icon: const Icon(Icons.info_outline, color: Colors.black), + onPressed: () => _showInfoDialog(context), + ), + const SizedBox(width: AppSpacing.sm), ], ), body: RefreshIndicator( @@ -72,26 +79,20 @@ class RewardsPage extends ConsumerWidget { sliver: filteredGifts.isEmpty ? _buildEmptyState() : SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 0.7, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - final gift = filteredGifts[index]; - return RewardCard( - gift: gift, - onRedeem: () => _handleRedeemGift( - context, - ref, - gift, - ), - ); - }, - childCount: filteredGifts.length, - ), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.7, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final gift = filteredGifts[index]; + return RewardCard( + gift: gift, + onRedeem: () => _handleRedeemGift(context, ref, gift), + ); + }, childCount: filteredGifts.length), ), ), ], @@ -100,6 +101,84 @@ class RewardsPage extends ConsumerWidget { ); } + /// Show info dialog with usage instructions + void _showInfoDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text( + 'Hướng dẫn sử dụng', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Đây là nội dung hướng dẫn sử dụng cho tính năng Đổi quà tặng:', + style: TextStyle(fontSize: 14), + ), + const SizedBox(height: 12), + _buildInfoItem( + 'Sử dụng điểm tích lũy của bạn để đổi các phần quà giá trị trong danh mục.', + ), + _buildInfoItem( + 'Bấm vào một phần quà để xem chi tiết và điều kiện áp dụng.', + ), + _buildInfoItem( + 'Khi xác nhận đổi quà, bạn có thể chọn "Nhận hàng tại Showroom".', + ), + _buildInfoItem( + 'Nếu chọn "Nhận hàng tại Showroom", bạn sẽ cần chọn Showroom bạn muốn đến nhận từ danh sách thả xuống.', + ), + _buildInfoItem( + 'Quà đã đổi sẽ được chuyển vào mục "Quà của tôi" (trong trang Hội viên).', + ), + ], + ), + ), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 44), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Đóng'), + ), + ], + actionsPadding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + contentPadding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + titlePadding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + ), + ); + } + + /// Build info item with bullet point + Widget _buildInfoItem(String text) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 6), + child: Icon(Icons.circle, size: 6, color: AppColors.grey500), + ), + const SizedBox(width: 12), + Expanded( + child: Text(text, style: TextStyle(fontSize: 14, height: 1.5)), + ), + ], + ), + ); + } + /// Build category filter pills Widget _buildCategoryFilter( BuildContext context, @@ -237,10 +316,7 @@ class RewardsPage extends ConsumerWidget { const SizedBox(height: 8), const Text( 'Vui lòng thử lại sau', - style: TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 14, color: AppColors.grey500), ), ], ), @@ -290,10 +366,7 @@ class RewardsPage extends ConsumerWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( - 'Chi phí:', - style: TextStyle(fontSize: 13), - ), + const Text('Chi phí:', style: TextStyle(fontSize: 13)), Text( '${numberFormat.format(gift.pointsCost)} điểm', style: const TextStyle( @@ -363,9 +436,7 @@ class RewardsPage extends ConsumerWidget { children: [ const Icon(Icons.check_circle, color: Colors.white), const SizedBox(width: 12), - Expanded( - child: Text('Đổi quà "${gift.name}" thành công!'), - ), + Expanded(child: Text('Đổi quà "${gift.name}" thành công!')), ], ), backgroundColor: AppColors.success, diff --git a/lib/features/loyalty/presentation/providers/points_history_provider.dart b/lib/features/loyalty/presentation/providers/points_history_provider.dart index b3b871b..a044e75 100644 --- a/lib/features/loyalty/presentation/providers/points_history_provider.dart +++ b/lib/features/loyalty/presentation/providers/points_history_provider.dart @@ -38,7 +38,9 @@ class PointsHistory extends _$PointsHistory { Future refresh() async { state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { - return await ref.read(pointsHistoryLocalDataSourceProvider).getAllEntries(); + return await ref + .read(pointsHistoryLocalDataSourceProvider) + .getAllEntries(); }); } } diff --git a/lib/features/loyalty/presentation/widgets/reward_card.dart b/lib/features/loyalty/presentation/widgets/reward_card.dart index 0430785..721675f 100644 --- a/lib/features/loyalty/presentation/widgets/reward_card.dart +++ b/lib/features/loyalty/presentation/widgets/reward_card.dart @@ -22,25 +22,17 @@ class RewardCard extends ConsumerWidget { /// Callback when redeem button is pressed final VoidCallback onRedeem; - const RewardCard({ - required this.gift, - required this.onRedeem, - super.key, - }); + const RewardCard({required this.gift, required this.onRedeem, super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final hasEnoughPoints = ref.watch( - hasEnoughPointsProvider(gift.pointsCost), - ); + final hasEnoughPoints = ref.watch(hasEnoughPointsProvider(gift.pointsCost)); final numberFormat = NumberFormat('#,###', 'vi_VN'); return Card( elevation: 2, margin: EdgeInsets.symmetric(horizontal: 8, vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -158,9 +150,7 @@ class RewardCard extends ConsumerWidget { placeholder: (context, url) => Container( color: AppColors.grey100, child: const Center( - child: CircularProgressIndicator( - strokeWidth: 2, - ), + child: CircularProgressIndicator(strokeWidth: 2), ), ), errorWidget: (context, url, error) => Container( diff --git a/lib/features/main/presentation/pages/main_scaffold.dart b/lib/features/main/presentation/pages/main_scaffold.dart index 68a3ece..80eeb94 100644 --- a/lib/features/main/presentation/pages/main_scaffold.dart +++ b/lib/features/main/presentation/pages/main_scaffold.dart @@ -50,7 +50,11 @@ class MainScaffold extends ConsumerWidget { onPressed: () => context.push(RouteNames.chat), backgroundColor: const Color(0xFF35C6F4), // Accent cyan color elevation: 4, - child: const Icon(Icons.chat_bubble, color: AppColors.white, size: 28), + child: const Icon( + Icons.chat_bubble, + color: AppColors.white, + size: 28, + ), ), ) : null, @@ -58,7 +62,11 @@ class MainScaffold extends ConsumerWidget { decoration: BoxDecoration( color: Colors.white, boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, -2)), + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, -2), + ), ], ), child: SafeArea( @@ -75,9 +83,18 @@ class MainScaffold extends ConsumerWidget { currentIndex: currentIndex, elevation: 0, items: [ - const BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Trang chủ'), - const BottomNavigationBarItem(icon: Icon(Icons.loyalty), label: 'Hội viên'), - const BottomNavigationBarItem(icon: Icon(Icons.local_offer), label: 'Tin tức'), + const BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'Trang chủ', + ), + const BottomNavigationBarItem( + icon: Icon(Icons.loyalty), + label: 'Hội viên', + ), + const BottomNavigationBarItem( + icon: Icon(Icons.local_offer), + label: 'Tin tức', + ), BottomNavigationBarItem( icon: Stack( clipBehavior: Clip.none, @@ -87,12 +104,25 @@ class MainScaffold extends ConsumerWidget { top: -4, right: -4, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration(color: AppColors.danger, borderRadius: BorderRadius.circular(12)), - constraints: const BoxConstraints(minWidth: 20, minHeight: 20), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.danger, + borderRadius: BorderRadius.circular(12), + ), + constraints: const BoxConstraints( + minWidth: 20, + minHeight: 20, + ), child: const Text( '5', - style: TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700), + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w700, + ), textAlign: TextAlign.center, ), ), @@ -101,7 +131,10 @@ class MainScaffold extends ConsumerWidget { ), label: 'Thông báo', ), - const BottomNavigationBarItem(icon: Icon(Icons.account_circle), label: 'Cài đặt'), + const BottomNavigationBarItem( + icon: Icon(Icons.account_circle), + label: 'Cài đặt', + ), ], onTap: (index) { ref.read(currentPageIndexProvider.notifier).setIndex(index); diff --git a/lib/features/notifications/data/datasources/notification_local_datasource.dart b/lib/features/notifications/data/datasources/notification_local_datasource.dart index d58dde4..2d1187a 100644 --- a/lib/features/notifications/data/datasources/notification_local_datasource.dart +++ b/lib/features/notifications/data/datasources/notification_local_datasource.dart @@ -66,7 +66,7 @@ class NotificationLocalDataSource { 'data': { 'current_tier': 'gold', 'next_tier': 'platinum', - 'points_needed': 2250 + 'points_needed': 2250, }, 'is_read': true, 'is_pushed': true, @@ -128,17 +128,20 @@ class NotificationLocalDataSource { /// Get notifications by category Future>> getNotificationsByCategory( - String category) async { + String category, + ) async { await Future.delayed(const Duration(milliseconds: 200)); if (category == 'general') { return _mockNotifications - .where((n) => - !(n['type'] as String).toLowerCase().contains('order') || - (n['type'] as String).toLowerCase().contains('loyalty') || - (n['type'] as String).toLowerCase().contains('promotion') || - (n['type'] as String).toLowerCase().contains('event') || - (n['type'] as String).toLowerCase().contains('birthday')) + .where( + (n) => + !(n['type'] as String).toLowerCase().contains('order') || + (n['type'] as String).toLowerCase().contains('loyalty') || + (n['type'] as String).toLowerCase().contains('promotion') || + (n['type'] as String).toLowerCase().contains('event') || + (n['type'] as String).toLowerCase().contains('birthday'), + ) .toList(); } else if (category == 'order') { return _mockNotifications @@ -159,8 +162,9 @@ class NotificationLocalDataSource { Future markAsRead(String notificationId) async { await Future.delayed(const Duration(milliseconds: 150)); - final index = _mockNotifications - .indexWhere((n) => n['notification_id'] == notificationId); + final index = _mockNotifications.indexWhere( + (n) => n['notification_id'] == notificationId, + ); if (index != -1) { _mockNotifications[index]['is_read'] = true; _mockNotifications[index]['read_at'] = DateTime.now().toIso8601String(); diff --git a/lib/features/notifications/data/models/notification_model.dart b/lib/features/notifications/data/models/notification_model.dart index 85b1127..681c965 100644 --- a/lib/features/notifications/data/models/notification_model.dart +++ b/lib/features/notifications/data/models/notification_model.dart @@ -21,7 +21,7 @@ class NotificationModel { isRead: json['is_read'] as bool? ?? false, isPushed: json['is_pushed'] as bool? ?? false, createdAt: DateTime.parse(json['created_at'] as String), - readAt: json['read_at'] != null + readAt: json['read_at'] != null ? DateTime.parse(json['read_at'] as String) : null, ); diff --git a/lib/features/notifications/data/repositories/notification_repository_impl.dart b/lib/features/notifications/data/repositories/notification_repository_impl.dart index 8c71d30..18c994d 100644 --- a/lib/features/notifications/data/repositories/notification_repository_impl.dart +++ b/lib/features/notifications/data/repositories/notification_repository_impl.dart @@ -21,7 +21,9 @@ class NotificationRepositoryImpl implements NotificationRepository { Future> getAllNotifications() async { final jsonList = await localDataSource.getAllNotifications(); return jsonList.map((json) => NotificationModel.fromJson(json)).toList() - ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); // Sort by newest first + ..sort( + (a, b) => b.createdAt.compareTo(a.createdAt), + ); // Sort by newest first } @override diff --git a/lib/features/notifications/domain/entities/notification.dart b/lib/features/notifications/domain/entities/notification.dart index a6ba25d..0feaf29 100644 --- a/lib/features/notifications/domain/entities/notification.dart +++ b/lib/features/notifications/domain/entities/notification.dart @@ -60,7 +60,8 @@ class Notification { bool get isOrderNotification => type.toLowerCase().contains('order'); /// Check if notification is loyalty-related - bool get isLoyaltyNotification => type.toLowerCase().contains('loyalty') || + bool get isLoyaltyNotification => + type.toLowerCase().contains('loyalty') || type.toLowerCase().contains('points'); /// Check if notification is promotion-related diff --git a/lib/features/orders/data/datasources/invoices_local_datasource.dart b/lib/features/orders/data/datasources/invoices_local_datasource.dart index fc875b3..7784b1a 100644 --- a/lib/features/orders/data/datasources/invoices_local_datasource.dart +++ b/lib/features/orders/data/datasources/invoices_local_datasource.dart @@ -27,7 +27,9 @@ class InvoicesLocalDataSource { .map((json) => InvoiceModel.fromJson(json as Map)) .toList(); - debugPrint('[InvoicesLocalDataSource] Loaded ${invoices.length} invoices'); + debugPrint( + '[InvoicesLocalDataSource] Loaded ${invoices.length} invoices', + ); return invoices; } catch (e, stackTrace) { debugPrint('[InvoicesLocalDataSource] Error loading invoices: $e'); @@ -65,8 +67,11 @@ class InvoicesLocalDataSource { final filtered = allInvoices .where( (invoice) => - invoice.invoiceNumber.toLowerCase().contains(query.toLowerCase()) || - (invoice.orderId?.toLowerCase().contains(query.toLowerCase()) ?? false), + invoice.invoiceNumber.toLowerCase().contains( + query.toLowerCase(), + ) || + (invoice.orderId?.toLowerCase().contains(query.toLowerCase()) ?? + false), ) .toList(); @@ -89,7 +94,9 @@ class InvoicesLocalDataSource { orElse: () => throw Exception('Invoice not found: $invoiceId'), ); - debugPrint('[InvoicesLocalDataSource] Found invoice: ${invoice.invoiceNumber}'); + debugPrint( + '[InvoicesLocalDataSource] Found invoice: ${invoice.invoiceNumber}', + ); return invoice; } catch (e) { debugPrint('[InvoicesLocalDataSource] Error getting invoice: $e'); @@ -105,10 +112,14 @@ class InvoicesLocalDataSource { .where((invoice) => invoice.isOverdue) .toList(); - debugPrint('[InvoicesLocalDataSource] Found ${overdue.length} overdue invoices'); + debugPrint( + '[InvoicesLocalDataSource] Found ${overdue.length} overdue invoices', + ); return overdue; } catch (e) { - debugPrint('[InvoicesLocalDataSource] Error getting overdue invoices: $e'); + debugPrint( + '[InvoicesLocalDataSource] Error getting overdue invoices: $e', + ); rethrow; } } @@ -118,10 +129,14 @@ class InvoicesLocalDataSource { try { final allInvoices = await getAllInvoices(); final unpaid = allInvoices - .where((invoice) => invoice.status.name == 'issued' && !invoice.isPaid) + .where( + (invoice) => invoice.status.name == 'issued' && !invoice.isPaid, + ) .toList(); - debugPrint('[InvoicesLocalDataSource] Found ${unpaid.length} unpaid invoices'); + debugPrint( + '[InvoicesLocalDataSource] Found ${unpaid.length} unpaid invoices', + ); return unpaid; } catch (e) { debugPrint('[InvoicesLocalDataSource] Error getting unpaid invoices: $e'); diff --git a/lib/features/orders/data/models/invoice_model.dart b/lib/features/orders/data/models/invoice_model.dart index b37a60e..9713af0 100644 --- a/lib/features/orders/data/models/invoice_model.dart +++ b/lib/features/orders/data/models/invoice_model.dart @@ -6,37 +6,84 @@ part 'invoice_model.g.dart'; @HiveType(typeId: HiveTypeIds.invoiceModel) class InvoiceModel extends HiveObject { - InvoiceModel({required this.invoiceId, required this.invoiceNumber, required this.userId, this.orderId, required this.invoiceType, required this.issueDate, required this.dueDate, required this.currency, required this.subtotalAmount, required this.taxAmount, required this.discountAmount, required this.shippingAmount, required this.totalAmount, required this.amountPaid, required this.amountRemaining, required this.status, this.paymentTerms, this.notes, this.erpnextInvoice, required this.createdAt, this.updatedAt, this.lastReminderSent}); - - @HiveField(0) final String invoiceId; - @HiveField(1) final String invoiceNumber; - @HiveField(2) final String userId; - @HiveField(3) final String? orderId; - @HiveField(4) final InvoiceType invoiceType; - @HiveField(5) final DateTime issueDate; - @HiveField(6) final DateTime dueDate; - @HiveField(7) final String currency; - @HiveField(8) final double subtotalAmount; - @HiveField(9) final double taxAmount; - @HiveField(10) final double discountAmount; - @HiveField(11) final double shippingAmount; - @HiveField(12) final double totalAmount; - @HiveField(13) final double amountPaid; - @HiveField(14) final double amountRemaining; - @HiveField(15) final InvoiceStatus status; - @HiveField(16) final String? paymentTerms; - @HiveField(17) final String? notes; - @HiveField(18) final String? erpnextInvoice; - @HiveField(19) final DateTime createdAt; - @HiveField(20) final DateTime? updatedAt; - @HiveField(21) final DateTime? lastReminderSent; + InvoiceModel({ + required this.invoiceId, + required this.invoiceNumber, + required this.userId, + this.orderId, + required this.invoiceType, + required this.issueDate, + required this.dueDate, + required this.currency, + required this.subtotalAmount, + required this.taxAmount, + required this.discountAmount, + required this.shippingAmount, + required this.totalAmount, + required this.amountPaid, + required this.amountRemaining, + required this.status, + this.paymentTerms, + this.notes, + this.erpnextInvoice, + required this.createdAt, + this.updatedAt, + this.lastReminderSent, + }); + + @HiveField(0) + final String invoiceId; + @HiveField(1) + final String invoiceNumber; + @HiveField(2) + final String userId; + @HiveField(3) + final String? orderId; + @HiveField(4) + final InvoiceType invoiceType; + @HiveField(5) + final DateTime issueDate; + @HiveField(6) + final DateTime dueDate; + @HiveField(7) + final String currency; + @HiveField(8) + final double subtotalAmount; + @HiveField(9) + final double taxAmount; + @HiveField(10) + final double discountAmount; + @HiveField(11) + final double shippingAmount; + @HiveField(12) + final double totalAmount; + @HiveField(13) + final double amountPaid; + @HiveField(14) + final double amountRemaining; + @HiveField(15) + final InvoiceStatus status; + @HiveField(16) + final String? paymentTerms; + @HiveField(17) + final String? notes; + @HiveField(18) + final String? erpnextInvoice; + @HiveField(19) + final DateTime createdAt; + @HiveField(20) + final DateTime? updatedAt; + @HiveField(21) + final DateTime? lastReminderSent; factory InvoiceModel.fromJson(Map json) => InvoiceModel( invoiceId: json['invoice_id'] as String, invoiceNumber: json['invoice_number'] as String, userId: json['user_id'] as String, orderId: json['order_id'] as String?, - invoiceType: InvoiceType.values.firstWhere((e) => e.name == json['invoice_type']), + invoiceType: InvoiceType.values.firstWhere( + (e) => e.name == json['invoice_type'], + ), issueDate: DateTime.parse(json['issue_date']?.toString() ?? ''), dueDate: DateTime.parse(json['due_date']?.toString() ?? ''), currency: json['currency'] as String? ?? 'VND', @@ -52,8 +99,12 @@ class InvoiceModel extends HiveObject { notes: json['notes'] as String?, erpnextInvoice: json['erpnext_invoice'] as String?, createdAt: DateTime.parse(json['created_at']?.toString() ?? ''), - updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']?.toString() ?? '') : null, - lastReminderSent: json['last_reminder_sent'] != null ? DateTime.parse(json['last_reminder_sent']?.toString() ?? '') : null, + updatedAt: json['updated_at'] != null + ? DateTime.parse(json['updated_at']?.toString() ?? '') + : null, + lastReminderSent: json['last_reminder_sent'] != null + ? DateTime.parse(json['last_reminder_sent']?.toString() ?? '') + : null, ); Map toJson() => { @@ -81,6 +132,7 @@ class InvoiceModel extends HiveObject { 'last_reminder_sent': lastReminderSent?.toIso8601String(), }; - bool get isOverdue => DateTime.now().isAfter(dueDate) && status != InvoiceStatus.paid; + bool get isOverdue => + DateTime.now().isAfter(dueDate) && status != InvoiceStatus.paid; bool get isPaid => status == InvoiceStatus.paid; } diff --git a/lib/features/orders/data/models/order_item_model.dart b/lib/features/orders/data/models/order_item_model.dart index fbc8ebe..069a900 100644 --- a/lib/features/orders/data/models/order_item_model.dart +++ b/lib/features/orders/data/models/order_item_model.dart @@ -5,16 +5,33 @@ part 'order_item_model.g.dart'; @HiveType(typeId: HiveTypeIds.orderItemModel) class OrderItemModel extends HiveObject { - OrderItemModel({required this.orderItemId, required this.orderId, required this.productId, required this.quantity, required this.unitPrice, required this.discountPercent, required this.subtotal, this.notes}); - - @HiveField(0) final String orderItemId; - @HiveField(1) final String orderId; - @HiveField(2) final String productId; - @HiveField(3) final double quantity; - @HiveField(4) final double unitPrice; - @HiveField(5) final double discountPercent; - @HiveField(6) final double subtotal; - @HiveField(7) final String? notes; + OrderItemModel({ + required this.orderItemId, + required this.orderId, + required this.productId, + required this.quantity, + required this.unitPrice, + required this.discountPercent, + required this.subtotal, + this.notes, + }); + + @HiveField(0) + final String orderItemId; + @HiveField(1) + final String orderId; + @HiveField(2) + final String productId; + @HiveField(3) + final double quantity; + @HiveField(4) + final double unitPrice; + @HiveField(5) + final double discountPercent; + @HiveField(6) + final double subtotal; + @HiveField(7) + final String? notes; factory OrderItemModel.fromJson(Map json) => OrderItemModel( orderItemId: json['order_item_id'] as String, diff --git a/lib/features/orders/data/models/order_model.dart b/lib/features/orders/data/models/order_model.dart index 9fed55b..b204821 100644 --- a/lib/features/orders/data/models/order_model.dart +++ b/lib/features/orders/data/models/order_model.dart @@ -94,15 +94,25 @@ class OrderModel extends HiveObject { taxAmount: (json['tax_amount'] as num).toDouble(), shippingFee: (json['shipping_fee'] as num).toDouble(), finalAmount: (json['final_amount'] as num).toDouble(), - shippingAddress: json['shipping_address'] != null ? jsonEncode(json['shipping_address']) : null, - billingAddress: json['billing_address'] != null ? jsonEncode(json['billing_address']) : null, - expectedDeliveryDate: json['expected_delivery_date'] != null ? DateTime.parse(json['expected_delivery_date']?.toString() ?? '') : null, - actualDeliveryDate: json['actual_delivery_date'] != null ? DateTime.parse(json['actual_delivery_date']?.toString() ?? '') : null, + shippingAddress: json['shipping_address'] != null + ? jsonEncode(json['shipping_address']) + : null, + billingAddress: json['billing_address'] != null + ? jsonEncode(json['billing_address']) + : null, + expectedDeliveryDate: json['expected_delivery_date'] != null + ? DateTime.parse(json['expected_delivery_date']?.toString() ?? '') + : null, + actualDeliveryDate: json['actual_delivery_date'] != null + ? DateTime.parse(json['actual_delivery_date']?.toString() ?? '') + : null, notes: json['notes'] as String?, cancellationReason: json['cancellation_reason'] as String?, erpnextSalesOrder: json['erpnext_sales_order'] as String?, 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, ); } @@ -116,8 +126,12 @@ class OrderModel extends HiveObject { 'tax_amount': taxAmount, 'shipping_fee': shippingFee, 'final_amount': finalAmount, - 'shipping_address': shippingAddress != null ? jsonDecode(shippingAddress!) : null, - 'billing_address': billingAddress != null ? jsonDecode(billingAddress!) : null, + 'shipping_address': shippingAddress != null + ? jsonDecode(shippingAddress!) + : null, + 'billing_address': billingAddress != null + ? jsonDecode(billingAddress!) + : null, 'expected_delivery_date': expectedDeliveryDate?.toIso8601String(), 'actual_delivery_date': actualDeliveryDate?.toIso8601String(), 'notes': notes, diff --git a/lib/features/orders/data/models/payment_line_model.dart b/lib/features/orders/data/models/payment_line_model.dart index a2bd171..606da6b 100644 --- a/lib/features/orders/data/models/payment_line_model.dart +++ b/lib/features/orders/data/models/payment_line_model.dart @@ -6,41 +6,79 @@ part 'payment_line_model.g.dart'; @HiveType(typeId: HiveTypeIds.paymentLineModel) class PaymentLineModel extends HiveObject { - PaymentLineModel({required this.paymentLineId, required this.invoiceId, required this.paymentNumber, required this.paymentDate, required this.amount, required this.paymentMethod, this.bankName, this.bankAccount, this.referenceNumber, this.notes, required this.status, this.receiptUrl, this.erpnextPaymentEntry, required this.createdAt, this.processedAt}); - - @HiveField(0) final String paymentLineId; - @HiveField(1) final String invoiceId; - @HiveField(2) final String paymentNumber; - @HiveField(3) final DateTime paymentDate; - @HiveField(4) final double amount; - @HiveField(5) final PaymentMethod paymentMethod; - @HiveField(6) final String? bankName; - @HiveField(7) final String? bankAccount; - @HiveField(8) final String? referenceNumber; - @HiveField(9) final String? notes; - @HiveField(10) final PaymentStatus status; - @HiveField(11) final String? receiptUrl; - @HiveField(12) final String? erpnextPaymentEntry; - @HiveField(13) final DateTime createdAt; - @HiveField(14) final DateTime? processedAt; + PaymentLineModel({ + required this.paymentLineId, + required this.invoiceId, + required this.paymentNumber, + required this.paymentDate, + required this.amount, + required this.paymentMethod, + this.bankName, + this.bankAccount, + this.referenceNumber, + this.notes, + required this.status, + this.receiptUrl, + this.erpnextPaymentEntry, + required this.createdAt, + this.processedAt, + }); - factory PaymentLineModel.fromJson(Map json) => PaymentLineModel( - paymentLineId: json['payment_line_id'] as String, - invoiceId: json['invoice_id'] as String, - paymentNumber: json['payment_number'] as String, - paymentDate: DateTime.parse(json['payment_date']?.toString() ?? ''), - amount: (json['amount'] as num).toDouble(), - paymentMethod: PaymentMethod.values.firstWhere((e) => e.name == json['payment_method']), - bankName: json['bank_name'] as String?, - bankAccount: json['bank_account'] as String?, - referenceNumber: json['reference_number'] as String?, - notes: json['notes'] as String?, - status: PaymentStatus.values.firstWhere((e) => e.name == json['status']), - receiptUrl: json['receipt_url'] as String?, - erpnextPaymentEntry: json['erpnext_payment_entry'] as String?, - createdAt: DateTime.parse(json['created_at']?.toString() ?? ''), - processedAt: json['processed_at'] != null ? DateTime.parse(json['processed_at']?.toString() ?? '') : null, - ); + @HiveField(0) + final String paymentLineId; + @HiveField(1) + final String invoiceId; + @HiveField(2) + final String paymentNumber; + @HiveField(3) + final DateTime paymentDate; + @HiveField(4) + final double amount; + @HiveField(5) + final PaymentMethod paymentMethod; + @HiveField(6) + final String? bankName; + @HiveField(7) + final String? bankAccount; + @HiveField(8) + final String? referenceNumber; + @HiveField(9) + final String? notes; + @HiveField(10) + final PaymentStatus status; + @HiveField(11) + final String? receiptUrl; + @HiveField(12) + final String? erpnextPaymentEntry; + @HiveField(13) + final DateTime createdAt; + @HiveField(14) + final DateTime? processedAt; + + factory PaymentLineModel.fromJson(Map json) => + PaymentLineModel( + paymentLineId: json['payment_line_id'] as String, + invoiceId: json['invoice_id'] as String, + paymentNumber: json['payment_number'] as String, + paymentDate: DateTime.parse(json['payment_date']?.toString() ?? ''), + amount: (json['amount'] as num).toDouble(), + paymentMethod: PaymentMethod.values.firstWhere( + (e) => e.name == json['payment_method'], + ), + bankName: json['bank_name'] as String?, + bankAccount: json['bank_account'] as String?, + referenceNumber: json['reference_number'] as String?, + notes: json['notes'] as String?, + status: PaymentStatus.values.firstWhere( + (e) => e.name == json['status'], + ), + receiptUrl: json['receipt_url'] as String?, + erpnextPaymentEntry: json['erpnext_payment_entry'] as String?, + createdAt: DateTime.parse(json['created_at']?.toString() ?? ''), + processedAt: json['processed_at'] != null + ? DateTime.parse(json['processed_at']?.toString() ?? '') + : null, + ); Map toJson() => { 'payment_line_id': paymentLineId, diff --git a/lib/features/orders/domain/entities/invoice.dart b/lib/features/orders/domain/entities/invoice.dart index 7054a5c..0bd5763 100644 --- a/lib/features/orders/domain/entities/invoice.dart +++ b/lib/features/orders/domain/entities/invoice.dart @@ -15,7 +15,7 @@ enum InvoiceType { creditNote, /// Debit note - debitNote; + debitNote, } /// Invoice status enum @@ -166,8 +166,7 @@ class Invoice { (!isPaid && DateTime.now().isAfter(dueDate)); /// Check if invoice is partially paid - bool get isPartiallyPaid => - amountPaid > 0 && amountPaid < totalAmount; + bool get isPartiallyPaid => amountPaid > 0 && amountPaid < totalAmount; /// Get payment percentage double get paymentPercentage { diff --git a/lib/features/orders/domain/entities/order_item.dart b/lib/features/orders/domain/entities/order_item.dart index df32ba9..9048b17 100644 --- a/lib/features/orders/domain/entities/order_item.dart +++ b/lib/features/orders/domain/entities/order_item.dart @@ -50,8 +50,7 @@ class OrderItem { double get subtotalBeforeDiscount => quantity * unitPrice; /// Calculate discount amount - double get discountAmount => - subtotalBeforeDiscount * (discountPercent / 100); + double get discountAmount => subtotalBeforeDiscount * (discountPercent / 100); /// Calculate subtotal after discount (for verification) double get calculatedSubtotal => subtotalBeforeDiscount - discountAmount; diff --git a/lib/features/orders/presentation/pages/order_detail_page.dart b/lib/features/orders/presentation/pages/order_detail_page.dart index 8271872..1f14021 100644 --- a/lib/features/orders/presentation/pages/order_detail_page.dart +++ b/lib/features/orders/presentation/pages/order_detail_page.dart @@ -21,11 +21,7 @@ import 'package:worker/core/theme/colors.dart'; /// - Order summary /// - Action buttons (Contact customer, Update status) class OrderDetailPage extends ConsumerWidget { - - const OrderDetailPage({ - required this.orderId, - super.key, - }); + const OrderDetailPage({required this.orderId, super.key}); final String orderId; @override @@ -51,7 +47,9 @@ class OrderDetailPage extends ConsumerWidget { onPressed: () { // TODO: Implement share functionality ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Chức năng chia sẻ đang phát triển')), + const SnackBar( + content: Text('Chức năng chia sẻ đang phát triển'), + ), ); }, ), @@ -142,14 +140,19 @@ class OrderDetailPage extends ConsumerWidget { onPressed: () { // TODO: Implement contact customer ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Gọi điện cho khách hàng...')), + const SnackBar( + content: Text('Gọi điện cho khách hàng...'), + ), ); }, icon: const Icon(Icons.phone), label: const Text('Liên hệ khách hàng'), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), - side: const BorderSide(color: AppColors.grey100, width: 2), + side: const BorderSide( + color: AppColors.grey100, + width: 2, + ), foregroundColor: AppColors.grey900, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), @@ -162,7 +165,9 @@ class OrderDetailPage extends ConsumerWidget { onPressed: () { // TODO: Implement update status ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Cập nhật trạng thái...')), + const SnackBar( + content: Text('Cập nhật trạng thái...'), + ), ); }, icon: const Icon(Icons.edit), @@ -196,9 +201,7 @@ class OrderDetailPage extends ConsumerWidget { return Card( margin: const EdgeInsets.all(16), elevation: 1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -282,11 +285,7 @@ class OrderDetailPage extends ConsumerWidget { color: iconBgColor, shape: BoxShape.circle, ), - child: Icon( - iconData, - size: 12, - color: iconColor, - ), + child: Icon(iconData, size: 12, color: iconColor), ), if (!isLast) Container( @@ -426,9 +425,7 @@ class OrderDetailPage extends ConsumerWidget { return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), elevation: 1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -436,7 +433,11 @@ class OrderDetailPage extends ConsumerWidget { children: [ Row( children: [ - const Icon(Icons.local_shipping, color: AppColors.primaryBlue, size: 20), + const Icon( + Icons.local_shipping, + color: AppColors.primaryBlue, + size: 20, + ), const SizedBox(width: 8), const Text( 'Thông tin giao hàng', @@ -576,7 +577,9 @@ class OrderDetailPage extends ConsumerWidget { textAlign: TextAlign.right, style: TextStyle( fontSize: 14, - fontWeight: valueColor != null ? FontWeight.w600 : FontWeight.w500, + fontWeight: valueColor != null + ? FontWeight.w600 + : FontWeight.w500, color: valueColor ?? AppColors.grey900, ), ), @@ -595,9 +598,7 @@ class OrderDetailPage extends ConsumerWidget { return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), elevation: 1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -605,7 +606,11 @@ class OrderDetailPage extends ConsumerWidget { children: [ Row( children: [ - const Icon(Icons.person_outline, color: AppColors.primaryBlue, size: 20), + const Icon( + Icons.person_outline, + color: AppColors.primaryBlue, + size: 20, + ), const SizedBox(width: 8), const Text( 'Thông tin khách hàng', @@ -634,13 +639,13 @@ class OrderDetailPage extends ConsumerWidget { children: [ const Text( 'Loại khách hàng:', - style: TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 14, color: AppColors.grey500), ), Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFFFFD700), Color(0xFFFFA500)], @@ -673,10 +678,7 @@ class OrderDetailPage extends ConsumerWidget { children: [ Text( label, - style: const TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14, color: AppColors.grey500), ), Text( value, @@ -714,9 +716,7 @@ class OrderDetailPage extends ConsumerWidget { return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), elevation: 1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -724,7 +724,11 @@ class OrderDetailPage extends ConsumerWidget { children: [ Row( children: [ - const Icon(Icons.inventory_2, color: AppColors.primaryBlue, size: 20), + const Icon( + Icons.inventory_2, + color: AppColors.primaryBlue, + size: 20, + ), const SizedBox(width: 8), const Text( 'Sản phẩm đặt hàng', @@ -739,113 +743,115 @@ class OrderDetailPage extends ConsumerWidget { const SizedBox(height: 16), - ...products.map((product) => Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all(color: AppColors.grey100), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Product Image - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: AppColors.grey50, - borderRadius: BorderRadius.circular(6), + ...products.map( + (product) => Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: AppColors.grey100), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Product Image + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: AppColors.grey50, + borderRadius: BorderRadius.circular(6), + ), + child: const Icon( + Icons.image, + color: AppColors.grey500, + size: 30, + ), ), - child: const Icon( - Icons.image, - color: AppColors.grey500, - size: 30, - ), - ), - const SizedBox(width: 12), + const SizedBox(width: 12), - // Product Info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - product['name']!, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.grey900, - ), - ), - const SizedBox(height: 4), - Text( - 'Kích thước: ${product['size']}', - style: const TextStyle( - fontSize: 12, - color: AppColors.grey500, - ), - ), - Text( - 'SKU: ${product['sku']}', - style: const TextStyle( - fontSize: 12, - color: AppColors.grey500, - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Số lượng:', - style: TextStyle( - fontSize: 11, - color: AppColors.grey500, - ), - ), - Text( - product['quantity']!, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.grey900, - ), - ), - ], + // Product Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product['name']!, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.grey900, ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - product['unitPrice']!, - style: const TextStyle( - fontSize: 12, - color: AppColors.grey500, - ), - ), - Text( - product['totalPrice']!, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.danger, - ), - ), - ], + ), + const SizedBox(height: 4), + Text( + 'Kích thước: ${product['size']}', + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, ), - ], - ), - ], + ), + Text( + 'SKU: ${product['sku']}', + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Số lượng:', + style: TextStyle( + fontSize: 11, + color: AppColors.grey500, + ), + ), + Text( + product['quantity']!, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + product['unitPrice']!, + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + ), + Text( + product['totalPrice']!, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.danger, + ), + ), + ], + ), + ], + ), + ], + ), ), - ), - ], + ], + ), ), - )), + ), ], ), ), @@ -870,9 +876,7 @@ class OrderDetailPage extends ConsumerWidget { return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), elevation: 1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -880,7 +884,11 @@ class OrderDetailPage extends ConsumerWidget { children: [ Row( children: [ - const Icon(Icons.receipt_long, color: AppColors.primaryBlue, size: 20), + const Icon( + Icons.receipt_long, + color: AppColors.primaryBlue, + size: 20, + ), const SizedBox(width: 8), const Text( 'Tổng kết đơn hàng', @@ -900,7 +908,9 @@ class OrderDetailPage extends ConsumerWidget { _buildSummaryRow( 'Phí vận chuyển:', - shippingFee == 0 ? 'Miễn phí' : currencyFormatter.format(shippingFee), + shippingFee == 0 + ? 'Miễn phí' + : currencyFormatter.format(shippingFee), valueColor: shippingFee == 0 ? AppColors.success : null, ), const SizedBox(height: 8), @@ -924,14 +934,15 @@ class OrderDetailPage extends ConsumerWidget { // Payment Method Row( children: [ - const Icon(Icons.credit_card, size: 16, color: AppColors.grey500), + const Icon( + Icons.credit_card, + size: 16, + color: AppColors.grey500, + ), const SizedBox(width: 6), const Text( 'Phương thức thanh toán:', - style: TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 14, color: AppColors.grey500), ), ], ), @@ -955,10 +966,7 @@ class OrderDetailPage extends ConsumerWidget { const SizedBox(width: 6), const Text( 'Ghi chú đơn hàng:', - style: TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 14, color: AppColors.grey500), ), ], ), @@ -980,7 +988,12 @@ class OrderDetailPage extends ConsumerWidget { } /// Build Summary Row - Widget _buildSummaryRow(String label, String value, {bool isTotal = false, Color? valueColor}) { + Widget _buildSummaryRow( + String label, + String value, { + bool isTotal = false, + Color? valueColor, + }) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -997,7 +1010,8 @@ class OrderDetailPage extends ConsumerWidget { style: TextStyle( fontSize: isTotal ? 16 : 14, fontWeight: isTotal ? FontWeight.w700 : FontWeight.w500, - color: valueColor ?? (isTotal ? AppColors.danger : AppColors.grey900), + color: + valueColor ?? (isTotal ? AppColors.danger : AppColors.grey900), ), ), ], @@ -1039,7 +1053,8 @@ class OrderDetailPage extends ConsumerWidget { 'deliveryMethod': 'Giao hàng tiêu chuẩn', 'warehouseDate': DateTime(2023, 8, 5), 'deliveryDate': DateTime(2023, 8, 7), - 'deliveryAddress': '123 Đường Lê Văn Lương, Phường Tân Hưng,\nQuận 7, TP. Hồ Chí Minh', + 'deliveryAddress': + '123 Đường Lê Văn Lương, Phường Tân Hưng,\nQuận 7, TP. Hồ Chí Minh', 'receiverName': 'Nguyễn Văn A', 'receiverPhone': '0901234567', 'customerName': 'Nguyễn Văn A', @@ -1051,7 +1066,8 @@ class OrderDetailPage extends ConsumerWidget { 'discount': 129000.0, 'total': 12771000.0, 'paymentMethod': 'Chuyển khoản ngân hàng', - 'notes': 'Giao hàng trong giờ hành chính. Vui lòng gọi trước 30 phút khi đến giao hàng.', + 'notes': + 'Giao hàng trong giờ hành chính. Vui lòng gọi trước 30 phút khi đến giao hàng.', }; } } diff --git a/lib/features/orders/presentation/pages/orders_page.dart b/lib/features/orders/presentation/pages/orders_page.dart index 4f32b78..6bd60a4 100644 --- a/lib/features/orders/presentation/pages/orders_page.dart +++ b/lib/features/orders/presentation/pages/orders_page.dart @@ -43,9 +43,9 @@ class _OrdersPageState extends ConsumerState { } void _onSearchChanged() { - ref.read(orderSearchQueryProvider.notifier).updateQuery( - _searchController.text, - ); + ref + .read(orderSearchQueryProvider.notifier) + .updateQuery(_searchController.text); } @override @@ -68,9 +68,7 @@ class _OrdersPageState extends ConsumerState { backgroundColor: AppColors.white, foregroundColor: AppColors.grey900, centerTitle: false, - actions: const [ - SizedBox(width: AppSpacing.sm), - ], + actions: const [SizedBox(width: AppSpacing.sm)], ), body: RefreshIndicator( onRefresh: () async { @@ -87,9 +85,7 @@ class _OrdersPageState extends ConsumerState { ), // Filter Pills - SliverToBoxAdapter( - child: _buildFilterPills(selectedStatus), - ), + SliverToBoxAdapter(child: _buildFilterPills(selectedStatus)), // Orders List SliverPadding( @@ -101,18 +97,15 @@ class _OrdersPageState extends ConsumerState { } return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final order = orders[index]; - return OrderCard( - order: order, - onTap: () { - context.push('/orders/${order.orderId}'); - }, - ); - }, - childCount: orders.length, - ), + delegate: SliverChildBuilderDelegate((context, index) { + final order = orders[index]; + return OrderCard( + order: order, + onTap: () { + context.push('/orders/${order.orderId}'); + }, + ); + }, childCount: orders.length), ); }, loading: () => _buildLoadingState(), @@ -143,10 +136,7 @@ class _OrdersPageState extends ConsumerState { controller: _searchController, decoration: InputDecoration( hintText: 'Mã đơn hàng', - hintStyle: const TextStyle( - color: AppColors.grey500, - fontSize: 14, - ), + hintStyle: const TextStyle(color: AppColors.grey500, fontSize: 14), prefixIcon: const Icon( Icons.search, color: AppColors.grey500, @@ -310,10 +300,7 @@ class _OrdersPageState extends ConsumerState { const SizedBox(height: 8), const Text( 'Thử tìm kiếm với từ khóa khác', - style: TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 14, color: AppColors.grey500), ), ], ), @@ -324,9 +311,7 @@ class _OrdersPageState extends ConsumerState { /// Build loading state Widget _buildLoadingState() { return const SliverFillRemaining( - child: Center( - child: CircularProgressIndicator(), - ), + child: Center(child: CircularProgressIndicator()), ); } @@ -354,10 +339,7 @@ class _OrdersPageState extends ConsumerState { const SizedBox(height: 8), Text( error.toString(), - style: const TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14, color: AppColors.grey500), textAlign: TextAlign.center, ), ], diff --git a/lib/features/orders/presentation/pages/payment_detail_page.dart b/lib/features/orders/presentation/pages/payment_detail_page.dart index b20ee0d..2b676f6 100644 --- a/lib/features/orders/presentation/pages/payment_detail_page.dart +++ b/lib/features/orders/presentation/pages/payment_detail_page.dart @@ -21,11 +21,8 @@ import 'package:worker/features/orders/presentation/providers/invoices_provider. /// - Payment history /// - Action buttons class PaymentDetailPage extends ConsumerWidget { + const PaymentDetailPage({required this.invoiceId, super.key}); - const PaymentDetailPage({ - required this.invoiceId, - super.key, - }); /// Invoice ID final String invoiceId; @@ -53,9 +50,9 @@ class PaymentDetailPage extends ConsumerWidget { icon: const Icon(Icons.share, color: Colors.black), onPressed: () { // TODO: Implement share functionality - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Chia sẻ hóa đơn')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Chia sẻ hóa đơn'))); }, ), ], @@ -134,8 +131,14 @@ class PaymentDetailPage extends ConsumerWidget { icon: const Icon(Icons.chat_bubble_outline), label: const Text('Liên hệ hỗ trợ'), style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), - side: const BorderSide(color: AppColors.grey100, width: 2), + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + side: const BorderSide( + color: AppColors.grey100, + width: 2, + ), foregroundColor: AppColors.grey900, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), @@ -151,12 +154,15 @@ class PaymentDetailPage extends ConsumerWidget { width: double.infinity, margin: const EdgeInsets.symmetric(horizontal: 16), child: ElevatedButton.icon( - onPressed: (invoice.status == InvoiceStatus.paid || invoice.isPaid) + onPressed: + (invoice.status == InvoiceStatus.paid || invoice.isPaid) ? null : () { // TODO: Navigate to payment page ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Mở cổng thanh toán')), + const SnackBar( + content: Text('Mở cổng thanh toán'), + ), ); }, icon: Icon( @@ -171,7 +177,9 @@ class PaymentDetailPage extends ConsumerWidget { ), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), - backgroundColor: (invoice.status == InvoiceStatus.paid || invoice.isPaid) + backgroundColor: + (invoice.status == InvoiceStatus.paid || + invoice.isPaid) ? AppColors.success : AppColors.primaryBlue, disabledBackgroundColor: AppColors.success, @@ -195,11 +203,18 @@ class PaymentDetailPage extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.error_outline, size: 64, color: AppColors.danger), + const Icon( + Icons.error_outline, + size: 64, + color: AppColors.danger, + ), const SizedBox(height: 16), Text( 'Không tìm thấy hóa đơn', - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 24), ElevatedButton( @@ -287,7 +302,9 @@ class PaymentDetailPage extends ConsumerWidget { 'Còn lại:', currencyFormatter.format(amountRemaining), isHighlighted: true, - valueColor: amountRemaining > 0 ? AppColors.danger : AppColors.success, + valueColor: amountRemaining > 0 + ? AppColors.danger + : AppColors.success, ), ], ), @@ -354,7 +371,9 @@ class PaymentDetailPage extends ConsumerWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: isOverdue ? AppColors.danger : AppColors.grey900, + color: isOverdue + ? AppColors.danger + : AppColors.grey900, ), ), ], @@ -451,79 +470,84 @@ class PaymentDetailPage extends ConsumerWidget { ), const SizedBox(height: 16), - ...products.map((product) => Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all(color: AppColors.grey100), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Product image placeholder - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: AppColors.grey50, - borderRadius: BorderRadius.circular(8), + ...products + .map( + (product) => Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: AppColors.grey100), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Product image placeholder + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: AppColors.grey50, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.image, + color: AppColors.grey500, + size: 24, + ), ), - child: const Icon( - Icons.image, - color: AppColors.grey500, - size: 24, - ), - ), - const SizedBox(width: 16), - // Product info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - product['name']!, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.grey900, - ), - ), - const SizedBox(height: 4), - Text( - 'SKU: ${product['sku']}', - style: const TextStyle( - fontSize: 12, - color: AppColors.grey500, - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Số lượng: ${product['quantity']}', - style: const TextStyle( - fontSize: 12, - color: AppColors.grey500, - ), + const SizedBox(width: 16), + // Product info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product['name']!, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.grey900, ), - Text( - product['price']!, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.grey900, - ), + ), + const SizedBox(height: 4), + Text( + 'SKU: ${product['sku']}', + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, ), - ], - ), - ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Số lượng: ${product['quantity']}', + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + ), + Text( + product['price']!, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + ), + ], + ), + ], + ), ), - ), - ], + ], + ), ), - )).toList(), + ) + .toList(), ], ), ), diff --git a/lib/features/orders/presentation/pages/payment_qr_page.dart b/lib/features/orders/presentation/pages/payment_qr_page.dart index 41ddd39..46df8ea 100644 --- a/lib/features/orders/presentation/pages/payment_qr_page.dart +++ b/lib/features/orders/presentation/pages/payment_qr_page.dart @@ -27,11 +27,7 @@ class PaymentQrPage extends HookConsumerWidget { final String orderId; final double amount; - const PaymentQrPage({ - super.key, - required this.orderId, - required this.amount, - }); + const PaymentQrPage({super.key, required this.orderId, required this.amount}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -186,7 +182,8 @@ class PaymentQrPage extends HookConsumerWidget { Widget _buildQrCodeCard(double amount, String orderId) { // Generate QR code data URL final qrData = Uri.encodeComponent( - 'https://eurotile.com/payment/$orderId?amount=$amount'); + 'https://eurotile.com/payment/$orderId?amount=$amount', + ); final qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=$qrData'; @@ -283,11 +280,7 @@ class PaymentQrPage extends HookConsumerWidget { const SizedBox(height: AppSpacing.md), // Bank Name - _buildInfoRow( - context: context, - label: 'Ngân hàng:', - value: 'BIDV', - ), + _buildInfoRow(context: context, label: 'Ngân hàng:', value: 'BIDV'), const Divider(height: 24), @@ -329,8 +322,11 @@ class PaymentQrPage extends HookConsumerWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.lightbulb_outline, - color: AppColors.primaryBlue, size: 20), + const Icon( + Icons.lightbulb_outline, + color: AppColors.primaryBlue, + size: 20, + ), const SizedBox(width: 8), Expanded( child: RichText( @@ -414,7 +410,10 @@ class PaymentQrPage extends HookConsumerWidget { ), style: OutlinedButton.styleFrom( 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(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.button), @@ -534,9 +533,7 @@ class PaymentQrPage extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('• ', style: TextStyle(fontSize: 14)), - Expanded( - child: Text(text, style: const TextStyle(fontSize: 14)), - ), + Expanded(child: Text(text, style: const TextStyle(fontSize: 14))), ], ), ); @@ -597,9 +594,6 @@ class PaymentQrPage extends HookConsumerWidget { /// Format currency String _formatCurrency(double amount) { - return '${amount.toStringAsFixed(0).replaceAllMapped( - RegExp(r'(\d)(?=(\d{3})+(?!\d))'), - (Match m) => '${m[1]}.', - )}₫'; + return '${amount.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}₫'; } } diff --git a/lib/features/orders/presentation/pages/payments_page.dart b/lib/features/orders/presentation/pages/payments_page.dart index c560649..8a17b28 100644 --- a/lib/features/orders/presentation/pages/payments_page.dart +++ b/lib/features/orders/presentation/pages/payments_page.dart @@ -47,8 +47,9 @@ class _PaymentsPageState extends ConsumerState @override void dispose() { - _tabController..removeListener(_onTabChanged) - ..dispose(); + _tabController + ..removeListener(_onTabChanged) + ..dispose(); super.dispose(); } @@ -57,26 +58,38 @@ class _PaymentsPageState extends ConsumerState } /// Filter invoices based on tab key - List _filterInvoices(List invoices, String tabKey) { + List _filterInvoices( + List invoices, + String tabKey, + ) { var filtered = List.from(invoices); switch (tabKey) { case 'unpaid': // Unpaid tab: issued status only filtered = filtered - .where((invoice) => invoice.status == InvoiceStatus.issued && !invoice.isPaid) + .where( + (invoice) => + invoice.status == InvoiceStatus.issued && !invoice.isPaid, + ) .toList(); break; case 'overdue': // Overdue tab: overdue status filtered = filtered - .where((invoice) => invoice.status == InvoiceStatus.overdue || invoice.isOverdue) + .where( + (invoice) => + invoice.status == InvoiceStatus.overdue || invoice.isOverdue, + ) .toList(); break; case 'paid': // Paid tab: paid status filtered = filtered - .where((invoice) => invoice.status == InvoiceStatus.paid || invoice.isPaid) + .where( + (invoice) => + invoice.status == InvoiceStatus.paid || invoice.isPaid, + ) .toList(); break; case 'all': @@ -96,13 +109,21 @@ class _PaymentsPageState extends ConsumerState return { 'all': invoices.length, 'unpaid': invoices - .where((invoice) => invoice.status == InvoiceStatus.issued && !invoice.isPaid) + .where( + (invoice) => + invoice.status == InvoiceStatus.issued && !invoice.isPaid, + ) .length, 'overdue': invoices - .where((invoice) => invoice.status == InvoiceStatus.overdue || invoice.isOverdue) + .where( + (invoice) => + invoice.status == InvoiceStatus.overdue || invoice.isOverdue, + ) .length, 'paid': invoices - .where((invoice) => invoice.status == InvoiceStatus.paid || invoice.isPaid) + .where( + (invoice) => invoice.status == InvoiceStatus.paid || invoice.isPaid, + ) .length, }; } @@ -190,7 +211,10 @@ class _PaymentsPageState extends ConsumerState child: TabBarView( controller: _tabController, children: _tabs.map((tab) { - final filteredInvoices = _filterInvoices(allInvoices, tab['key']!); + final filteredInvoices = _filterInvoices( + allInvoices, + tab['key']!, + ); return CustomScrollView( slivers: [ @@ -200,21 +224,25 @@ class _PaymentsPageState extends ConsumerState sliver: filteredInvoices.isEmpty ? _buildEmptyState(tab['label']!) : SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final invoice = filteredInvoices[index]; - return InvoiceCard( - invoice: invoice, - onTap: () { - context.push('/payments/${invoice.invoiceId}'); - }, - onPaymentTap: () { - context.push('/payments/${invoice.invoiceId}'); - }, - ); - }, - childCount: filteredInvoices.length, - ), + delegate: SliverChildBuilderDelegate(( + context, + index, + ) { + final invoice = filteredInvoices[index]; + return InvoiceCard( + invoice: invoice, + onTap: () { + context.push( + '/payments/${invoice.invoiceId}', + ); + }, + onPaymentTap: () { + context.push( + '/payments/${invoice.invoiceId}', + ); + }, + ); + }, childCount: filteredInvoices.length), ), ), ], @@ -240,9 +268,7 @@ class _PaymentsPageState extends ConsumerState foregroundColor: AppColors.grey900, centerTitle: false, ), - body: const Center( - child: CircularProgressIndicator(), - ), + body: const Center(child: CircularProgressIndicator()), ), error: (error, stack) => Scaffold( backgroundColor: const Color(0xFFF4F6F8), @@ -281,10 +307,7 @@ class _PaymentsPageState extends ConsumerState const SizedBox(height: 8), Text( error.toString(), - style: const TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14, color: AppColors.grey500), textAlign: TextAlign.center, ), ], @@ -339,10 +362,7 @@ class _PaymentsPageState extends ConsumerState const SizedBox(height: 8), const Text( 'Kéo xuống để làm mới', - style: TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 14, color: AppColors.grey500), ), ], ), diff --git a/lib/features/orders/presentation/providers/invoices_provider.dart b/lib/features/orders/presentation/providers/invoices_provider.dart index aa1aab7..9d61b21 100644 --- a/lib/features/orders/presentation/providers/invoices_provider.dart +++ b/lib/features/orders/presentation/providers/invoices_provider.dart @@ -74,17 +74,27 @@ Future> filteredInvoices(Ref ref) async { if (selectedStatus == 'unpaid') { // Unpaid tab: issued status only filtered = filtered - .where((invoice) => invoice.status == InvoiceStatus.issued && !invoice.isPaid) + .where( + (invoice) => + invoice.status == InvoiceStatus.issued && !invoice.isPaid, + ) .toList(); } else if (selectedStatus == 'overdue') { // Overdue tab: overdue status filtered = filtered - .where((invoice) => invoice.status == InvoiceStatus.overdue || invoice.isOverdue) + .where( + (invoice) => + invoice.status == InvoiceStatus.overdue || + invoice.isOverdue, + ) .toList(); } else if (selectedStatus == 'paid') { // Paid tab: paid status filtered = filtered - .where((invoice) => invoice.status == InvoiceStatus.paid || invoice.isPaid) + .where( + (invoice) => + invoice.status == InvoiceStatus.paid || invoice.isPaid, + ) .toList(); } } @@ -115,17 +125,25 @@ Future> invoicesCountByStatus(Ref ref) async { // Unpaid tab (issued status) counts['unpaid'] = invoices - .where((invoice) => invoice.status == InvoiceStatus.issued && !invoice.isPaid) + .where( + (invoice) => + invoice.status == InvoiceStatus.issued && !invoice.isPaid, + ) .length; // Overdue tab counts['overdue'] = invoices - .where((invoice) => invoice.status == InvoiceStatus.overdue || invoice.isOverdue) + .where( + (invoice) => + invoice.status == InvoiceStatus.overdue || invoice.isOverdue, + ) .length; // Paid tab counts['paid'] = invoices - .where((invoice) => invoice.status == InvoiceStatus.paid || invoice.isPaid) + .where( + (invoice) => invoice.status == InvoiceStatus.paid || invoice.isPaid, + ) .length; return counts; @@ -144,7 +162,10 @@ Future totalInvoicesAmount(Ref ref) async { return invoicesAsync.when( data: (invoices) { - return invoices.fold(0.0, (sum, invoice) => sum + invoice.totalAmount); + return invoices.fold( + 0.0, + (sum, invoice) => sum + invoice.totalAmount, + ); }, loading: () => 0.0, error: (error, stack) => 0.0, @@ -160,7 +181,10 @@ Future totalUnpaidAmount(Ref ref) async { return invoicesAsync.when( data: (invoices) { - return invoices.fold(0.0, (sum, invoice) => sum + invoice.amountRemaining); + return invoices.fold( + 0.0, + (sum, invoice) => sum + invoice.amountRemaining, + ); }, loading: () => 0.0, error: (error, stack) => 0.0, diff --git a/lib/features/orders/presentation/providers/orders_provider.dart b/lib/features/orders/presentation/providers/orders_provider.dart index f493ef3..537542b 100644 --- a/lib/features/orders/presentation/providers/orders_provider.dart +++ b/lib/features/orders/presentation/providers/orders_provider.dart @@ -102,9 +102,9 @@ Future> filteredOrders(Ref ref) async { if (searchQuery.isNotEmpty) { filtered = filtered .where( - (order) => order.orderNumber - .toLowerCase() - .contains(searchQuery.toLowerCase()), + (order) => order.orderNumber.toLowerCase().contains( + searchQuery.toLowerCase(), + ), ) .toList(); } diff --git a/lib/features/orders/presentation/widgets/invoice_card.dart b/lib/features/orders/presentation/widgets/invoice_card.dart index aa2e11e..02bf47a 100644 --- a/lib/features/orders/presentation/widgets/invoice_card.dart +++ b/lib/features/orders/presentation/widgets/invoice_card.dart @@ -40,9 +40,7 @@ class InvoiceCard extends StatelessWidget { return Card( margin: const EdgeInsets.only(bottom: 12), elevation: 1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, @@ -89,16 +87,10 @@ class InvoiceCard extends StatelessWidget { const SizedBox(height: 12), // Invoice dates - _buildDetailRow( - 'Ngày hóa đơn:', - _formatDate(invoice.issueDate), - ), + _buildDetailRow('Ngày hóa đơn:', _formatDate(invoice.issueDate)), const SizedBox(height: 6), - _buildDetailRow( - 'Hạn thanh toán:', - _formatDate(invoice.dueDate), - ), + _buildDetailRow('Hạn thanh toán:', _formatDate(invoice.dueDate)), const SizedBox(height: 12), @@ -161,10 +153,7 @@ class InvoiceCard extends StatelessWidget { children: [ Text( label, - style: const TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14, color: AppColors.grey500), ), Text( @@ -191,10 +180,7 @@ class InvoiceCard extends StatelessWidget { children: [ Text( label, - style: const TextStyle( - fontSize: 13, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 13, color: AppColors.grey500), ), Text( value, @@ -211,10 +197,7 @@ class InvoiceCard extends StatelessWidget { /// Build status badge Widget _buildStatusBadge() { return Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: _getStatusColor(invoice.status).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(16), @@ -250,9 +233,7 @@ class InvoiceCard extends StatelessWidget { foregroundColor: Colors.white, disabledForegroundColor: AppColors.grey500, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), elevation: 0, ), child: Row( @@ -263,10 +244,7 @@ class InvoiceCard extends StatelessWidget { Icon(Icons.credit_card), Text( buttonText, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), ), ], ), diff --git a/lib/features/orders/presentation/widgets/order_card.dart b/lib/features/orders/presentation/widgets/order_card.dart index bba5fa8..0d7d8c4 100644 --- a/lib/features/orders/presentation/widgets/order_card.dart +++ b/lib/features/orders/presentation/widgets/order_card.dart @@ -22,11 +22,7 @@ class OrderCard extends StatelessWidget { /// Tap callback final VoidCallback? onTap; - const OrderCard({ - required this.order, - this.onTap, - super.key, - }); + const OrderCard({required this.order, this.onTap, super.key}); @override Widget build(BuildContext context) { @@ -39,9 +35,7 @@ class OrderCard extends StatelessWidget { return Card( margin: const EdgeInsets.only(bottom: 12), elevation: 1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, @@ -80,10 +74,7 @@ class OrderCard extends StatelessWidget { const SizedBox(height: 12), // Order details - _buildDetailRow( - 'Ngày đặt:', - _formatDate(order.createdAt), - ), + _buildDetailRow('Ngày đặt:', _formatDate(order.createdAt)), const SizedBox(height: 6), _buildDetailRow( @@ -94,10 +85,7 @@ class OrderCard extends StatelessWidget { ), const SizedBox(height: 6), - _buildDetailRow( - 'Địa chỉ:', - _getShortAddress(), - ), + _buildDetailRow('Địa chỉ:', _getShortAddress()), const SizedBox(height: 12), // Status badge @@ -116,19 +104,13 @@ class OrderCard extends StatelessWidget { children: [ Text( label, - style: const TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14, color: AppColors.grey500), ), const SizedBox(width: 8), Expanded( child: Text( value, - style: const TextStyle( - fontSize: 14, - color: AppColors.grey900, - ), + style: const TextStyle(fontSize: 14, color: AppColors.grey900), ), ), ], @@ -138,10 +120,7 @@ class OrderCard extends StatelessWidget { /// Build status badge Widget _buildStatusBadge() { return Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: _getStatusColor(order.status).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(16), diff --git a/lib/features/products/data/datasources/products_local_datasource.dart b/lib/features/products/data/datasources/products_local_datasource.dart index 72a36b9..c4b59f7 100644 --- a/lib/features/products/data/datasources/products_local_datasource.dart +++ b/lib/features/products/data/datasources/products_local_datasource.dart @@ -74,12 +74,16 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource { { 'product_id': 'prod_001', 'name': 'Gạch Cát Tường 1200x1200', - 'description': 'Gạch men bóng kiếng cao cấp, chống trượt, độ bền cao. Phù hợp cho phòng khách, phòng ngủ.', + 'description': + 'Gạch men bóng kiếng cao cấp, chống trượt, độ bền cao. Phù hợp cho phòng khách, phòng ngủ.', 'base_price': 450000.0, 'unit': 'm²', - 'images': ['https://www.eurotile.vn/pictures/catalog/product/0-gachkholon/cat-tuong/CAT-S01G-1.jpg'], + 'images': [ + 'https://www.eurotile.vn/pictures/catalog/product/0-gachkholon/cat-tuong/CAT-S01G-1.jpg', + ], 'image_captions': {}, - 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US', + 'link_360': + 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US', 'specifications': {}, 'category': 'floor_tiles', 'brand': 'Eurotile', @@ -92,12 +96,16 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource { { 'product_id': 'prod_002', 'name': 'Gạch granite nhập khẩu', - 'description': 'Gạch granite nhập khẩu Tây Ban Nha, vân đá tự nhiên, sang trọng. Kích thước 80x80cm.', + 'description': + 'Gạch granite nhập khẩu Tây Ban Nha, vân đá tự nhiên, sang trọng. Kích thước 80x80cm.', 'base_price': 680000.0, 'unit': 'm²', - 'images': ['https://images.unsplash.com/photo-1565193566173-7a0ee3dbe261?w=300&h=300&fit=crop'], + 'images': [ + 'https://images.unsplash.com/photo-1565193566173-7a0ee3dbe261?w=300&h=300&fit=crop', + ], 'image_captions': {}, - 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R&locale=en_US', + 'link_360': + 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R&locale=en_US', 'specifications': {}, 'category': 'floor_tiles', 'brand': 'Vasta Stone', @@ -110,12 +118,16 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource { { 'product_id': 'prod_003', 'name': 'Gạch mosaic trang trí', - 'description': 'Gạch mosaic thủy tinh màu sắc đa dạng, tạo điểm nhấn cho không gian. Kích thước 30x30cm.', + 'description': + 'Gạch mosaic thủy tinh màu sắc đa dạng, tạo điểm nhấn cho không gian. Kích thước 30x30cm.', 'base_price': 320000.0, 'unit': 'm²', - 'images': ['https://images.unsplash.com/photo-1615971677499-5467cbab01c0?w=300&h=300&fit=crop'], + 'images': [ + 'https://images.unsplash.com/photo-1615971677499-5467cbab01c0?w=300&h=300&fit=crop', + ], 'image_captions': {}, - 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US', + 'link_360': + 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US', 'specifications': {}, 'category': 'decorative_tiles', 'brand': 'Eurotile', @@ -128,12 +140,16 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource { { 'product_id': 'prod_004', 'name': 'Gạch 3D họa tiết', - 'description': 'Gạch 3D với họa tiết nổi độc đáo, tạo hiệu ứng thị giác ấn tượng cho tường phòng khách.', + 'description': + 'Gạch 3D với họa tiết nổi độc đáo, tạo hiệu ứng thị giác ấn tượng cho tường phòng khách.', 'base_price': 750000.0, 'unit': 'm²', - 'images': ['https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=300&h=300&fit=crop'], + 'images': [ + 'https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=300&h=300&fit=crop', + ], 'image_captions': {}, - 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US', + 'link_360': + 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US', 'specifications': {}, 'category': 'wall_tiles', 'brand': 'Vasta Stone', @@ -146,12 +162,16 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource { { 'product_id': 'prod_005', 'name': 'Gạch ceramic chống trượt', - 'description': 'Gạch ceramic chống trượt cấp độ R11, an toàn cho phòng tắm và ban công. Kích thước 40x40cm.', + 'description': + 'Gạch ceramic chống trượt cấp độ R11, an toàn cho phòng tắm và ban công. Kích thước 40x40cm.', 'base_price': 380000.0, 'unit': 'm²', - 'images': ['https://images.unsplash.com/photo-1615874694520-474822394e73?w=300&h=300&fit=crop'], + 'images': [ + 'https://images.unsplash.com/photo-1615874694520-474822394e73?w=300&h=300&fit=crop', + ], 'image_captions': {}, - 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US', + 'link_360': + 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US', 'specifications': {}, 'category': 'outdoor_tiles', 'brand': 'Eurotile', @@ -164,10 +184,13 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource { { 'product_id': 'prod_006', 'name': 'Gạch terrazzo đá mài', - 'description': 'Gạch terrazzo phong cách retro, đá mài hạt màu, độc đáo và bền đẹp theo thời gian.', + 'description': + 'Gạch terrazzo phong cách retro, đá mài hạt màu, độc đáo và bền đẹp theo thời gian.', 'base_price': 890000.0, 'unit': 'm²', - 'images': ['https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=300&h=300&fit=crop'], + 'images': [ + 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=300&h=300&fit=crop', + ], 'image_captions': {}, 'link_360': null, 'specifications': {}, @@ -186,9 +209,7 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource { // Simulate network delay await Future.delayed(const Duration(milliseconds: 500)); - return _productsJson - .map((json) => ProductModel.fromJson(json)) - .toList(); + return _productsJson.map((json) => ProductModel.fromJson(json)).toList(); } @override @@ -245,8 +266,6 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource { // Simulate network delay await Future.delayed(const Duration(milliseconds: 300)); - return _categoriesJson - .map((json) => CategoryModel.fromJson(json)) - .toList(); + return _categoriesJson.map((json) => CategoryModel.fromJson(json)).toList(); } } diff --git a/lib/features/products/data/models/product_model.dart b/lib/features/products/data/models/product_model.dart index 68ae32e..4958e70 100644 --- a/lib/features/products/data/models/product_model.dart +++ b/lib/features/products/data/models/product_model.dart @@ -139,11 +139,13 @@ class ProductModel extends HiveObject { 'description': description, 'base_price': basePrice, 'images': images != null ? jsonDecode(images!) : null, - 'image_captions': - imageCaptions != null ? jsonDecode(imageCaptions!) : null, + 'image_captions': imageCaptions != null + ? jsonDecode(imageCaptions!) + : null, 'link_360': link360, - 'specifications': - specifications != null ? jsonDecode(specifications!) : null, + 'specifications': specifications != null + ? jsonDecode(specifications!) + : null, 'category': category, 'brand': brand, 'unit': unit, diff --git a/lib/features/products/data/repositories/products_repository_impl.dart b/lib/features/products/data/repositories/products_repository_impl.dart index ed5bf77..ba561c2 100644 --- a/lib/features/products/data/repositories/products_repository_impl.dart +++ b/lib/features/products/data/repositories/products_repository_impl.dart @@ -16,9 +16,7 @@ import 'package:worker/features/products/domain/repositories/products_repository class ProductsRepositoryImpl implements ProductsRepository { final ProductsLocalDataSource localDataSource; - const ProductsRepositoryImpl({ - required this.localDataSource, - }); + const ProductsRepositoryImpl({required this.localDataSource}); @override Future> getAllProducts() async { @@ -43,7 +41,9 @@ class ProductsRepositoryImpl implements ProductsRepository { @override Future> getProductsByCategory(String categoryId) async { try { - final productModels = await localDataSource.getProductsByCategory(categoryId); + final productModels = await localDataSource.getProductsByCategory( + categoryId, + ); return productModels.map((model) => model.toEntity()).toList(); } catch (e) { throw Exception('Failed to get products by category: $e'); diff --git a/lib/features/products/presentation/pages/product_detail_page.dart b/lib/features/products/presentation/pages/product_detail_page.dart index 6ffc583..718c7a4 100644 --- a/lib/features/products/presentation/pages/product_detail_page.dart +++ b/lib/features/products/presentation/pages/product_detail_page.dart @@ -26,10 +26,7 @@ import 'package:worker/features/products/presentation/widgets/product_detail/sti class ProductDetailPage extends ConsumerStatefulWidget { final String productId; - const ProductDetailPage({ - super.key, - required this.productId, - }); + const ProductDetailPage({super.key, required this.productId}); @override ConsumerState createState() => _ProductDetailPageState(); @@ -70,9 +67,7 @@ class _ProductDetailPageState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - _isFavorite - ? 'Đã thêm vào yêu thích' - : 'Đã xóa khỏi yêu thích', + _isFavorite ? 'Đã thêm vào yêu thích' : 'Đã xóa khỏi yêu thích', ), duration: const Duration(seconds: 1), ), @@ -109,10 +104,7 @@ class _ProductDetailPageState extends ConsumerState { // Title const Text( 'Chia sẻ sản phẩm', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - ), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), ), const SizedBox(height: AppSpacing.lg), @@ -144,9 +136,7 @@ class _ProductDetailPageState extends ConsumerState { Navigator.pop(context); // TODO: Copy to clipboard ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Đã sao chép link sản phẩm!'), - ), + const SnackBar(content: Text('Đã sao chép link sản phẩm!')), ); }, ), @@ -269,9 +259,7 @@ class _ProductDetailPageState extends ConsumerState { ); }, loading: () => const Center( - child: CircularProgressIndicator( - color: AppColors.primaryBlue, - ), + child: CircularProgressIndicator(color: AppColors.primaryBlue), ), error: (error, stack) => Center( child: Padding( @@ -287,10 +275,7 @@ class _ProductDetailPageState extends ConsumerState { const SizedBox(height: AppSpacing.lg), const Text( 'Không thể tải thông tin sản phẩm', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - ), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), textAlign: TextAlign.center, ), const SizedBox(height: AppSpacing.sm), diff --git a/lib/features/products/presentation/pages/products_page.dart b/lib/features/products/presentation/pages/products_page.dart index c041e58..0687218 100644 --- a/lib/features/products/presentation/pages/products_page.dart +++ b/lib/features/products/presentation/pages/products_page.dart @@ -55,7 +55,10 @@ class ProductsPage extends ConsumerWidget { backgroundColor: AppColors.danger, textColor: AppColors.white, isLabelVisible: cartItemCount > 0, - child: const Icon(Icons.shopping_cart_outlined, color: Colors.black), + child: const Icon( + Icons.shopping_cart_outlined, + color: Colors.black, + ), ), onPressed: () => context.push(RouteNames.cart), ), @@ -74,9 +77,7 @@ class ProductsPage extends ConsumerWidget { data: (categories) => CategoryFilterChips(categories: categories), loading: () => const SizedBox( height: 48.0, - child: Center( - child: CircularProgressIndicator(strokeWidth: 2.0), - ), + child: Center(child: CircularProgressIndicator(strokeWidth: 2.0)), ), error: (error, stack) => const SizedBox.shrink(), ), @@ -115,7 +116,8 @@ class ProductsPage extends ConsumerWidget { ); }, loading: () => _buildLoadingState(), - error: (error, stack) => _buildErrorState(context, l10n, error, ref), + error: (error, stack) => + _buildErrorState(context, l10n, error, ref), ), ), ], @@ -146,10 +148,7 @@ class ProductsPage extends ConsumerWidget { const SizedBox(height: AppSpacing.sm), Text( l10n.noResults, - style: const TextStyle( - fontSize: 14.0, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14.0, color: AppColors.grey500), ), ], ), @@ -159,9 +158,7 @@ class ProductsPage extends ConsumerWidget { /// Build loading state Widget _buildLoadingState() { return const Center( - child: CircularProgressIndicator( - color: AppColors.primaryBlue, - ), + child: CircularProgressIndicator(color: AppColors.primaryBlue), ); } @@ -195,10 +192,7 @@ class ProductsPage extends ConsumerWidget { const SizedBox(height: AppSpacing.sm), Text( error.toString(), - style: const TextStyle( - fontSize: 14.0, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14.0, color: AppColors.grey500), textAlign: TextAlign.center, ), const SizedBox(height: AppSpacing.lg), diff --git a/lib/features/products/presentation/widgets/category_filter_chips.dart b/lib/features/products/presentation/widgets/category_filter_chips.dart index 0fa9cea..9dd3eb6 100644 --- a/lib/features/products/presentation/widgets/category_filter_chips.dart +++ b/lib/features/products/presentation/widgets/category_filter_chips.dart @@ -17,10 +17,7 @@ import 'package:worker/features/products/presentation/providers/selected_categor class CategoryFilterChips extends ConsumerWidget { final List categories; - const CategoryFilterChips({ - super.key, - required this.categories, - }); + const CategoryFilterChips({super.key, required this.categories}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -32,7 +29,8 @@ class CategoryFilterChips extends ConsumerWidget { scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), itemCount: categories.length, - separatorBuilder: (context, index) => const SizedBox(width: AppSpacing.sm), + separatorBuilder: (context, index) => + const SizedBox(width: AppSpacing.sm), itemBuilder: (context, index) { final category = categories[index]; final isSelected = selectedCategory == category.id; @@ -49,7 +47,9 @@ class CategoryFilterChips extends ConsumerWidget { selected: isSelected, onSelected: (selected) { if (selected) { - ref.read(selectedCategoryProvider.notifier).updateCategory(category.id); + ref + .read(selectedCategoryProvider.notifier) + .updateCategory(category.id); } }, backgroundColor: AppColors.white, diff --git a/lib/features/products/presentation/widgets/product_card.dart b/lib/features/products/presentation/widgets/product_card.dart index 54f3f1b..0296d72 100644 --- a/lib/features/products/presentation/widgets/product_card.dart +++ b/lib/features/products/presentation/widgets/product_card.dart @@ -70,9 +70,7 @@ class ProductCard extends ConsumerWidget { placeholder: (context, url) => Shimmer.fromColors( baseColor: AppColors.grey100, highlightColor: AppColors.grey50, - child: Container( - color: AppColors.grey100, - ), + child: Container(color: AppColors.grey100), ), errorWidget: (context, url, error) => Container( color: AppColors.grey100, @@ -182,8 +180,12 @@ class ProductCard extends ConsumerWidget { ], ), child: Icon( - isFavorited ? Icons.favorite : Icons.favorite_border, - color: isFavorited ? AppColors.danger : AppColors.grey500, + isFavorited + ? Icons.favorite + : Icons.favorite_border, + color: isFavorited + ? AppColors.danger + : AppColors.grey500, size: 20, ), ), @@ -244,7 +246,9 @@ class ProductCard extends ConsumerWidget { disabledForegroundColor: AppColors.grey500, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.button), + borderRadius: BorderRadius.circular( + AppRadius.button, + ), ), padding: const EdgeInsets.symmetric( horizontal: AppSpacing.sm, @@ -272,7 +276,9 @@ class ProductCard extends ConsumerWidget { // For now, show a message ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Đang phát triển tính năng xem 360°'), + content: Text( + 'Đang phát triển tính năng xem 360°', + ), duration: Duration(seconds: 2), ), ); @@ -285,7 +291,9 @@ class ProductCard extends ConsumerWidget { ), elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.button), + borderRadius: BorderRadius.circular( + AppRadius.button, + ), ), padding: const EdgeInsets.symmetric( horizontal: AppSpacing.sm, diff --git a/lib/features/products/presentation/widgets/product_detail/image_gallery_section.dart b/lib/features/products/presentation/widgets/product_detail/image_gallery_section.dart index ec953e2..33029cb 100644 --- a/lib/features/products/presentation/widgets/product_detail/image_gallery_section.dart +++ b/lib/features/products/presentation/widgets/product_detail/image_gallery_section.dart @@ -20,10 +20,7 @@ import 'package:worker/features/products/domain/entities/product.dart'; class ImageGallerySection extends StatefulWidget { final Product product; - const ImageGallerySection({ - super.key, - required this.product, - }); + const ImageGallerySection({super.key, required this.product}); @override State createState() => _ImageGallerySectionState(); @@ -232,9 +229,8 @@ class _ImageGallerySectionState extends State { child: CachedNetworkImage( imageUrl: images[index], fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: AppColors.grey100, - ), + placeholder: (context, url) => + Container(color: AppColors.grey100), errorWidget: (context, url, error) => Container( color: AppColors.grey100, child: const Icon( @@ -321,10 +317,7 @@ class _ImageLightboxState extends State<_ImageLightbox> { ), title: Text( '${_currentIndex + 1} / ${widget.images.length}', - style: const TextStyle( - color: AppColors.white, - fontSize: 16, - ), + style: const TextStyle(color: AppColors.white, fontSize: 16), ), ), body: Stack( @@ -414,18 +407,12 @@ class _ImageLightboxState extends State<_ImageLightbox> { gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, - colors: [ - Colors.black.withAlpha(128), - Colors.transparent, - ], + colors: [Colors.black.withAlpha(128), Colors.transparent], ), ), child: Text( widget.imageCaptions[widget.images[_currentIndex]] ?? '', - style: const TextStyle( - color: AppColors.white, - fontSize: 16, - ), + style: const TextStyle(color: AppColors.white, fontSize: 16), textAlign: TextAlign.center, ), ), diff --git a/lib/features/products/presentation/widgets/product_detail/product_info_section.dart b/lib/features/products/presentation/widgets/product_detail/product_info_section.dart index beacf36..d6b100d 100644 --- a/lib/features/products/presentation/widgets/product_detail/product_info_section.dart +++ b/lib/features/products/presentation/widgets/product_detail/product_info_section.dart @@ -19,10 +19,7 @@ import 'package:worker/features/products/domain/entities/product.dart'; class ProductInfoSection extends StatelessWidget { final Product product; - const ProductInfoSection({ - super.key, - required this.product, - }); + const ProductInfoSection({super.key, required this.product}); String _formatPrice(double price) { final formatter = NumberFormat('#,###', 'vi_VN'); @@ -40,10 +37,7 @@ class ProductInfoSection extends StatelessWidget { // SKU Text( 'SKU: ${product.erpnextItemCode ?? product.productId}', - style: const TextStyle( - fontSize: 12, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 12, color: AppColors.grey500), ), const SizedBox(height: 8), @@ -168,18 +162,11 @@ class _QuickInfoCard extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - Icon( - icon, - color: AppColors.primaryBlue, - size: 24, - ), + Icon(icon, color: AppColors.primaryBlue, size: 24), const SizedBox(height: 4), Text( label, - style: const TextStyle( - fontSize: 12, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 12, color: AppColors.grey500), textAlign: TextAlign.center, ), const SizedBox(height: 2), diff --git a/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart b/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart index c9f8b1b..9494024 100644 --- a/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart +++ b/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart @@ -17,10 +17,7 @@ import 'package:worker/features/products/domain/entities/product.dart'; class ProductTabsSection extends StatefulWidget { final Product product; - const ProductTabsSection({ - super.key, - required this.product, - }); + const ProductTabsSection({super.key, required this.product}); @override State createState() => _ProductTabsSectionState(); @@ -52,10 +49,7 @@ class _ProductTabsSectionState extends State Container( decoration: const BoxDecoration( border: Border( - bottom: BorderSide( - color: Color(0xFFe0e0e0), - width: 1, - ), + bottom: BorderSide(color: Color(0xFFe0e0e0), width: 1), ), ), child: TabBar( @@ -152,29 +146,31 @@ class _DescriptionTab extends StatelessWidget { 'Màu sắc bền đẹp theo thời gian', 'Dễ dàng vệ sinh và bảo trì', 'Thân thiện với môi trường', - ].map((feature) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon( - Icons.check_circle, - size: 18, - color: AppColors.success, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - feature, - style: const TextStyle( - fontSize: 14, - color: AppColors.grey900, - ), + ].map( + (feature) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.check_circle, + size: 18, + color: AppColors.success, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + feature, + style: const TextStyle( + fontSize: 14, + color: AppColors.grey900, ), ), - ], - ), - )), + ), + ], + ), + ), + ), const SizedBox(height: 20), @@ -242,9 +238,7 @@ class _SpecificationsTab extends StatelessWidget { border: isLast ? null : const Border( - bottom: BorderSide( - color: Color(0xFFe0e0e0), - ), + bottom: BorderSide(color: Color(0xFFe0e0e0)), ), ), child: Row( @@ -335,9 +329,7 @@ class _ReviewsTab extends StatelessWidget { children: List.generate( 5, (index) => Icon( - index < 4 - ? Icons.star - : Icons.star_half, + index < 4 ? Icons.star : Icons.star_half, color: const Color(0xFFffc107), size: 18, ), @@ -349,10 +341,7 @@ class _ReviewsTab extends StatelessWidget { // Review count const Text( '125 đánh giá', - style: TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 14, color: AppColors.grey500), ), ], ), @@ -382,11 +371,7 @@ class _ReviewItem extends StatelessWidget { padding: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 16), decoration: const BoxDecoration( - border: Border( - bottom: BorderSide( - color: Color(0xFFe0e0e0), - ), - ), + border: Border(bottom: BorderSide(color: Color(0xFFe0e0e0))), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -482,6 +467,7 @@ final _mockReviews = [ 'name': 'Trần Thị B', 'date': '1 tháng trước', 'rating': 4, - 'text': 'Gạch đẹp, vân gỗ rất chân thực. Giao hàng nhanh chóng và đóng gói cẩn thận.', + 'text': + 'Gạch đẹp, vân gỗ rất chân thực. Giao hàng nhanh chóng và đóng gói cẩn thận.', }, ]; diff --git a/lib/features/products/presentation/widgets/product_detail/sticky_action_bar.dart b/lib/features/products/presentation/widgets/product_detail/sticky_action_bar.dart index 36f1049..634896b 100644 --- a/lib/features/products/presentation/widgets/product_detail/sticky_action_bar.dart +++ b/lib/features/products/presentation/widgets/product_detail/sticky_action_bar.dart @@ -37,10 +37,7 @@ class StickyActionBar extends StatelessWidget { decoration: BoxDecoration( color: AppColors.white, border: Border( - top: BorderSide( - color: const Color(0xFFe0e0e0), - width: 1, - ), + top: BorderSide(color: const Color(0xFFe0e0e0), width: 1), ), boxShadow: [ BoxShadow( @@ -58,9 +55,7 @@ class StickyActionBar extends StatelessWidget { // Quantity Controls Container( decoration: BoxDecoration( - border: Border.all( - color: const Color(0xFFe0e0e0), - ), + border: Border.all(color: const Color(0xFFe0e0e0)), borderRadius: BorderRadius.circular(8), ), child: Row( @@ -81,9 +76,7 @@ class StickyActionBar extends StatelessWidget { fontWeight: FontWeight.w600, ), keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], + inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration( border: InputBorder.none, contentPadding: EdgeInsets.zero, @@ -101,10 +94,7 @@ class StickyActionBar extends StatelessWidget { ), // Increase Button - _QuantityButton( - icon: Icons.add, - onPressed: onIncrease, - ), + _QuantityButton(icon: Icons.add, onPressed: onIncrease), ], ), ), @@ -151,10 +141,7 @@ class _QuantityButton extends StatelessWidget { final IconData icon; final VoidCallback? onPressed; - const _QuantityButton({ - required this.icon, - this.onPressed, - }); + const _QuantityButton({required this.icon, this.onPressed}); @override Widget build(BuildContext context) { diff --git a/lib/features/products/presentation/widgets/product_search_bar.dart b/lib/features/products/presentation/widgets/product_search_bar.dart index 4b892ed..a17592a 100644 --- a/lib/features/products/presentation/widgets/product_search_bar.dart +++ b/lib/features/products/presentation/widgets/product_search_bar.dart @@ -104,9 +104,7 @@ class _ProductSearchBarState extends ConsumerState { vertical: AppSpacing.md, ), ), - style: const TextStyle( - fontSize: InputFieldSpecs.fontSize, - ), + style: const TextStyle(fontSize: InputFieldSpecs.fontSize), ), ); } diff --git a/lib/features/projects/data/models/design_request_model.dart b/lib/features/projects/data/models/design_request_model.dart index 5fc7455..5838083 100644 --- a/lib/features/projects/data/models/design_request_model.dart +++ b/lib/features/projects/data/models/design_request_model.dart @@ -7,51 +7,103 @@ part 'design_request_model.g.dart'; @HiveType(typeId: HiveTypeIds.designRequestModel) class DesignRequestModel extends HiveObject { - DesignRequestModel({required this.requestId, required this.userId, required this.projectName, required this.projectType, required this.area, required this.style, required this.budget, required this.currentSituation, required this.requirements, this.notes, this.attachments, required this.status, this.assignedDesigner, this.finalDesignLink, this.feedback, this.rating, this.estimatedCompletion, required this.createdAt, this.completedAt, this.updatedAt}); - - @HiveField(0) final String requestId; - @HiveField(1) final String userId; - @HiveField(2) final String projectName; - @HiveField(3) final ProjectType projectType; - @HiveField(4) final double area; - @HiveField(5) final String style; - @HiveField(6) final double budget; - @HiveField(7) final String currentSituation; - @HiveField(8) final String requirements; - @HiveField(9) final String? notes; - @HiveField(10) final String? attachments; - @HiveField(11) final DesignStatus status; - @HiveField(12) final String? assignedDesigner; - @HiveField(13) final String? finalDesignLink; - @HiveField(14) final String? feedback; - @HiveField(15) final int? rating; - @HiveField(16) final DateTime? estimatedCompletion; - @HiveField(17) final DateTime createdAt; - @HiveField(18) final DateTime? completedAt; - @HiveField(19) final DateTime? updatedAt; + DesignRequestModel({ + required this.requestId, + required this.userId, + required this.projectName, + required this.projectType, + required this.area, + required this.style, + required this.budget, + required this.currentSituation, + required this.requirements, + this.notes, + this.attachments, + required this.status, + this.assignedDesigner, + this.finalDesignLink, + this.feedback, + this.rating, + this.estimatedCompletion, + required this.createdAt, + this.completedAt, + this.updatedAt, + }); - factory DesignRequestModel.fromJson(Map json) => DesignRequestModel( - requestId: json['request_id'] as String, - userId: json['user_id'] as String, - projectName: json['project_name'] as String, - projectType: ProjectType.values.firstWhere((e) => e.name == json['project_type']), - area: (json['area'] as num).toDouble(), - style: json['style'] as String, - budget: (json['budget'] as num).toDouble(), - currentSituation: json['current_situation'] as String, - requirements: json['requirements'] as String, - notes: json['notes'] as String?, - attachments: json['attachments'] != null ? jsonEncode(json['attachments']) : null, - status: DesignStatus.values.firstWhere((e) => e.name == json['status']), - assignedDesigner: json['assigned_designer'] as String?, - finalDesignLink: json['final_design_link'] as String?, - feedback: json['feedback'] as String?, - rating: json['rating'] as int?, - estimatedCompletion: json['estimated_completion'] != null ? DateTime.parse(json['estimated_completion']?.toString() ?? '') : null, - createdAt: DateTime.parse(json['created_at']?.toString() ?? ''), - completedAt: json['completed_at'] != null ? DateTime.parse(json['completed_at']?.toString() ?? '') : null, - updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']?.toString() ?? '') : null, - ); + @HiveField(0) + final String requestId; + @HiveField(1) + final String userId; + @HiveField(2) + final String projectName; + @HiveField(3) + final ProjectType projectType; + @HiveField(4) + final double area; + @HiveField(5) + final String style; + @HiveField(6) + final double budget; + @HiveField(7) + final String currentSituation; + @HiveField(8) + final String requirements; + @HiveField(9) + final String? notes; + @HiveField(10) + final String? attachments; + @HiveField(11) + final DesignStatus status; + @HiveField(12) + final String? assignedDesigner; + @HiveField(13) + final String? finalDesignLink; + @HiveField(14) + final String? feedback; + @HiveField(15) + final int? rating; + @HiveField(16) + final DateTime? estimatedCompletion; + @HiveField(17) + final DateTime createdAt; + @HiveField(18) + final DateTime? completedAt; + @HiveField(19) + final DateTime? updatedAt; + + factory DesignRequestModel.fromJson(Map json) => + DesignRequestModel( + requestId: json['request_id'] as String, + userId: json['user_id'] as String, + projectName: json['project_name'] as String, + projectType: ProjectType.values.firstWhere( + (e) => e.name == json['project_type'], + ), + area: (json['area'] as num).toDouble(), + style: json['style'] as String, + budget: (json['budget'] as num).toDouble(), + currentSituation: json['current_situation'] as String, + requirements: json['requirements'] as String, + notes: json['notes'] as String?, + attachments: json['attachments'] != null + ? jsonEncode(json['attachments']) + : null, + status: DesignStatus.values.firstWhere((e) => e.name == json['status']), + assignedDesigner: json['assigned_designer'] as String?, + finalDesignLink: json['final_design_link'] as String?, + feedback: json['feedback'] as String?, + rating: json['rating'] as int?, + estimatedCompletion: json['estimated_completion'] != null + ? DateTime.parse(json['estimated_completion']?.toString() ?? '') + : null, + createdAt: DateTime.parse(json['created_at']?.toString() ?? ''), + completedAt: json['completed_at'] != null + ? DateTime.parse(json['completed_at']?.toString() ?? '') + : null, + updatedAt: json['updated_at'] != null + ? DateTime.parse(json['updated_at']?.toString() ?? '') + : null, + ); Map toJson() => { 'request_id': requestId, diff --git a/lib/features/projects/data/models/project_submission_model.dart b/lib/features/projects/data/models/project_submission_model.dart index f47057c..bc0e147 100644 --- a/lib/features/projects/data/models/project_submission_model.dart +++ b/lib/features/projects/data/models/project_submission_model.dart @@ -7,41 +7,84 @@ part 'project_submission_model.g.dart'; @HiveType(typeId: HiveTypeIds.projectSubmissionModel) class ProjectSubmissionModel extends HiveObject { - ProjectSubmissionModel({required this.submissionId, required this.userId, required this.projectName, required this.projectAddress, required this.projectValue, required this.projectType, this.beforePhotos, this.afterPhotos, this.invoices, required this.status, this.reviewNotes, this.rejectionReason, this.pointsEarned, required this.submittedAt, this.reviewedAt, this.reviewedBy}); - - @HiveField(0) final String submissionId; - @HiveField(1) final String userId; - @HiveField(2) final String projectName; - @HiveField(3) final String projectAddress; - @HiveField(4) final double projectValue; - @HiveField(5) final ProjectType projectType; - @HiveField(6) final String? beforePhotos; - @HiveField(7) final String? afterPhotos; - @HiveField(8) final String? invoices; - @HiveField(9) final SubmissionStatus status; - @HiveField(10) final String? reviewNotes; - @HiveField(11) final String? rejectionReason; - @HiveField(12) final int? pointsEarned; - @HiveField(13) final DateTime submittedAt; - @HiveField(14) final DateTime? reviewedAt; - @HiveField(15) final String? reviewedBy; + ProjectSubmissionModel({ + required this.submissionId, + required this.userId, + required this.projectName, + required this.projectAddress, + required this.projectValue, + required this.projectType, + this.beforePhotos, + this.afterPhotos, + this.invoices, + required this.status, + this.reviewNotes, + this.rejectionReason, + this.pointsEarned, + required this.submittedAt, + this.reviewedAt, + this.reviewedBy, + }); - factory ProjectSubmissionModel.fromJson(Map json) => ProjectSubmissionModel( + @HiveField(0) + final String submissionId; + @HiveField(1) + final String userId; + @HiveField(2) + final String projectName; + @HiveField(3) + final String projectAddress; + @HiveField(4) + final double projectValue; + @HiveField(5) + final ProjectType projectType; + @HiveField(6) + final String? beforePhotos; + @HiveField(7) + final String? afterPhotos; + @HiveField(8) + final String? invoices; + @HiveField(9) + final SubmissionStatus status; + @HiveField(10) + final String? reviewNotes; + @HiveField(11) + final String? rejectionReason; + @HiveField(12) + final int? pointsEarned; + @HiveField(13) + final DateTime submittedAt; + @HiveField(14) + final DateTime? reviewedAt; + @HiveField(15) + final String? reviewedBy; + + factory ProjectSubmissionModel.fromJson( + Map json, + ) => ProjectSubmissionModel( submissionId: json['submission_id'] as String, userId: json['user_id'] as String, projectName: json['project_name'] as String, projectAddress: json['project_address'] as String, projectValue: (json['project_value'] as num).toDouble(), - projectType: ProjectType.values.firstWhere((e) => e.name == json['project_type']), - beforePhotos: json['before_photos'] != null ? jsonEncode(json['before_photos']) : null, - afterPhotos: json['after_photos'] != null ? jsonEncode(json['after_photos']) : null, + projectType: ProjectType.values.firstWhere( + (e) => e.name == json['project_type'], + ), + beforePhotos: json['before_photos'] != null + ? jsonEncode(json['before_photos']) + : null, + afterPhotos: json['after_photos'] != null + ? jsonEncode(json['after_photos']) + : null, invoices: json['invoices'] != null ? jsonEncode(json['invoices']) : null, status: SubmissionStatus.values.firstWhere((e) => e.name == json['status']), reviewNotes: json['review_notes'] as String?, rejectionReason: json['rejection_reason'] as String?, pointsEarned: json['points_earned'] as int?, submittedAt: DateTime.parse(json['submitted_at']?.toString() ?? ''), - reviewedAt: json['reviewed_at'] != null ? DateTime.parse(json['reviewed_at']?.toString() ?? '') : null, + reviewedAt: json['reviewed_at'] != null + ? DateTime.parse(json['reviewed_at']?.toString() ?? '') + : null, reviewedBy: json['reviewed_by'] as String?, ); diff --git a/lib/features/projects/domain/entities/design_request.dart b/lib/features/projects/domain/entities/design_request.dart index 37624c8..de20a15 100644 --- a/lib/features/projects/domain/entities/design_request.dart +++ b/lib/features/projects/domain/entities/design_request.dart @@ -150,8 +150,7 @@ class DesignRequest { bool get hasAttachments => attachments.isNotEmpty; /// Check if design is ready - bool get hasDesign => - finalDesignLink != null && finalDesignLink!.isNotEmpty; + bool get hasDesign => finalDesignLink != null && finalDesignLink!.isNotEmpty; /// Check if user has provided feedback bool get hasFeedback => feedback != null || rating != null; @@ -231,13 +230,7 @@ class DesignRequest { @override int get hashCode { - return Object.hash( - requestId, - userId, - projectName, - area, - status, - ); + return Object.hash(requestId, userId, projectName, area, status); } @override diff --git a/lib/features/projects/domain/entities/project_submission.dart b/lib/features/projects/domain/entities/project_submission.dart index 3c391c1..405957b 100644 --- a/lib/features/projects/domain/entities/project_submission.dart +++ b/lib/features/projects/domain/entities/project_submission.dart @@ -156,7 +156,8 @@ class ProjectSubmission { /// Check if submission has been reviewed bool get isReviewed => - status == SubmissionStatus.approved || status == SubmissionStatus.rejected; + status == SubmissionStatus.approved || + status == SubmissionStatus.rejected; /// Check if submission has before photos bool get hasBeforePhotos => beforePhotos.isNotEmpty; @@ -229,13 +230,7 @@ class ProjectSubmission { @override int get hashCode { - return Object.hash( - submissionId, - userId, - projectName, - projectValue, - status, - ); + return Object.hash(submissionId, userId, projectName, projectValue, status); } @override diff --git a/lib/features/promotions/presentation/pages/promotion_detail_page.dart b/lib/features/promotions/presentation/pages/promotion_detail_page.dart index edcdf1f..84daa92 100644 --- a/lib/features/promotions/presentation/pages/promotion_detail_page.dart +++ b/lib/features/promotions/presentation/pages/promotion_detail_page.dart @@ -25,10 +25,7 @@ import 'package:worker/features/promotions/presentation/widgets/promotion_sectio /// Full-screen detail view of a promotion with scrollable content /// and fixed bottom action bar. class PromotionDetailPage extends ConsumerStatefulWidget { - const PromotionDetailPage({ - this.promotionId, - super.key, - }); + const PromotionDetailPage({this.promotionId, super.key}); /// Promotion ID final String? promotionId; @@ -62,15 +59,16 @@ class _PromotionDetailPageState extends ConsumerState { icon: const Icon(Icons.arrow_back, color: Colors.black), onPressed: () => context.pop(), ), - title: const Text('Chi tiết khuyến mãi', style: TextStyle(color: Colors.black)), + title: const Text( + 'Chi tiết khuyến mãi', + style: TextStyle(color: Colors.black), + ), elevation: AppBarSpecs.elevation, backgroundColor: AppColors.white, foregroundColor: AppColors.grey900, centerTitle: false, ), - body: const Center( - child: CircularProgressIndicator(), - ), + body: const Center(child: CircularProgressIndicator()), ), error: (error, stack) => Scaffold( appBar: AppBar( @@ -78,7 +76,10 @@ class _PromotionDetailPageState extends ConsumerState { icon: const Icon(Icons.arrow_back, color: Colors.black), onPressed: () => context.pop(), ), - title: const Text('Chi tiết khuyến mãi', style: TextStyle(color: Colors.black)), + title: const Text( + 'Chi tiết khuyến mãi', + style: TextStyle(color: Colors.black), + ), elevation: AppBarSpecs.elevation, backgroundColor: AppColors.white, foregroundColor: AppColors.grey900, @@ -96,18 +97,12 @@ class _PromotionDetailPageState extends ConsumerState { const SizedBox(height: 16), const Text( 'Không thể tải thông tin khuyến mãi', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Text( error.toString(), - style: const TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14, color: AppColors.grey500), textAlign: TextAlign.center, ), ], @@ -118,7 +113,6 @@ class _PromotionDetailPageState extends ConsumerState { } Widget _buildDetailContent(Promotion promotion) { - return Scaffold( backgroundColor: Colors.white, body: Stack( @@ -189,12 +183,7 @@ class _PromotionDetailPageState extends ConsumerState { ), // Fixed Bottom Action Bar - Positioned( - left: 0, - right: 0, - bottom: 0, - child: _buildActionBar(), - ), + Positioned(left: 0, right: 0, bottom: 0, child: _buildActionBar()), ], ), ); @@ -210,9 +199,7 @@ class _PromotionDetailPageState extends ConsumerState { placeholder: (context, url) => Container( height: 200, color: AppColors.grey100, - child: const Center( - child: CircularProgressIndicator(), - ), + child: const Center(child: CircularProgressIndicator()), ), errorWidget: (context, url, error) => Container( height: 200, @@ -233,12 +220,7 @@ class _PromotionDetailPageState extends ConsumerState { return Container( padding: const EdgeInsets.fromLTRB(16, 24, 16, 16), decoration: const BoxDecoration( - border: Border( - bottom: BorderSide( - color: Color(0xFFE2E8F0), - width: 1, - ), - ), + border: Border(bottom: BorderSide(color: Color(0xFFE2E8F0), width: 1)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -331,10 +313,7 @@ class _PromotionDetailPageState extends ConsumerState { ), // Discount Details - PromotionContentText( - 'Ưu đãi chi tiết:', - isBold: true, - ), + PromotionContentText('Ưu đãi chi tiết:', isBold: true), PromotionBulletList( items: [ 'Gạch men 60x60cm: Giảm 25% - 30%', @@ -348,10 +327,7 @@ class _PromotionDetailPageState extends ConsumerState { SizedBox(height: 16), // Additional Benefits - PromotionContentText( - 'Ưu đãi bổ sung:', - isBold: true, - ), + PromotionContentText('Ưu đãi bổ sung:', isBold: true), PromotionBulletList( items: [ 'Miễn phí vận chuyển cho đơn hàng từ 500m²', @@ -398,14 +374,8 @@ class _PromotionDetailPageState extends ConsumerState { label: 'Hotline', value: '1900-xxxx (8:00 - 18:00 hàng ngày)', ), - ContactInfo( - label: 'Email', - value: 'promotion@company.com', - ), - ContactInfo( - label: 'Zalo', - value: '0123.456.789', - ), + ContactInfo(label: 'Email', value: 'promotion@company.com'), + ContactInfo(label: 'Zalo', value: '0123.456.789'), ], ), ); @@ -417,10 +387,7 @@ class _PromotionDetailPageState extends ConsumerState { decoration: BoxDecoration( color: Colors.white, border: const Border( - top: BorderSide( - color: Color(0xFFE2E8F0), - width: 1, - ), + top: BorderSide(color: Color(0xFFE2E8F0), width: 1), ), boxShadow: [ BoxShadow( @@ -451,10 +418,7 @@ class _PromotionDetailPageState extends ConsumerState { SizedBox(width: 8), Text( 'Xem sản phẩm áp dụng', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), ], ), diff --git a/lib/features/promotions/presentation/pages/promotions_page.dart b/lib/features/promotions/presentation/pages/promotions_page.dart index 8c74195..4264d80 100644 --- a/lib/features/promotions/presentation/pages/promotions_page.dart +++ b/lib/features/promotions/presentation/pages/promotions_page.dart @@ -40,13 +40,9 @@ class PromotionsPage extends ConsumerWidget { // Scrollable content Expanded( child: promotionsAsync.when( - data: (promotions) => _buildPromotionsContent( - context, - promotions, - ), - loading: () => const Center( - child: CircularProgressIndicator(), - ), + data: (promotions) => + _buildPromotionsContent(context, promotions), + loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -138,10 +134,7 @@ class PromotionsPage extends ConsumerWidget { const SizedBox(height: 8), Text( 'Hãy quay lại sau để xem các ưu đãi mới', - style: const TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14, color: AppColors.grey500), ), ], ), @@ -185,11 +178,8 @@ class PromotionsPage extends ConsumerWidget { ), // Bottom padding for navigation clearance - const SliverToBoxAdapter( - child: SizedBox(height: 16), - ), + const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ); } - } diff --git a/lib/features/promotions/presentation/widgets/highlight_box.dart b/lib/features/promotions/presentation/widgets/highlight_box.dart index f179967..cb0309f 100644 --- a/lib/features/promotions/presentation/widgets/highlight_box.dart +++ b/lib/features/promotions/presentation/widgets/highlight_box.dart @@ -14,11 +14,7 @@ import 'package:flutter/material.dart'; /// - Centered text /// - Optional emoji/icon class HighlightBox extends StatelessWidget { - const HighlightBox({ - required this.text, - this.emoji, - super.key, - }); + const HighlightBox({required this.text, this.emoji, super.key}); /// Text to display in the highlight box final String text; diff --git a/lib/features/promotions/presentation/widgets/promotion_card.dart b/lib/features/promotions/presentation/widgets/promotion_card.dart index 76999e5..a8143e2 100644 --- a/lib/features/promotions/presentation/widgets/promotion_card.dart +++ b/lib/features/promotions/presentation/widgets/promotion_card.dart @@ -25,11 +25,7 @@ class PromotionCard extends StatelessWidget { /// Callback when card or detail button is tapped final VoidCallback? onTap; - const PromotionCard({ - required this.promotion, - this.onTap, - super.key, - }); + const PromotionCard({required this.promotion, this.onTap, super.key}); @override Widget build(BuildContext context) { @@ -60,9 +56,7 @@ class PromotionCard extends StatelessWidget { placeholder: (context, url) => Container( height: 150, color: AppColors.grey100, - child: const Center( - child: CircularProgressIndicator(), - ), + child: const Center(child: CircularProgressIndicator()), ), errorWidget: (context, url, error) => Container( height: 150, diff --git a/lib/features/promotions/presentation/widgets/promotion_section.dart b/lib/features/promotions/presentation/widgets/promotion_section.dart index 5d0d92a..b9bbb31 100644 --- a/lib/features/promotions/presentation/widgets/promotion_section.dart +++ b/lib/features/promotions/presentation/widgets/promotion_section.dart @@ -54,11 +54,7 @@ class PromotionSection extends StatelessWidget { // Section Title with Icon Row( children: [ - Icon( - icon, - size: 20, - color: AppColors.primaryBlue, - ), + Icon(icon, size: 20, color: AppColors.primaryBlue), const SizedBox(width: 8), Text( title, @@ -84,11 +80,7 @@ class PromotionSection extends StatelessWidget { /// /// Standard text styling for section content with proper line height. class PromotionContentText extends StatelessWidget { - const PromotionContentText( - this.text, { - this.isBold = false, - super.key, - }); + const PromotionContentText(this.text, {this.isBold = false, super.key}); final String text; final bool isBold; @@ -114,10 +106,7 @@ class PromotionContentText extends StatelessWidget { /// /// Displays a list with custom bullet points. class PromotionBulletList extends StatelessWidget { - const PromotionBulletList({ - required this.items, - super.key, - }); + const PromotionBulletList({required this.items, super.key}); final List items; @@ -178,11 +167,7 @@ class PromotionBulletList extends StatelessWidget { /// /// Displays contact information with labels and values. class ContactInfo extends StatelessWidget { - const ContactInfo({ - required this.label, - required this.value, - super.key, - }); + const ContactInfo({required this.label, required this.value, super.key}); final String label; final String value; diff --git a/lib/features/quotes/data/datasources/quotes_local_datasource.dart b/lib/features/quotes/data/datasources/quotes_local_datasource.dart index 1f6c98c..001335d 100644 --- a/lib/features/quotes/data/datasources/quotes_local_datasource.dart +++ b/lib/features/quotes/data/datasources/quotes_local_datasource.dart @@ -74,7 +74,9 @@ class QuotesLocalDataSource { final lowerQuery = query.toLowerCase(); return quotes.where((quote) { - final matchesNumber = quote.quoteNumber.toLowerCase().contains(lowerQuery); + final matchesNumber = quote.quoteNumber.toLowerCase().contains( + lowerQuery, + ); final matchesProject = quote.projectName?.toLowerCase().contains(lowerQuery) ?? false; return matchesNumber || matchesProject; diff --git a/lib/features/quotes/data/models/quote_item_model.dart b/lib/features/quotes/data/models/quote_item_model.dart index 86988f0..b844072 100644 --- a/lib/features/quotes/data/models/quote_item_model.dart +++ b/lib/features/quotes/data/models/quote_item_model.dart @@ -5,17 +5,36 @@ part 'quote_item_model.g.dart'; @HiveType(typeId: HiveTypeIds.quoteItemModel) class QuoteItemModel extends HiveObject { - QuoteItemModel({required this.quoteItemId, required this.quoteId, required this.productId, required this.quantity, required this.originalPrice, required this.negotiatedPrice, required this.discountPercent, required this.subtotal, this.notes}); - - @HiveField(0) final String quoteItemId; - @HiveField(1) final String quoteId; - @HiveField(2) final String productId; - @HiveField(3) final double quantity; - @HiveField(4) final double originalPrice; - @HiveField(5) final double negotiatedPrice; - @HiveField(6) final double discountPercent; - @HiveField(7) final double subtotal; - @HiveField(8) final String? notes; + QuoteItemModel({ + required this.quoteItemId, + required this.quoteId, + required this.productId, + required this.quantity, + required this.originalPrice, + required this.negotiatedPrice, + required this.discountPercent, + required this.subtotal, + this.notes, + }); + + @HiveField(0) + final String quoteItemId; + @HiveField(1) + final String quoteId; + @HiveField(2) + final String productId; + @HiveField(3) + final double quantity; + @HiveField(4) + final double originalPrice; + @HiveField(5) + final double negotiatedPrice; + @HiveField(6) + final double discountPercent; + @HiveField(7) + final double subtotal; + @HiveField(8) + final String? notes; factory QuoteItemModel.fromJson(Map json) => QuoteItemModel( quoteItemId: json['quote_item_id'] as String, diff --git a/lib/features/quotes/data/models/quote_model.dart b/lib/features/quotes/data/models/quote_model.dart index b77d3ef..d4fc517 100644 --- a/lib/features/quotes/data/models/quote_model.dart +++ b/lib/features/quotes/data/models/quote_model.dart @@ -7,24 +7,57 @@ part 'quote_model.g.dart'; @HiveType(typeId: HiveTypeIds.quoteModel) class QuoteModel extends HiveObject { - QuoteModel({required this.quoteId, required this.quoteNumber, required this.userId, required this.status, required this.totalAmount, required this.discountAmount, required this.finalAmount, this.projectName, this.deliveryAddress, this.paymentTerms, this.notes, this.validUntil, this.convertedOrderId, this.erpnextQuotation, required this.createdAt, this.updatedAt}); - - @HiveField(0) final String quoteId; - @HiveField(1) final String quoteNumber; - @HiveField(2) final String userId; - @HiveField(3) final QuoteStatus status; - @HiveField(4) final double totalAmount; - @HiveField(5) final double discountAmount; - @HiveField(6) final double finalAmount; - @HiveField(7) final String? projectName; - @HiveField(8) final String? deliveryAddress; - @HiveField(9) final String? paymentTerms; - @HiveField(10) final String? notes; - @HiveField(11) final DateTime? validUntil; - @HiveField(12) final String? convertedOrderId; - @HiveField(13) final String? erpnextQuotation; - @HiveField(14) final DateTime createdAt; - @HiveField(15) final DateTime? updatedAt; + QuoteModel({ + required this.quoteId, + required this.quoteNumber, + required this.userId, + required this.status, + required this.totalAmount, + required this.discountAmount, + required this.finalAmount, + this.projectName, + this.deliveryAddress, + this.paymentTerms, + this.notes, + this.validUntil, + this.convertedOrderId, + this.erpnextQuotation, + required this.createdAt, + this.updatedAt, + }); + + @HiveField(0) + final String quoteId; + @HiveField(1) + final String quoteNumber; + @HiveField(2) + final String userId; + @HiveField(3) + final QuoteStatus status; + @HiveField(4) + final double totalAmount; + @HiveField(5) + final double discountAmount; + @HiveField(6) + final double finalAmount; + @HiveField(7) + final String? projectName; + @HiveField(8) + final String? deliveryAddress; + @HiveField(9) + final String? paymentTerms; + @HiveField(10) + final String? notes; + @HiveField(11) + final DateTime? validUntil; + @HiveField(12) + final String? convertedOrderId; + @HiveField(13) + final String? erpnextQuotation; + @HiveField(14) + final DateTime createdAt; + @HiveField(15) + final DateTime? updatedAt; factory QuoteModel.fromJson(Map json) => QuoteModel( quoteId: json['quote_id'] as String, @@ -35,14 +68,20 @@ class QuoteModel extends HiveObject { discountAmount: (json['discount_amount'] as num).toDouble(), finalAmount: (json['final_amount'] as num).toDouble(), projectName: json['project_name'] as String?, - deliveryAddress: json['delivery_address'] != null ? jsonEncode(json['delivery_address']) : null, + deliveryAddress: json['delivery_address'] != null + ? jsonEncode(json['delivery_address']) + : null, paymentTerms: json['payment_terms'] as String?, notes: json['notes'] as String?, - validUntil: json['valid_until'] != null ? DateTime.parse(json['valid_until']?.toString() ?? '') : null, + validUntil: json['valid_until'] != null + ? DateTime.parse(json['valid_until']?.toString() ?? '') + : null, convertedOrderId: json['converted_order_id'] as String?, erpnextQuotation: json['erpnext_quotation'] as String?, 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 toJson() => { @@ -54,7 +93,9 @@ class QuoteModel extends HiveObject { 'discount_amount': discountAmount, 'final_amount': finalAmount, 'project_name': projectName, - 'delivery_address': deliveryAddress != null ? jsonDecode(deliveryAddress!) : null, + 'delivery_address': deliveryAddress != null + ? jsonDecode(deliveryAddress!) + : null, 'payment_terms': paymentTerms, 'notes': notes, 'valid_until': validUntil?.toIso8601String(), @@ -64,6 +105,7 @@ class QuoteModel extends HiveObject { 'updated_at': updatedAt?.toIso8601String(), }; - bool get isExpired => validUntil != null && DateTime.now().isAfter(validUntil!); + bool get isExpired => + validUntil != null && DateTime.now().isAfter(validUntil!); bool get isConverted => convertedOrderId != null; } diff --git a/lib/features/quotes/presentation/pages/quotes_page.dart b/lib/features/quotes/presentation/pages/quotes_page.dart index 3055e0a..ba1bbe1 100644 --- a/lib/features/quotes/presentation/pages/quotes_page.dart +++ b/lib/features/quotes/presentation/pages/quotes_page.dart @@ -38,15 +38,16 @@ class _QuotesPageState extends ConsumerState { @override void dispose() { - _searchController..removeListener(_onSearchChanged) - ..dispose(); + _searchController + ..removeListener(_onSearchChanged) + ..dispose(); super.dispose(); } void _onSearchChanged() { - ref.read(quoteSearchQueryProvider.notifier).updateQuery( - _searchController.text, - ); + ref + .read(quoteSearchQueryProvider.notifier) + .updateQuery(_searchController.text); } @override @@ -104,13 +105,10 @@ class _QuotesPageState extends ConsumerState { } return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final quote = quotes[index]; - return _buildQuoteCard(quote); - }, - childCount: quotes.length, - ), + delegate: SliverChildBuilderDelegate((context, index) { + final quote = quotes[index]; + return _buildQuoteCard(quote); + }, childCount: quotes.length), ); }, loading: () => _buildLoadingState(), @@ -149,10 +147,7 @@ class _QuotesPageState extends ConsumerState { color: AppColors.grey500, fontSize: 14, ), - prefixIcon: const Icon( - Icons.search, - color: AppColors.grey500, - ), + prefixIcon: const Icon(Icons.search, color: AppColors.grey500), suffixIcon: _searchController.text.isNotEmpty ? IconButton( icon: const Icon(Icons.clear, color: AppColors.grey500), @@ -190,16 +185,10 @@ class _QuotesPageState extends ConsumerState { onPressed: () { _showFilterDialog(); }, - icon: Icon( - Icons.filter_list, - color: AppColors.primaryBlue, - ), + icon: Icon(Icons.filter_list, color: AppColors.primaryBlue), iconSize: 24, padding: const EdgeInsets.all(12), - constraints: const BoxConstraints( - minWidth: 48, - minHeight: 48, - ), + constraints: const BoxConstraints(minWidth: 48, minHeight: 48), ), ), ], @@ -213,9 +202,7 @@ class _QuotesPageState extends ConsumerState { return Card( margin: const EdgeInsets.only(bottom: 12), elevation: 1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), clipBehavior: Clip.antiAlias, child: InkWell( onTap: () { @@ -270,10 +257,7 @@ class _QuotesPageState extends ConsumerState { // Additional Info (placeholder for now) const Text( '5 sản phẩm - Diện tích: 200m²', - style: TextStyle( - fontSize: 13, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 13, color: AppColors.grey500), ), const SizedBox(height: 8), @@ -455,10 +439,7 @@ class _QuotesPageState extends ConsumerState { const SizedBox(height: 8), const Text( 'Kéo xuống để làm mới', - style: TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: TextStyle(fontSize: 14, color: AppColors.grey500), ), ], ), @@ -469,9 +450,7 @@ class _QuotesPageState extends ConsumerState { /// Build loading state Widget _buildLoadingState() { return const SliverFillRemaining( - child: Center( - child: CircularProgressIndicator(), - ), + child: Center(child: CircularProgressIndicator()), ); } @@ -499,10 +478,7 @@ class _QuotesPageState extends ConsumerState { const SizedBox(height: 8), Text( error.toString(), - style: const TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14, color: AppColors.grey500), textAlign: TextAlign.center, ), ], diff --git a/lib/features/quotes/presentation/providers/quotes_provider.dart b/lib/features/quotes/presentation/providers/quotes_provider.dart index 9e98222..d0072bb 100644 --- a/lib/features/quotes/presentation/providers/quotes_provider.dart +++ b/lib/features/quotes/presentation/providers/quotes_provider.dart @@ -95,7 +95,9 @@ Future> filteredQuotes(Ref ref) async { if (searchQuery.isNotEmpty) { final lowerQuery = searchQuery.toLowerCase(); filtered = filtered.where((quote) { - final matchesNumber = quote.quoteNumber.toLowerCase().contains(lowerQuery); + final matchesNumber = quote.quoteNumber.toLowerCase().contains( + lowerQuery, + ); final matchesProject = quote.projectName?.toLowerCase().contains(lowerQuery) ?? false; return matchesNumber || matchesProject; @@ -104,7 +106,9 @@ Future> filteredQuotes(Ref ref) async { // Filter by status if (selectedStatus != null) { - filtered = filtered.where((quote) => quote.status == selectedStatus).toList(); + filtered = filtered + .where((quote) => quote.status == selectedStatus) + .toList(); } // Sort by creation date (newest first) diff --git a/lib/features/showrooms/data/models/showroom_model.dart b/lib/features/showrooms/data/models/showroom_model.dart index 2e72ec6..4f4d8f8 100644 --- a/lib/features/showrooms/data/models/showroom_model.dart +++ b/lib/features/showrooms/data/models/showroom_model.dart @@ -6,22 +6,51 @@ part 'showroom_model.g.dart'; @HiveType(typeId: HiveTypeIds.showroomModel) class ShowroomModel extends HiveObject { - ShowroomModel({required this.showroomId, required this.title, required this.description, this.coverImage, this.link360, required this.area, required this.style, required this.location, this.galleryImages, required this.viewCount, required this.isFeatured, required this.isActive, this.publishedAt, this.createdBy}); - - @HiveField(0) final String showroomId; - @HiveField(1) final String title; - @HiveField(2) final String description; - @HiveField(3) final String? coverImage; - @HiveField(4) final String? link360; - @HiveField(5) final double area; - @HiveField(6) final String style; - @HiveField(7) final String location; - @HiveField(8) final String? galleryImages; - @HiveField(9) final int viewCount; - @HiveField(10) final bool isFeatured; - @HiveField(11) final bool isActive; - @HiveField(12) final DateTime? publishedAt; - @HiveField(13) final String? createdBy; + ShowroomModel({ + required this.showroomId, + required this.title, + required this.description, + this.coverImage, + this.link360, + required this.area, + required this.style, + required this.location, + this.galleryImages, + required this.viewCount, + required this.isFeatured, + required this.isActive, + this.publishedAt, + this.createdBy, + }); + + @HiveField(0) + final String showroomId; + @HiveField(1) + final String title; + @HiveField(2) + final String description; + @HiveField(3) + final String? coverImage; + @HiveField(4) + final String? link360; + @HiveField(5) + final double area; + @HiveField(6) + final String style; + @HiveField(7) + final String location; + @HiveField(8) + final String? galleryImages; + @HiveField(9) + final int viewCount; + @HiveField(10) + final bool isFeatured; + @HiveField(11) + final bool isActive; + @HiveField(12) + final DateTime? publishedAt; + @HiveField(13) + final String? createdBy; factory ShowroomModel.fromJson(Map json) => ShowroomModel( showroomId: json['showroom_id'] as String, @@ -32,11 +61,15 @@ class ShowroomModel extends HiveObject { area: (json['area'] as num).toDouble(), style: json['style'] as String, location: json['location'] as String, - galleryImages: json['gallery_images'] != null ? jsonEncode(json['gallery_images']) : null, + galleryImages: json['gallery_images'] != null + ? jsonEncode(json['gallery_images']) + : null, viewCount: json['view_count'] as int? ?? 0, isFeatured: json['is_featured'] as bool? ?? false, isActive: json['is_active'] as bool? ?? true, - publishedAt: json['published_at'] != null ? DateTime.parse(json['published_at']?.toString() ?? '') : null, + publishedAt: json['published_at'] != null + ? DateTime.parse(json['published_at']?.toString() ?? '') + : null, createdBy: json['created_by'] as String?, ); diff --git a/lib/features/showrooms/data/models/showroom_product_model.dart b/lib/features/showrooms/data/models/showroom_product_model.dart index d31c9b3..e8c4ffc 100644 --- a/lib/features/showrooms/data/models/showroom_product_model.dart +++ b/lib/features/showrooms/data/models/showroom_product_model.dart @@ -5,17 +5,25 @@ part 'showroom_product_model.g.dart'; @HiveType(typeId: HiveTypeIds.showroomProductModel) class ShowroomProductModel extends HiveObject { - ShowroomProductModel({required this.showroomId, required this.productId, required this.quantityUsed}); - - @HiveField(0) final String showroomId; - @HiveField(1) final String productId; - @HiveField(2) final double quantityUsed; + ShowroomProductModel({ + required this.showroomId, + required this.productId, + required this.quantityUsed, + }); - factory ShowroomProductModel.fromJson(Map json) => ShowroomProductModel( - showroomId: json['showroom_id'] as String, - productId: json['product_id'] as String, - quantityUsed: (json['quantity_used'] as num).toDouble(), - ); + @HiveField(0) + final String showroomId; + @HiveField(1) + final String productId; + @HiveField(2) + final double quantityUsed; + + factory ShowroomProductModel.fromJson(Map json) => + ShowroomProductModel( + showroomId: json['showroom_id'] as String, + productId: json['product_id'] as String, + quantityUsed: (json['quantity_used'] as num).toDouble(), + ); Map toJson() => { 'showroom_id': showroomId, diff --git a/lib/features/showrooms/domain/entities/showroom.dart b/lib/features/showrooms/domain/entities/showroom.dart index febdbaf..3c4b113 100644 --- a/lib/features/showrooms/domain/entities/showroom.dart +++ b/lib/features/showrooms/domain/entities/showroom.dart @@ -145,13 +145,7 @@ class Showroom { @override int get hashCode { - return Object.hash( - showroomId, - title, - style, - isFeatured, - isActive, - ); + return Object.hash(showroomId, title, style, isFeatured, isActive); } @override diff --git a/lib/features/showrooms/domain/entities/showroom_product.dart b/lib/features/showrooms/domain/entities/showroom_product.dart index b0df39b..68d6078 100644 --- a/lib/features/showrooms/domain/entities/showroom_product.dart +++ b/lib/features/showrooms/domain/entities/showroom_product.dart @@ -50,11 +50,7 @@ class ShowroomProduct { @override int get hashCode { - return Object.hash( - showroomId, - productId, - quantityUsed, - ); + return Object.hash(showroomId, productId, quantityUsed); } @override diff --git a/lib/features/showrooms/presentation/pages/design_request_create_page.dart b/lib/features/showrooms/presentation/pages/design_request_create_page.dart index 881f756..a083bbd 100644 --- a/lib/features/showrooms/presentation/pages/design_request_create_page.dart +++ b/lib/features/showrooms/presentation/pages/design_request_create_page.dart @@ -46,7 +46,8 @@ class DesignRequestCreatePage extends HookConsumerWidget { // Validate file sizes final validFiles = []; for (final file in result.files) { - if (file.size <= 10 * 1024 * 1024) { // 10MB max + if (file.size <= 10 * 1024 * 1024) { + // 10MB max validFiles.add(file); } else { if (context.mounted) { @@ -131,9 +132,7 @@ class DesignRequestCreatePage extends HookConsumerWidget { fontWeight: FontWeight.w600, ), ), - actions: const [ - SizedBox(width: AppSpacing.sm), - ], + actions: const [SizedBox(width: AppSpacing.sm)], ), body: SingleChildScrollView( padding: const EdgeInsets.all(20), @@ -272,20 +271,31 @@ class DesignRequestCreatePage extends HookConsumerWidget { ), const SizedBox(height: 8), DropdownButtonFormField( - value: selectedStyle.value.isEmpty ? null : selectedStyle.value, + value: selectedStyle.value.isEmpty + ? null + : selectedStyle.value, decoration: InputDecoration( hintText: '-- Chọn phong cách --', border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.grey100, width: 2), + borderSide: const BorderSide( + color: AppColors.grey100, + width: 2, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.grey100, width: 2), + borderSide: const BorderSide( + color: AppColors.grey100, + width: 2, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2), + borderSide: const BorderSide( + color: AppColors.primaryBlue, + width: 2, + ), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, @@ -293,14 +303,38 @@ class DesignRequestCreatePage extends HookConsumerWidget { ), ), items: const [ - DropdownMenuItem(value: 'hien-dai', child: Text('Hiện đại')), - DropdownMenuItem(value: 'toi-gian', child: Text('Tối giản')), - DropdownMenuItem(value: 'co-dien', child: Text('Cổ điển')), - DropdownMenuItem(value: 'scandinavian', child: Text('Scandinavian')), - DropdownMenuItem(value: 'industrial', child: Text('Industrial')), - DropdownMenuItem(value: 'tropical', child: Text('Tropical')), - DropdownMenuItem(value: 'luxury', child: Text('Luxury')), - DropdownMenuItem(value: 'khac', child: Text('Khác (ghi rõ trong ghi chú)')), + DropdownMenuItem( + value: 'hien-dai', + child: Text('Hiện đại'), + ), + DropdownMenuItem( + value: 'toi-gian', + child: Text('Tối giản'), + ), + DropdownMenuItem( + value: 'co-dien', + child: Text('Cổ điển'), + ), + DropdownMenuItem( + value: 'scandinavian', + child: Text('Scandinavian'), + ), + DropdownMenuItem( + value: 'industrial', + child: Text('Industrial'), + ), + DropdownMenuItem( + value: 'tropical', + child: Text('Tropical'), + ), + DropdownMenuItem( + value: 'luxury', + child: Text('Luxury'), + ), + DropdownMenuItem( + value: 'khac', + child: Text('Khác (ghi rõ trong ghi chú)'), + ), ], onChanged: (value) { selectedStyle.value = value ?? ''; @@ -331,20 +365,31 @@ class DesignRequestCreatePage extends HookConsumerWidget { ), const SizedBox(height: 8), DropdownButtonFormField( - value: selectedBudget.value.isEmpty ? null : selectedBudget.value, + value: selectedBudget.value.isEmpty + ? null + : selectedBudget.value, decoration: InputDecoration( hintText: '-- Chọn ngân sách --', border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.grey100, width: 2), + borderSide: const BorderSide( + color: AppColors.grey100, + width: 2, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.grey100, width: 2), + borderSide: const BorderSide( + color: AppColors.grey100, + width: 2, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2), + borderSide: const BorderSide( + color: AppColors.primaryBlue, + width: 2, + ), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, @@ -352,12 +397,30 @@ class DesignRequestCreatePage extends HookConsumerWidget { ), ), items: const [ - DropdownMenuItem(value: 'duoi-100tr', child: Text('Dưới 100 triệu')), - DropdownMenuItem(value: '100-300tr', child: Text('100 - 300 triệu')), - DropdownMenuItem(value: '300-500tr', child: Text('300 - 500 triệu')), - DropdownMenuItem(value: '500tr-1ty', child: Text('500 triệu - 1 tỷ')), - DropdownMenuItem(value: 'tren-1ty', child: Text('Trên 1 tỷ')), - DropdownMenuItem(value: 'trao-doi', child: Text('Trao đổi trực tiếp')), + DropdownMenuItem( + value: 'duoi-100tr', + child: Text('Dưới 100 triệu'), + ), + DropdownMenuItem( + value: '100-300tr', + child: Text('100 - 300 triệu'), + ), + DropdownMenuItem( + value: '300-500tr', + child: Text('300 - 500 triệu'), + ), + DropdownMenuItem( + value: '500tr-1ty', + child: Text('500 triệu - 1 tỷ'), + ), + DropdownMenuItem( + value: 'tren-1ty', + child: Text('Trên 1 tỷ'), + ), + DropdownMenuItem( + value: 'trao-doi', + child: Text('Trao đổi trực tiếp'), + ), ], onChanged: (value) { selectedBudget.value = value ?? ''; @@ -429,18 +492,28 @@ class DesignRequestCreatePage extends HookConsumerWidget { controller: notesController, maxLines: 5, decoration: InputDecoration( - hintText: 'Mô tả chi tiết về yêu cầu thiết kế, số phòng, công năng sử dụng, sở thích cá nhân...', + hintText: + 'Mô tả chi tiết về yêu cầu thiết kế, số phòng, công năng sử dụng, sở thích cá nhân...', border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.grey100, width: 2), + borderSide: const BorderSide( + color: AppColors.grey100, + width: 2, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.grey100, width: 2), + borderSide: const BorderSide( + color: AppColors.grey100, + width: 2, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2), + borderSide: const BorderSide( + color: AppColors.primaryBlue, + width: 2, + ), ), contentPadding: const EdgeInsets.all(16), ), @@ -626,8 +699,8 @@ class _ProgressStep extends StatelessWidget { color: isCompleted ? AppColors.success : isActive - ? AppColors.primaryBlue - : AppColors.grey100, + ? AppColors.primaryBlue + : AppColors.grey100, ), child: Center( child: Text( @@ -635,7 +708,9 @@ class _ProgressStep extends StatelessWidget { style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, - color: isActive || isCompleted ? AppColors.white : AppColors.grey500, + color: isActive || isCompleted + ? AppColors.white + : AppColors.grey500, ), ), ), @@ -700,7 +775,10 @@ class _FormField extends StatelessWidget { ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2), + borderSide: const BorderSide( + color: AppColors.primaryBlue, + width: 2, + ), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), @@ -727,10 +805,7 @@ class _FilePreviewItem extends StatelessWidget { final PlatformFile file; final VoidCallback onRemove; - const _FilePreviewItem({ - required this.file, - required this.onRemove, - }); + const _FilePreviewItem({required this.file, required this.onRemove}); IconData _getFileIcon() { final extension = file.extension?.toLowerCase(); @@ -765,11 +840,7 @@ class _FilePreviewItem extends StatelessWidget { color: AppColors.primaryBlue, borderRadius: BorderRadius.circular(6), ), - child: Icon( - _getFileIcon(), - color: AppColors.white, - size: 20, - ), + child: Icon(_getFileIcon(), color: AppColors.white, size: 20), ), const SizedBox(width: 12), Expanded( @@ -802,10 +873,7 @@ class _FilePreviewItem extends StatelessWidget { color: AppColors.danger, onPressed: onRemove, padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 24, - minHeight: 24, - ), + constraints: const BoxConstraints(minWidth: 24, minHeight: 24), ), ], ), diff --git a/lib/features/showrooms/presentation/pages/design_request_detail_page.dart b/lib/features/showrooms/presentation/pages/design_request_detail_page.dart index 6658b76..d60a314 100644 --- a/lib/features/showrooms/presentation/pages/design_request_detail_page.dart +++ b/lib/features/showrooms/presentation/pages/design_request_detail_page.dart @@ -23,10 +23,7 @@ import 'package:worker/features/showrooms/presentation/pages/model_houses_page.d /// - Status timeline /// - Action buttons (edit, contact) class DesignRequestDetailPage extends ConsumerWidget { - const DesignRequestDetailPage({ - required this.requestId, - super.key, - }); + const DesignRequestDetailPage({required this.requestId, super.key}); final String requestId; @@ -252,7 +249,11 @@ class DesignRequestDetailPage extends ConsumerWidget { ); } - Future _shareRequest(BuildContext context, String requestId, String name) async { + Future _shareRequest( + BuildContext context, + String requestId, + String name, + ) async { try { await Share.share( 'Yêu cầu thiết kế #$requestId\n$name', @@ -402,9 +403,7 @@ class DesignRequestDetailPage extends ConsumerWidget { const SizedBox(height: 12), const Text( 'Thiết kế 3D của bạn đã sẵn sàng để xem', - style: TextStyle( - color: Color(0xFF065f46), - ), + style: TextStyle(color: Color(0xFF065f46)), textAlign: TextAlign.center, ), const SizedBox(height: 16), @@ -451,10 +450,7 @@ class DesignRequestDetailPage extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Project Name - _SectionHeader( - icon: Icons.info, - title: 'Thông tin dự án', - ), + _SectionHeader(icon: Icons.info, title: 'Thông tin dự án'), const SizedBox(height: 12), RichText( text: TextSpan( @@ -476,10 +472,7 @@ class DesignRequestDetailPage extends ConsumerWidget { const SizedBox(height: 24), // Description - _SectionHeader( - icon: Icons.edit, - title: 'Mô tả yêu cầu', - ), + _SectionHeader(icon: Icons.edit, title: 'Mô tả yêu cầu'), const SizedBox(height: 12), Text( request['description'] as String, @@ -525,10 +518,8 @@ class DesignRequestDetailPage extends ConsumerWidget { ) else ...files.map( - (file) => _FileItem( - fileName: file, - icon: _getFileIcon(file), - ), + (file) => + _FileItem(fileName: file, icon: _getFileIcon(file)), ), ], ), @@ -553,25 +544,22 @@ class DesignRequestDetailPage extends ConsumerWidget { title: 'Lịch sử trạng thái', ), const SizedBox(height: 16), - ...List.generate( - timeline.length, - (index) { - final item = timeline[index]; - return _TimelineItem( - title: item['title'] as String, - description: item['description'] as String, - date: item['date'] as String, - status: item['status'] as DesignRequestStatus, - icon: _getTimelineIcon( - item['status'] as DesignRequestStatus, - timeline.length - index - 1, - ), - isLast: index == timeline.length - 1, - getStatusColor: _getStatusColor, - getStatusBackgroundColor: _getStatusBackgroundColor, - ); - }, - ), + ...List.generate(timeline.length, (index) { + final item = timeline[index]; + return _TimelineItem( + title: item['title'] as String, + description: item['description'] as String, + date: item['date'] as String, + status: item['status'] as DesignRequestStatus, + icon: _getTimelineIcon( + item['status'] as DesignRequestStatus, + timeline.length - index - 1, + ), + isLast: index == timeline.length - 1, + getStatusColor: _getStatusColor, + getStatusBackgroundColor: _getStatusBackgroundColor, + ); + }), ], ), ), @@ -587,7 +575,10 @@ class DesignRequestDetailPage extends ConsumerWidget { onPressed: () => _editRequest(context), style: OutlinedButton.styleFrom( foregroundColor: AppColors.grey900, - side: const BorderSide(color: AppColors.grey100, width: 2), + side: const BorderSide( + color: AppColors.grey100, + width: 2, + ), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), @@ -678,10 +669,7 @@ class _InfoGrid extends StatelessWidget { /// Info Item Widget class _InfoItem extends StatelessWidget { - const _InfoItem({ - required this.label, - required this.value, - }); + const _InfoItem({required this.label, required this.value}); final String label; final String value; @@ -723,10 +711,7 @@ class _InfoItem extends StatelessWidget { /// Section Header Widget class _SectionHeader extends StatelessWidget { - const _SectionHeader({ - required this.icon, - required this.title, - }); + const _SectionHeader({required this.icon, required this.title}); final IconData icon; final String title; @@ -752,10 +737,7 @@ class _SectionHeader extends StatelessWidget { /// File Item Widget class _FileItem extends StatelessWidget { - const _FileItem({ - required this.fileName, - required this.icon, - }); + const _FileItem({required this.fileName, required this.icon}); final String fileName; final IconData icon; @@ -835,11 +817,7 @@ class _TimelineItem extends StatelessWidget { color: getStatusBackgroundColor(status), shape: BoxShape.circle, ), - child: Icon( - icon, - color: getStatusColor(status), - size: 20, - ), + child: Icon(icon, color: getStatusColor(status), size: 20), ), if (!isLast) Expanded( diff --git a/lib/features/showrooms/presentation/pages/model_houses_page.dart b/lib/features/showrooms/presentation/pages/model_houses_page.dart index 381627a..3e64bbf 100644 --- a/lib/features/showrooms/presentation/pages/model_houses_page.dart +++ b/lib/features/showrooms/presentation/pages/model_houses_page.dart @@ -54,13 +54,21 @@ class _ModelHousesPageState extends ConsumerState children: [ Text('Đây là nội dung hướng dẫn sử dụng cho tính năng Nhà mẫu:'), SizedBox(height: 12), - Text('• Tab "Thư viện Mẫu 360": Là nơi công ty cung cấp các mẫu thiết kế 360° có sẵn để bạn tham khảo.'), + Text( + '• Tab "Thư viện Mẫu 360": Là nơi công ty cung cấp các mẫu thiết kế 360° có sẵn để bạn tham khảo.', + ), SizedBox(height: 8), - Text('• Tab "Yêu cầu Thiết kế": Là nơi bạn gửi yêu cầu (ticket) để đội ngũ thiết kế của chúng tôi hỗ trợ bạn.'), + Text( + '• Tab "Yêu cầu Thiết kế": Là nơi bạn gửi yêu cầu (ticket) để đội ngũ thiết kế của chúng tôi hỗ trợ bạn.', + ), SizedBox(height: 8), - Text('• Bấm nút "+" trong tab "Yêu cầu Thiết kế" để tạo một Yêu cầu Thiết kế mới.'), + Text( + '• Bấm nút "+" trong tab "Yêu cầu Thiết kế" để tạo một Yêu cầu Thiết kế mới.', + ), SizedBox(height: 8), - Text('• Khi yêu cầu hoàn thành, bạn có thể xem link thiết kế 3D trong trang chi tiết yêu cầu.'), + Text( + '• Khi yêu cầu hoàn thành, bạn có thể xem link thiết kế 3D trong trang chi tiết yêu cầu.', + ), ], ), ), @@ -127,10 +135,7 @@ class _ModelHousesPageState extends ConsumerState ), body: TabBarView( controller: _tabController, - children: const [ - _LibraryTab(), - _DesignRequestsTab(), - ], + children: const [_LibraryTab(), _DesignRequestsTab()], ), floatingActionButton: AnimatedBuilder( animation: _tabController, @@ -141,7 +146,11 @@ class _ModelHousesPageState extends ConsumerState onPressed: _createNewRequest, backgroundColor: AppColors.primaryBlue, elevation: 4, - child: const Icon(Icons.add, color: AppColors.white, size: 28), + child: const Icon( + Icons.add, + color: AppColors.white, + size: 28, + ), ) : const SizedBox.shrink(); }, @@ -160,31 +169,39 @@ class _LibraryTab extends StatelessWidget { padding: const EdgeInsets.all(20), children: const [ _LibraryCard( - imageUrl: 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800&h=200&fit=crop', + imageUrl: + 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800&h=200&fit=crop', title: 'Căn hộ Studio', date: '15/11/2024', - description: 'Thiết kế hiện đại cho căn hộ studio 35m², tối ưu không gian sống với gạch men cao cấp và màu sắc hài hòa.', + description: + 'Thiết kế hiện đại cho căn hộ studio 35m², tối ưu không gian sống với gạch men cao cấp và màu sắc hài hòa.', has360View: true, ), _LibraryCard( - imageUrl: 'https://images.unsplash.com/photo-1570129477492-45c003edd2be?w=800&h=200&fit=crop', + imageUrl: + 'https://images.unsplash.com/photo-1570129477492-45c003edd2be?w=800&h=200&fit=crop', title: 'Biệt thự Hiện đại', date: '12/11/2024', - description: 'Biệt thự 3 tầng với phong cách kiến trúc hiện đại, sử dụng gạch granite và ceramic premium tạo điểm nhấn.', + description: + 'Biệt thự 3 tầng với phong cách kiến trúc hiện đại, sử dụng gạch granite và ceramic premium tạo điểm nhấn.', has360View: true, ), _LibraryCard( - imageUrl: 'https://images.unsplash.com/photo-1562663474-6cbb3eaa4d14?w=800&h=200&fit=crop', + imageUrl: + 'https://images.unsplash.com/photo-1562663474-6cbb3eaa4d14?w=800&h=200&fit=crop', title: 'Nhà phố Tối giản', date: '08/11/2024', - description: 'Nhà phố 4x15m với thiết kế tối giản, tận dụng ánh sáng tự nhiên và gạch men màu trung tính.', + description: + 'Nhà phố 4x15m với thiết kế tối giản, tận dụng ánh sáng tự nhiên và gạch men màu trung tính.', has360View: true, ), _LibraryCard( - imageUrl: 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=800&h=200&fit=crop', + imageUrl: + 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=800&h=200&fit=crop', title: 'Chung cư Cao cấp', date: '05/11/2024', - description: 'Căn hộ 3PN với nội thất sang trọng, sử dụng gạch marble và ceramic cao cấp nhập khẩu Italy.', + description: + 'Căn hộ 3PN với nội thất sang trọng, sử dụng gạch marble và ceramic cao cấp nhập khẩu Italy.', has360View: true, ), ], @@ -212,15 +229,15 @@ class _LibraryCard extends StatelessWidget { Widget build(BuildContext context) { return Card( elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), margin: const EdgeInsets.only(bottom: 20), child: InkWell( onTap: () { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Chức năng xem chi tiết sẽ được triển khai trong phiên bản tiếp theo'), + content: Text( + 'Chức năng xem chi tiết sẽ được triển khai trong phiên bản tiếp theo', + ), ), ); }, @@ -243,9 +260,7 @@ class _LibraryCard extends StatelessWidget { placeholder: (context, url) => Container( height: 200, color: AppColors.grey100, - child: const Center( - child: CircularProgressIndicator(), - ), + child: const Center(child: CircularProgressIndicator()), ), errorWidget: (context, url, error) => Container( height: 200, @@ -381,11 +396,7 @@ class _DesignRequestsTab extends StatelessWidget { } /// Design Request Status -enum DesignRequestStatus { - pending, - designing, - completed, -} +enum DesignRequestStatus { pending, designing, completed } /// Request Card Widget class _RequestCard extends StatelessWidget { @@ -438,13 +449,13 @@ class _RequestCard extends StatelessWidget { Widget build(BuildContext context) { return Card( elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), margin: const EdgeInsets.only(bottom: 16), child: InkWell( onTap: () { - context.push('/model-houses/design-request/${code.replaceAll('#', '')}'); + context.push( + '/model-houses/design-request/${code.replaceAll('#', '')}', + ); }, borderRadius: BorderRadius.circular(12), child: Padding( @@ -490,10 +501,7 @@ class _RequestCard extends StatelessWidget { // Date Text( 'Ngày gửi: $date', - style: const TextStyle( - fontSize: 14, - color: AppColors.grey500, - ), + style: const TextStyle(fontSize: 14, color: AppColors.grey500), ), const SizedBox(height: 8), @@ -501,10 +509,7 @@ class _RequestCard extends StatelessWidget { // Description Text( description, - style: const TextStyle( - fontSize: 14, - color: AppColors.grey900, - ), + style: const TextStyle(fontSize: 14, color: AppColors.grey900), ), ], ), diff --git a/lib/hive_registrar.g.dart b/lib/hive_registrar.g.dart new file mode 100644 index 0000000..a86bbc0 --- /dev/null +++ b/lib/hive_registrar.g.dart @@ -0,0 +1,145 @@ +// Generated by Hive CE +// Do not modify +// Check in to version control + +import 'package:hive_ce/hive.dart'; +import 'package:worker/core/database/models/cached_data.dart'; +import 'package:worker/core/database/models/enums.dart'; +import 'package:worker/features/account/data/models/audit_log_model.dart'; +import 'package:worker/features/account/data/models/payment_reminder_model.dart'; +import 'package:worker/features/auth/data/models/user_model.dart'; +import 'package:worker/features/auth/data/models/user_session_model.dart'; +import 'package:worker/features/cart/data/models/cart_item_model.dart'; +import 'package:worker/features/cart/data/models/cart_model.dart'; +import 'package:worker/features/chat/data/models/chat_room_model.dart'; +import 'package:worker/features/chat/data/models/message_model.dart'; +import 'package:worker/features/favorites/data/models/favorite_model.dart'; +import 'package:worker/features/home/data/models/member_card_model.dart'; +import 'package:worker/features/home/data/models/promotion_model.dart'; +import 'package:worker/features/loyalty/data/models/gift_catalog_model.dart'; +import 'package:worker/features/loyalty/data/models/loyalty_point_entry_model.dart'; +import 'package:worker/features/loyalty/data/models/points_record_model.dart'; +import 'package:worker/features/loyalty/data/models/redeemed_gift_model.dart'; +import 'package:worker/features/orders/data/models/invoice_model.dart'; +import 'package:worker/features/orders/data/models/order_item_model.dart'; +import 'package:worker/features/orders/data/models/order_model.dart'; +import 'package:worker/features/orders/data/models/payment_line_model.dart'; +import 'package:worker/features/products/data/models/category_model.dart'; +import 'package:worker/features/products/data/models/product_model.dart'; +import 'package:worker/features/products/data/models/stock_level_model.dart'; +import 'package:worker/features/projects/data/models/design_request_model.dart'; +import 'package:worker/features/projects/data/models/project_submission_model.dart'; +import 'package:worker/features/quotes/data/models/quote_item_model.dart'; +import 'package:worker/features/quotes/data/models/quote_model.dart'; +import 'package:worker/features/showrooms/data/models/showroom_model.dart'; +import 'package:worker/features/showrooms/data/models/showroom_product_model.dart'; + +extension HiveRegistrar on HiveInterface { + void registerAdapters() { + registerAdapter(AuditLogModelAdapter()); + registerAdapter(CachedDataAdapter()); + registerAdapter(CartItemModelAdapter()); + registerAdapter(CartModelAdapter()); + registerAdapter(CategoryModelAdapter()); + registerAdapter(ChatRoomModelAdapter()); + registerAdapter(ComplaintStatusAdapter()); + registerAdapter(ContentTypeAdapter()); + registerAdapter(DesignRequestModelAdapter()); + registerAdapter(DesignStatusAdapter()); + registerAdapter(EntrySourceAdapter()); + registerAdapter(EntryTypeAdapter()); + registerAdapter(FavoriteModelAdapter()); + registerAdapter(GiftCatalogModelAdapter()); + registerAdapter(GiftCategoryAdapter()); + registerAdapter(GiftStatusAdapter()); + registerAdapter(InvoiceModelAdapter()); + registerAdapter(InvoiceStatusAdapter()); + registerAdapter(InvoiceTypeAdapter()); + registerAdapter(LoyaltyPointEntryModelAdapter()); + registerAdapter(LoyaltyTierAdapter()); + registerAdapter(MemberCardModelAdapter()); + registerAdapter(MessageModelAdapter()); + registerAdapter(OrderItemModelAdapter()); + registerAdapter(OrderModelAdapter()); + registerAdapter(OrderStatusAdapter()); + registerAdapter(PaymentLineModelAdapter()); + registerAdapter(PaymentMethodAdapter()); + registerAdapter(PaymentReminderModelAdapter()); + registerAdapter(PaymentStatusAdapter()); + registerAdapter(PointsRecordModelAdapter()); + registerAdapter(PointsStatusAdapter()); + registerAdapter(ProductModelAdapter()); + registerAdapter(ProjectSubmissionModelAdapter()); + registerAdapter(ProjectTypeAdapter()); + registerAdapter(PromotionModelAdapter()); + registerAdapter(QuoteItemModelAdapter()); + registerAdapter(QuoteModelAdapter()); + registerAdapter(QuoteStatusAdapter()); + registerAdapter(RedeemedGiftModelAdapter()); + registerAdapter(ReminderTypeAdapter()); + registerAdapter(RoomTypeAdapter()); + registerAdapter(ShowroomModelAdapter()); + registerAdapter(ShowroomProductModelAdapter()); + registerAdapter(StockLevelModelAdapter()); + registerAdapter(SubmissionStatusAdapter()); + registerAdapter(UserModelAdapter()); + registerAdapter(UserRoleAdapter()); + registerAdapter(UserSessionModelAdapter()); + registerAdapter(UserStatusAdapter()); + } +} + +extension IsolatedHiveRegistrar on IsolatedHiveInterface { + void registerAdapters() { + registerAdapter(AuditLogModelAdapter()); + registerAdapter(CachedDataAdapter()); + registerAdapter(CartItemModelAdapter()); + registerAdapter(CartModelAdapter()); + registerAdapter(CategoryModelAdapter()); + registerAdapter(ChatRoomModelAdapter()); + registerAdapter(ComplaintStatusAdapter()); + registerAdapter(ContentTypeAdapter()); + registerAdapter(DesignRequestModelAdapter()); + registerAdapter(DesignStatusAdapter()); + registerAdapter(EntrySourceAdapter()); + registerAdapter(EntryTypeAdapter()); + registerAdapter(FavoriteModelAdapter()); + registerAdapter(GiftCatalogModelAdapter()); + registerAdapter(GiftCategoryAdapter()); + registerAdapter(GiftStatusAdapter()); + registerAdapter(InvoiceModelAdapter()); + registerAdapter(InvoiceStatusAdapter()); + registerAdapter(InvoiceTypeAdapter()); + registerAdapter(LoyaltyPointEntryModelAdapter()); + registerAdapter(LoyaltyTierAdapter()); + registerAdapter(MemberCardModelAdapter()); + registerAdapter(MessageModelAdapter()); + registerAdapter(OrderItemModelAdapter()); + registerAdapter(OrderModelAdapter()); + registerAdapter(OrderStatusAdapter()); + registerAdapter(PaymentLineModelAdapter()); + registerAdapter(PaymentMethodAdapter()); + registerAdapter(PaymentReminderModelAdapter()); + registerAdapter(PaymentStatusAdapter()); + registerAdapter(PointsRecordModelAdapter()); + registerAdapter(PointsStatusAdapter()); + registerAdapter(ProductModelAdapter()); + registerAdapter(ProjectSubmissionModelAdapter()); + registerAdapter(ProjectTypeAdapter()); + registerAdapter(PromotionModelAdapter()); + registerAdapter(QuoteItemModelAdapter()); + registerAdapter(QuoteModelAdapter()); + registerAdapter(QuoteStatusAdapter()); + registerAdapter(RedeemedGiftModelAdapter()); + registerAdapter(ReminderTypeAdapter()); + registerAdapter(RoomTypeAdapter()); + registerAdapter(ShowroomModelAdapter()); + registerAdapter(ShowroomProductModelAdapter()); + registerAdapter(StockLevelModelAdapter()); + registerAdapter(SubmissionStatusAdapter()); + registerAdapter(UserModelAdapter()); + registerAdapter(UserRoleAdapter()); + registerAdapter(UserSessionModelAdapter()); + registerAdapter(UserStatusAdapter()); + } +} diff --git a/lib/main.dart b/lib/main.dart index 6536c2e..617f4cc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -38,17 +38,10 @@ Future _initializeApp() async { try { // Initialize core dependencies in parallel for faster startup - await Future.wait([ - _initializeHive(), - _initializeSharedPreferences(), - ]); + await Future.wait([_initializeHive(), _initializeSharedPreferences()]); // Run the app with Riverpod ProviderScope - runApp( - const ProviderScope( - child: WorkerApp(), - ), - ); + runApp(const ProviderScope(child: WorkerApp())); } catch (error, stackTrace) { // Critical initialization error - show error screen debugPrint('Failed to initialize app: $error'); @@ -192,10 +185,7 @@ Widget _buildErrorApp(Object error, StackTrace stackTrace) { const Text( 'Đã xảy ra lỗi khi khởi động ứng dụng. ' 'Vui lòng thử lại sau hoặc liên hệ hỗ trợ.', - style: TextStyle( - fontSize: 16, - color: Color(0xFF6C757D), - ), + style: TextStyle(fontSize: 16, color: Color(0xFF6C757D)), textAlign: TextAlign.center, ), const SizedBox(height: 32), diff --git a/lib/shared/widgets/custom_app_bar.dart b/lib/shared/widgets/custom_app_bar.dart index 1f4af4a..ef49390 100644 --- a/lib/shared/widgets/custom_app_bar.dart +++ b/lib/shared/widgets/custom_app_bar.dart @@ -47,9 +47,8 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { } @override - Size get preferredSize => Size.fromHeight( - AppBarSpecs.height + (bottom?.preferredSize.height ?? 0), - ); + Size get preferredSize => + Size.fromHeight(AppBarSpecs.height + (bottom?.preferredSize.height ?? 0)); } /// Transparent app bar for overlay scenarios @@ -110,7 +109,8 @@ class SearchAppBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { return AppBar( - leading: leading ?? + leading: + leading ?? IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), diff --git a/lib/shared/widgets/date_picker_field.dart b/lib/shared/widgets/date_picker_field.dart index 74b68ba..9665d22 100644 --- a/lib/shared/widgets/date_picker_field.dart +++ b/lib/shared/widgets/date_picker_field.dart @@ -113,7 +113,8 @@ class _DatePickerFieldState extends State { readOnly: true, enabled: widget.enabled, onTap: () => _selectDate(context), - decoration: widget.decoration ?? + decoration: + widget.decoration ?? InputDecoration( labelText: widget.labelText ?? 'Ngày', hintText: widget.hintText ?? 'dd/MM/yyyy', @@ -124,8 +125,7 @@ class _DatePickerFieldState extends State { ), contentPadding: InputFieldSpecs.contentPadding, ), - validator: widget.validator ?? - (widget.required ? Validators.date : null), + validator: widget.validator ?? (widget.required ? Validators.date : null), ); } } diff --git a/lib/shared/widgets/gradient_card.dart b/lib/shared/widgets/gradient_card.dart index bf8fca4..d5224cf 100644 --- a/lib/shared/widgets/gradient_card.dart +++ b/lib/shared/widgets/gradient_card.dart @@ -41,7 +41,8 @@ class GradientCard extends StatelessWidget { decoration: BoxDecoration( gradient: gradient, borderRadius: BorderRadius.circular(borderRadius), - boxShadow: shadows ?? + boxShadow: + shadows ?? [ BoxShadow( color: Colors.black.withOpacity(0.1 * (elevation / 4)), diff --git a/lib/shared/widgets/price_display.dart b/lib/shared/widgets/price_display.dart index fb80e21..db1c2fa 100644 --- a/lib/shared/widgets/price_display.dart +++ b/lib/shared/widgets/price_display.dart @@ -37,7 +37,8 @@ class PriceDisplay extends StatelessWidget { return Text( formattedPrice, - style: style ?? + style: + style ?? TextStyle( color: color, fontWeight: fontWeight ?? FontWeight.w600, @@ -76,7 +77,8 @@ class SalePriceDisplay extends StatelessWidget { // Sale price (larger, prominent) Text( CurrencyFormatter.format(salePrice, showSymbol: showSymbol), - style: salePriceStyle ?? + style: + salePriceStyle ?? const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -87,7 +89,8 @@ class SalePriceDisplay extends StatelessWidget { // Original price (smaller, strikethrough) Text( CurrencyFormatter.format(originalPrice, showSymbol: showSymbol), - style: originalPriceStyle ?? + style: + originalPriceStyle ?? TextStyle( fontSize: 14, fontWeight: FontWeight.normal, @@ -131,7 +134,8 @@ class PriceWithDiscount extends StatelessWidget { // Sale price Text( CurrencyFormatter.format(salePrice, showSymbol: showSymbol), - style: salePriceStyle ?? + style: + salePriceStyle ?? const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -161,7 +165,8 @@ class PriceWithDiscount extends StatelessWidget { // Original price Text( CurrencyFormatter.format(originalPrice, showSymbol: showSymbol), - style: originalPriceStyle ?? + style: + originalPriceStyle ?? TextStyle( fontSize: 14, fontWeight: FontWeight.normal, @@ -217,10 +222,7 @@ class CompactPriceDisplay extends StatelessWidget { return Text( CurrencyFormatter.format(price, showSymbol: showSymbol), - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ); } } @@ -246,13 +248,7 @@ class LargePriceDisplay extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (label != null) ...[ - Text( - label!, - style: TextStyle( - fontSize: 14, - color: Colors.grey[600], - ), - ), + Text(label!, style: TextStyle(fontSize: 14, color: Colors.grey[600])), const SizedBox(height: 4), ], Text( diff --git a/lib/shared/widgets/status_badge.dart b/lib/shared/widgets/status_badge.dart index 5576ac3..a821004 100644 --- a/lib/shared/widgets/status_badge.dart +++ b/lib/shared/widgets/status_badge.dart @@ -30,104 +30,66 @@ class StatusBadge extends StatelessWidget { }); /// Order status badges - factory StatusBadge.orderPending() => const StatusBadge( - label: 'Chờ xử lý', - color: AppColors.info, - ); + factory StatusBadge.orderPending() => + const StatusBadge(label: 'Chờ xử lý', color: AppColors.info); - factory StatusBadge.orderProcessing() => const StatusBadge( - label: 'Đang xử lý', - color: AppColors.warning, - ); + factory StatusBadge.orderProcessing() => + const StatusBadge(label: 'Đang xử lý', color: AppColors.warning); - factory StatusBadge.orderShipping() => const StatusBadge( - label: 'Đang giao', - color: AppColors.lightBlue, - ); + factory StatusBadge.orderShipping() => + const StatusBadge(label: 'Đang giao', color: AppColors.lightBlue); - factory StatusBadge.orderCompleted() => const StatusBadge( - label: 'Hoàn thành', - color: AppColors.success, - ); + factory StatusBadge.orderCompleted() => + const StatusBadge(label: 'Hoàn thành', color: AppColors.success); - factory StatusBadge.orderCancelled() => const StatusBadge( - label: 'Đã hủy', - color: AppColors.danger, - ); + factory StatusBadge.orderCancelled() => + const StatusBadge(label: 'Đã hủy', color: AppColors.danger); /// Payment status badges - factory StatusBadge.paymentPending() => const StatusBadge( - label: 'Chờ thanh toán', - color: AppColors.warning, - ); + factory StatusBadge.paymentPending() => + const StatusBadge(label: 'Chờ thanh toán', color: AppColors.warning); - factory StatusBadge.paymentProcessing() => const StatusBadge( - label: 'Đang xử lý', - color: AppColors.info, - ); + factory StatusBadge.paymentProcessing() => + const StatusBadge(label: 'Đang xử lý', color: AppColors.info); - factory StatusBadge.paymentCompleted() => const StatusBadge( - label: 'Đã thanh toán', - color: AppColors.success, - ); + factory StatusBadge.paymentCompleted() => + const StatusBadge(label: 'Đã thanh toán', color: AppColors.success); - factory StatusBadge.paymentFailed() => const StatusBadge( - label: 'Thất bại', - color: AppColors.danger, - ); + factory StatusBadge.paymentFailed() => + const StatusBadge(label: 'Thất bại', color: AppColors.danger); /// Project status badges - factory StatusBadge.projectPlanning() => const StatusBadge( - label: 'Lập kế hoạch', - color: AppColors.info, - ); + factory StatusBadge.projectPlanning() => + const StatusBadge(label: 'Lập kế hoạch', color: AppColors.info); - factory StatusBadge.projectInProgress() => const StatusBadge( - label: 'Đang thực hiện', - color: AppColors.warning, - ); + factory StatusBadge.projectInProgress() => + const StatusBadge(label: 'Đang thực hiện', color: AppColors.warning); - factory StatusBadge.projectCompleted() => const StatusBadge( - label: 'Hoàn thành', - color: AppColors.success, - ); + factory StatusBadge.projectCompleted() => + const StatusBadge(label: 'Hoàn thành', color: AppColors.success); - factory StatusBadge.projectOnHold() => const StatusBadge( - label: 'Tạm dừng', - color: AppColors.grey500, - ); + factory StatusBadge.projectOnHold() => + const StatusBadge(label: 'Tạm dừng', color: AppColors.grey500); /// Gift status badges - factory StatusBadge.giftActive() => const StatusBadge( - label: 'Còn hạn', - color: AppColors.success, - ); + factory StatusBadge.giftActive() => + const StatusBadge(label: 'Còn hạn', color: AppColors.success); - factory StatusBadge.giftUsed() => const StatusBadge( - label: 'Đã sử dụng', - color: AppColors.grey500, - ); + factory StatusBadge.giftUsed() => + const StatusBadge(label: 'Đã sử dụng', color: AppColors.grey500); - factory StatusBadge.giftExpired() => const StatusBadge( - label: 'Hết hạn', - color: AppColors.danger, - ); + factory StatusBadge.giftExpired() => + const StatusBadge(label: 'Hết hạn', color: AppColors.danger); /// Member tier badges - factory StatusBadge.tierDiamond() => const StatusBadge( - label: 'Kim Cương', - color: Color(0xFF4A00E0), - ); + factory StatusBadge.tierDiamond() => + const StatusBadge(label: 'Kim Cương', color: Color(0xFF4A00E0)); - factory StatusBadge.tierPlatinum() => const StatusBadge( - label: 'Bạch Kim', - color: Color(0xFF7F8C8D), - ); + factory StatusBadge.tierPlatinum() => + const StatusBadge(label: 'Bạch Kim', color: Color(0xFF7F8C8D)); - factory StatusBadge.tierGold() => const StatusBadge( - label: 'Vàng', - color: Color(0xFFf7b733), - ); + factory StatusBadge.tierGold() => + const StatusBadge(label: 'Vàng', color: Color(0xFFf7b733)); @override Widget build(BuildContext context) { @@ -224,11 +186,7 @@ class IconStatusBadge extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - icon, - size: iconSize, - color: textColor ?? Colors.white, - ), + Icon(icon, size: iconSize, color: textColor ?? Colors.white), const SizedBox(width: 4), Text( label, diff --git a/lib/shared/widgets/vietnamese_phone_field.dart b/lib/shared/widgets/vietnamese_phone_field.dart index 771bbd4..b0c0a94 100644 --- a/lib/shared/widgets/vietnamese_phone_field.dart +++ b/lib/shared/widgets/vietnamese_phone_field.dart @@ -6,9 +6,9 @@ library; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../../core/utils/validators.dart'; -import '../../core/utils/formatters.dart'; -import '../../core/constants/ui_constants.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/utils/formatters.dart'; +import 'package:worker/core/utils/validators.dart'; /// Phone number input field with Vietnamese formatting class VietnamesePhoneField extends StatefulWidget { @@ -89,15 +89,15 @@ class _VietnamesePhoneFieldState extends State { decoration: InputDecoration( labelText: widget.labelText ?? 'Số điện thoại', hintText: widget.hintText ?? '0xxx xxx xxx', - prefixIcon: widget.prefixIcon ?? - const Icon(Icons.phone), + prefixIcon: widget.prefixIcon ?? const Icon(Icons.phone), suffixIcon: widget.suffixIcon, border: OutlineInputBorder( borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius), ), contentPadding: InputFieldSpecs.contentPadding, ), - validator: widget.validator ?? + validator: + widget.validator ?? (widget.required ? Validators.phone : Validators.phoneOptional), onChanged: widget.onChanged, onFieldSubmitted: widget.onSubmitted, diff --git a/pubspec.lock b/pubspec.lock index 0a33c27..5623157 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -480,6 +480,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -712,10 +760,10 @@ packages: dependency: transitive description: name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.6.7" json_annotation: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 62d991d..0b593da 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,7 @@ dependencies: url_launcher: ^6.3.0 path_provider: ^2.1.3 shared_preferences: ^2.2.3 + flutter_secure_storage: ^9.2.4 # Navigation go_router: ^14.6.2