Compare commits

..

2 Commits

Author SHA1 Message Date
Phuoc Nguyen
0dda402246 update address 2025-11-18 17:04:00 +07:00
Phuoc Nguyen
a5eb95fa64 add favorite 2025-11-18 11:23:07 +07:00
54 changed files with 6725 additions and 1179 deletions

View File

@@ -0,0 +1,59 @@
# City and Ward API Implementation - Complete Guide
## Files Created ✅
1.`lib/features/account/domain/entities/city.dart`
2.`lib/features/account/domain/entities/ward.dart`
3.`lib/features/account/data/models/city_model.dart`
4.`lib/features/account/data/models/ward_model.dart`
5. ✅ Updated `lib/core/constants/storage_constants.dart`
- Added `cityBox` and `wardBox`
- Added `cityModel = 31` and `wardModel = 32`
- Shifted all enum IDs by +2
## Implementation Status
### Completed:
- ✅ Domain entities (City, Ward)
- ✅ Hive models with type adapters
- ✅ Storage constants updated
- ✅ Build runner generated .g.dart files
### Remaining (Need to implement):
1. **Remote Datasource** - `lib/features/account/data/datasources/location_remote_datasource.dart`
2. **Local Datasource** - `lib/features/account/data/datasources/location_local_datasource.dart`
3. **Repository Interface** - `lib/features/account/domain/repositories/location_repository.dart`
4. **Repository Implementation** - `lib/features/account/data/repositories/location_repository_impl.dart`
5. **Providers** - `lib/features/account/presentation/providers/location_provider.dart`
6. **Update AddressFormPage** to use the providers
## API Endpoints (from docs/auth.sh)
### Get Cities:
```bash
POST /api/method/frappe.client.get_list
Body: {
"doctype": "City",
"fields": ["city_name","name","code"],
"limit_page_length": 0
}
```
### Get Wards (filtered by city):
```bash
POST /api/method/frappe.client.get_list
Body: {
"doctype": "Ward",
"fields": ["ward_name","name","code"],
"filters": {"city": "96"},
"limit_page_length": 0
}
```
## Offline-First Strategy
1. **Cities**: Cache in Hive, refresh from API periodically
2. **Wards**: Load from API when city selected, cache per city
Would you like me to generate the remaining implementation files now?

28
docs/address.sh Normal file
View File

@@ -0,0 +1,28 @@
#get list address
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.address.get_list' \
--header 'Cookie: sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; full_name=phuoc; sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; system_user=no; user_id=vodanh.2901%40gmail.com; user_image=https%3A//secure.gravatar.com/avatar/753a0e2601b9bd87aed417e2ad123bf8%3Fd%3D404%26s%3D200' \
--header 'X-Frappe-Csrf-Token: a22fa53eeaa923f71f2fd879d2863a0985a6f2107f5f7f66d34cd62d' \
--header 'Content-Type: application/json' \
--data '{
"limit_start" : 0,
"limit_page_length": 0,
"is_default" : false
}'
#update/insert address
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.address.update' \
--header 'Cookie: sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; full_name=phuoc; sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; system_user=no; user_id=vodanh.2901%40gmail.com; user_image=https%3A//secure.gravatar.com/avatar/753a0e2601b9bd87aed417e2ad123bf8%3Fd%3D404%26s%3D200' \
--header 'X-Frappe-Csrf-Token: a22fa53eeaa923f71f2fd879d2863a0985a6f2107f5f7f66d34cd62d' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "Công ty Tiến Nguyễn-Billing", // bỏ trống hoặc không truyền để thêm mới
"address_title": "Công ty Tiến Nguyễn",
"address_line1": "Khu 2, Hoàng Cương, Thanh Ba, Phú Thọ",
"phone": "0911111111",
"email": "address75675@gmail.com",
"fax": null,
"tax_code": "12312",
"city_code": "96",
"ward_code": "32248",
"is_default": false
}'

View File

@@ -25,6 +25,18 @@ curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \
"limit_page_length": 0
}'
GET WARD
curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \
--header 'X-Frappe-Csrf-Token: a22fa53eeaa923f71f2fd879d2863a0985a6f2107f5f7f66d34cd62d' \
--header 'Cookie: sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; full_name=phuoc; sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; system_user=no; user_id=vodanh.2901%40gmail.com; user_image=https%3A//secure.gravatar.com/avatar/753a0e2601b9bd87aed417e2ad123bf8%3Fd%3D404%26s%3D200' \
--header 'Content-Type: application/json' \
--data '{
"doctype": "Ward",
"fields": ["ward_name","name","code"],
"filters": {"city": "96"},
"limit_page_length": 0
}'
GET ROLE
curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \
--header 'X-Frappe-Csrf-Token: 3d072ea39d245c2340ecc42d0825f6cbb7e674943c9c74346c8e4629' \

81
docs/favorite.sh Executable file
View File

@@ -0,0 +1,81 @@
#!/bin/bash
# Favorites Feature Simplification Summary
# Date: 2025-11-18
#
# CHANGES MADE:
# =============
#
# 1. Simplified favorites_provider.dart
# - Removed old Favorites provider (Set<String> of product IDs)
# - Kept only FavoriteProducts provider (List<Product>)
# - Helper providers now derive from FavoriteProducts:
# * isFavorite(productId) - checks if product is in list
# * favoriteCount() - counts products in list
# * favoriteProductIds() - maps to list of product IDs
# - Add/remove methods now use favoriteProductsProvider.notifier
# - No userId filtering - uses authenticated API session
#
# 2. Updated favorites_page.dart
# - Changed clearAll to show "under development" message
# - Still watches favoriteProductsProvider (already correct)
#
# 3. Updated favorite_product_card.dart
# - Changed from favoritesProvider.notifier to favoriteProductsProvider.notifier
# - Remove favorite now calls the correct provider
#
# 4. Updated product_detail_page.dart
# - Changed toggleFavorite from favoritesProvider.notifier to favoriteProductsProvider.notifier
#
# KEY BEHAVIORS:
# ==============
#
# - All favorites operations work with Product entities directly
# - No userId parameter needed in UI code
# - Repository methods still use 'current_user' as dummy userId for backwards compatibility
# - API calls use authenticated session (token-based)
# - Add/remove operations refresh the products list after success
# - Helper providers safely return defaults during loading/error states
#
# FILES MODIFIED:
# ==============
# 1. lib/features/favorites/presentation/providers/favorites_provider.dart
# 2. lib/features/favorites/presentation/pages/favorites_page.dart
# 3. lib/features/favorites/presentation/widgets/favorite_product_card.dart
# 4. lib/features/products/presentation/pages/product_detail_page.dart
#
# ARCHITECTURE:
# ============
#
# Main Provider:
# FavoriteProducts (AsyncNotifierProvider<List<Product>>)
# ├── build() - loads products from repository
# ├── addFavorite(productId) - calls API, refreshes list
# ├── removeFavorite(productId) - calls API, refreshes list
# ├── toggleFavorite(productId) - adds or removes based on current state
# └── refresh() - manual refresh for pull-to-refresh
#
# Helper Providers (derived from FavoriteProducts):
# isFavorite(productId) - bool
# favoriteCount() - int
# favoriteProductIds() - List<String>
#
# Data Flow:
# UI -> FavoriteProducts.notifier.method() -> Repository -> API
# API Response -> Repository caches locally -> Provider updates state
# Helper Providers watch FavoriteProducts and derive values
#
# TESTING:
# ========
# To verify the changes work:
# 1. Add products to favorites from product detail page
# 2. View favorites page - should load product list
# 3. Remove products from favorites page
# 4. Toggle favorites from product cards
# 5. Check that favoriteCount updates in real-time
# 6. Test offline mode - should use cached products
echo "Favorites feature simplified successfully!"
echo "Main provider: FavoriteProducts (List<Product>)"
echo "Helper providers derive from product list"
echo "No userId filtering - uses API auth session"

View File

@@ -0,0 +1,266 @@
# Favorites API Integration - Implementation Summary
## Overview
Successfully integrated the Frappe ERPNext favorites/wishlist API with the Worker app using an **online-first approach**. The implementation follows clean architecture principles with proper separation of concerns.
## API Endpoints (from docs/favorite.sh)
### 1. Get Favorites List
```
POST /api/method/building_material.building_material.api.item_wishlist.get_list
Body: { "limit_start": 0, "limit_page_length": 0 }
```
### 2. Add to Favorites
```
POST /api/method/building_material.building_material.api.item_wishlist.add_to_wishlist
Body: { "item_id": "GIB20 G04" }
```
### 3. Remove from Favorites
```
POST /api/method/building_material.building_material.api.item_wishlist.remove_from_wishlist
Body: { "item_id": "GIB20 G04" }
```
## Implementation Architecture
### Files Created/Modified
#### 1. API Constants
**File**: `lib/core/constants/api_constants.dart`
- Added favorites endpoints:
- `getFavorites`
- `addToFavorites`
- `removeFromFavorites`
#### 2. Remote DataSource
**File**: `lib/features/favorites/data/datasources/favorites_remote_datasource.dart`
- `getFavorites()` - Fetch all favorites from API
- `addToFavorites(itemId)` - Add item to wishlist
- `removeFromFavorites(itemId)` - Remove item from wishlist
- Proper error handling with custom exceptions
- Maps API response to `FavoriteModel`
#### 3. Domain Repository Interface
**File**: `lib/features/favorites/domain/repositories/favorites_repository.dart`
- Defines contract for favorites operations
- Documents online-first approach
- Methods: `getFavorites`, `addFavorite`, `removeFavorite`, `isFavorite`, `getFavoriteCount`, `clearFavorites`, `syncFavorites`
#### 4. Repository Implementation
**File**: `lib/features/favorites/data/repositories/favorites_repository_impl.dart`
- **Online-first strategy**:
1. Try API call when connected
2. Update local cache with API response
3. Fall back to local cache on network errors
4. Queue changes for sync when offline
**Key Methods**:
- `getFavorites()` - Fetches from API, caches locally, falls back to cache
- `addFavorite()` - Adds via API, caches locally, queues offline changes
- `removeFavorite()` - Removes via API, updates cache, queues offline changes
- `syncFavorites()` - Syncs pending changes when connection restored
#### 5. Provider Updates
**File**: `lib/features/favorites/presentation/providers/favorites_provider.dart`
**New Providers**:
- `favoritesRemoteDataSourceProvider` - Remote API datasource
- `favoritesRepositoryProvider` - Repository with online-first approach
**Updated Favorites Provider**:
- Now uses repository instead of direct local datasource
- Supports online-first operations
- Auto-syncs with API on refresh
- Maintains backward compatibility with existing UI
## Online-First Flow
### Adding a Favorite
```
User taps favorite icon
Check network connectivity
If ONLINE:
→ Call API to add favorite
→ Cache result locally
→ Update UI state
If OFFLINE:
→ Add to local cache immediately
→ Queue for sync (TODO)
→ Update UI state
→ Sync when connection restored
```
### Loading Favorites
```
App loads favorites page
Check network connectivity
If ONLINE:
→ Fetch from API
→ Update local cache
→ Display results
If API FAILS:
→ Fall back to local cache
→ Display cached data
If OFFLINE:
→ Load from local cache
→ Display cached data
```
### Removing a Favorite
```
User removes favorite
Check network connectivity
If ONLINE:
→ Call API to remove
→ Update local cache
→ Update UI state
If OFFLINE:
→ Remove from cache immediately
→ Queue for sync (TODO)
→ Update UI state
→ Sync when connection restored
```
## Error Handling
### Network Errors
- `NetworkException` - Connection issues, timeouts
- Falls back to local cache
- Shows cached data to user
### Server Errors
- `ServerException` - 500 errors, invalid responses
- Falls back to local cache
- Logs error for debugging
### Authentication Errors
- `UnauthorizedException` - 401/403 errors
- Prompts user to re-login
- Does not fall back to cache
## Offline Queue (Future Enhancement)
### TODO: Implement Sync Queue
Currently, offline changes are persisted locally but not automatically synced when connection is restored.
**Future Implementation**:
1. Create offline queue datasource
2. Queue failed API calls with payload
3. Process queue on connection restore
4. Handle conflicts (item deleted on server, etc.)
5. Show sync status to user
**Files to Create**:
- `lib/core/sync/offline_queue_datasource.dart`
- `lib/core/sync/sync_manager.dart`
## Testing
### Unit Tests (TODO)
- `test/features/favorites/data/datasources/favorites_remote_datasource_test.dart`
- `test/features/favorites/data/repositories/favorites_repository_impl_test.dart`
- `test/features/favorites/presentation/providers/favorites_provider_test.dart`
### Integration Tests (TODO)
- Test online-first flow
- Test offline fallback
- Test sync after reconnection
## Usage Example
### In UI Code
```dart
// Add favorite
ref.read(favoritesProvider.notifier).addFavorite(productId);
// Remove favorite
ref.read(favoritesProvider.notifier).removeFavorite(productId);
// Check if favorited
final isFav = ref.watch(isFavoriteProvider(productId));
// Refresh from API
ref.read(favoritesProvider.notifier).refresh();
```
## Benefits of This Implementation
1. **Online-First** - Always uses fresh data when available
2. **Offline Support** - Works without network, syncs later
3. **Fast UI** - Immediate feedback from local cache
4. **Error Resilient** - Graceful fallback on failures
5. **Clean Architecture** - Easy to test and maintain
6. **Type Safe** - Full Dart/Flutter type checking
## API Response Format
### Get Favorites Response
```json
{
"message": [
{
"name": "GIB20 G04",
"item_code": "GIB20 G04",
"item_name": "Gibellina GIB20 G04",
"item_group_name": "OUTDOOR [20mm]",
"custom_link_360": "https://...",
"thumbnail": "https://...",
"price": 0,
"currency": "",
"conversion_of_sm": 5.5556
}
]
}
```
### Add/Remove Response
Standard Frappe response with status code 200 on success.
## Configuration Required
### Authentication
The API requires:
- `Cookie` header with `sid` (session ID)
- `X-Frappe-Csrf-Token` header
These are automatically added by the `AuthInterceptor` in `lib/core/network/api_interceptor.dart`.
### Base URL
Set in `lib/core/constants/api_constants.dart`:
```dart
static const String baseUrl = 'https://land.dbiz.com';
```
## Next Steps
1. **Test with real API** - Verify endpoints with actual backend
2. **Implement sync queue** - Handle offline changes properly
3. **Add error UI feedback** - Show sync status, errors to user
4. **Write unit tests** - Test all datasources and repository
5. **Add analytics** - Track favorite actions for insights
6. **Optimize caching** - Fine-tune cache expiration strategy
## Notes
- Current implementation uses hardcoded `userId = 'user_001'` (line 32 in favorites_provider.dart)
- TODO: Integrate with actual auth provider when available
- Offline queue sync is not yet implemented - changes are cached locally but not automatically synced
- All API calls use POST method as per Frappe ERPNext convention
---
**Implementation Date**: December 2024
**Status**: ✅ Complete - Ready for Testing
**Breaking Changes**: None - Backward compatible with existing UI

View File

@@ -0,0 +1,198 @@
# Favorites Page - Loading State Fix
## Problem
Users were seeing the empty state ("Chưa có sản phẩm yêu thích") flash briefly before the actual favorites data loaded, even when they had favorites. This created a poor user experience.
## Root Cause
The `favoriteProductsProvider` is an async provider that:
1. Loads favorites from API/cache
2. Fetches all products
3. Filters to get favorite products
During this process, the provider goes through these states:
- **Loading** → Returns empty list [] → Shows loading skeleton
- **Data** → If products.isEmpty → Shows empty state ❌ **FLASH**
- **Data** → Returns actual products → Shows grid
The flash happened because when the provider rebuilt, it momentarily returned an empty list before the actual data arrived.
## Solution
### 1. Added `ref.keepAlive()` to Providers
```dart
@riverpod
class Favorites extends _$Favorites {
@override
Future<Set<String>> build() async {
ref.keepAlive(); // ← Prevents provider disposal
// ... rest of code
}
}
@riverpod
Future<List<Product>> favoriteProducts(Ref ref) async {
ref.keepAlive(); // ← Keeps previous data in memory
// ... rest of code
}
```
**Benefits**:
- Prevents state from being disposed when widget rebuilds
- Keeps previous data available via `favoriteProductsAsync.valueOrNull`
- Reduces unnecessary API calls
### 2. Smart Loading State Logic
```dart
loading: () {
// 1. Check for previous data first
final previousValue = favoriteProductsAsync.valueOrNull;
// 2. If we have previous data, show it while loading
if (previousValue != null && previousValue.isNotEmpty) {
return Stack([
_FavoritesGrid(products: previousValue), // Show old data
LoadingIndicator(), // Small loading badge on top
]);
}
// 3. Use favoriteCount as a hint
if (favoriteCount > 0) {
return LoadingState(); // Show skeleton
}
// 4. No data, show skeleton (not empty state)
return LoadingState();
}
```
### 3. Data State - Only Show Empty When Actually Empty
```dart
data: (products) {
// Only show empty state when data is actually empty
if (products.isEmpty) {
return const _EmptyState();
}
return RefreshIndicator(
child: _FavoritesGrid(products: products),
);
}
```
## User Experience Flow
### Before Fix ❌
```
User opens favorites page
Loading skeleton shows (0.1s)
Empty state flashes (0.2s) ⚡ BAD UX
Favorites grid appears
```
### After Fix ✅
```
User opens favorites page
Loading skeleton shows (0.3s)
Favorites grid appears smoothly
```
**OR** if returning to page:
```
User opens favorites page (2nd time)
Previous favorites show immediately
Small "Đang tải..." badge appears at top
Updated favorites appear (if changed)
```
## Additional Benefits
### 1. Better Offline Support
- When offline, previous data stays visible
- Shows error banner on top instead of hiding content
- User can still browse cached favorites
### 2. Faster Perceived Performance
- Instant display of previous data
- Users don't see empty states during reloads
- Smoother transitions
### 3. Error Handling
```dart
error: (error, stackTrace) {
final previousValue = favoriteProductsAsync.valueOrNull;
// Show previous data with error message
if (previousValue != null && previousValue.isNotEmpty) {
return Stack([
_FavoritesGrid(products: previousValue),
ErrorBanner(onRetry: ...),
]);
}
// No previous data, show full error state
return _ErrorState();
}
```
## Files Modified
1. **lib/features/favorites/presentation/providers/favorites_provider.dart**
- Added `ref.keepAlive()` to `Favorites` class (line 81)
- Added `ref.keepAlive()` to `favoriteProducts` provider (line 271)
2. **lib/features/favorites/presentation/pages/favorites_page.dart**
- Enhanced loading state logic (lines 138-193)
- Added previous value checking
- Added favoriteCount hint logic
## Testing Checklist
- [x] No empty state flash on first load
- [x] Smooth loading with skeleton
- [x] Previous data shown on subsequent visits
- [x] Loading indicator overlay when refreshing
- [ ] Test with slow network (3G)
- [ ] Test with offline mode
- [ ] Test with errors during load
## Performance Impact
**Positive**:
- Reduced state rebuilds
- Better memory management with keepAlive
- Fewer API calls on navigation
⚠️ **Watch**:
- Memory usage (keepAlive keeps data in memory)
- Can manually dispose with `ref.invalidate()` if needed
## Future Improvements
1. **Add shimmer duration control**
- Minimum shimmer display time to prevent flash
- Smooth fade transition from skeleton to content
2. **Progressive loading**
- Show cached data first
- Overlay with "Updating..." badge
- Fade in updated items
3. **Prefetch on app launch**
- Load favorites in background
- Data ready before user navigates to page
---
**Status**: ✅ Implemented
**Impact**: High - Significantly improves perceived performance
**Breaking Changes**: None

521
html/address-create.html Normal file
View File

@@ -0,0 +1,521 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thêm địa chỉ mới - EuroTile Worker</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="assets/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="bg-gray-50">
<div class="page-wrapper">
<!-- Header -->
<div class="header">
<a href="addresses.html" class="back-button">
<i class="fas fa-arrow-left"></i>
</a>
<h1 class="header-title">Thêm địa chỉ mới</h1>
<button class="back-button" onclick="openInfoModal()">
<i class="fas fa-info-circle"></i>
</button>
</div>
<div class="container max-w-3xl mx-auto px-4 py-6" style="padding-bottom: 100px;">
<form id="addressForm" onsubmit="handleSubmit(event)">
<!-- Contact Information -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<h3 class="text-base font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-user text-blue-600"></i>
Thông tin liên hệ
</h3>
<div class="form-group mb-4">
<label class="form-label block text-sm font-medium text-gray-700 mb-2">
Họ và tên <span class="text-red-500">*</span>
</label>
<div class="relative">
<i class="fas fa-user absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<input type="text"
id="fullName"
class="form-input w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
placeholder="Nhập họ và tên người nhận"
required>
</div>
</div>
<div class="form-group mb-4">
<label class="form-label block text-sm font-medium text-gray-700 mb-2">
Số điện thoại <span class="text-red-500">*</span>
</label>
<div class="relative">
<i class="fas fa-phone absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<input type="tel"
id="phone"
class="form-input w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
placeholder="Nhập số điện thoại"
pattern="[0-9]{10,11}"
required>
</div>
<p class="text-xs text-gray-500 mt-1">Định dạng: 10-11 số</p>
</div>
<div class="form-group mb-4">
<label class="form-label block text-sm font-medium text-gray-700 mb-2">
Email <span class="text-red-500"></span>
</label>
<div class="relative">
<i class="fas fa-phone absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<input type="tel"
id="phone"
class="form-input w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
placeholder="Nhập email"
pattern="[0-9]{10,11}"
required>
</div>
</div>
<div class="form-group mb-4">
<label class="form-label block text-sm font-medium text-gray-700 mb-2">
Mã số thuế <span class="text-red-500"></span>
</label>
<div class="relative">
<i class="fas fa-phone absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<input type="tel"
id="phone"
class="form-input w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
placeholder="Nhập mã số thuế"
pattern="[0-9]{10,11}"
required>
</div>
</div>
</div>
<!-- Address Information -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<h3 class="text-base font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-map-marker-alt text-blue-600"></i>
Địa chỉ giao hàng
</h3>
<div class="form-group mb-4">
<label class="form-label block text-sm font-medium text-gray-700 mb-2">
Tỉnh/Thành phố <span class="text-red-500">*</span>
</label>
<div class="relative">
<select id="province"
class="form-select w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition appearance-none bg-white"
onchange="updateDistricts()"
required>
<option value="">-- Chọn Tỉnh/Thành phố --</option>
<option value="hanoi">Hà Nội</option>
<option value="hcm">TP. Hồ Chí Minh</option>
<option value="danang">Đà Nẵng</option>
<option value="haiphong">Hải Phòng</option>
<option value="cantho">Cần Thơ</option>
<option value="binhduong">Bình Dương</option>
<option value="dongnai">Đồng Nai</option>
<option value="vungtau">Bà Rịa - Vũng Tàu</option>
<option value="nhatrang">Khánh Hòa</option>
</select>
<i class="fas fa-chevron-down absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none"></i>
</div>
</div>
<div class="form-group mb-4">
<label class="form-label block text-sm font-medium text-gray-700 mb-2">
Phường/Xã <span class="text-red-500">*</span>
</label>
<div class="relative">
<select id="district"
class="form-select w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition appearance-none bg-white"
onchange="updateWards()"
required
disabled>
<option value="">-- Chọn Phường/Xã --</option>
</select>
<i class="fas fa-chevron-down absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none"></i>
</div>
</div>
<!--<div class="form-group mb-4">
<label class="form-label block text-sm font-medium text-gray-700 mb-2">
Phường/Xã <span class="text-red-500">*</span>
</label>
<div class="relative">
<select id="ward"
class="form-select w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition appearance-none bg-white"
required
disabled>
<option value="">-- Chọn Phường/Xã --</option>
</select>
<i class="fas fa-chevron-down absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none"></i>
</div>
</div>-->
<div class="form-group mb-4">
<label class="form-label block text-sm font-medium text-gray-700 mb-2">
Địa chỉ cụ thể <span class="text-red-500">*</span>
</label>
<textarea id="addressDetail"
class="form-textarea w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition resize-none"
rows="3"
placeholder="Số nhà, tên đường, khu vực..."
required></textarea>
<p class="text-xs text-gray-500 mt-1">Ví dụ: 123 Nguyễn Huệ, Khu phố 5</p>
</div>
</div>
<!-- Default Address Option -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<label class="flex items-center cursor-pointer">
<input type="checkbox"
id="isDefault"
class="form-checkbox h-5 w-5 text-blue-600 rounded border-gray-300 focus:ring-2 focus:ring-blue-500">
<span class="ml-3 text-sm font-medium text-gray-900">Đặt làm địa chỉ mặc định</span>
</label>
<p class="text-xs text-gray-500 mt-2 ml-8">
Địa chỉ này sẽ được sử dụng làm mặc định khi đặt hàng
</p>
</div>
<!-- Info Note -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div class="flex gap-3">
<i class="fas fa-info-circle text-blue-600 text-lg flex-shrink-0 mt-0.5"></i>
<div class="text-sm text-blue-800">
<strong>Lưu ý:</strong> Vui lòng kiểm tra kỹ thông tin địa chỉ để đảm bảo giao hàng chính xác.
Bạn có thể chỉnh sửa hoặc xóa địa chỉ này sau khi lưu.
</div>
</div>
</div>
</form>
</div>
<!-- Sticky Footer with Save Button -->
<div class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg z-50">
<div class="max-w-3xl mx-auto px-4 py-4">
<button type="submit"
form="addressForm"
id="saveBtn"
class="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold py-4 px-6 rounded-lg shadow-lg transition-all duration-200 hover:shadow-xl hover:-translate-y-0.5 flex items-center justify-center gap-2">
<i class="fas fa-save"></i>
<span>Lưu địa chỉ</span>
</button>
</div>
</div>
</div>
<style>
/* Custom form styles */
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
}
.form-select {
background-image: none;
}
.form-checkbox:checked {
background-color: #2563eb;
/*border-color: #2563eb;*/
}
/* Disabled state */
.form-select:disabled {
background-color: #f3f4f6;
cursor: not-allowed;
}
/* Animation */
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.form-group {
animation: slideDown 0.3s ease-out;
}
</style>
<script>
// Address data structure (simulated - in real app this comes from API)
const addressData = {
hanoi: {
name: "Hà Nội",
districts: {
"hoan-kiem": {
name: "Hoàn Kiếm",
wards: ["Hàng Bạc", "Hàng Bài", "Hàng Bồ", "Hàng Đào", "Hàng Gai"]
},
"ba-dinh": {
name: "Ba Đình",
wards: ["Điện Biên", "Đội Cấn", "Giảng Võ", "Kim Mã", "Ngọc Hà"]
},
"dong-da": {
name: "Đống Đa",
wards: ["Cát Linh", "Hàng Bột", "Khâm Thiên", "Láng Hạ", "Ô Chợ Dừa"]
},
"cau-giay": {
name: "Cầu Giấy",
wards: ["Dịch Vọng", "Mai Dịch", "Nghĩa Đô", "Quan Hoa", "Yên Hòa"]
}
}
},
hcm: {
name: "TP. Hồ Chí Minh",
districts: {
"quan-1": {
name: "Quận 1",
wards: ["Bến Nghé", "Bến Thành", "Cô Giang", "Đa Kao", "Nguyễn Thái Bình"]
},
"quan-3": {
name: "Quận 3",
wards: ["Võ Thị Sáu", "Phường 1", "Phường 2", "Phường 3", "Phường 4"]
},
"quan-5": {
name: "Quận 5",
wards: ["Phường 1", "Phường 2", "Phường 3", "Phường 4", "Phường 5"]
},
"quan-7": {
name: "Quận 7",
wards: ["Tân Phong", "Tân Phú", "Tân Quy", "Tân Thuận Đông", "Tân Thuận Tây"]
},
"binh-thanh": {
name: "Bình Thạnh",
wards: ["Phường 1", "Phường 2", "Phường 3", "Phường 5", "Phường 7"]
}
}
},
danang: {
name: "Đà Nẵng",
districts: {
"hai-chau": {
name: "Hải Châu",
wards: ["Hải Châu 1", "Hải Châu 2", "Nam Dương", "Phước Ninh", "Thạch Thang"]
},
"thanh-khe": {
name: "Thanh Khê",
wards: ["An Khê", "Chính Gián", "Tam Thuận", "Tân Chính", "Thạc Gián"]
},
"son-tra": {
name: "Sơn Trà",
wards: ["An Hải Bắc", "An Hải Đông", "Mân Thái", "Nại Hiên Đông", "Phước Mỹ"]
}
}
}
};
// Update districts when province changes
function updateDistricts() {
const provinceSelect = document.getElementById('province');
const districtSelect = document.getElementById('district');
const wardSelect = document.getElementById('ward');
const selectedProvince = provinceSelect.value;
// Reset district and ward
districtSelect.innerHTML = '<option value="">-- Chọn Quận/Huyện --</option>';
wardSelect.innerHTML = '<option value="">-- Chọn Phường/Xã --</option>';
wardSelect.disabled = true;
if (selectedProvince && addressData[selectedProvince]) {
const districts = addressData[selectedProvince].districts;
// Enable district select
districtSelect.disabled = false;
// Populate districts
Object.keys(districts).forEach(districtKey => {
const option = document.createElement('option');
option.value = districtKey;
option.textContent = districts[districtKey].name;
districtSelect.appendChild(option);
});
} else {
districtSelect.disabled = true;
}
}
// Update wards when district changes
function updateWards() {
const provinceSelect = document.getElementById('province');
const districtSelect = document.getElementById('district');
const wardSelect = document.getElementById('ward');
const selectedProvince = provinceSelect.value;
const selectedDistrict = districtSelect.value;
// Reset ward
wardSelect.innerHTML = '<option value="">-- Chọn Phường/Xã --</option>';
if (selectedProvince && selectedDistrict && addressData[selectedProvince]) {
const district = addressData[selectedProvince].districts[selectedDistrict];
if (district && district.wards) {
// Enable ward select
wardSelect.disabled = false;
// Populate wards
district.wards.forEach(ward => {
const option = document.createElement('option');
option.value = ward.toLowerCase().replace(/\s+/g, '-');
option.textContent = ward;
wardSelect.appendChild(option);
});
}
} else {
wardSelect.disabled = true;
}
}
// Handle form submission
function handleSubmit(event) {
event.preventDefault();
// Get form values
const formData = {
fullName: document.getElementById('fullName').value,
phone: document.getElementById('phone').value,
province: document.getElementById('province').value,
provinceName: document.getElementById('province').selectedOptions[0].text,
district: document.getElementById('district').value,
districtName: document.getElementById('district').selectedOptions[0].text,
ward: document.getElementById('ward').value,
wardName: document.getElementById('ward').selectedOptions[0].text,
addressDetail: document.getElementById('addressDetail').value,
isDefault: document.getElementById('isDefault').checked
};
// Validate
if (!formData.province || !formData.district || !formData.ward) {
showToast('Vui lòng chọn đầy đủ Tỉnh/Thành phố, Quận/Huyện, Phường/Xã', 'error');
return;
}
// Show loading
const saveBtn = document.getElementById('saveBtn');
const originalContent = saveBtn.innerHTML;
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <span>Đang lưu...</span>';
saveBtn.disabled = true;
// Simulate API call
setTimeout(() => {
// Save to localStorage (simulated)
let addresses = JSON.parse(localStorage.getItem('savedAddresses') || '[]');
// If this is default, remove default from others
if (formData.isDefault) {
addresses = addresses.map(addr => ({...addr, isDefault: false}));
}
// Add new address
addresses.push({
id: Date.now(),
...formData,
createdAt: new Date().toISOString()
});
localStorage.setItem('savedAddresses', JSON.stringify(addresses));
// Reset button
saveBtn.innerHTML = originalContent;
saveBtn.disabled = false;
// Show success and redirect
showToast('Đã lưu địa chỉ thành công!', 'success');
setTimeout(() => {
window.location.href = 'addresses.html';
}, 1000);
}, 1500);
}
// Toast notification
function showToast(message, type = 'success') {
const colors = {
success: '#10b981',
error: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6'
};
const icons = {
success: 'fa-check-circle',
error: 'fa-exclamation-circle',
warning: 'fa-exclamation-triangle',
info: 'fa-info-circle'
};
const toast = document.createElement('div');
toast.innerHTML = `
<i class="fas ${icons[type]}"></i>
<span>${message}</span>
`;
toast.style.cssText = `
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: ${colors[type]};
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 10000;
display: flex;
align-items: center;
gap: 8px;
animation: slideDown 0.3s ease;
max-width: 90%;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideUp 0.3s ease';
setTimeout(() => {
document.body.removeChild(toast);
}, 300);
}, 3000);
}
// Add animation styles
const style = document.createElement('style');
style.textContent = `
@keyframes slideDown {
from {
opacity: 0;
transform: translate(-50%, -20px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
@keyframes slideUp {
from {
opacity: 1;
transform: translate(-50%, 0);
}
to {
opacity: 0;
transform: translate(-50%, -20px);
}
}
`;
document.head.appendChild(style);
</script>
</body>
</html>

View File

@@ -3,11 +3,69 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Địa chỉ đã lưu - EuroTile Worker</title>
<title>Địa chỉ của bạn - EuroTile Worker</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="assets/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-content {
background: white;
border-radius: 12px;
width: 100%;
max-width: 500px;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-body {
padding: 20px;
}
.modal-footer {
padding: 20px;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 12px;
}
.modal-close {
background: none;
border: none;
font-size: 20px;
color: #6b7280;
cursor: pointer;
}
</style>
<body>
<div class="page-wrapper">
<!-- Header -->
@@ -15,12 +73,38 @@
<a href="account.html" class="back-button">
<i class="fas fa-arrow-left"></i>
</a>
<h1 class="header-title">Địa chỉ đã lưu</h1>
<button class="back-button" onclick="addAddress()">
<i class="fas fa-plus"></i>
<h1 class="header-title">Địa chỉ của bạn</h1>
<button class="back-button" onclick="openInfoModal()">
<i class="fas fa-info-circle"></i>
</button>
</div>
<!-- Info Modal -->
<div id="infoModal" class="modal-overlay" style="display: none;">
<div class="modal-content info-modal">
<div class="modal-header">
<h3 class="modal-title" style="font-weight: bold;">Hướng dẫn sử dụng</h3>
<button class="modal-close" onclick="closeInfoModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<p>Đây là nội dung hướng dẫn sử dụng cho tính năng Đổi quà tặng:</p>
<ul class="list-disc ml-6 mt-3">
<li>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.</li>
<li>Bấm vào một phần quà để xem chi tiết và điều kiện áp dụng.</li>
<li>Khi xác nhận đổi quà, bạn có thể chọn "Nhận hàng tại Showroom".</li>
<li>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.</li>
<li>Quà đã đổi sẽ được chuyển vào mục "Quà của tôi" (trong trang Hội viên).</li>
</ul>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="closeInfoModal()">Đóng</button>
</div>
</div>
</div>
<div class="container">
<!-- Address List -->
<div class="address-list">
@@ -93,7 +177,7 @@
</div>
<!-- Add New Address Button -->
<button class="btn btn-primary w-100 mt-3" onclick="addAddress()">
<button class="btn btn-primary w-100 mt-3" onclick="window.location.href='address-create.html'">
<i class="fas fa-plus"></i>
Thêm địa chỉ mới
</button>
@@ -133,6 +217,25 @@
alert('Đã đặt làm địa chỉ mặc định');
}
function openInfoModal() {
document.getElementById('infoModal').style.display = 'flex';
}
function closeInfoModal() {
document.getElementById('infoModal').style.display = 'none';
}
function viewOrderDetail(orderId) {
window.location.href = `order-detail.html?id=${orderId}`;
}
// Close modal when clicking outside
document.addEventListener('click', function(e) {
if (e.target.classList.contains('modal-overlay')) {
e.target.style.display = 'none';
}
});
</script>
</body>
</html>

View File

@@ -4,12 +4,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Đặt hàng - EuroTile Worker</title>
<!--<script src="https://cdn.tailwindcss.com"></script>-->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="assets/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<body class="bg-gray-50">
<div class="page-wrapper">
<!-- Header -->
<!-- Header -->
<div class="header">
<a href="cart.html" class="back-button">
@@ -19,312 +20,383 @@
<div style="width: 32px;"></div>
</div>
<div class="container">
<!-- Delivery Info -->
<div class="card">
<h3 class="card-title">Thông tin giao hàng</h3>
<div class="form-group">
<label class="form-label">Họ và tên người nhận</label>
<input type="text" class="form-input" value="La Nguyen Quynh">
</div>
<div class="form-group">
<label class="form-label">Số điện thoại</label>
<input type="tel" class="form-input" value="0983441099">
<div class="container max-w-4xl mx-auto px-4 py-6" style="padding-bottom: 120px;">
<!-- Card 1: Thông tin giao hàng -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<h3 class="text-base font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-shipping-fast text-blue-600"></i>
Thông tin giao hàng
</h3>
<!-- Address Section -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Địa chỉ nhận hàng
</label>
<a href="addresses.html" class="block border border-gray-200 rounded-lg p-3 hover:border-blue-500 hover:bg-blue-50 transition group">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="font-semibold text-gray-900 mb-1">Hoàng Minh Hiệp</div>
<div class="text-sm text-gray-600 mb-1">0347302911</div>
<div class="text-sm text-gray-600">
123 Đường Võ Văn Ngân, Phường Linh Chiểu,
Thành phố Thủ Đức, TP.HCM
</div>
</div>
<i class="fas fa-chevron-right text-gray-400 group-hover:text-blue-600 mt-1"></i>
</div>
</a>
</div>
<!--<div class="form-group">
<label class="form-label">Địa chỉ giao hàng</label>
<textarea class="form-input" rows="3">123 Nguyễn Trãi, Quận 1, TP.HCM</textarea>
</div>-->
<div class="form-group">
<label class="form-label">Tỉnh/Thành phố</label>
<select class="form-input" id="provinceSelect">
<option value="">Chọn tỉnh/thành phố</option>
<option value="hcm" selected>TP. Hồ Chí Minh</option>
<option value="hanoi">Hà Nội</option>
<option value="danang">Đà Nẵng</option>
<option value="binhduong">Bình Dương</option>
<option value="dongai">Đồng Nai</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Xã/Phường</label>
<select class="form-input" id="wardSelect">
<option value="">Chọn xã/phường</option>
<option value="ward1" selected>Phường 1</option>
<option value="ward2">Phường 2</option>
<option value="ward3">Phường 3</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Địa chỉ cụ thể</label>
<input type="text" class="form-input" value="123 Nguyễn Trãi" placeholder="Số nhà, tên đường">
</div>
<div class="form-group">
<label class="form-label">Ngày lấy hàng</label>
<input type="date" class="form-input" id="pickupDate">
<!-- Pickup Date -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Ngày lấy hàng
</label>
<div class="relative">
<i class="fas fa-calendar-alt absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<input type="date"
id="pickupDate"
class="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition">
</div>
</div>
<div class="form-group">
<label class="form-label">Ghi chú</label>
<input type="text" class="form-input" placeholder="Ví dụ: Thời gian yêu cầu giao hàng">
<!-- Note -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Ghi chú
</label>
<textarea id="orderNote"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition resize-none"
rows="2"
placeholder="Ví dụ: Thời gian yêu cầu giao hàng, lưu ý đặc biệt..."></textarea>
</div>
</div>
<!-- Invoice Information -->
<div class="card">
<div class="form-group" style="height:24px;">
<label class="checkbox-label" style="font-size:16px;">
<input type="checkbox" id="invoiceCheckbox" onchange="toggleInvoiceInfo()">
<span class="checkmark"></span>
<!-- Card 2: Phát hành hóa đơn -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900 flex items-center gap-2">
<i class="fas fa-file-invoice text-blue-600"></i>
Phát hành hóa đơn
</h3>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox"
id="invoiceCheckbox"
class="sr-only peer"
onchange="toggleInvoiceInfo()">
<div class="w-11 h-6 bg-gray-300 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
<div id="invoiceInfoCard" class="invoice-info-card" style="display: none;">
<h4 class="invoice-title">Thông tin hóa đơn</h4>
<div class="form-group">
<label class="form-label">Tên người mua</label>
<input type="text" class="form-input" id="buyerName" placeholder="Tên công ty/cá nhân">
</div>
<div class="form-group">
<label class="form-label">Mã số thuế</label>
<input type="text" class="form-input" id="taxCode" placeholder="Mã số thuế">
</div>
<!--<div class="form-group">
<label class="form-label">Tên công ty</label>
<input type="text" class="form-input" id="companyName" placeholder="Tên công ty/tổ chức">
</div>-->
<div class="form-group">
<label class="form-label">Địa chỉ</label>
<input type="text" class="form-input" id="companyAddress" placeholder="Địa chỉ">
</div>
<div class="form-group">
<label class="form-label">Email nhận hóa đơn</label>
<input type="email" class="form-input" id="invoiceEmail" placeholder="email@company.com">
</div>
<div class="form-group">
<label class="form-label">Số điện thoại</label>
<input type="tel" class="form-input" id="invoicePhone" placeholder="Số điện thoại liên hệ">
<!-- Invoice Information (Hidden by default) -->
<div id="invoiceInfoCard" class="hidden">
<div class="border-t border-gray-200 pt-4">
<a href="addresses.html" class="block border border-gray-200 rounded-lg p-3 hover:border-blue-500 hover:bg-blue-50 transition group">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="font-semibold text-gray-900 mb-1">Công ty TNHH Xây dựng Minh Long</div>
<div class="text-sm text-gray-600 mb-0.5">Mã số thuế: 0134000687</div>
<div class="text-sm text-gray-600 mb-0.5">Số điện thoại: 0339797979</div>
<div class="text-sm text-gray-600 mb-0.5">Email: minhlong.org@gmail.com</div>
<div class="text-sm text-gray-600">
Địa chỉ: 11 Đường Hoàng Hữu Nam, Phường Linh Chiểu,
Thành phố Thủ Đức, TP.HCM
</div>
</div>
<i class="fas fa-chevron-right text-gray-400 group-hover:text-blue-600 mt-1"></i>
</div>
</a>
</div>
</div>
</div>
<!-- Card 3: Phương thức thanh toán -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-4" id="paymentMethodCard">
<h3 class="text-base font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-credit-card text-blue-600"></i>
Phương thức thanh toán
</h3>
<!-- Payment Method -->
<div class="card">
<h3 class="card-title">Phương thức thanh toán</h3>
<label class="list-item" style="cursor: pointer;">
<input type="radio" name="payment" checked style="margin-right: 12px;">
<div class="list-item-icon">
<i class="fas fa-money-check-alt"></i>
</div>
<div class="list-item-content">
<div class="list-item-title">Thanh toán hoàn toàn</div>
<div class="list-item-subtitle">Thanh toán qua tài khoản ngân hàng</div>
<label class="flex items-center p-3 border border-gray-200 rounded-lg mb-3 cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition">
<input type="radio" name="payment" value="full" checked class="w-4 h-4 text-blue-600 focus:ring-blue-500">
<div class="ml-3 flex-1">
<div class="flex items-center gap-2">
<i class="fas fa-money-check-alt text-gray-600"></i>
<div class="font-medium text-gray-900">Thanh toán hoàn toàn</div>
</div>
<div class="text-sm text-gray-500 mt-0.5">Thanh toán qua tài khoản ngân hàng</div>
</div>
</label>
<label class="list-item" style="cursor: pointer;">
<input type="radio" name="payment" style="margin-right: 12px;">
<div class="list-item-icon">
<i class="fas fa-hand-holding-usd"></i>
</div>
<div class="list-item-content">
<div class="list-item-title">Thanh toán một phần</div>
<div class="list-item-subtitle">Trả trước(≥20%), còn lại thanh toán trong vòng 30 ngày</div>
<label class="flex items-center p-3 border border-gray-200 rounded-lg cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition">
<input type="radio" name="payment" value="partial" class="w-4 h-4 text-blue-600 focus:ring-blue-500">
<div class="ml-3 flex-1">
<div class="flex items-center gap-2">
<i class="fas fa-hand-holding-usd text-gray-600"></i>
<div class="font-medium text-gray-900">Thanh toán một phần</div>
</div>
<div class="text-sm text-gray-500 mt-0.5">Trả trước (≥20%), còn lại thanh toán trong vòng 30 ngày</div>
</div>
</label>
</div>
<!-- Discount Code -->
<div class="card">
<div class="form-group" style="margin-bottom: 8px;">
<label class="form-label">Mã giảm giá</label>
<div style="display: flex; gap: 8px;">
<input type="text" class="form-input" style="flex: 1;" placeholder="Nhập mã giảm giá">
<button class="btn btn-primary">Áp dụng</button>
<!-- Card 4: Mã giảm giá -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<h3 class="text-base font-semibold text-gray-900 mb-3 flex items-center gap-2">
<i class="fas fa-ticket-alt text-blue-600"></i>
Mã giảm giá
</h3>
<div class="flex gap-2 mb-3">
<input type="text"
class="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
placeholder="Nhập mã giảm giá">
<button class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition">
Áp dụng
</button>
</div>
<div class="flex items-center gap-2 p-3 bg-green-50 border border-green-200 rounded-lg text-green-800 text-sm">
<i class="fas fa-check-circle"></i>
<span>Bạn được giảm 15% (hạng Diamond)</span>
</div>
</div>
<!-- Card 5: Tóm tắt đơn hàng -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<h3 class="text-base font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-shopping-cart text-blue-600"></i>
Tóm tắt đơn hàng
</h3>
<!-- Product Items -->
<div class="space-y-3 mb-4">
<div class="flex justify-between items-start pb-3 border-b border-gray-100">
<div class="flex-1">
<div class="font-medium text-gray-900">Gạch men cao cấp 60x60</div>
<div class="text-sm text-gray-500 mt-0.5">10 m² (28 viên / 10.08 m²)</div>
</div>
<div class="font-semibold text-gray-900">4.536.000đ</div>
</div>
<div class="flex justify-between items-start pb-3 border-b border-gray-100">
<div class="flex-1">
<div class="font-medium text-gray-900">Gạch granite nhập khẩu 1200x1200</div>
<div class="text-sm text-gray-500 mt-0.5">15 m² (11 viên / 15.84 m²)</div>
</div>
<div class="font-semibold text-gray-900">10.771.200đ</div>
</div>
<div class="flex justify-between items-start pb-3 border-b border-gray-100">
<div class="flex-1">
<div class="font-medium text-gray-900">Gạch mosaic trang trí 750x1500</div>
<div class="text-sm text-gray-500 mt-0.5">5 m² (5 viên / 5.625 m²)</div>
</div>
<div class="font-semibold text-gray-900">1.800.000đ</div>
</div>
</div>
<p class="text-small text-success">
<i class="fas fa-check-circle"></i> Bạn được giảm 15% (hạng Diamond)
</p>
</div>
<!-- Order Summary -->
<div class="card">
<h3 class="card-title">Tóm tắt đơn hàng</h3>
<div class="d-flex justify-between mb-2">
<div>
<div>Gạch men cao cấp</div>
<div class="text-small text-muted">10 m² (28 viên / 10.08 m²)</div>
</div>
<span>4.536.000đ</span>
<!-- Summary -->
<div class="space-y-2 pt-3 border-t border-gray-200">
<div class="flex justify-between text-sm">
<span class="text-gray-600">Tạm tính</span>
<span class="text-gray-900">17.107.200đ</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600">Giảm giá Diamond</span>
<span class="text-green-600 font-medium">-2.566.000đ</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600">Phí vận chuyển</span>
<span class="text-gray-900">Miễn phí</span>
</div>
</div>
<div class="d-flex justify-between mb-2">
<div>
<div>Gạch granite nhập khẩu 1200x1200</div>
<div class="text-small text-muted">(11 viên / 15.84 m²)</div>
</div>
<span>10.771.200đ</span>
</div>
<div class="d-flex justify-between mb-2">
<div>
<div>Gạch mosaic trang trí</div>
<div class="text-small text-muted">(5 viên / 5.625 m²)</div>
</div>
<span>1.800.000đ</span>
</div>
<hr style="margin: 12px 0;">
<div class="d-flex justify-between mb-2">
<span>Tạm tính</span>
<span>17.107.200đ</span>
</div>
<div class="d-flex justify-between mb-2">
<span>Giảm giá Diamond</span>
<span class="text-success">-2.566.000đ</span>
</div>
<div class="d-flex justify-between mb-2">
<span>Phí vận chuyển</span>
<span>Miễn phí</span>
</div>
<hr style="margin: 12px 0;">
<div class="d-flex justify-between">
<span class="text-bold" style="font-size: 16px;">Tổng thanh toán</span>
<span class="text-bold text-primary" style="font-size: 18px;">14.541.120đ</span>
<!-- Total -->
<div class="flex justify-between items-center pt-4 mt-4 border-t-2 border-gray-300">
<span class="text-lg font-semibold text-gray-900">Tổng thanh toán</span>
<span class="text-2xl font-bold text-blue-600">14.541.120đ</span>
</div>
</div>
<!-- Price Negotiation -->
<div class="negotiation-checkbox">
<label class="checkbox-label">
<input type="checkbox" id="negotiationCheckbox" onchange="toggleNegotiation()">
<span>Yêu cầu đàm phán giá</span>
<!-- Card 6: Tùy chọn đàm phán giá -->
<div class="bg-yellow-50 border-2 border-yellow-300 rounded-lg p-4 mb-4">
<label class="flex items-start cursor-pointer">
<input type="checkbox"
id="negotiationCheckbox"
class="mt-1 w-5 h-5 text-yellow-600 rounded focus:ring-yellow-500"
onchange="toggleNegotiation()">
<div class="ml-3 flex-1">
<div class="font-semibold text-yellow-900 mb-1">Yêu cầu đàm phán giá</div>
<div class="text-sm text-yellow-800">
Chọn tùy chọn này nếu bạn muốn đàm phán giá với nhân viên bán hàng trước khi thanh toán.
</div>
</div>
</label>
<div class="negotiation-info">
Chọn tùy chọn này nếu bạn muốn đàm phán giá với nhân viên bán hàng trước khi thanh toán.
</div>
</div>
<!-- Terms -->
<div class="text-center text-sm text-gray-600 mb-4">
Bằng cách đặt hàng, bạn đồng ý với
<a href="#" class="text-blue-600 hover:underline">Điều khoản & Điều kiện</a>
</div>
</div>
<!-- Place Order Button -->
<div style="margin-bottom: 24px;">
<a href="payment-qr.html" class="btn btn-primary btn-block btn-submit">
<i class="fas fa-check-circle"></i> Hoàn tất đặt hàng
</a>
<p class="text-center text-small text-muted mt-2">
Bằng cách đặt hàng, bạn đồng ý với
<a href="#" class="text-primary">Điều khoản & Điều kiện</a>
</p>
<!-- Sticky Footer -->
<div class="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-gray-200 shadow-lg z-50">
<div class="max-w-4xl mx-auto px-4 py-4">
<button id="submitBtn"
onclick="handleSubmit()"
class="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-bold py-4 px-6 rounded-lg shadow-lg transition-all duration-200 hover:shadow-xl hover:-translate-y-0.5 flex items-center justify-center gap-2">
<i class="fas fa-check-circle text-xl"></i>
<span id="submitBtnText">Hoàn tất đặt hàng</span>
</button>
</div>
</div>
</div>
<style>
.invoice-info-card {
margin-top: 16px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.invoice-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #374151;
}
.checkbox-label {
display: flex;
align-items: center;
cursor: pointer;
font-weight: 500;
}
.checkbox-label input[type="checkbox"] {
margin-right: 8px;
transform: scale(1.1);
}
.negotiation-checkbox {
margin: 16px 0;
padding: 16px;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 8px;
}
.negotiation-info {
font-size: 13px;
color: #92400e;
margin-top: 8px;
}
.payment-method-section.hidden {
display: none;
}
</style>
<script>
// Set default pickup date to tomorrow
document.addEventListener('DOMContentLoaded', function() {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
document.getElementById('pickupDate').value = dateString;
});
// Toggle invoice info
function toggleInvoiceInfo() {
const checkbox = document.getElementById('invoiceCheckbox');
const invoiceCard = document.getElementById('invoiceInfoCard');
if (checkbox.checked) {
invoiceCard.style.display = 'block';
invoiceCard.classList.remove('hidden');
invoiceCard.classList.add('animate-slideDown');
} else {
invoiceCard.style.display = 'none';
invoiceCard.classList.add('hidden');
invoiceCard.classList.remove('animate-slideDown');
}
}
// Toggle negotiation
function toggleNegotiation() {
const checkbox = document.getElementById('negotiationCheckbox');
const paymentSection = document.querySelector('.card:has(.list-item)');
// Payment method section
const submitBtn = document.querySelector('.btn-submit');
const paymentMethodCard = document.getElementById('paymentMethodCard');
const submitBtnText = document.getElementById('submitBtnText');
if (checkbox.checked) {
paymentSection.classList.add('hidden');
submitBtn.innerHTML = '<i class="fas fa-handshake"></i> Gửi Yêu cầu & Đàm phán';
paymentMethodCard.classList.add('opacity-50', 'pointer-events-none');
submitBtnText.textContent = 'Gửi Yêu cầu & Đàm phán';
} else {
paymentSection.classList.remove('hidden');
submitBtn.innerHTML = '<i class="fas fa-check-circle"></i> Hoàn tất đặt hàng';
paymentMethodCard.classList.remove('opacity-50', 'pointer-events-none');
submitBtnText.textContent = 'Hoàn tất đặt hàng';
}
}
function toggleNegotiation() {
const checkbox = document.getElementById('negotiationCheckbox');
const paymentMethods = document.querySelectorAll('.card')[2]; // Payment method section is 3rd card
const submitBtn = document.querySelector('.btn-submit');
// Handle submit
function handleSubmit() {
const negotiationCheckbox = document.getElementById('negotiationCheckbox');
if (checkbox.checked) {
paymentMethods.style.display = 'none';
submitBtn.innerHTML = '<i class="fas fa-handshake"></i> Gửi Yêu cầu & Đàm phán';
submitBtn.href = '#'; // Don't redirect to order success
submitBtn.onclick = function(e) {
e.preventDefault();
alert('Yêu cầu đàm phán đã được gửi! Nhân viên bán hàng sẽ liên hệ với bạn sớm.');
window.location.href = 'order-dam-phan.html';
};
if (negotiationCheckbox.checked) {
// Navigate to negotiation page
showToast('Đang gửi yêu cầu đàm phán...', 'info');
setTimeout(() => {
window.location.href = 'order-success.html?type=negotiation';
}, 1000);
} else {
paymentMethods.style.display = 'block';
submitBtn.innerHTML = '<i class="fas fa-check-circle"></i> Hoàn tất đặt hàng';
submitBtn.href = 'payment-qr.html';
submitBtn.onclick = null;
// Navigate to payment page
showToast('Đang xử lý đơn hàng...', 'info');
setTimeout(() => {
window.location.href = 'payment-qr.html';
}, 1000);
}
}
// Set minimum date for pickup
document.addEventListener('DOMContentLoaded', function() {
const pickupDateInput = document.getElementById('pickupDate');
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const minDate = tomorrow.toISOString().split('T')[0];
pickupDateInput.min = minDate;
pickupDateInput.value = minDate;
});
// Toast notification
function showToast(message, type = 'success') {
const colors = {
success: '#10b981',
error: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6'
};
const icons = {
success: 'fa-check-circle',
error: 'fa-exclamation-circle',
warning: 'fa-exclamation-triangle',
info: 'fa-info-circle'
};
const toast = document.createElement('div');
toast.innerHTML = `
<i class="fas ${icons[type]}"></i>
<span>${message}</span>
`;
toast.style.cssText = `
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: ${colors[type]};
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 10000;
display: flex;
align-items: center;
gap: 8px;
animation: slideDown 0.3s ease;
max-width: 90%;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideUp 0.3s ease';
setTimeout(() => {
document.body.removeChild(toast);
}, 300);
}, 3000);
}
// Animation styles
const style = document.createElement('style');
style.textContent = `
@keyframes slideDown {
from {
opacity: 0;
transform: translate(-50%, -20px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
@keyframes slideUp {
from {
opacity: 1;
transform: translate(-50%, 0);
}
to {
opacity: 0;
transform: translate(-50%, -20px);
}
}
.animate-slideDown {
animation: slideDown 0.3s ease;
}
`;
document.head.appendChild(style);
</script>
</body>
</html>

View File

@@ -145,6 +145,25 @@ class ApiConstants {
/// Body: { "method": "whatsapp|telegram|sms" }
static const String shareReferral = '/loyalty/referral/share';
// ============================================================================
// Favorites/Wishlist Endpoints (Frappe ERPNext)
// ============================================================================
/// Get favorite/wishlist items for current user
/// POST /api/method/building_material.building_material.api.item_wishlist.get_list
/// Body: { "limit_start": 0, "limit_page_length": 0 }
static const String getFavorites = '/building_material.building_material.api.item_wishlist.get_list';
/// Add item to wishlist
/// POST /api/method/building_material.building_material.api.item_wishlist.add_to_wishlist
/// Body: { "item_id": "GIB20 G04" }
static const String addToFavorites = '/building_material.building_material.api.item_wishlist.add_to_wishlist';
/// Remove item from wishlist
/// POST /api/method/building_material.building_material.api.item_wishlist.remove_from_wishlist
/// Body: { "item_id": "GIB20 G04" }
static const String removeFromFavorites = '/building_material.building_material.api.item_wishlist.remove_from_wishlist';
// ============================================================================
// Product Endpoints
// ============================================================================

View File

@@ -51,12 +51,16 @@ class HiveBoxNames {
/// Address book
static const String addressBox = 'address_box';
/// Favorite products
static const String favoriteBox = 'favorite_box';
/// Favorite products data (cached from wishlist API)
static const String favoriteProductsBox = 'favorite_products_box';
/// Offline request queue for failed API calls
static const String offlineQueueBox = 'offline_queue_box';
/// City and Ward boxes for location data
static const String cityBox = 'city_box';
static const String wardBox = 'ward_box';
/// Get all box names for initialization
static List<String> get allBoxes => [
userBox,
@@ -67,12 +71,14 @@ class HiveBoxNames {
quotes,
loyaltyBox,
rewardsBox,
cityBox,
wardBox,
settingsBox,
cacheBox,
syncStateBox,
notificationBox,
addressBox,
favoriteBox,
favoriteProductsBox,
offlineQueueBox,
];
}
@@ -114,7 +120,7 @@ class HiveTypeIds {
static const int chatRoomModel = 18;
static const int messageModel = 19;
// Extended Models (20-29)
// Extended Models (20-30)
static const int notificationModel = 20;
static const int showroomModel = 21;
static const int showroomProductModel = 22;
@@ -125,30 +131,33 @@ class HiveTypeIds {
static const int categoryModel = 27;
static const int favoriteModel = 28;
static const int businessUnitModel = 29;
static const int addressModel = 30;
static const int cityModel = 31;
static const int wardModel = 32;
// Enums (30-59)
static const int userRole = 30;
static const int userStatus = 31;
static const int loyaltyTier = 32;
static const int orderStatus = 33;
static const int invoiceType = 34;
static const int invoiceStatus = 35;
static const int paymentMethod = 36;
static const int paymentStatus = 37;
static const int entryType = 38;
static const int entrySource = 39;
static const int complaintStatus = 40;
static const int giftCategory = 41;
static const int giftStatus = 42;
static const int pointsStatus = 43;
static const int projectType = 44;
static const int submissionStatus = 45;
static const int designStatus = 46;
static const int quoteStatus = 47;
static const int roomType = 48;
static const int contentType = 49;
static const int reminderType = 50;
static const int notificationType = 51;
// Enums (33-62)
static const int userRole = 33;
static const int userStatus = 34;
static const int loyaltyTier = 35;
static const int orderStatus = 36;
static const int invoiceType = 37;
static const int invoiceStatus = 38;
static const int paymentMethod = 39;
static const int paymentStatus = 40;
static const int entryType = 41;
static const int entrySource = 42;
static const int complaintStatus = 43;
static const int giftCategory = 44;
static const int giftStatus = 45;
static const int pointsStatus = 46;
static const int projectType = 47;
static const int submissionStatus = 48;
static const int designStatus = 49;
static const int quoteStatus = 50;
static const int roomType = 51;
static const int contentType = 52;
static const int reminderType = 53;
static const int notificationType = 54;
// Aliases for backward compatibility and clarity
static const int memberTier = loyaltyTier; // Alias for loyaltyTier

View File

@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:worker/core/database/database_manager.dart';
import 'package:worker/core/database/hive_service.dart';
@@ -53,6 +54,9 @@ class HiveInitializer {
final dbManager = DatabaseManager();
// Migration: Delete old favoriteBox (deprecated, replaced with favoriteProductsBox)
await _deleteLegacyFavoriteBox(verbose);
// Clear expired cache on app start
await dbManager.clearExpiredCache();
@@ -97,6 +101,33 @@ class HiveInitializer {
await hiveService.clearUserData();
}
/// Delete legacy favoriteBox (migration helper)
///
/// The old favoriteBox stored FavoriteModel which has been removed.
/// This method deletes the old box to prevent typeId errors.
static Future<void> _deleteLegacyFavoriteBox(bool verbose) async {
try {
const legacyBoxName = 'favorite_box';
// Check if the old box exists
if (await Hive.boxExists(legacyBoxName)) {
if (verbose) {
debugPrint('HiveInitializer: Deleting legacy favoriteBox...');
}
// Delete the box from disk
await Hive.deleteBoxFromDisk(legacyBoxName);
if (verbose) {
debugPrint('HiveInitializer: Legacy favoriteBox deleted successfully');
}
}
} catch (e) {
debugPrint('HiveInitializer: Error deleting legacy favoriteBox: $e');
// Don't rethrow - this is just a cleanup operation
}
}
/// Get database statistics
///
/// Returns statistics about all Hive boxes.

View File

@@ -129,6 +129,12 @@ class HiveService {
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.userModel) ? "" : ""} UserModel adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.cityModel) ? "" : ""} CityModel adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.wardModel) ? "" : ""} WardModel adapter',
);
debugPrint('HiveService: Type adapters registered successfully');
}
@@ -156,8 +162,12 @@ class HiveService {
// Notification box (non-sensitive)
Hive.openBox<dynamic>(HiveBoxNames.notificationBox),
// Favorites box (non-sensitive)
Hive.openBox<dynamic>(HiveBoxNames.favoriteBox),
// Favorite products box (non-sensitive) - caches Product entities from wishlist API
Hive.openBox<dynamic>(HiveBoxNames.favoriteProductsBox),
// Location boxes (non-sensitive) - caches cities and wards for address forms
Hive.openBox<dynamic>(HiveBoxNames.cityBox),
Hive.openBox<dynamic>(HiveBoxNames.wardBox),
]);
// Open potentially encrypted boxes (sensitive data)

View File

@@ -8,7 +8,7 @@ part of 'enums.dart';
class UserRoleAdapter extends TypeAdapter<UserRole> {
@override
final typeId = 30;
final typeId = 33;
@override
UserRole read(BinaryReader reader) {
@@ -53,7 +53,7 @@ class UserRoleAdapter extends TypeAdapter<UserRole> {
class UserStatusAdapter extends TypeAdapter<UserStatus> {
@override
final typeId = 31;
final typeId = 34;
@override
UserStatus read(BinaryReader reader) {
@@ -98,7 +98,7 @@ class UserStatusAdapter extends TypeAdapter<UserStatus> {
class LoyaltyTierAdapter extends TypeAdapter<LoyaltyTier> {
@override
final typeId = 32;
final typeId = 35;
@override
LoyaltyTier read(BinaryReader reader) {
@@ -151,7 +151,7 @@ class LoyaltyTierAdapter extends TypeAdapter<LoyaltyTier> {
class OrderStatusAdapter extends TypeAdapter<OrderStatus> {
@override
final typeId = 33;
final typeId = 36;
@override
OrderStatus read(BinaryReader reader) {
@@ -216,7 +216,7 @@ class OrderStatusAdapter extends TypeAdapter<OrderStatus> {
class InvoiceTypeAdapter extends TypeAdapter<InvoiceType> {
@override
final typeId = 34;
final typeId = 37;
@override
InvoiceType read(BinaryReader reader) {
@@ -261,7 +261,7 @@ class InvoiceTypeAdapter extends TypeAdapter<InvoiceType> {
class InvoiceStatusAdapter extends TypeAdapter<InvoiceStatus> {
@override
final typeId = 35;
final typeId = 38;
@override
InvoiceStatus read(BinaryReader reader) {
@@ -318,7 +318,7 @@ class InvoiceStatusAdapter extends TypeAdapter<InvoiceStatus> {
class PaymentMethodAdapter extends TypeAdapter<PaymentMethod> {
@override
final typeId = 36;
final typeId = 39;
@override
PaymentMethod read(BinaryReader reader) {
@@ -375,7 +375,7 @@ class PaymentMethodAdapter extends TypeAdapter<PaymentMethod> {
class PaymentStatusAdapter extends TypeAdapter<PaymentStatus> {
@override
final typeId = 37;
final typeId = 40;
@override
PaymentStatus read(BinaryReader reader) {
@@ -428,7 +428,7 @@ class PaymentStatusAdapter extends TypeAdapter<PaymentStatus> {
class EntryTypeAdapter extends TypeAdapter<EntryType> {
@override
final typeId = 38;
final typeId = 41;
@override
EntryType read(BinaryReader reader) {
@@ -477,7 +477,7 @@ class EntryTypeAdapter extends TypeAdapter<EntryType> {
class EntrySourceAdapter extends TypeAdapter<EntrySource> {
@override
final typeId = 39;
final typeId = 42;
@override
EntrySource read(BinaryReader reader) {
@@ -538,7 +538,7 @@ class EntrySourceAdapter extends TypeAdapter<EntrySource> {
class ComplaintStatusAdapter extends TypeAdapter<ComplaintStatus> {
@override
final typeId = 40;
final typeId = 43;
@override
ComplaintStatus read(BinaryReader reader) {
@@ -587,7 +587,7 @@ class ComplaintStatusAdapter extends TypeAdapter<ComplaintStatus> {
class GiftCategoryAdapter extends TypeAdapter<GiftCategory> {
@override
final typeId = 41;
final typeId = 44;
@override
GiftCategory read(BinaryReader reader) {
@@ -636,7 +636,7 @@ class GiftCategoryAdapter extends TypeAdapter<GiftCategory> {
class GiftStatusAdapter extends TypeAdapter<GiftStatus> {
@override
final typeId = 42;
final typeId = 45;
@override
GiftStatus read(BinaryReader reader) {
@@ -681,7 +681,7 @@ class GiftStatusAdapter extends TypeAdapter<GiftStatus> {
class PointsStatusAdapter extends TypeAdapter<PointsStatus> {
@override
final typeId = 43;
final typeId = 46;
@override
PointsStatus read(BinaryReader reader) {
@@ -722,7 +722,7 @@ class PointsStatusAdapter extends TypeAdapter<PointsStatus> {
class ProjectTypeAdapter extends TypeAdapter<ProjectType> {
@override
final typeId = 44;
final typeId = 47;
@override
ProjectType read(BinaryReader reader) {
@@ -779,7 +779,7 @@ class ProjectTypeAdapter extends TypeAdapter<ProjectType> {
class SubmissionStatusAdapter extends TypeAdapter<SubmissionStatus> {
@override
final typeId = 45;
final typeId = 48;
@override
SubmissionStatus read(BinaryReader reader) {
@@ -828,7 +828,7 @@ class SubmissionStatusAdapter extends TypeAdapter<SubmissionStatus> {
class DesignStatusAdapter extends TypeAdapter<DesignStatus> {
@override
final typeId = 46;
final typeId = 49;
@override
DesignStatus read(BinaryReader reader) {
@@ -885,7 +885,7 @@ class DesignStatusAdapter extends TypeAdapter<DesignStatus> {
class QuoteStatusAdapter extends TypeAdapter<QuoteStatus> {
@override
final typeId = 47;
final typeId = 50;
@override
QuoteStatus read(BinaryReader reader) {
@@ -946,7 +946,7 @@ class QuoteStatusAdapter extends TypeAdapter<QuoteStatus> {
class RoomTypeAdapter extends TypeAdapter<RoomType> {
@override
final typeId = 48;
final typeId = 51;
@override
RoomType read(BinaryReader reader) {
@@ -995,7 +995,7 @@ class RoomTypeAdapter extends TypeAdapter<RoomType> {
class ContentTypeAdapter extends TypeAdapter<ContentType> {
@override
final typeId = 49;
final typeId = 52;
@override
ContentType read(BinaryReader reader) {
@@ -1056,7 +1056,7 @@ class ContentTypeAdapter extends TypeAdapter<ContentType> {
class ReminderTypeAdapter extends TypeAdapter<ReminderType> {
@override
final typeId = 50;
final typeId = 53;
@override
ReminderType read(BinaryReader reader) {

View File

@@ -8,10 +8,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/features/account/domain/entities/address.dart';
import 'package:worker/features/account/presentation/pages/address_form_page.dart';
import 'package:worker/features/account/presentation/pages/addresses_page.dart';
import 'package:worker/features/auth/presentation/providers/auth_provider.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/providers/auth_provider.dart';
import 'package:worker/features/auth/domain/entities/business_unit.dart';
import 'package:worker/features/auth/presentation/pages/business_unit_selection_page.dart';
import 'package:worker/features/auth/presentation/pages/forgot_password_page.dart';
@@ -369,6 +371,19 @@ final routerProvider = Provider<GoRouter>((ref) {
MaterialPage(key: state.pageKey, child: const AddressesPage()),
),
// Address Form Route (Create/Edit)
GoRoute(
path: RouteNames.addressForm,
name: RouteNames.addressForm,
pageBuilder: (context, state) {
final address = state.extra as Address?;
return MaterialPage(
key: state.pageKey,
child: AddressFormPage(address: address),
);
},
),
// Change Password Route
GoRoute(
path: RouteNames.changePassword,

View File

@@ -0,0 +1,208 @@
/// Address Remote Data Source
///
/// Handles API calls to Frappe ERPNext address endpoints.
library;
import 'package:dio/dio.dart';
import 'package:worker/core/errors/exceptions.dart';
import 'package:worker/features/account/data/models/address_model.dart';
/// Address Remote Data Source
///
/// Provides methods to interact with address API endpoints.
/// Online-only approach - no offline caching.
class AddressRemoteDataSource {
final Dio _dio;
AddressRemoteDataSource(this._dio);
/// Get list of addresses
///
/// Fetches all addresses for the authenticated user.
/// Optionally filter by default address.
///
/// API: GET /api/method/building_material.building_material.api.address.get_list
Future<List<AddressModel>> getAddresses({
int limitStart = 0,
int limitPageLength = 0,
bool? isDefault,
}) async {
try {
_debugPrint('Fetching addresses list...');
final response = await _dio.post(
'/api/method/building_material.building_material.api.address.get_list',
data: {
'limit_start': limitStart,
'limit_page_length': limitPageLength,
if (isDefault != null) 'is_default': isDefault,
},
);
if (response.statusCode == 200) {
final data = response.data;
_debugPrint('Response data: $data');
// Extract addresses from response
if (data is Map<String, dynamic> && data.containsKey('message')) {
final message = data['message'];
_debugPrint('Message type: ${message.runtimeType}');
// Handle array response
if (message is List) {
_debugPrint('Parsing ${message.length} addresses from list');
final addresses = <AddressModel>[];
for (var i = 0; i < message.length; i++) {
try {
final item = message[i] as Map<String, dynamic>;
_debugPrint('Parsing address $i: $item');
final address = AddressModel.fromJson(item);
addresses.add(address);
} catch (e) {
_debugPrint('Error parsing address $i: $e');
rethrow;
}
}
_debugPrint('Fetched ${addresses.length} addresses');
return addresses;
}
// Handle object with data field
if (message is Map<String, dynamic> && message.containsKey('data')) {
final dataList = message['data'] as List;
_debugPrint('Parsing ${dataList.length} addresses from data field');
final addresses = <AddressModel>[];
for (var i = 0; i < dataList.length; i++) {
try {
final item = dataList[i] as Map<String, dynamic>;
_debugPrint('Parsing address $i: $item');
final address = AddressModel.fromJson(item);
addresses.add(address);
} catch (e) {
_debugPrint('Error parsing address $i: $e');
rethrow;
}
}
_debugPrint('Fetched ${addresses.length} addresses');
return addresses;
}
}
throw const ServerException('Invalid response format');
} else {
throw ServerException(
'Failed to fetch addresses: ${response.statusCode}',
);
}
} catch (e) {
_debugPrint('Error fetching addresses: $e');
rethrow;
}
}
/// Create or update address
///
/// If name is provided (not empty), updates existing address.
/// If name is null/empty, creates new address.
///
/// Per API docs: When name field is null/empty, the API creates a new address.
/// When name has a value, the API updates the existing address.
///
/// API: POST /api/method/building_material.building_material.api.address.update
Future<AddressModel> saveAddress(AddressModel address) async {
try {
final isUpdate = address.name.isNotEmpty;
_debugPrint(
isUpdate
? 'Updating address: ${address.name}'
: 'Creating new address',
);
// toJson() already handles setting name to null for creation
final data = address.toJson();
_debugPrint('Request data: $data');
final response = await _dio.post(
'/api/method/building_material.building_material.api.address.update',
data: data,
);
if (response.statusCode == 200) {
final data = response.data;
_debugPrint('Response data: $data');
// Check for API error response (even with 200 status)
if (data is Map<String, dynamic> && data.containsKey('message')) {
final message = data['message'];
// Check for error response format
if (message is Map<String, dynamic> && message.containsKey('error')) {
final error = message['error'] as String;
_debugPrint('API error: $error');
throw ServerException(error);
}
// Handle direct address object
if (message is Map<String, dynamic>) {
final savedAddress = AddressModel.fromJson(message);
_debugPrint('Address saved: ${savedAddress.name}');
return savedAddress;
}
// Handle nested data
if (message is Map<String, dynamic> && message.containsKey('data')) {
final savedAddress =
AddressModel.fromJson(message['data'] as Map<String, dynamic>);
_debugPrint('Address saved: ${savedAddress.name}');
return savedAddress;
}
}
throw const ServerException('Invalid response format');
} else {
throw ServerException(
'Failed to save address: ${response.statusCode}',
);
}
} catch (e) {
_debugPrint('Error saving address: $e');
rethrow;
}
}
/// Delete address
///
/// Note: API endpoint for delete not provided in docs.
/// This is a placeholder - adjust when endpoint is available.
Future<void> deleteAddress(String name) async {
try {
_debugPrint('Deleting address: $name');
// TODO: Update with actual delete endpoint when available
final response = await _dio.post(
'/api/method/building_material.building_material.api.address.delete',
data: {'name': name},
);
if (response.statusCode == 200) {
_debugPrint('Address deleted: $name');
return;
} else {
throw ServerException(
'Failed to delete address: ${response.statusCode}',
);
}
} catch (e) {
_debugPrint('Error deleting address: $e');
rethrow;
}
}
/// Debug print helper
void _debugPrint(String message) {
// ignore: avoid_print
print('[AddressRemoteDataSource] $message');
}
}

View File

@@ -0,0 +1,137 @@
/// Location Local Data Source
///
/// Handles Hive caching for cities and wards.
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/core/database/hive_service.dart';
import 'package:worker/features/account/data/models/city_model.dart';
import 'package:worker/features/account/data/models/ward_model.dart';
/// Location Local Data Source
///
/// Provides offline-first caching for cities and wards using Hive.
class LocationLocalDataSource {
final HiveService _hiveService;
LocationLocalDataSource(this._hiveService);
// ============================================================================
// CITIES
// ============================================================================
/// Get city box
Box<dynamic> get _cityBox => _hiveService.getBox(HiveBoxNames.cityBox);
/// Get all cached cities
List<CityModel> getCities() {
try {
final cities = _cityBox.values.whereType<CityModel>().toList();
return cities;
} catch (e) {
return [];
}
}
/// Save cities to cache
Future<void> saveCities(List<CityModel> cities) async {
try {
// Only clear if there are existing cities
if (_cityBox.isNotEmpty) {
await _cityBox.clear();
}
for (final city in cities) {
await _cityBox.put(city.code, city);
}
} catch (e) {
rethrow;
}
}
/// Get city by code
CityModel? getCityByCode(String code) {
try {
return _cityBox.get(code) as CityModel?;
} catch (e) {
return null;
}
}
/// Check if cities are cached
bool hasCities() {
return _cityBox.isNotEmpty;
}
// ============================================================================
// WARDS
// ============================================================================
/// Get ward box
Box<dynamic> get _wardBox => _hiveService.getBox(HiveBoxNames.wardBox);
/// Get cached wards for a city
///
/// Wards are stored with key: "cityCode_wardCode"
List<WardModel> getWards(String cityCode) {
try {
final wards = _wardBox.values
.whereType<WardModel>()
.where((ward) {
// Check if this ward belongs to the city
final key = '${cityCode}_${ward.code}';
return _wardBox.containsKey(key);
})
.toList();
return wards;
} catch (e) {
return [];
}
}
/// Save wards for a specific city to cache
Future<void> saveWards(String cityCode, List<WardModel> wards) async {
try {
// Remove old wards for this city (only if they exist)
final keysToDelete = _wardBox.keys
.where((key) => key.toString().startsWith('${cityCode}_'))
.toList();
if (keysToDelete.isNotEmpty) {
for (final key in keysToDelete) {
await _wardBox.delete(key);
}
}
// Save new wards
for (final ward in wards) {
final key = '${cityCode}_${ward.code}';
await _wardBox.put(key, ward);
}
} catch (e) {
rethrow;
}
}
/// Check if wards are cached for a city
bool hasWards(String cityCode) {
return _wardBox.keys.any((key) => key.toString().startsWith('${cityCode}_'));
}
/// Clear all cached data
Future<void> clearAll() async {
try {
// Only clear if boxes are not empty
if (_cityBox.isNotEmpty) {
await _cityBox.clear();
}
if (_wardBox.isNotEmpty) {
await _wardBox.clear();
}
} catch (e) {
rethrow;
}
}
}

View File

@@ -0,0 +1,96 @@
/// Location Remote Data Source
///
/// Handles API calls for cities and wards using Frappe ERPNext client.get_list.
library;
import 'package:dio/dio.dart';
import 'package:worker/core/errors/exceptions.dart';
import 'package:worker/features/account/data/models/city_model.dart';
import 'package:worker/features/account/data/models/ward_model.dart';
/// Location Remote Data Source
///
/// Provides methods to fetch cities and wards from API.
class LocationRemoteDataSource {
final Dio _dio;
LocationRemoteDataSource(this._dio);
/// Get all cities
///
/// API: POST /api/method/frappe.client.get_list
Future<List<CityModel>> getCities() async {
try {
final response = await _dio.post(
'/api/method/frappe.client.get_list',
data: {
'doctype': 'City',
'fields': ['city_name', 'name', 'code'],
'limit_page_length': 0,
},
);
if (response.statusCode == 200) {
final data = response.data;
if (data is Map<String, dynamic> && data.containsKey('message')) {
final message = data['message'];
if (message is List) {
final cities = message
.map((item) => CityModel.fromJson(item as Map<String, dynamic>))
.toList();
return cities;
}
}
throw const ServerException('Invalid response format');
} else {
throw ServerException('Failed to fetch cities: ${response.statusCode}');
}
} catch (e) {
rethrow;
}
}
/// Get wards for a specific city
///
/// API: POST /api/method/frappe.client.get_list
/// [cityCode] - The city code to filter wards
Future<List<WardModel>> getWards(String cityCode) async {
try {
final response = await _dio.post(
'/api/method/frappe.client.get_list',
data: {
'doctype': 'Ward',
'fields': ['ward_name', 'name', 'code'],
'filters': {'city': cityCode},
'limit_page_length': 0,
},
);
if (response.statusCode == 200) {
final data = response.data;
if (data is Map<String, dynamic> && data.containsKey('message')) {
final message = data['message'];
if (message is List) {
final wards = message
.map((item) => WardModel.fromJson(item as Map<String, dynamic>))
.toList();
return wards;
}
}
throw const ServerException('Invalid response format');
} else {
throw ServerException('Failed to fetch wards: ${response.statusCode}');
}
} catch (e) {
rethrow;
}
}
}

View File

@@ -0,0 +1,158 @@
/// Address Model
///
/// Hive model for caching address data from ERPNext API.
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/account/domain/entities/address.dart';
part 'address_model.g.dart';
/// Address Model
///
/// Hive model for storing address data with ERPNext API compatibility.
@HiveType(typeId: HiveTypeIds.addressModel)
class AddressModel extends HiveObject {
/// Address name (ID in ERPNext)
@HiveField(0)
String name;
/// Display title for the address
@HiveField(1)
String addressTitle;
/// Address line 1 (street, number, etc.)
@HiveField(2)
String addressLine1;
/// Phone number
@HiveField(3)
String phone;
/// Email address
@HiveField(4)
String? email;
/// Fax number (optional)
@HiveField(5)
String? fax;
/// Tax code/ID
@HiveField(6)
String? taxCode;
/// City code (from ERPNext location master)
@HiveField(7)
String cityCode;
/// Ward code (from ERPNext location master)
@HiveField(8)
String wardCode;
/// Whether this is the default address
@HiveField(9)
bool isDefault;
/// City name (for display)
@HiveField(10)
String? cityName;
/// Ward name (for display)
@HiveField(11)
String? wardName;
AddressModel({
required this.name,
required this.addressTitle,
required this.addressLine1,
required this.phone,
this.email,
this.fax,
this.taxCode,
required this.cityCode,
required this.wardCode,
this.isDefault = false,
this.cityName,
this.wardName,
});
/// Create from JSON (API response)
factory AddressModel.fromJson(Map<String, dynamic> json) {
return AddressModel(
name: json['name'] as String? ?? '',
addressTitle: json['address_title'] as String? ?? '',
addressLine1: json['address_line1'] as String? ?? '',
phone: json['phone'] as String? ?? '',
email: json['email'] as String?,
fax: json['fax'] as String?,
taxCode: json['tax_code'] as String?,
cityCode: json['city_code'] as String? ?? '',
wardCode: json['ward_code'] as String? ?? '',
isDefault: json['is_default'] == 1 || json['is_default'] == true,
cityName: json['city_name'] as String?,
wardName: json['ward_name'] as String?,
);
}
/// Convert to JSON (API request)
Map<String, dynamic> toJson() {
return {
// If name is empty, send null to indicate new address creation
'name': name.isEmpty ? null : name,
'address_title': addressTitle,
'address_line1': addressLine1,
'phone': phone,
if (email != null && email!.isNotEmpty) 'email': email,
if (fax != null && fax!.isNotEmpty) 'fax': fax,
if (taxCode != null && taxCode!.isNotEmpty) 'tax_code': taxCode,
'city_code': cityCode,
'ward_code': wardCode,
'is_default': isDefault,
if (cityName != null && cityName!.isNotEmpty) 'city_name': cityName,
if (wardName != null && wardName!.isNotEmpty) 'ward_name': wardName,
};
}
/// Convert to domain entity
Address toEntity() {
return Address(
name: name,
addressTitle: addressTitle,
addressLine1: addressLine1,
phone: phone,
email: email,
fax: fax,
taxCode: taxCode,
cityCode: cityCode,
wardCode: wardCode,
isDefault: isDefault,
cityName: cityName,
wardName: wardName,
);
}
/// Create from domain entity
factory AddressModel.fromEntity(Address entity) {
return AddressModel(
name: entity.name,
addressTitle: entity.addressTitle,
addressLine1: entity.addressLine1,
phone: entity.phone,
email: entity.email,
fax: entity.fax,
taxCode: entity.taxCode,
cityCode: entity.cityCode,
wardCode: entity.wardCode,
isDefault: entity.isDefault,
cityName: entity.cityName,
wardName: entity.wardName,
);
}
@override
String toString() {
return 'AddressModel(name: $name, addressTitle: $addressTitle, '
'addressLine1: $addressLine1, phone: $phone, isDefault: $isDefault)';
}
}

View File

@@ -0,0 +1,74 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'address_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AddressModelAdapter extends TypeAdapter<AddressModel> {
@override
final typeId = 30;
@override
AddressModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AddressModel(
name: fields[0] as String,
addressTitle: fields[1] as String,
addressLine1: fields[2] as String,
phone: fields[3] as String,
email: fields[4] as String?,
fax: fields[5] as String?,
taxCode: fields[6] as String?,
cityCode: fields[7] as String,
wardCode: fields[8] as String,
isDefault: fields[9] == null ? false : fields[9] as bool,
cityName: fields[10] as String?,
wardName: fields[11] as String?,
);
}
@override
void write(BinaryWriter writer, AddressModel obj) {
writer
..writeByte(12)
..writeByte(0)
..write(obj.name)
..writeByte(1)
..write(obj.addressTitle)
..writeByte(2)
..write(obj.addressLine1)
..writeByte(3)
..write(obj.phone)
..writeByte(4)
..write(obj.email)
..writeByte(5)
..write(obj.fax)
..writeByte(6)
..write(obj.taxCode)
..writeByte(7)
..write(obj.cityCode)
..writeByte(8)
..write(obj.wardCode)
..writeByte(9)
..write(obj.isDefault)
..writeByte(10)
..write(obj.cityName)
..writeByte(11)
..write(obj.wardName);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AddressModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,73 @@
/// City Model
///
/// Hive model for caching city/province data.
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/account/domain/entities/city.dart';
part 'city_model.g.dart';
/// City Model
///
/// Hive model for storing city/province data with offline support.
@HiveType(typeId: HiveTypeIds.cityModel)
class CityModel extends HiveObject {
/// Frappe ERPNext name/ID
@HiveField(0)
String name;
/// Display name (city_name)
@HiveField(1)
String cityName;
/// City code
@HiveField(2)
String code;
CityModel({
required this.name,
required this.cityName,
required this.code,
});
/// Create from JSON (API response)
factory CityModel.fromJson(Map<String, dynamic> json) {
return CityModel(
name: json['name'] as String? ?? '',
cityName: json['city_name'] as String? ?? '',
code: json['code'] as String? ?? '',
);
}
/// Convert to JSON (API request)
Map<String, dynamic> toJson() {
return {
'name': name,
'city_name': cityName,
'code': code,
};
}
/// Convert to domain entity
City toEntity() {
return City(
name: name,
cityName: cityName,
code: code,
);
}
/// Create from domain entity
factory CityModel.fromEntity(City entity) {
return CityModel(
name: entity.name,
cityName: entity.cityName,
code: entity.code,
);
}
@override
String toString() => 'CityModel(name: $name, cityName: $cityName, code: $code)';
}

View File

@@ -1,41 +1,38 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'favorite_model.dart';
part of 'city_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class FavoriteModelAdapter extends TypeAdapter<FavoriteModel> {
class CityModelAdapter extends TypeAdapter<CityModel> {
@override
final typeId = 28;
final typeId = 31;
@override
FavoriteModel read(BinaryReader reader) {
CityModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return FavoriteModel(
favoriteId: fields[0] as String,
productId: fields[1] as String,
userId: fields[2] as String,
createdAt: fields[3] as DateTime,
return CityModel(
name: fields[0] as String,
cityName: fields[1] as String,
code: fields[2] as String,
);
}
@override
void write(BinaryWriter writer, FavoriteModel obj) {
void write(BinaryWriter writer, CityModel obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.favoriteId)
..writeByte(1)
..write(obj.productId)
..writeByte(2)
..write(obj.userId)
..writeByte(3)
..write(obj.createdAt);
..writeByte(0)
..write(obj.name)
..writeByte(1)
..write(obj.cityName)
..writeByte(2)
..write(obj.code);
}
@override
@@ -44,7 +41,7 @@ class FavoriteModelAdapter extends TypeAdapter<FavoriteModel> {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is FavoriteModelAdapter &&
other is CityModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,73 @@
/// Ward Model
///
/// Hive model for caching ward/district data.
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/account/domain/entities/ward.dart';
part 'ward_model.g.dart';
/// Ward Model
///
/// Hive model for storing ward/district data with offline support.
@HiveType(typeId: HiveTypeIds.wardModel)
class WardModel extends HiveObject {
/// Frappe ERPNext name/ID
@HiveField(0)
String name;
/// Display name (ward_name)
@HiveField(1)
String wardName;
/// Ward code
@HiveField(2)
String code;
WardModel({
required this.name,
required this.wardName,
required this.code,
});
/// Create from JSON (API response)
factory WardModel.fromJson(Map<String, dynamic> json) {
return WardModel(
name: json['name'] as String? ?? '',
wardName: json['ward_name'] as String? ?? '',
code: json['code'] as String? ?? '',
);
}
/// Convert to JSON (API request)
Map<String, dynamic> toJson() {
return {
'name': name,
'ward_name': wardName,
'code': code,
};
}
/// Convert to domain entity
Ward toEntity() {
return Ward(
name: name,
wardName: wardName,
code: code,
);
}
/// Create from domain entity
factory WardModel.fromEntity(Ward entity) {
return WardModel(
name: entity.name,
wardName: entity.wardName,
code: entity.code,
);
}
@override
String toString() => 'WardModel(name: $name, wardName: $wardName, code: $code)';
}

View File

@@ -0,0 +1,47 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ward_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class WardModelAdapter extends TypeAdapter<WardModel> {
@override
final typeId = 32;
@override
WardModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return WardModel(
name: fields[0] as String,
wardName: fields[1] as String,
code: fields[2] as String,
);
}
@override
void write(BinaryWriter writer, WardModel obj) {
writer
..writeByte(3)
..writeByte(0)
..write(obj.name)
..writeByte(1)
..write(obj.wardName)
..writeByte(2)
..write(obj.code);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is WardModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,159 @@
/// Address Repository Implementation
///
/// Implements address repository with online-only API calls.
library;
import 'package:worker/features/account/data/datasources/address_remote_datasource.dart';
import 'package:worker/features/account/data/models/address_model.dart';
import 'package:worker/features/account/domain/entities/address.dart';
import 'package:worker/features/account/domain/repositories/address_repository.dart';
/// Address Repository Implementation
///
/// Online-only implementation - all operations go directly to API.
/// No local caching or offline support.
class AddressRepositoryImpl implements AddressRepository {
final AddressRemoteDataSource _remoteDataSource;
AddressRepositoryImpl({
required AddressRemoteDataSource remoteDataSource,
}) : _remoteDataSource = remoteDataSource;
@override
Future<List<Address>> getAddresses({bool? isDefault}) async {
_debugPrint('Getting addresses...');
try {
final addressModels = await _remoteDataSource.getAddresses(
isDefault: isDefault,
);
final addresses = addressModels.map((model) => model.toEntity()).toList();
_debugPrint('Retrieved ${addresses.length} addresses');
return addresses;
} catch (e) {
_debugPrint('Error getting addresses: $e');
rethrow;
}
}
@override
Future<Address> createAddress(Address address) async {
_debugPrint('Creating address: ${address.addressTitle}');
try {
// Create model with empty name (API will generate)
final addressModel = AddressModel.fromEntity(address).copyWith(
name: '', // Empty name indicates creation
);
final savedModel = await _remoteDataSource.saveAddress(addressModel);
_debugPrint('Address created: ${savedModel.name}');
return savedModel.toEntity();
} catch (e) {
_debugPrint('Error creating address: $e');
rethrow;
}
}
@override
Future<Address> updateAddress(Address address) async {
_debugPrint('Updating address: ${address.name}');
try {
final addressModel = AddressModel.fromEntity(address);
final savedModel = await _remoteDataSource.saveAddress(addressModel);
_debugPrint('Address updated: ${savedModel.name}');
return savedModel.toEntity();
} catch (e) {
_debugPrint('Error updating address: $e');
rethrow;
}
}
@override
Future<void> deleteAddress(String name) async {
_debugPrint('Deleting address: $name');
try {
await _remoteDataSource.deleteAddress(name);
_debugPrint('Address deleted: $name');
} catch (e) {
_debugPrint('Error deleting address: $e');
rethrow;
}
}
@override
Future<void> setDefaultAddress(String name) async {
_debugPrint('Setting default address: $name');
try {
// Get all addresses
final addresses = await getAddresses();
// Find the address to set as default
final targetAddress = addresses.firstWhere(
(addr) => addr.name == name,
orElse: () => throw Exception('Address not found: $name'),
);
// Update the target address to be default
await updateAddress(targetAddress.copyWith(isDefault: true));
// Update other addresses to not be default
for (final addr in addresses) {
if (addr.name != name && addr.isDefault) {
await updateAddress(addr.copyWith(isDefault: false));
}
}
_debugPrint('Default address set: $name');
} catch (e) {
_debugPrint('Error setting default address: $e');
rethrow;
}
}
/// Debug print helper
void _debugPrint(String message) {
// ignore: avoid_print
print('[AddressRepository] $message');
}
}
/// Extension to create a copy with modifications (since AddressModel is not freezed)
extension _AddressModelCopyWith on AddressModel {
AddressModel copyWith({
String? name,
String? addressTitle,
String? addressLine1,
String? phone,
String? email,
String? fax,
String? taxCode,
String? cityCode,
String? wardCode,
bool? isDefault,
String? cityName,
String? wardName,
}) {
return AddressModel(
name: name ?? this.name,
addressTitle: addressTitle ?? this.addressTitle,
addressLine1: addressLine1 ?? this.addressLine1,
phone: phone ?? this.phone,
email: email ?? this.email,
fax: fax ?? this.fax,
taxCode: taxCode ?? this.taxCode,
cityCode: cityCode ?? this.cityCode,
wardCode: wardCode ?? this.wardCode,
isDefault: isDefault ?? this.isDefault,
cityName: cityName ?? this.cityName,
wardName: wardName ?? this.wardName,
);
}
}

View File

@@ -0,0 +1,96 @@
/// Location Repository Implementation
///
/// Implements location repository with offline-first strategy.
library;
import 'package:worker/features/account/data/datasources/location_local_datasource.dart';
import 'package:worker/features/account/data/datasources/location_remote_datasource.dart';
import 'package:worker/features/account/domain/entities/city.dart';
import 'package:worker/features/account/domain/entities/ward.dart';
import 'package:worker/features/account/domain/repositories/location_repository.dart';
/// Location Repository Implementation
///
/// Offline-first implementation:
/// - Cities: Cache in Hive, fetch from API if cache is empty or force refresh
/// - Wards: Cache per city, fetch from API if not cached or force refresh
class LocationRepositoryImpl implements LocationRepository {
final LocationRemoteDataSource _remoteDataSource;
final LocationLocalDataSource _localDataSource;
LocationRepositoryImpl({
required LocationRemoteDataSource remoteDataSource,
required LocationLocalDataSource localDataSource,
}) : _remoteDataSource = remoteDataSource,
_localDataSource = localDataSource;
@override
Future<List<City>> getCities({bool forceRefresh = false}) async {
try {
// Check cache first (offline-first)
if (!forceRefresh && _localDataSource.hasCities()) {
final cachedCities = _localDataSource.getCities();
if (cachedCities.isNotEmpty) {
return cachedCities.map((model) => model.toEntity()).toList();
}
}
// Fetch from API
final cityModels = await _remoteDataSource.getCities();
// Save to cache
await _localDataSource.saveCities(cityModels);
return cityModels.map((model) => model.toEntity()).toList();
} catch (e) {
// Fallback to cache on error
if (!forceRefresh) {
final cachedCities = _localDataSource.getCities();
if (cachedCities.isNotEmpty) {
return cachedCities.map((model) => model.toEntity()).toList();
}
}
rethrow;
}
}
@override
Future<List<Ward>> getWards(
String cityCode, {
bool forceRefresh = false,
}) async {
try {
// Check cache first (offline-first)
if (!forceRefresh && _localDataSource.hasWards(cityCode)) {
final cachedWards = _localDataSource.getWards(cityCode);
if (cachedWards.isNotEmpty) {
return cachedWards.map((model) => model.toEntity()).toList();
}
}
// Fetch from API
final wardModels = await _remoteDataSource.getWards(cityCode);
// Save to cache
await _localDataSource.saveWards(cityCode, wardModels);
return wardModels.map((model) => model.toEntity()).toList();
} catch (e) {
// Fallback to cache on error
if (!forceRefresh) {
final cachedWards = _localDataSource.getWards(cityCode);
if (cachedWards.isNotEmpty) {
return cachedWards.map((model) => model.toEntity()).toList();
}
}
rethrow;
}
}
@override
Future<void> clearCache() async {
await _localDataSource.clearAll();
}
}

View File

@@ -0,0 +1,105 @@
/// Address Entity
///
/// Represents a delivery/billing address for the user.
/// Corresponds to Frappe ERPNext Address doctype.
library;
import 'package:equatable/equatable.dart';
/// Address Entity
///
/// Domain entity representing a user's delivery or billing address.
class Address extends Equatable {
final String name;
final String addressTitle;
final String addressLine1;
final String phone;
final String? email;
final String? fax;
final String? taxCode;
final String cityCode;
final String wardCode;
final bool isDefault;
final String? cityName;
final String? wardName;
const Address({
required this.name,
required this.addressTitle,
required this.addressLine1,
required this.phone,
this.email,
this.fax,
this.taxCode,
required this.cityCode,
required this.wardCode,
this.isDefault = false,
this.cityName,
this.wardName,
});
@override
List<Object?> get props => [
name,
addressTitle,
addressLine1,
phone,
email,
fax,
taxCode,
cityCode,
wardCode,
isDefault,
cityName,
wardName,
];
/// Get full address display string
String get fullAddress {
final parts = <String>[];
parts.add(addressLine1);
if (wardName != null && wardName!.isNotEmpty) {
parts.add(wardName!);
}
if (cityName != null && cityName!.isNotEmpty) {
parts.add(cityName!);
}
return parts.join(', ');
}
/// Create a copy with modified fields
Address copyWith({
String? name,
String? addressTitle,
String? addressLine1,
String? phone,
String? email,
String? fax,
String? taxCode,
String? cityCode,
String? wardCode,
bool? isDefault,
String? cityName,
String? wardName,
}) {
return Address(
name: name ?? this.name,
addressTitle: addressTitle ?? this.addressTitle,
addressLine1: addressLine1 ?? this.addressLine1,
phone: phone ?? this.phone,
email: email ?? this.email,
fax: fax ?? this.fax,
taxCode: taxCode ?? this.taxCode,
cityCode: cityCode ?? this.cityCode,
wardCode: wardCode ?? this.wardCode,
isDefault: isDefault ?? this.isDefault,
cityName: cityName ?? this.cityName,
wardName: wardName ?? this.wardName,
);
}
@override
String toString() {
return 'Address(name: $name, addressTitle: $addressTitle, addressLine1: $addressLine1, phone: $phone, isDefault: $isDefault)';
}
}

View File

@@ -0,0 +1,27 @@
/// City Entity
///
/// Represents a city/province in Vietnam.
library;
import 'package:equatable/equatable.dart';
/// City Entity
///
/// Domain entity representing a city or province.
class City extends Equatable {
final String name; // Frappe ERPNext name/ID
final String cityName; // Display name
final String code; // City code
const City({
required this.name,
required this.cityName,
required this.code,
});
@override
List<Object?> get props => [name, cityName, code];
@override
String toString() => 'City(name: $name, cityName: $cityName, code: $code)';
}

View File

@@ -0,0 +1,27 @@
/// Ward Entity
///
/// Represents a ward/district in a city.
library;
import 'package:equatable/equatable.dart';
/// Ward Entity
///
/// Domain entity representing a ward or district within a city.
class Ward extends Equatable {
final String name; // Frappe ERPNext name/ID
final String wardName; // Display name
final String code; // Ward code
const Ward({
required this.name,
required this.wardName,
required this.code,
});
@override
List<Object?> get props => [name, wardName, code];
@override
String toString() => 'Ward(name: $name, wardName: $wardName, code: $code)';
}

View File

@@ -0,0 +1,38 @@
/// Address Repository Interface
///
/// Defines contract for address data operations.
library;
import 'package:worker/features/account/domain/entities/address.dart';
/// Address Repository
///
/// Repository interface for managing user addresses.
/// Online-only approach - all operations go directly to API.
abstract class AddressRepository {
/// Get list of addresses
///
/// Fetches all addresses for the authenticated user.
/// Optionally filter by default address status.
Future<List<Address>> getAddresses({bool? isDefault});
/// Create new address
///
/// Creates a new address and returns the created address with ID.
Future<Address> createAddress(Address address);
/// Update existing address
///
/// Updates an existing address identified by its name (ID).
Future<Address> updateAddress(Address address);
/// Delete address
///
/// Deletes an address by its name (ID).
Future<void> deleteAddress(String name);
/// Set address as default
///
/// Marks the specified address as default and unmarks others.
Future<void> setDefaultAddress(String name);
}

View File

@@ -0,0 +1,21 @@
/// Location Repository Interface
///
/// Contract for location (city/ward) data operations.
library;
import 'package:worker/features/account/domain/entities/city.dart';
import 'package:worker/features/account/domain/entities/ward.dart';
/// Location Repository
///
/// Defines methods for accessing city and ward data.
abstract class LocationRepository {
/// Get all cities (offline-first: cache → API)
Future<List<City>> getCities({bool forceRefresh = false});
/// Get wards for a specific city code
Future<List<Ward>> getWards(String cityCode, {bool forceRefresh = false});
/// Clear all cached location data
Future<void> clearCache();
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,136 +10,163 @@
library;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/account/domain/entities/address.dart';
import 'package:worker/features/account/presentation/providers/address_provider.dart';
import 'package:worker/features/account/presentation/widgets/address_card.dart';
/// Addresses Page
///
/// Page for managing saved delivery addresses.
class AddressesPage extends HookConsumerWidget {
class AddressesPage extends ConsumerWidget {
const AddressesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Mock addresses data
final addresses = useState<List<Map<String, dynamic>>>([
{
'id': '1',
'name': 'Hoàng Minh Hiệp',
'phone': '0347302911',
'address':
'123 Đường Võ Văn Ngân, Phường Linh Chiểu, Thành phố Thủ Đức, TP.HCM',
'isDefault': true,
},
{
'id': '2',
'name': 'Hoàng Minh Hiệp',
'phone': '0347302911',
'address': '456 Đường Nguyễn Thị Minh Khai, Quận 3, TP.HCM',
'isDefault': false,
},
{
'id': '3',
'name': 'Công ty TNHH ABC',
'phone': '0283445566',
'address': '789 Đường Lê Văn Sỹ, Quận Phú Nhuận, TP.HCM',
'isDefault': false,
},
]);
// Watch addresses from API
final addressesAsync = ref.watch(addressesProvider);
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
backgroundColor: AppColors.white,
elevation: AppBarSpecs.elevation,
leading: IconButton(
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
onPressed: () => context.pop(),
),
title: const Text(
'Địa chỉ đã lưu',
style: TextStyle(
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.bold,
),
'Địa chỉ của bạn',
style: TextStyle(color: Colors.black),
),
foregroundColor: AppColors.grey900,
centerTitle: false,
actions: [
IconButton(
icon: const FaIcon(FontAwesomeIcons.plus, color: Colors.black, size: 20),
icon: const FaIcon(FontAwesomeIcons.circleInfo, color: Colors.black, size: 20),
onPressed: () {
_showAddAddress(context);
_showInfoDialog(context);
},
),
const SizedBox(width: AppSpacing.sm),
],
),
body: Column(
children: [
// Address List
Expanded(
child: addresses.value.isEmpty
? _buildEmptyState(context)
: ListView.separated(
padding: const EdgeInsets.all(AppSpacing.md),
itemCount: addresses.value.length,
separatorBuilder: (context, index) =>
const SizedBox(height: AppSpacing.md),
itemBuilder: (context, index) {
final address = addresses.value[index];
return AddressCard(
name: address['name'] as String,
phone: address['phone'] as String,
address: address['address'] as String,
isDefault: address['isDefault'] as bool,
onEdit: () {
_showEditAddress(context, address);
},
onDelete: () {
_showDeleteConfirmation(context, addresses, index);
},
onSetDefault: () {
_setDefaultAddress(addresses, index);
},
);
},
),
),
// Add New Address Button
Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
_showAddAddress(context);
body: addressesAsync.when(
data: (addresses) => Column(
children: [
// Address List
Expanded(
child: RefreshIndicator(
onRefresh: () async {
await ref.read(addressesProvider.notifier).refresh();
},
icon: const FaIcon(FontAwesomeIcons.plus, size: 18),
label: const Text(
'Thêm địa chỉ mới',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
child: addresses.isEmpty
? _buildEmptyState(context)
: ListView.separated(
padding: const EdgeInsets.all(AppSpacing.md),
itemCount: addresses.length,
separatorBuilder: (context, index) =>
const SizedBox(height: AppSpacing.md),
itemBuilder: (context, index) {
final address = addresses[index];
return AddressCard(
name: address.addressTitle,
phone: address.phone,
address: address.fullAddress,
isDefault: address.isDefault,
onEdit: () {
context.push(
RouteNames.addressForm,
extra: address,
);
},
onDelete: () {
_showDeleteConfirmation(context, ref, address);
},
onSetDefault: () {
_setDefaultAddress(context, ref, address);
},
);
},
),
),
),
// Add New Address Button
Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
context.push(RouteNames.addressForm);
},
icon: const FaIcon(FontAwesomeIcons.plus, size: 18),
label: const Text(
'Thêm địa chỉ mới',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
),
),
),
),
),
],
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const FaIcon(
FontAwesomeIcons.triangleExclamation,
size: 64,
color: AppColors.danger,
),
const SizedBox(height: 16),
const Text(
'Không thể tải danh sách địa chỉ',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
const SizedBox(height: 8),
Text(
error.toString(),
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
ref.read(addressesProvider.notifier).refresh();
},
icon: const FaIcon(FontAwesomeIcons.arrowsRotate, size: 18),
label: const Text('Thử lại'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
),
),
],
),
],
),
),
);
}
@@ -152,7 +179,7 @@ class AddressesPage extends HookConsumerWidget {
FaIcon(
FontAwesomeIcons.locationDot,
size: 64,
color: AppColors.grey500.withValues(alpha: 0.5),
color: AppColors.grey500.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
const Text(
@@ -160,18 +187,21 @@ class AddressesPage extends HookConsumerWidget {
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey500,
color: AppColors.grey900,
),
),
const SizedBox(height: 8),
const Text(
Text(
'Thêm địa chỉ để nhận hàng nhanh hơn',
style: TextStyle(fontSize: 14, color: AppColors.grey500),
style: TextStyle(
fontSize: 14,
color: AppColors.grey500.withValues(alpha: 0.8),
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
_showAddAddress(context);
context.push(RouteNames.addressForm);
},
icon: const FaIcon(FontAwesomeIcons.plus, size: 18),
label: const Text(
@@ -194,34 +224,57 @@ class AddressesPage extends HookConsumerWidget {
}
/// Set address as default
void _setDefaultAddress(
ValueNotifier<List<Map<String, dynamic>>> addresses,
int index,
) {
final updatedAddresses = addresses.value.map((address) {
return {...address, 'isDefault': false};
}).toList();
void _setDefaultAddress(BuildContext context, WidgetRef ref, Address address) {
ref.read(addressesProvider.notifier).setDefaultAddress(address.name);
updatedAddresses[index]['isDefault'] = true;
addresses.value = updatedAddresses;
}
/// Show add address dialog (TODO: implement form page)
void _showAddAddress(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Chức năng thêm địa chỉ mới sẽ được phát triển'),
duration: Duration(seconds: 2),
SnackBar(
content: Row(
children: [
const FaIcon(
FontAwesomeIcons.circleCheck,
color: Colors.white,
size: 18,
),
const SizedBox(width: 12),
const Text('Đã đặt làm địa chỉ mặc định'),
],
),
backgroundColor: const Color(0xFF10B981),
duration: const Duration(seconds: 2),
),
);
}
/// Show edit address dialog (TODO: implement form page)
void _showEditAddress(BuildContext context, Map<String, dynamic> address) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Chỉnh sửa địa chỉ: ${address['name']}'),
duration: const Duration(seconds: 2),
/// Show info dialog
void _showInfoDialog(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text(
'Hướng dẫn sử dụng',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: const SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Quản lý địa chỉ giao hàng của bạn:'),
SizedBox(height: 12),
Text('• Thêm địa chỉ mới để dễ dàng đặt hàng'),
Text('• Đặt địa chỉ mặc định cho đơn hàng'),
Text('• Chỉnh sửa hoặc xóa địa chỉ bất kỳ'),
Text('• Lưu nhiều địa chỉ cho các mục đích khác nhau'),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Đóng'),
),
],
),
);
}
@@ -229,8 +282,8 @@ class AddressesPage extends HookConsumerWidget {
/// Show delete confirmation dialog
void _showDeleteConfirmation(
BuildContext context,
ValueNotifier<List<Map<String, dynamic>>> addresses,
int index,
WidgetRef ref,
Address address,
) {
showDialog<void>(
context: context,
@@ -245,7 +298,7 @@ class AddressesPage extends HookConsumerWidget {
TextButton(
onPressed: () {
Navigator.pop(context);
_deleteAddress(context, addresses, index);
_deleteAddress(context, ref, address);
},
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
child: const Text('Xóa'),
@@ -258,26 +311,51 @@ class AddressesPage extends HookConsumerWidget {
/// Delete address
void _deleteAddress(
BuildContext context,
ValueNotifier<List<Map<String, dynamic>>> addresses,
int index,
) {
final deletedAddress = addresses.value[index];
final updatedAddresses = List<Map<String, dynamic>>.from(addresses.value);
updatedAddresses.removeAt(index);
WidgetRef ref,
Address address,
) async {
try {
await ref.read(addressesProvider.notifier).deleteAddress(address.name);
// If deleted address was default and there are other addresses,
// set the first one as default
if (deletedAddress['isDefault'] == true && updatedAddresses.isNotEmpty) {
updatedAddresses[0]['isDefault'] = true;
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const FaIcon(
FontAwesomeIcons.circleCheck,
color: Colors.white,
size: 18,
),
const SizedBox(width: 12),
const Text('Đã xóa địa chỉ'),
],
),
backgroundColor: const Color(0xFF10B981),
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const FaIcon(
FontAwesomeIcons.circleExclamation,
color: Colors.white,
size: 18,
),
const SizedBox(width: 12),
Text('Lỗi: ${e.toString()}'),
],
),
backgroundColor: AppColors.danger,
duration: const Duration(seconds: 3),
),
);
}
}
addresses.value = updatedAddresses;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Đã xóa địa chỉ'),
duration: Duration(seconds: 2),
),
);
}
}

View File

@@ -0,0 +1,221 @@
/// Address Provider
///
/// Riverpod providers for address management.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/network/dio_client.dart';
import 'package:worker/features/account/data/datasources/address_remote_datasource.dart';
import 'package:worker/features/account/data/repositories/address_repository_impl.dart';
import 'package:worker/features/account/domain/entities/address.dart';
import 'package:worker/features/account/domain/repositories/address_repository.dart';
part 'address_provider.g.dart';
// ============================================================================
// DATASOURCE PROVIDER
// ============================================================================
/// Provides instance of AddressRemoteDataSource
@riverpod
Future<AddressRemoteDataSource> addressRemoteDataSource(Ref ref) async {
final dioClient = await ref.watch(dioClientProvider.future);
return AddressRemoteDataSource(dioClient.dio);
}
// ============================================================================
// REPOSITORY PROVIDER
// ============================================================================
/// Provides instance of AddressRepository
@riverpod
Future<AddressRepository> addressRepository(Ref ref) async {
final remoteDataSource =
await ref.watch(addressRemoteDataSourceProvider.future);
return AddressRepositoryImpl(
remoteDataSource: remoteDataSource,
);
}
// ============================================================================
// ADDRESSES LIST PROVIDER
// ============================================================================
/// Manages list of addresses with online-only approach
///
/// This is the MAIN provider for the addresses feature.
/// Returns list of Address entities from the API.
///
/// Online-only: Always fetches from API, no offline caching.
/// Uses keepAlive to prevent unnecessary reloads.
/// Provides refresh() method for pull-to-refresh functionality.
@Riverpod(keepAlive: true)
class Addresses extends _$Addresses {
late AddressRepository _repository;
@override
Future<List<Address>> build() async {
_repository = await ref.read(addressRepositoryProvider.future);
return await _loadAddresses();
}
// ==========================================================================
// PRIVATE METHODS
// ==========================================================================
/// Load addresses from repository
///
/// Online-only: Fetches from API
Future<List<Address>> _loadAddresses() async {
try {
final addresses = await _repository.getAddresses();
_debugPrint('Loaded ${addresses.length} addresses');
return addresses;
} catch (e) {
_debugPrint('Error loading addresses: $e');
rethrow;
}
}
// ==========================================================================
// PUBLIC METHODS
// ==========================================================================
/// Create new address
///
/// Calls API to create address, then refreshes the list.
Future<void> createAddress(Address address) async {
try {
_debugPrint('Creating address: ${address.addressTitle}');
await _repository.createAddress(address);
// Refresh the list after successful creation
await refresh();
_debugPrint('Successfully created address');
} catch (e) {
_debugPrint('Error creating address: $e');
rethrow;
}
}
/// Update existing address
///
/// Calls API to update address, then refreshes the list.
Future<void> updateAddress(Address address) async {
try {
_debugPrint('Updating address: ${address.name}');
await _repository.updateAddress(address);
// Refresh the list after successful update
await refresh();
_debugPrint('Successfully updated address');
} catch (e) {
_debugPrint('Error updating address: $e');
rethrow;
}
}
/// Delete address
///
/// Calls API to delete address, then refreshes the list.
Future<void> deleteAddress(String name) async {
try {
_debugPrint('Deleting address: $name');
await _repository.deleteAddress(name);
// Refresh the list after successful deletion
await refresh();
_debugPrint('Successfully deleted address');
} catch (e) {
_debugPrint('Error deleting address: $e');
rethrow;
}
}
/// Set address as default
///
/// Calls API to set address as default, then refreshes the list.
Future<void> setDefaultAddress(String name) async {
try {
_debugPrint('Setting default address: $name');
await _repository.setDefaultAddress(name);
// Refresh the list after successful update
await refresh();
_debugPrint('Successfully set default address');
} catch (e) {
_debugPrint('Error setting default address: $e');
rethrow;
}
}
/// Refresh addresses from API
///
/// Used for pull-to-refresh functionality.
/// Fetches latest data from API.
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
return await _loadAddresses();
});
}
}
// ============================================================================
// HELPER PROVIDERS
// ============================================================================
/// Get the default address
///
/// Derived from the addresses list.
/// Returns the address marked as default, or null if none.
@riverpod
Address? defaultAddress(Ref ref) {
final addressesAsync = ref.watch(addressesProvider);
return addressesAsync.when(
data: (addresses) {
try {
return addresses.firstWhere((addr) => addr.isDefault);
} catch (e) {
return null;
}
},
loading: () => null,
error: (_, __) => null,
);
}
/// Get address count
///
/// Derived from the addresses list.
/// Returns the number of addresses.
@riverpod
int addressCount(Ref ref) {
final addressesAsync = ref.watch(addressesProvider);
return addressesAsync.when(
data: (addresses) => addresses.length,
loading: () => 0,
error: (_, __) => 0,
);
}
// ============================================================================
// DEBUG UTILITIES
// ============================================================================
/// Debug print helper
void _debugPrint(String message) {
// ignore: avoid_print
print('[AddressProvider] $message');
}

View File

@@ -0,0 +1,290 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'address_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provides instance of AddressRemoteDataSource
@ProviderFor(addressRemoteDataSource)
const addressRemoteDataSourceProvider = AddressRemoteDataSourceProvider._();
/// Provides instance of AddressRemoteDataSource
final class AddressRemoteDataSourceProvider
extends
$FunctionalProvider<
AsyncValue<AddressRemoteDataSource>,
AddressRemoteDataSource,
FutureOr<AddressRemoteDataSource>
>
with
$FutureModifier<AddressRemoteDataSource>,
$FutureProvider<AddressRemoteDataSource> {
/// Provides instance of AddressRemoteDataSource
const AddressRemoteDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'addressRemoteDataSourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$addressRemoteDataSourceHash();
@$internal
@override
$FutureProviderElement<AddressRemoteDataSource> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<AddressRemoteDataSource> create(Ref ref) {
return addressRemoteDataSource(ref);
}
}
String _$addressRemoteDataSourceHash() =>
r'e244b9f1270d1b81d65b82a9d5b34ead33bd7b79';
/// Provides instance of AddressRepository
@ProviderFor(addressRepository)
const addressRepositoryProvider = AddressRepositoryProvider._();
/// Provides instance of AddressRepository
final class AddressRepositoryProvider
extends
$FunctionalProvider<
AsyncValue<AddressRepository>,
AddressRepository,
FutureOr<AddressRepository>
>
with
$FutureModifier<AddressRepository>,
$FutureProvider<AddressRepository> {
/// Provides instance of AddressRepository
const AddressRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'addressRepositoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$addressRepositoryHash();
@$internal
@override
$FutureProviderElement<AddressRepository> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<AddressRepository> create(Ref ref) {
return addressRepository(ref);
}
}
String _$addressRepositoryHash() => r'87d8fa124d6f32c4f073acd30ba09b1eee5b0227';
/// Manages list of addresses with online-only approach
///
/// This is the MAIN provider for the addresses feature.
/// Returns list of Address entities from the API.
///
/// Online-only: Always fetches from API, no offline caching.
/// Uses keepAlive to prevent unnecessary reloads.
/// Provides refresh() method for pull-to-refresh functionality.
@ProviderFor(Addresses)
const addressesProvider = AddressesProvider._();
/// Manages list of addresses with online-only approach
///
/// This is the MAIN provider for the addresses feature.
/// Returns list of Address entities from the API.
///
/// Online-only: Always fetches from API, no offline caching.
/// Uses keepAlive to prevent unnecessary reloads.
/// Provides refresh() method for pull-to-refresh functionality.
final class AddressesProvider
extends $AsyncNotifierProvider<Addresses, List<Address>> {
/// Manages list of addresses with online-only approach
///
/// This is the MAIN provider for the addresses feature.
/// Returns list of Address entities from the API.
///
/// Online-only: Always fetches from API, no offline caching.
/// Uses keepAlive to prevent unnecessary reloads.
/// Provides refresh() method for pull-to-refresh functionality.
const AddressesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'addressesProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$addressesHash();
@$internal
@override
Addresses create() => Addresses();
}
String _$addressesHash() => r'c8018cffc89b03e687052802d3d0cd16cd1d5047';
/// Manages list of addresses with online-only approach
///
/// This is the MAIN provider for the addresses feature.
/// Returns list of Address entities from the API.
///
/// Online-only: Always fetches from API, no offline caching.
/// Uses keepAlive to prevent unnecessary reloads.
/// Provides refresh() method for pull-to-refresh functionality.
abstract class _$Addresses extends $AsyncNotifier<List<Address>> {
FutureOr<List<Address>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<List<Address>>, List<Address>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<Address>>, List<Address>>,
AsyncValue<List<Address>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Get the default address
///
/// Derived from the addresses list.
/// Returns the address marked as default, or null if none.
@ProviderFor(defaultAddress)
const defaultAddressProvider = DefaultAddressProvider._();
/// Get the default address
///
/// Derived from the addresses list.
/// Returns the address marked as default, or null if none.
final class DefaultAddressProvider
extends $FunctionalProvider<Address?, Address?, Address?>
with $Provider<Address?> {
/// Get the default address
///
/// Derived from the addresses list.
/// Returns the address marked as default, or null if none.
const DefaultAddressProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'defaultAddressProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$defaultAddressHash();
@$internal
@override
$ProviderElement<Address?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Address? create(Ref ref) {
return defaultAddress(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Address? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Address?>(value),
);
}
}
String _$defaultAddressHash() => r'debdc71d6a480cf1ceb9536a4b6d9690aede1d72';
/// Get address count
///
/// Derived from the addresses list.
/// Returns the number of addresses.
@ProviderFor(addressCount)
const addressCountProvider = AddressCountProvider._();
/// Get address count
///
/// Derived from the addresses list.
/// Returns the number of addresses.
final class AddressCountProvider extends $FunctionalProvider<int, int, int>
with $Provider<int> {
/// Get address count
///
/// Derived from the addresses list.
/// Returns the number of addresses.
const AddressCountProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'addressCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$addressCountHash();
@$internal
@override
$ProviderElement<int> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
int create(Ref ref) {
return addressCount(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
}
String _$addressCountHash() => r'e4480805fd484cd477fd0f232902afdfdd0ed342';

View File

@@ -0,0 +1,153 @@
/// Location Provider
///
/// Riverpod providers for cities and wards management.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/database/hive_service.dart';
import 'package:worker/core/network/dio_client.dart';
import 'package:worker/features/account/data/datasources/location_local_datasource.dart';
import 'package:worker/features/account/data/datasources/location_remote_datasource.dart';
import 'package:worker/features/account/data/repositories/location_repository_impl.dart';
import 'package:worker/features/account/domain/entities/city.dart';
import 'package:worker/features/account/domain/entities/ward.dart';
import 'package:worker/features/account/domain/repositories/location_repository.dart';
part 'location_provider.g.dart';
// ============================================================================
// DATASOURCE PROVIDERS
// ============================================================================
/// Provides instance of LocationRemoteDataSource
@riverpod
Future<LocationRemoteDataSource> locationRemoteDataSource(Ref ref) async {
final dioClient = await ref.watch(dioClientProvider.future);
return LocationRemoteDataSource(dioClient.dio);
}
/// Provides instance of LocationLocalDataSource
@riverpod
LocationLocalDataSource locationLocalDataSource(Ref ref) {
final hiveService = HiveService();
return LocationLocalDataSource(hiveService);
}
// ============================================================================
// REPOSITORY PROVIDER
// ============================================================================
/// Provides instance of LocationRepository
@riverpod
Future<LocationRepository> locationRepository(Ref ref) async {
final remoteDataSource = await ref.watch(locationRemoteDataSourceProvider.future);
final localDataSource = ref.watch(locationLocalDataSourceProvider);
return LocationRepositoryImpl(
remoteDataSource: remoteDataSource,
localDataSource: localDataSource,
);
}
// ============================================================================
// CITIES PROVIDER
// ============================================================================
/// Manages list of cities with offline-first approach
///
/// This is the MAIN provider for cities.
/// Returns list of City entities (cached → API).
@Riverpod(keepAlive: true)
class Cities extends _$Cities {
late LocationRepository _repository;
@override
Future<List<City>> build() async {
_repository = await ref.read(locationRepositoryProvider.future);
return await _loadCities();
}
/// Load cities (offline-first)
Future<List<City>> _loadCities({bool forceRefresh = false}) async {
try {
final cities = await _repository.getCities(forceRefresh: forceRefresh);
return cities;
} catch (e) {
rethrow;
}
}
/// Refresh cities from API
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
return await _loadCities(forceRefresh: true);
});
}
}
// ============================================================================
// WARDS PROVIDER (per city)
// ============================================================================
/// Manages list of wards for a specific city with offline-first approach
///
/// Uses .family modifier to create a provider per city code.
/// Returns list of Ward entities (cached → API).
@riverpod
Future<List<Ward>> wards(Ref ref, String cityCode) async {
final repository = await ref.watch(locationRepositoryProvider.future);
try {
final wards = await repository.getWards(cityCode);
return wards;
} catch (e) {
rethrow;
}
}
// ============================================================================
// HELPER PROVIDERS
// ============================================================================
/// Get city by code
@riverpod
City? cityByCode(Ref ref, String code) {
final citiesAsync = ref.watch(citiesProvider);
return citiesAsync.when(
data: (cities) {
try {
return cities.firstWhere((city) => city.code == code);
} catch (e) {
return null;
}
},
loading: () => null,
error: (_, __) => null,
);
}
/// Get cities as map (code → City) for easy lookup
@riverpod
Map<String, City> citiesMap(Ref ref) {
final citiesAsync = ref.watch(citiesProvider);
return citiesAsync.when(
data: (cities) => {for (final city in cities) city.code: city},
loading: () => {},
error: (_, __) => {},
);
}
/// Get wards as map (code → Ward) for a city
@riverpod
Map<String, Ward> wardsMap(Ref ref, String cityCode) {
final wardsAsync = ref.watch(wardsProvider(cityCode));
return wardsAsync.when(
data: (wards) => {for (final ward in wards) ward.code: ward},
loading: () => {},
error: (_, __) => {},
);
}

View File

@@ -0,0 +1,545 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'location_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provides instance of LocationRemoteDataSource
@ProviderFor(locationRemoteDataSource)
const locationRemoteDataSourceProvider = LocationRemoteDataSourceProvider._();
/// Provides instance of LocationRemoteDataSource
final class LocationRemoteDataSourceProvider
extends
$FunctionalProvider<
AsyncValue<LocationRemoteDataSource>,
LocationRemoteDataSource,
FutureOr<LocationRemoteDataSource>
>
with
$FutureModifier<LocationRemoteDataSource>,
$FutureProvider<LocationRemoteDataSource> {
/// Provides instance of LocationRemoteDataSource
const LocationRemoteDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'locationRemoteDataSourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$locationRemoteDataSourceHash();
@$internal
@override
$FutureProviderElement<LocationRemoteDataSource> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<LocationRemoteDataSource> create(Ref ref) {
return locationRemoteDataSource(ref);
}
}
String _$locationRemoteDataSourceHash() =>
r'f66b9d96a2c01c00c90a2c8c0414b027d8079e0f';
/// Provides instance of LocationLocalDataSource
@ProviderFor(locationLocalDataSource)
const locationLocalDataSourceProvider = LocationLocalDataSourceProvider._();
/// Provides instance of LocationLocalDataSource
final class LocationLocalDataSourceProvider
extends
$FunctionalProvider<
LocationLocalDataSource,
LocationLocalDataSource,
LocationLocalDataSource
>
with $Provider<LocationLocalDataSource> {
/// Provides instance of LocationLocalDataSource
const LocationLocalDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'locationLocalDataSourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$locationLocalDataSourceHash();
@$internal
@override
$ProviderElement<LocationLocalDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
LocationLocalDataSource create(Ref ref) {
return locationLocalDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(LocationLocalDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<LocationLocalDataSource>(value),
);
}
}
String _$locationLocalDataSourceHash() =>
r'160b82535ae14c4644b4285243a03335d472f584';
/// Provides instance of LocationRepository
@ProviderFor(locationRepository)
const locationRepositoryProvider = LocationRepositoryProvider._();
/// Provides instance of LocationRepository
final class LocationRepositoryProvider
extends
$FunctionalProvider<
AsyncValue<LocationRepository>,
LocationRepository,
FutureOr<LocationRepository>
>
with
$FutureModifier<LocationRepository>,
$FutureProvider<LocationRepository> {
/// Provides instance of LocationRepository
const LocationRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'locationRepositoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$locationRepositoryHash();
@$internal
@override
$FutureProviderElement<LocationRepository> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<LocationRepository> create(Ref ref) {
return locationRepository(ref);
}
}
String _$locationRepositoryHash() =>
r'7ead096fe90803ecc8ef7c27186a59044c306668';
/// Manages list of cities with offline-first approach
///
/// This is the MAIN provider for cities.
/// Returns list of City entities (cached → API).
@ProviderFor(Cities)
const citiesProvider = CitiesProvider._();
/// Manages list of cities with offline-first approach
///
/// This is the MAIN provider for cities.
/// Returns list of City entities (cached → API).
final class CitiesProvider extends $AsyncNotifierProvider<Cities, List<City>> {
/// Manages list of cities with offline-first approach
///
/// This is the MAIN provider for cities.
/// Returns list of City entities (cached → API).
const CitiesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'citiesProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$citiesHash();
@$internal
@override
Cities create() => Cities();
}
String _$citiesHash() => r'92405067c99ad5e33bd1b4fecd33576baa0c4e2f';
/// Manages list of cities with offline-first approach
///
/// This is the MAIN provider for cities.
/// Returns list of City entities (cached → API).
abstract class _$Cities extends $AsyncNotifier<List<City>> {
FutureOr<List<City>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<List<City>>, List<City>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<City>>, List<City>>,
AsyncValue<List<City>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Manages list of wards for a specific city with offline-first approach
///
/// Uses .family modifier to create a provider per city code.
/// Returns list of Ward entities (cached → API).
@ProviderFor(wards)
const wardsProvider = WardsFamily._();
/// Manages list of wards for a specific city with offline-first approach
///
/// Uses .family modifier to create a provider per city code.
/// Returns list of Ward entities (cached → API).
final class WardsProvider
extends
$FunctionalProvider<
AsyncValue<List<Ward>>,
List<Ward>,
FutureOr<List<Ward>>
>
with $FutureModifier<List<Ward>>, $FutureProvider<List<Ward>> {
/// Manages list of wards for a specific city with offline-first approach
///
/// Uses .family modifier to create a provider per city code.
/// Returns list of Ward entities (cached → API).
const WardsProvider._({
required WardsFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'wardsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$wardsHash();
@override
String toString() {
return r'wardsProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<List<Ward>> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<List<Ward>> create(Ref ref) {
final argument = this.argument as String;
return wards(ref, argument);
}
@override
bool operator ==(Object other) {
return other is WardsProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$wardsHash() => r'7e970ebd13149d6c1d4e76d0ba9f2a9a43cd62fc';
/// Manages list of wards for a specific city with offline-first approach
///
/// Uses .family modifier to create a provider per city code.
/// Returns list of Ward entities (cached → API).
final class WardsFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<List<Ward>>, String> {
const WardsFamily._()
: super(
retry: null,
name: r'wardsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Manages list of wards for a specific city with offline-first approach
///
/// Uses .family modifier to create a provider per city code.
/// Returns list of Ward entities (cached → API).
WardsProvider call(String cityCode) =>
WardsProvider._(argument: cityCode, from: this);
@override
String toString() => r'wardsProvider';
}
/// Get city by code
@ProviderFor(cityByCode)
const cityByCodeProvider = CityByCodeFamily._();
/// Get city by code
final class CityByCodeProvider extends $FunctionalProvider<City?, City?, City?>
with $Provider<City?> {
/// Get city by code
const CityByCodeProvider._({
required CityByCodeFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'cityByCodeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$cityByCodeHash();
@override
String toString() {
return r'cityByCodeProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<City?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
City? create(Ref ref) {
final argument = this.argument as String;
return cityByCode(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(City? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<City?>(value),
);
}
@override
bool operator ==(Object other) {
return other is CityByCodeProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$cityByCodeHash() => r'dd5e7296f16d6c78beadc28eb97adf5ba06549a5';
/// Get city by code
final class CityByCodeFamily extends $Family
with $FunctionalFamilyOverride<City?, String> {
const CityByCodeFamily._()
: super(
retry: null,
name: r'cityByCodeProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Get city by code
CityByCodeProvider call(String code) =>
CityByCodeProvider._(argument: code, from: this);
@override
String toString() => r'cityByCodeProvider';
}
/// Get cities as map (code → City) for easy lookup
@ProviderFor(citiesMap)
const citiesMapProvider = CitiesMapProvider._();
/// Get cities as map (code → City) for easy lookup
final class CitiesMapProvider
extends
$FunctionalProvider<
Map<String, City>,
Map<String, City>,
Map<String, City>
>
with $Provider<Map<String, City>> {
/// Get cities as map (code → City) for easy lookup
const CitiesMapProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'citiesMapProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$citiesMapHash();
@$internal
@override
$ProviderElement<Map<String, City>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
Map<String, City> create(Ref ref) {
return citiesMap(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, City> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, City>>(value),
);
}
}
String _$citiesMapHash() => r'80d684d68276eac20208d977be382004971738fa';
/// Get wards as map (code → Ward) for a city
@ProviderFor(wardsMap)
const wardsMapProvider = WardsMapFamily._();
/// Get wards as map (code → Ward) for a city
final class WardsMapProvider
extends
$FunctionalProvider<
Map<String, Ward>,
Map<String, Ward>,
Map<String, Ward>
>
with $Provider<Map<String, Ward>> {
/// Get wards as map (code → Ward) for a city
const WardsMapProvider._({
required WardsMapFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'wardsMapProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$wardsMapHash();
@override
String toString() {
return r'wardsMapProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<Map<String, Ward>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
Map<String, Ward> create(Ref ref) {
final argument = this.argument as String;
return wardsMap(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, Ward> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, Ward>>(value),
);
}
@override
bool operator ==(Object other) {
return other is WardsMapProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$wardsMapHash() => r'977cb8eb6974a46a8dbc6a68bea004dc64dcfbb9';
/// Get wards as map (code → Ward) for a city
final class WardsMapFamily extends $Family
with $FunctionalFamilyOverride<Map<String, Ward>, String> {
const WardsMapFamily._()
: super(
retry: null,
name: r'wardsMapProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Get wards as map (code → Ward) for a city
WardsMapProvider call(String cityCode) =>
WardsMapProvider._(argument: cityCode, from: this);
@override
String toString() => r'wardsMapProvider';
}

View File

@@ -42,13 +42,21 @@ class AddressCard extends StatelessWidget {
border: isDefault
? Border.all(color: AppColors.primaryBlue, width: 2)
: null,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
boxShadow: isDefault
? [
BoxShadow(
color: AppColors.primaryBlue.withValues(alpha: 0.15),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -93,21 +101,27 @@ class AddressCard extends StatelessWidget {
),
)
else if (onSetDefault != null)
TextButton(
onPressed: onSetDefault,
style: TextButton.styleFrom(
InkWell(
onTap: onSetDefault,
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
vertical: 4,
),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text(
'Đặt mặc định',
style: TextStyle(
fontSize: 12,
color: AppColors.primaryBlue,
decoration: BoxDecoration(
border: Border.all(
color: AppColors.primaryBlue.withValues(alpha: 0.3),
),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Đặt mặc định',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppColors.primaryBlue,
),
),
),
),
@@ -147,20 +161,25 @@ class AddressCard extends StatelessWidget {
children: [
// Edit Button
if (onEdit != null)
InkWell(
onTap: onEdit,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE2E8F0)),
borderRadius: BorderRadius.circular(8),
),
child: const FaIcon(
FontAwesomeIcons.penToSquare,
size: 16,
color: AppColors.primaryBlue,
Material(
color: Colors.transparent,
child: InkWell(
onTap: onEdit,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE2E8F0)),
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: FaIcon(
FontAwesomeIcons.penToSquare,
size: 16,
color: AppColors.primaryBlue,
),
),
),
),
),
@@ -169,20 +188,25 @@ class AddressCard extends StatelessWidget {
// Delete Button
if (onDelete != null)
InkWell(
onTap: onDelete,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE2E8F0)),
borderRadius: BorderRadius.circular(8),
),
child: const FaIcon(
FontAwesomeIcons.trashCan,
size: 16,
color: AppColors.danger,
Material(
color: Colors.transparent,
child: InkWell(
onTap: onDelete,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE2E8F0)),
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: FaIcon(
FontAwesomeIcons.trashCan,
size: 16,
color: AppColors.danger,
),
),
),
),
),

View File

@@ -0,0 +1,69 @@
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/products/data/models/product_model.dart';
/// Favorite Products Local DataSource
///
/// Caches the actual product data from wishlist API.
/// This is separate from ProductsLocalDataSource to avoid conflicts.
class FavoriteProductsLocalDataSource {
/// Get the Hive box for favorite products
Box<dynamic> get _box {
return Hive.box<dynamic>(HiveBoxNames.favoriteProductsBox);
}
/// Get all favorite products from cache
Future<List<ProductModel>> getAllProducts() async {
try {
final products = _box.values
.whereType<ProductModel>()
.toList();
_debugPrint('Loaded ${products.length} favorite products from cache');
return products;
} catch (e) {
_debugPrint('Error getting favorite products: $e');
return [];
}
}
/// Save products from wishlist API to cache
Future<void> saveProducts(List<ProductModel> products) async {
try {
// Clear existing cache
await _box.clear();
// Save new products
for (final product in products) {
await _box.put(product.productId, product);
}
_debugPrint('Cached ${products.length} favorite products');
} catch (e) {
_debugPrint('Error saving favorite products: $e');
rethrow;
}
}
/// Clear all cached favorite products
Future<void> clearAll() async {
try {
await _box.clear();
_debugPrint('Cleared all favorite products cache');
} catch (e) {
_debugPrint('Error clearing favorite products: $e');
rethrow;
}
}
/// Check if the box is open
bool isBoxOpen() {
return Hive.isBoxOpen(HiveBoxNames.favoriteProductsBox);
}
}
/// Debug print helper
void _debugPrint(String message) {
// ignore: avoid_print
print('[FavoriteProductsLocalDataSource] $message');
}

View File

@@ -1,161 +0,0 @@
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/favorites/data/models/favorite_model.dart';
/// Favorites Local DataSource
///
/// Handles all local database operations for favorites using Hive.
/// Supports multi-user functionality by filtering favorites by userId.
class FavoritesLocalDataSource {
/// Get the Hive box for favorites
Box<dynamic> get _box {
return Hive.box<dynamic>(HiveBoxNames.favoriteBox);
}
/// Get all favorites for a specific user
///
/// Returns a list of [FavoriteModel] filtered by [userId].
/// If the box is not open or an error occurs, returns an empty list.
Future<List<FavoriteModel>> getAllFavorites(String userId) async {
try {
final favorites = _box.values
.whereType<FavoriteModel>()
.where((fav) => fav.userId == userId)
.toList();
// Sort by creation date (newest first)
favorites.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return favorites;
} catch (e) {
debugPrint('[FavoritesLocalDataSource] Error getting favorites: $e');
return [];
}
}
/// Add a favorite to the database
///
/// Adds a new [FavoriteModel] to the Hive box.
/// Uses the favoriteId as the key for efficient lookup.
Future<void> addFavorite(FavoriteModel favorite) async {
try {
await _box.put(favorite.favoriteId, favorite);
debugPrint(
'[FavoritesLocalDataSource] Added favorite: ${favorite.favoriteId} for user: ${favorite.userId}',
);
} catch (e) {
debugPrint('[FavoritesLocalDataSource] Error adding favorite: $e');
rethrow;
}
}
/// Remove a favorite from the database
///
/// Removes a favorite by finding it with the combination of [productId] and [userId].
/// Returns true if the favorite was found and removed, false otherwise.
Future<bool> removeFavorite(String productId, String userId) async {
try {
// Find the favorite by productId and userId
final favorites = _box.values
.whereType<FavoriteModel>()
.where((fav) => fav.productId == productId && fav.userId == userId)
.toList();
if (favorites.isEmpty) {
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',
);
return true;
} catch (e) {
debugPrint('[FavoritesLocalDataSource] Error removing favorite: $e');
rethrow;
}
}
/// Check if a product is favorited by a user
///
/// Returns true if the product is in the user's favorites, false otherwise.
bool isFavorite(String productId, String userId) {
try {
return _box.values.whereType<FavoriteModel>().any(
(fav) => fav.productId == productId && fav.userId == userId,
);
} catch (e) {
debugPrint('[FavoritesLocalDataSource] Error checking favorite: $e');
return false;
}
}
/// Clear all favorites for a specific user
///
/// Removes all favorites for the given [userId].
/// Useful for logout or data cleanup scenarios.
Future<void> clearFavorites(String userId) async {
try {
final favoriteIds = _box.values
.whereType<FavoriteModel>()
.where((fav) => fav.userId == userId)
.map((fav) => fav.favoriteId)
.toList();
await _box.deleteAll(favoriteIds);
debugPrint(
'[FavoritesLocalDataSource] Cleared ${favoriteIds.length} favorites for user: $userId',
);
} catch (e) {
debugPrint('[FavoritesLocalDataSource] Error clearing favorites: $e');
rethrow;
}
}
/// Get the count of favorites for a user
///
/// Returns the total number of favorites for the given [userId].
int getFavoriteCount(String userId) {
try {
return _box.values
.whereType<FavoriteModel>()
.where((fav) => fav.userId == userId)
.length;
} catch (e) {
debugPrint('[FavoritesLocalDataSource] Error getting favorite count: $e');
return 0;
}
}
/// Check if the favorites box is open
///
/// Returns true if the box is open and ready to use.
bool isBoxOpen() {
return Hive.isBoxOpen(HiveBoxNames.favoriteBox);
}
/// Compact the favorites box to reduce storage space
///
/// Should be called periodically to optimize database size.
Future<void> compact() async {
try {
if (isBoxOpen()) {
await _box.compact();
debugPrint('[FavoritesLocalDataSource] Favorites box compacted');
}
} catch (e) {
debugPrint(
'[FavoritesLocalDataSource] Error compacting favorites box: $e',
);
}
}
}
/// Debug print helper that works in both Flutter and Dart
void debugPrint(String message) {
print('[FavoritesLocalDataSource] $message');
}

View File

@@ -0,0 +1,193 @@
import 'package:dio/dio.dart';
import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/errors/exceptions.dart';
import 'package:worker/features/products/data/models/product_model.dart';
/// Favorites Remote DataSource
///
/// Handles all API operations for favorites/wishlist using Frappe ERPNext backend.
/// Follows the API spec from docs/favorite.sh
///
/// Note: The API returns Product objects directly, not separate favorite entities.
class FavoritesRemoteDataSource {
FavoritesRemoteDataSource(this._dio);
final Dio _dio;
/// Get all favorites/wishlist items for the current user
///
/// API: POST /api/method/building_material.building_material.api.item_wishlist.get_list
/// Body: { "limit_start": 0, "limit_page_length": 0 }
///
/// Response format (from docs/favorite.sh):
/// ```json
/// {
/// "message": [
/// {
/// "name": "GIB20 G04",
/// "item_code": "GIB20 G04",
/// "item_name": "Gibellina GIB20 G04",
/// "item_group_name": "OUTDOOR [20mm]",
/// "custom_link_360": "https://...",
/// "thumbnail": "https://...",
/// "price": 0,
/// "currency": "",
/// "conversion_of_sm": 5.5556
/// }
/// ]
/// }
/// ```
///
/// Returns list of Product objects that are favorited
Future<List<ProductModel>> getFavorites({
int limitStart = 0,
int limitPageLength = 0, // 0 means get all
}) async {
try {
final response = await _dio.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.getFavorites}',
data: {
'limit_start': limitStart,
'limit_page_length': limitPageLength,
},
);
if (response.data == null) {
throw const ServerException('Response data is null');
}
// Parse response according to Frappe format
final data = response.data!;
final messageList = data['message'] as List<dynamic>?;
if (messageList == null || messageList.isEmpty) {
_debugPrint('No favorites found');
return [];
}
final products = <ProductModel>[];
// Convert API response - each item is a product object from wishlist
for (final item in messageList) {
try {
final itemMap = item as Map<String, dynamic>;
final productModel = ProductModel.fromWishlistApi(itemMap);
products.add(productModel);
} catch (e) {
_debugPrint('Error parsing product: $e');
// Continue with other products even if one fails
}
}
_debugPrint('Fetched ${products.length} favorite products');
return products;
} on DioException catch (e) {
_handleDioException(e);
} catch (e) {
_debugPrint('Error getting favorites: $e');
throw ServerException('Failed to get favorites: $e');
}
}
/// Add item to wishlist
///
/// API: POST /api/method/building_material.building_material.api.item_wishlist.add_to_wishlist
/// Body: { "item_id": "GIB20 G04" }
///
/// Returns true if successful
Future<bool> addToFavorites(String itemId) async {
try {
final response = await _dio.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.addToFavorites}',
data: {
'item_id': itemId,
},
);
_debugPrint('Added to favorites: $itemId');
return response.statusCode == 200;
} on DioException catch (e) {
_handleDioException(e);
} catch (e) {
_debugPrint('Error adding to favorites: $e');
throw ServerException('Failed to add to favorites: $e');
}
}
/// Remove item from wishlist
///
/// API: POST /api/method/building_material.building_material.api.item_wishlist.remove_from_wishlist
/// Body: { "item_id": "GIB20 G04" }
///
/// Returns true if successful
Future<bool> removeFromFavorites(String itemId) async {
try {
final response = await _dio.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.removeFromFavorites}',
data: {
'item_id': itemId,
},
);
_debugPrint('Removed from favorites: $itemId');
return response.statusCode == 200;
} on DioException catch (e) {
_handleDioException(e);
} catch (e) {
_debugPrint('Error removing from favorites: $e');
throw ServerException('Failed to remove from favorites: $e');
}
}
// =========================================================================
// ERROR HANDLING
// =========================================================================
/// Handle Dio exceptions and convert to custom exceptions
Never _handleDioException(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
throw const NetworkException('Request timeout. Please check your connection.');
case DioExceptionType.connectionError:
throw const NetworkException('No internet connection.');
case DioExceptionType.badResponse:
final statusCode = e.response?.statusCode;
final message = e.response?.data?['message'] as String? ??
e.response?.data?['error'] as String? ??
'Server error';
if (statusCode == 401) {
throw const UnauthorizedException('Unauthorized. Please login again.');
} else if (statusCode == 403) {
throw const UnauthorizedException('Access forbidden.');
} else if (statusCode == 404) {
throw const ServerException('Resource not found.');
} else if (statusCode != null && statusCode >= 500) {
throw ServerException('Server error: $message');
} else {
throw ServerException(message);
}
case DioExceptionType.cancel:
throw const NetworkException('Request was cancelled');
case DioExceptionType.unknown:
case DioExceptionType.badCertificate:
throw NetworkException('Network error: ${e.message}');
}
}
}
// ============================================================================
// DEBUG UTILITIES
// ============================================================================
/// Debug print helper
void _debugPrint(String message) {
// ignore: avoid_print
print('[FavoritesRemoteDataSource] $message');
}

View File

@@ -1,120 +0,0 @@
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/favorites/domain/entities/favorite.dart';
part 'favorite_model.g.dart';
/// Favorite Model
///
/// Hive CE model for storing user's favorite products locally.
/// Maps to the 'favorites' table in the database.
///
/// Type ID: 28
@HiveType(typeId: HiveTypeIds.favoriteModel)
class FavoriteModel extends HiveObject {
FavoriteModel({
required this.favoriteId,
required this.productId,
required this.userId,
required this.createdAt,
});
/// Favorite ID (Primary Key)
@HiveField(0)
final String favoriteId;
/// Product ID (Foreign Key)
@HiveField(1)
final String productId;
/// User ID (Foreign Key)
@HiveField(2)
final String userId;
/// Created timestamp
@HiveField(3)
final DateTime createdAt;
// =========================================================================
// JSON SERIALIZATION
// =========================================================================
/// Create FavoriteModel from JSON
factory FavoriteModel.fromJson(Map<String, dynamic> json) {
return FavoriteModel(
favoriteId: json['favorite_id'] as String,
productId: json['product_id'] as String,
userId: json['user_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
/// Convert FavoriteModel to JSON
Map<String, dynamic> toJson() {
return {
'favorite_id': favoriteId,
'product_id': productId,
'user_id': userId,
'created_at': createdAt.toIso8601String(),
};
}
// =========================================================================
// COPY WITH
// =========================================================================
/// Create a copy with updated fields
FavoriteModel copyWith({
String? favoriteId,
String? productId,
String? userId,
DateTime? createdAt,
}) {
return FavoriteModel(
favoriteId: favoriteId ?? this.favoriteId,
productId: productId ?? this.productId,
userId: userId ?? this.userId,
createdAt: createdAt ?? this.createdAt,
);
}
@override
String toString() {
return 'FavoriteModel(favoriteId: $favoriteId, productId: $productId, userId: $userId)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is FavoriteModel && other.favoriteId == favoriteId;
}
@override
int get hashCode => favoriteId.hashCode;
// =========================================================================
// ENTITY CONVERSION
// =========================================================================
/// Convert FavoriteModel to Favorite entity
Favorite toEntity() {
return Favorite(
favoriteId: favoriteId,
productId: productId,
userId: userId,
createdAt: createdAt,
);
}
/// Create FavoriteModel from Favorite entity
factory FavoriteModel.fromEntity(Favorite favorite) {
return FavoriteModel(
favoriteId: favorite.favoriteId,
productId: favorite.productId,
userId: favorite.userId,
createdAt: favorite.createdAt,
);
}
}

View File

@@ -0,0 +1,162 @@
import 'package:worker/core/errors/exceptions.dart';
import 'package:worker/core/network/network_info.dart';
import 'package:worker/features/favorites/data/datasources/favorite_products_local_datasource.dart';
import 'package:worker/features/favorites/data/datasources/favorites_remote_datasource.dart';
import 'package:worker/features/favorites/domain/repositories/favorites_repository.dart';
import 'package:worker/features/products/domain/entities/product.dart';
/// Favorites Repository Implementation
///
/// Implements the FavoritesRepository interface with online-first approach:
/// 1. Always try API first when online
/// 2. Cache API responses locally
/// 3. Fall back to local cache on network errors
class FavoritesRepositoryImpl implements FavoritesRepository {
FavoritesRepositoryImpl({
required this.remoteDataSource,
required this.productsLocalDataSource,
required this.networkInfo,
});
final FavoritesRemoteDataSource remoteDataSource;
final FavoriteProductsLocalDataSource productsLocalDataSource;
final NetworkInfo networkInfo;
// =========================================================================
// GET FAVORITE PRODUCTS (Returns actual Product entities)
// =========================================================================
/// Get favorite products with full product data
///
/// Online-first: Fetches from API, caches locally
/// Offline: Returns cached products
@override
Future<List<Product>> getFavoriteProducts() async {
try {
// Online-first: Try to fetch from API
if (await networkInfo.isConnected) {
_debugPrint('Fetching favorite products from API');
try {
// Get products from wishlist API
final remoteProducts = await remoteDataSource.getFavorites();
// Cache products locally
await productsLocalDataSource.saveProducts(remoteProducts);
_debugPrint('Fetched ${remoteProducts.length} favorite products from API');
return remoteProducts.map((model) => model.toEntity()).toList();
} on ServerException catch (e) {
_debugPrint('API error, falling back to cache: $e');
return _getProductsFromCache();
} on NetworkException catch (e) {
_debugPrint('Network error, falling back to cache: $e');
return _getProductsFromCache();
}
} else {
// Offline: Use local cache
_debugPrint('Offline - using cached favorite products');
return _getProductsFromCache();
}
} catch (e) {
_debugPrint('Error getting favorite products: $e');
return _getProductsFromCache();
}
}
/// Get favorite products from local cache
Future<List<Product>> _getProductsFromCache() async {
try {
final cachedProducts = await productsLocalDataSource.getAllProducts();
_debugPrint('Loaded ${cachedProducts.length} favorite products from cache');
return cachedProducts.map((model) => model.toEntity()).toList();
} catch (e) {
_debugPrint('Error loading from cache: $e');
return [];
}
}
// =========================================================================
// ADD FAVORITE
// =========================================================================
@override
Future<void> addFavorite(String productId) async {
try {
// Online-first: Try to add via API
if (await networkInfo.isConnected) {
_debugPrint('Adding favorite via API: $productId');
try {
final success = await remoteDataSource.addToFavorites(productId);
if (success) {
_debugPrint('Added favorite successfully: $productId');
} else {
throw const ServerException('Failed to add to favorites');
}
} on ServerException catch (e) {
_debugPrint('API error adding favorite: $e');
rethrow;
} on NetworkException catch (e) {
_debugPrint('Network error adding favorite: $e');
rethrow;
}
} else {
// Offline: Queue for later sync
_debugPrint('Offline - cannot add favorite: $productId');
throw const NetworkException('No internet connection');
}
} catch (e) {
_debugPrint('Error adding favorite: $e');
rethrow;
}
}
// =========================================================================
// REMOVE FAVORITE
// =========================================================================
@override
Future<bool> removeFavorite(String productId) async {
try {
// Online-first: Try to remove via API
if (await networkInfo.isConnected) {
_debugPrint('Removing favorite via API: $productId');
try {
final success = await remoteDataSource.removeFromFavorites(productId);
if (success) {
_debugPrint('Removed favorite successfully: $productId');
}
return success;
} on ServerException catch (e) {
_debugPrint('API error removing favorite: $e');
return false;
} on NetworkException catch (e) {
_debugPrint('Network error removing favorite: $e');
return false;
}
} else {
// Offline: Cannot remove
_debugPrint('Offline - cannot remove favorite: $productId');
return false;
}
} catch (e) {
_debugPrint('Error removing favorite: $e');
return false;
}
}
}
// ============================================================================
// DEBUG UTILITIES
// ============================================================================
/// Debug print helper
void _debugPrint(String message) {
// ignore: avoid_print
print('[FavoritesRepository] $message');
}

View File

@@ -1,67 +0,0 @@
/// Domain Entity: Favorite
///
/// Pure business entity representing a user's favorite product.
/// This entity is framework-independent and contains only business logic.
library;
/// Favorite Entity
///
/// Represents a product that a user has marked as favorite.
/// Used across all layers but originates in the domain layer.
class Favorite {
/// Unique identifier for the favorite entry
final String favoriteId;
/// Reference to the product that was favorited
final String productId;
/// Reference to the user who favorited the product
final String userId;
/// Timestamp when the product was favorited
final DateTime createdAt;
const Favorite({
required this.favoriteId,
required this.productId,
required this.userId,
required this.createdAt,
});
/// Copy with method for creating modified copies
Favorite copyWith({
String? favoriteId,
String? productId,
String? userId,
DateTime? createdAt,
}) {
return Favorite(
favoriteId: favoriteId ?? this.favoriteId,
productId: productId ?? this.productId,
userId: userId ?? this.userId,
createdAt: createdAt ?? this.createdAt,
);
}
@override
String toString() {
return 'Favorite(favoriteId: $favoriteId, productId: $productId, '
'userId: $userId, createdAt: $createdAt)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Favorite &&
other.favoriteId == favoriteId &&
other.productId == productId &&
other.userId == userId &&
other.createdAt == createdAt;
}
@override
int get hashCode {
return Object.hash(favoriteId, productId, userId, createdAt);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:worker/features/products/domain/entities/product.dart';
/// Favorites Repository Interface
///
/// Defines the contract for favorites data operations.
/// Implementations should follow the online-first approach:
/// 1. Try to fetch/update data from API
/// 2. Update local cache with API response
/// 3. On network failure, fall back to local cache
abstract class FavoritesRepository {
/// Get favorite products with full product data
///
/// Online-first: Fetches from wishlist API, caches locally.
/// Falls back to local cache on network failure.
/// Returns `List<Product>` with complete product information.
Future<List<Product>> getFavoriteProducts();
/// Add a product to favorites
///
/// Online-first: Adds to API, then caches locally.
/// On network failure, queues for later sync.
Future<void> addFavorite(String productId);
/// Remove a product from favorites
///
/// Online-first: Removes from API, then updates local cache.
/// On network failure, queues for later sync.
Future<bool> removeFavorite(String productId);
}

View File

@@ -58,14 +58,15 @@ class FavoritesPage extends ConsumerWidget {
);
if (confirmed == true && context.mounted) {
// Clear all favorites
await ref.read(favoritesProvider.notifier).clearAll();
// TODO: Implement clear all functionality
// For now, we would need to remove each product individually
// or add a clearAll method to the repository
// Show snackbar
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Đã xóa tất cả yêu thích'),
content: Text('Chức năng này đang được phát triển'),
duration: Duration(seconds: 2),
),
);
@@ -79,6 +80,9 @@ class FavoritesPage extends ConsumerWidget {
final favoriteProductsAsync = ref.watch(favoriteProductsProvider);
final favoriteCount = ref.watch(favoriteCountProvider);
// Track if we've loaded data at least once to prevent empty state flash
final hasLoadedOnce = favoriteProductsAsync.hasValue || favoriteProductsAsync.hasError;
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
appBar: AppBar(
@@ -121,26 +125,148 @@ class FavoritesPage extends ConsumerWidget {
body: SafeArea(
child: favoriteProductsAsync.when(
data: (products) {
if (products.isEmpty) {
// IMPORTANT: Only show empty state after we've confirmed data loaded
// This prevents empty state flash during initial load
if (products.isEmpty && hasLoadedOnce) {
return const _EmptyState();
}
// If products is empty but we haven't loaded yet, show loading
if (products.isEmpty && !hasLoadedOnce) {
return const _LoadingState();
}
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(favoritesProvider);
ref.invalidate(favoriteProductsProvider);
// Use the new refresh method from AsyncNotifier
await ref.read(favoriteProductsProvider.notifier).refresh();
},
child: _FavoritesGrid(products: products),
);
},
loading: () => const _LoadingState(),
error: (error, stackTrace) => _ErrorState(
error: error,
onRetry: () {
ref.invalidate(favoritesProvider);
ref.invalidate(favoriteProductsProvider);
},
),
loading: () {
// IMPORTANT: Check for previous data first to prevent empty state flash
final previousValue = favoriteProductsAsync.hasValue
? favoriteProductsAsync.value
: null;
// If we have previous data, show it while loading new data
if (previousValue != null && previousValue.isNotEmpty) {
return Stack(
children: [
RefreshIndicator(
onRefresh: () async {
await ref.read(favoriteProductsProvider.notifier).refresh();
},
child: _FavoritesGrid(products: previousValue),
),
const Positioned(
top: 16,
left: 0,
right: 0,
child: Center(
child: Card(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 8),
Text('Đang tải...'),
],
),
),
),
),
),
],
);
}
// Check if we should show skeleton or empty state
// Use favoriteCount as a hint - if it's > 0, we likely have data coming
if (favoriteCount > 0) {
// Show skeleton loading for better UX
return const _LoadingState();
}
// No previous data and no favorites - show skeleton briefly
return const _LoadingState();
},
error: (error, stackTrace) {
// Check if we have previous data to show with error
final previousValue = favoriteProductsAsync.hasValue
? favoriteProductsAsync.value
: null;
if (previousValue != null && previousValue.isNotEmpty) {
// Show previous data with error message
return Stack(
children: [
RefreshIndicator(
onRefresh: () async {
await ref.read(favoriteProductsProvider.notifier).refresh();
},
child: _FavoritesGrid(products: previousValue),
),
Positioned(
top: 16,
left: 16,
right: 16,
child: Material(
color: AppColors.danger,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
const Icon(
Icons.error_outline,
color: AppColors.white,
size: 20,
),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Không thể tải dữ liệu mới',
style: TextStyle(color: AppColors.white),
),
),
TextButton(
onPressed: () async {
await ref.read(favoriteProductsProvider.notifier).refresh();
},
child: const Text(
'Thử lại',
style: TextStyle(
color: AppColors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
),
],
);
}
// No previous data, show full error state
return _ErrorState(
error: error,
onRetry: () async {
await ref.read(favoriteProductsProvider.notifier).refresh();
},
);
},
),
),
);
@@ -188,7 +314,7 @@ class _EmptyState extends StatelessWidget {
const SizedBox(height: AppSpacing.sm),
// Subtext
Text(
const 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),
textAlign: TextAlign.center,
@@ -269,9 +395,9 @@ class _ShimmerCard extends StatelessWidget {
// Image placeholder
Expanded(
child: Container(
decoration: BoxDecoration(
decoration: const BoxDecoration(
color: AppColors.grey100,
borderRadius: const BorderRadius.vertical(
borderRadius: BorderRadius.vertical(
top: Radius.circular(ProductCardSpecs.borderRadius),
),
),
@@ -347,11 +473,11 @@ class _ShimmerCard extends StatelessWidget {
///
/// Displayed when there's an error loading favorites.
class _ErrorState extends StatelessWidget {
final Object error;
final VoidCallback onRetry;
const _ErrorState({required this.error, required this.onRetry});
final Object error;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
return Center(
@@ -428,10 +554,10 @@ class _ErrorState extends StatelessWidget {
///
/// Displays favorite products in a grid layout.
class _FavoritesGrid extends StatelessWidget {
final List<Product> products;
const _FavoritesGrid({required this.products});
final List<Product> products;
@override
Widget build(BuildContext context) {
return GridView.builder(

View File

@@ -1,148 +1,141 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/features/favorites/data/datasources/favorites_local_datasource.dart';
import 'package:worker/features/favorites/data/models/favorite_model.dart';
import 'package:worker/core/network/dio_client.dart';
import 'package:worker/core/network/network_info.dart';
import 'package:worker/features/favorites/data/datasources/favorite_products_local_datasource.dart';
import 'package:worker/features/favorites/data/datasources/favorites_remote_datasource.dart';
import 'package:worker/features/favorites/data/repositories/favorites_repository_impl.dart';
import 'package:worker/features/favorites/domain/repositories/favorites_repository.dart';
import 'package:worker/features/products/domain/entities/product.dart';
import 'package:worker/features/products/domain/usecases/get_products.dart';
import 'package:worker/features/products/presentation/providers/products_provider.dart';
part 'favorites_provider.g.dart';
// ============================================================================
// DATASOURCE PROVIDER
// DATASOURCE PROVIDERS
// ============================================================================
/// Provides instance of FavoritesLocalDataSource
/// Provides instance of FavoritesRemoteDataSource
@riverpod
FavoritesLocalDataSource favoritesLocalDataSource(Ref ref) {
return FavoritesLocalDataSource();
Future<FavoritesRemoteDataSource> favoritesRemoteDataSource(Ref ref) async {
final dioClient = await ref.watch(dioClientProvider.future);
return FavoritesRemoteDataSource(dioClient.dio);
}
/// Provides instance of FavoriteProductsLocalDataSource
@riverpod
FavoriteProductsLocalDataSource favoriteProductsLocalDataSource(Ref ref) {
return FavoriteProductsLocalDataSource();
}
// ============================================================================
// CURRENT USER ID PROVIDER
// REPOSITORY PROVIDER
// ============================================================================
/// Provides the current logged-in user's ID
///
/// TODO: Replace with actual auth provider integration
/// For now, using hardcoded userId for development
/// Provides instance of FavoritesRepository with online-first approach
@riverpod
String currentUserId(Ref ref) {
// TODO: Integrate with actual auth provider when available
// Example: return ref.watch(authProvider).user?.id ?? 'user_001';
return 'user_001';
Future<FavoritesRepository> favoritesRepository(Ref ref) async {
final remoteDataSource = await ref.watch(favoritesRemoteDataSourceProvider.future);
final productsLocalDataSource = ref.watch(favoriteProductsLocalDataSourceProvider);
final networkInfo = ref.watch(networkInfoProvider);
return FavoritesRepositoryImpl(
remoteDataSource: remoteDataSource,
productsLocalDataSource: productsLocalDataSource,
networkInfo: networkInfo,
);
}
// ============================================================================
// MAIN FAVORITES PROVIDER
// MAIN FAVORITE PRODUCTS PROVIDER
// ============================================================================
/// Manages the favorites state for the current user
/// Manages favorite products with full Product data from wishlist API
///
/// Uses a Set<String> to store product IDs for efficient lookup.
/// Data is persisted to Hive for offline access.
@riverpod
class Favorites extends _$Favorites {
late FavoritesLocalDataSource _dataSource;
late String _userId;
/// This is the MAIN provider for the favorites feature.
/// Returns full Product objects with all data from the wishlist API.
///
/// Online-first: Fetches from API, caches locally
/// Offline: Returns cached products
///
/// Uses keepAlive to prevent unnecessary reloads.
/// Provides refresh() method for pull-to-refresh functionality.
///
/// AsyncNotifier pattern allows:
/// - Manual refresh capability
/// - Proper loading states during operations
/// - State updates after mutations
/// - Better error handling
@Riverpod(keepAlive: true)
class FavoriteProducts extends _$FavoriteProducts {
late FavoritesRepository _repository;
@override
Future<Set<String>> build() async {
_dataSource = ref.read(favoritesLocalDataSourceProvider);
_userId = ref.read(currentUserIdProvider);
// Load favorites from Hive
return await _loadFavorites();
Future<List<Product>> build() async {
_repository = await ref.read(favoritesRepositoryProvider.future);
return await _loadProducts();
}
// ==========================================================================
// PRIVATE METHODS
// ==========================================================================
/// Load favorites from Hive database
Future<Set<String>> _loadFavorites() async {
/// Load favorite products from repository
///
/// Online-first: Fetches from API, caches locally
/// Falls back to local cache on network failure
Future<List<Product>> _loadProducts() async {
try {
final favorites = await _dataSource.getAllFavorites(_userId);
final productIds = favorites.map((fav) => fav.productId).toSet();
debugPrint('Loaded ${productIds.length} favorites for user: $_userId');
return productIds;
final products = await _repository.getFavoriteProducts();
_debugPrint('Loaded ${products.length} favorite products');
return products;
} catch (e) {
debugPrint('Error loading favorites: $e');
return {};
_debugPrint('Error loading favorite products: $e');
rethrow;
}
}
/// Generate a unique favorite ID
String _generateFavoriteId(String productId) {
// Using format: userId_productId_timestamp
final timestamp = DateTime.now().millisecondsSinceEpoch;
return '${_userId}_${productId}_$timestamp';
}
// ==========================================================================
// PUBLIC METHODS
// ==========================================================================
/// Add a product to favorites
///
/// Creates a new favorite entry and persists it to Hive.
/// If the product is already favorited, this operation is a no-op.
/// Calls API to add to wishlist, then refreshes the products list.
/// No userId needed - the API uses the authenticated session.
Future<void> addFavorite(String productId) async {
try {
// Check if already favorited
final currentState = state.value ?? <String>{};
if (currentState.contains(productId)) {
debugPrint('Product $productId is already favorited');
return;
}
_debugPrint('Adding product to favorites: $productId');
// Create favorite model
final favorite = FavoriteModel(
favoriteId: _generateFavoriteId(productId),
productId: productId,
userId: _userId,
createdAt: DateTime.now(),
);
// Call repository to add to favorites (uses auth token from session)
await _repository.addFavorite(productId);
// Persist to Hive
await _dataSource.addFavorite(favorite);
// Refresh the products list after successful addition
await refresh();
// Update state
final newState = <String>{...currentState, productId};
state = AsyncValue.data(newState);
debugPrint('Added favorite: $productId');
} catch (e, stackTrace) {
debugPrint('Error adding favorite: $e');
state = AsyncValue.error(e, stackTrace);
_debugPrint('Successfully added favorite: $productId');
} catch (e) {
_debugPrint('Error adding favorite: $e');
rethrow;
}
}
/// Remove a product from favorites
///
/// Removes the favorite entry from Hive.
/// If the product is not favorited, this operation is a no-op.
/// Calls API to remove from wishlist, then refreshes the products list.
/// No userId needed - the API uses the authenticated session.
Future<void> removeFavorite(String productId) async {
try {
// Check if favorited
final currentState = state.value ?? <String>{};
if (!currentState.contains(productId)) {
debugPrint('Product $productId is not favorited');
return;
}
_debugPrint('Removing product from favorites: $productId');
// Remove from Hive
await _dataSource.removeFavorite(productId, _userId);
// Call repository to remove from favorites (uses auth token from session)
await _repository.removeFavorite(productId);
// Update state
final newState = <String>{...currentState};
newState.remove(productId);
state = AsyncValue.data(newState);
// Refresh the products list after successful removal
await refresh();
debugPrint('Removed favorite: $productId');
} catch (e, stackTrace) {
debugPrint('Error removing favorite: $e');
state = AsyncValue.error(e, stackTrace);
_debugPrint('Successfully removed favorite: $productId');
} catch (e) {
_debugPrint('Error removing favorite: $e');
rethrow;
}
}
@@ -151,38 +144,26 @@ class Favorites extends _$Favorites {
/// If the product is favorited, it will be removed.
/// If the product is not favorited, it will be added.
Future<void> toggleFavorite(String productId) async {
final currentState = state.value ?? <String>{};
final currentProducts = state.value ?? [];
final isFavorited = currentProducts.any((p) => p.productId == productId);
if (currentState.contains(productId)) {
if (isFavorited) {
await removeFavorite(productId);
} else {
await addFavorite(productId);
}
}
/// Refresh favorites from database
/// Refresh favorite products from API
///
/// Useful for syncing state after external changes or on app resume.
/// Used for pull-to-refresh functionality.
/// Fetches latest data from API and updates cache.
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
return await _loadFavorites();
return await _loadProducts();
});
}
/// Clear all favorites for the current user
///
/// Removes all favorite entries from Hive.
Future<void> clearAll() async {
try {
await _dataSource.clearFavorites(_userId);
state = const AsyncValue.data({});
debugPrint('Cleared all favorites for user: $_userId');
} catch (e, stackTrace) {
debugPrint('Error clearing favorites: $e');
state = AsyncValue.error(e, stackTrace);
}
}
}
// ============================================================================
@@ -191,14 +172,15 @@ class Favorites extends _$Favorites {
/// Check if a specific product is favorited
///
/// Derived from the favorite products list.
/// Returns true if the product is in the user's favorites, false otherwise.
/// Safe to use in build methods - will return false during loading/error states.
@riverpod
bool isFavorite(Ref ref, String productId) {
final favoritesAsync = ref.watch(favoritesProvider);
final favoriteProductsAsync = ref.watch(favoriteProductsProvider);
return favoritesAsync.when(
data: (favorites) => favorites.contains(productId),
return favoriteProductsAsync.when(
data: (products) => products.any((p) => p.productId == productId),
loading: () => false,
error: (_, __) => false,
);
@@ -206,14 +188,15 @@ bool isFavorite(Ref ref, String productId) {
/// Get the total count of favorites
///
/// Derived from the favorite products list.
/// Returns the number of products in the user's favorites.
/// Safe to use in build methods - will return 0 during loading/error states.
@riverpod
int favoriteCount(Ref ref) {
final favoritesAsync = ref.watch(favoritesProvider);
final favoriteProductsAsync = ref.watch(favoriteProductsProvider);
return favoritesAsync.when(
data: (favorites) => favorites.length,
return favoriteProductsAsync.when(
data: (products) => products.length,
loading: () => 0,
error: (_, __) => 0,
);
@@ -221,51 +204,26 @@ int favoriteCount(Ref ref) {
/// Get all favorite product IDs as a list
///
/// Derived from the favorite products list.
/// Useful for filtering product lists or bulk operations.
/// Returns an empty list during loading/error states.
@riverpod
List<String> favoriteProductIds(Ref ref) {
final favoritesAsync = ref.watch(favoritesProvider);
final favoriteProductsAsync = ref.watch(favoriteProductsProvider);
return favoritesAsync.when(
data: (favorites) => favorites.toList(),
return favoriteProductsAsync.when(
data: (products) => products.map((p) => p.productId).toList(),
loading: () => <String>[],
error: (_, __) => <String>[],
);
}
// ============================================================================
// FAVORITE PRODUCTS PROVIDER
// ============================================================================
/// Get actual Product entities for favorited product IDs
///
/// Combines favorites state with products data to return full Product objects.
/// This is useful for displaying favorite products with complete information.
@riverpod
Future<List<Product>> favoriteProducts(Ref ref) async {
final favoriteIds = ref.watch(favoriteProductIdsProvider);
if (favoriteIds.isEmpty) {
return [];
}
// Get products repository with injected dependencies
final productsRepository = await ref.watch(productsRepositoryProvider.future);
final getProductsUseCase = GetProducts(productsRepository);
final allProducts = await getProductsUseCase();
// Filter to only include favorited products
return allProducts
.where((product) => favoriteIds.contains(product.productId))
.toList();
}
// ============================================================================
// DEBUG UTILITIES
// ============================================================================
/// Debug print helper
void debugPrint(String message) {
void _debugPrint(String message) {
// ignore: avoid_print
print('[FavoritesProvider] $message');
}

View File

@@ -8,170 +8,260 @@ part of 'favorites_provider.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provides instance of FavoritesLocalDataSource
/// Provides instance of FavoritesRemoteDataSource
@ProviderFor(favoritesLocalDataSource)
const favoritesLocalDataSourceProvider = FavoritesLocalDataSourceProvider._();
@ProviderFor(favoritesRemoteDataSource)
const favoritesRemoteDataSourceProvider = FavoritesRemoteDataSourceProvider._();
/// Provides instance of FavoritesLocalDataSource
/// Provides instance of FavoritesRemoteDataSource
final class FavoritesLocalDataSourceProvider
final class FavoritesRemoteDataSourceProvider
extends
$FunctionalProvider<
FavoritesLocalDataSource,
FavoritesLocalDataSource,
FavoritesLocalDataSource
AsyncValue<FavoritesRemoteDataSource>,
FavoritesRemoteDataSource,
FutureOr<FavoritesRemoteDataSource>
>
with $Provider<FavoritesLocalDataSource> {
/// Provides instance of FavoritesLocalDataSource
const FavoritesLocalDataSourceProvider._()
with
$FutureModifier<FavoritesRemoteDataSource>,
$FutureProvider<FavoritesRemoteDataSource> {
/// Provides instance of FavoritesRemoteDataSource
const FavoritesRemoteDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'favoritesLocalDataSourceProvider',
name: r'favoritesRemoteDataSourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$favoritesLocalDataSourceHash();
String debugGetCreateSourceHash() => _$favoritesRemoteDataSourceHash();
@$internal
@override
$ProviderElement<FavoritesLocalDataSource> $createElement(
$FutureProviderElement<FavoritesRemoteDataSource> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<FavoritesRemoteDataSource> create(Ref ref) {
return favoritesRemoteDataSource(ref);
}
}
String _$favoritesRemoteDataSourceHash() =>
r'ec129162e49f37512950106516c0be6cbe1dfceb';
/// Provides instance of FavoriteProductsLocalDataSource
@ProviderFor(favoriteProductsLocalDataSource)
const favoriteProductsLocalDataSourceProvider =
FavoriteProductsLocalDataSourceProvider._();
/// Provides instance of FavoriteProductsLocalDataSource
final class FavoriteProductsLocalDataSourceProvider
extends
$FunctionalProvider<
FavoriteProductsLocalDataSource,
FavoriteProductsLocalDataSource,
FavoriteProductsLocalDataSource
>
with $Provider<FavoriteProductsLocalDataSource> {
/// Provides instance of FavoriteProductsLocalDataSource
const FavoriteProductsLocalDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'favoriteProductsLocalDataSourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$favoriteProductsLocalDataSourceHash();
@$internal
@override
$ProviderElement<FavoriteProductsLocalDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
FavoritesLocalDataSource create(Ref ref) {
return favoritesLocalDataSource(ref);
FavoriteProductsLocalDataSource create(Ref ref) {
return favoriteProductsLocalDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(FavoritesLocalDataSource value) {
Override overrideWithValue(FavoriteProductsLocalDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<FavoritesLocalDataSource>(value),
providerOverride: $SyncValueProvider<FavoriteProductsLocalDataSource>(
value,
),
);
}
}
String _$favoritesLocalDataSourceHash() =>
r'2f6ff99042b7cc1087d8cfdad517f448952c25be';
String _$favoriteProductsLocalDataSourceHash() =>
r'852ae8132f466b3fa6549c26880821ea31e00092';
/// Provides the current logged-in user's ID
///
/// TODO: Replace with actual auth provider integration
/// For now, using hardcoded userId for development
/// Provides instance of FavoritesRepository with online-first approach
@ProviderFor(currentUserId)
const currentUserIdProvider = CurrentUserIdProvider._();
@ProviderFor(favoritesRepository)
const favoritesRepositoryProvider = FavoritesRepositoryProvider._();
/// Provides the current logged-in user's ID
///
/// TODO: Replace with actual auth provider integration
/// For now, using hardcoded userId for development
/// Provides instance of FavoritesRepository with online-first approach
final class CurrentUserIdProvider
extends $FunctionalProvider<String, String, String>
with $Provider<String> {
/// Provides the current logged-in user's ID
///
/// TODO: Replace with actual auth provider integration
/// For now, using hardcoded userId for development
const CurrentUserIdProvider._()
final class FavoritesRepositoryProvider
extends
$FunctionalProvider<
AsyncValue<FavoritesRepository>,
FavoritesRepository,
FutureOr<FavoritesRepository>
>
with
$FutureModifier<FavoritesRepository>,
$FutureProvider<FavoritesRepository> {
/// Provides instance of FavoritesRepository with online-first approach
const FavoritesRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentUserIdProvider',
name: r'favoritesRepositoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentUserIdHash();
String debugGetCreateSourceHash() => _$favoritesRepositoryHash();
@$internal
@override
$ProviderElement<String> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
$FutureProviderElement<FavoritesRepository> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
String create(Ref ref) {
return currentUserId(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String>(value),
);
FutureOr<FavoritesRepository> create(Ref ref) {
return favoritesRepository(ref);
}
}
String _$currentUserIdHash() => r'7f968e463454a4ad87bce0442f62ecc24a6f756e';
String _$favoritesRepositoryHash() =>
r'1856b5972aaf9d243f8e5450973ea3ab4aead3f6';
/// Manages the favorites state for the current user
/// Manages favorite products with full Product data from wishlist API
///
/// Uses a Set<String> to store product IDs for efficient lookup.
/// Data is persisted to Hive for offline access.
@ProviderFor(Favorites)
const favoritesProvider = FavoritesProvider._();
/// Manages the favorites state for the current user
/// This is the MAIN provider for the favorites feature.
/// Returns full Product objects with all data from the wishlist API.
///
/// Uses a Set<String> to store product IDs for efficient lookup.
/// Data is persisted to Hive for offline access.
final class FavoritesProvider
extends $AsyncNotifierProvider<Favorites, Set<String>> {
/// Manages the favorites state for the current user
/// Online-first: Fetches from API, caches locally
/// Offline: Returns cached products
///
/// Uses keepAlive to prevent unnecessary reloads.
/// Provides refresh() method for pull-to-refresh functionality.
///
/// AsyncNotifier pattern allows:
/// - Manual refresh capability
/// - Proper loading states during operations
/// - State updates after mutations
/// - Better error handling
@ProviderFor(FavoriteProducts)
const favoriteProductsProvider = FavoriteProductsProvider._();
/// Manages favorite products with full Product data from wishlist API
///
/// This is the MAIN provider for the favorites feature.
/// Returns full Product objects with all data from the wishlist API.
///
/// Online-first: Fetches from API, caches locally
/// Offline: Returns cached products
///
/// Uses keepAlive to prevent unnecessary reloads.
/// Provides refresh() method for pull-to-refresh functionality.
///
/// AsyncNotifier pattern allows:
/// - Manual refresh capability
/// - Proper loading states during operations
/// - State updates after mutations
/// - Better error handling
final class FavoriteProductsProvider
extends $AsyncNotifierProvider<FavoriteProducts, List<Product>> {
/// Manages favorite products with full Product data from wishlist API
///
/// Uses a Set<String> to store product IDs for efficient lookup.
/// Data is persisted to Hive for offline access.
const FavoritesProvider._()
/// This is the MAIN provider for the favorites feature.
/// Returns full Product objects with all data from the wishlist API.
///
/// Online-first: Fetches from API, caches locally
/// Offline: Returns cached products
///
/// Uses keepAlive to prevent unnecessary reloads.
/// Provides refresh() method for pull-to-refresh functionality.
///
/// AsyncNotifier pattern allows:
/// - Manual refresh capability
/// - Proper loading states during operations
/// - State updates after mutations
/// - Better error handling
const FavoriteProductsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'favoritesProvider',
isAutoDispose: true,
name: r'favoriteProductsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$favoritesHash();
String debugGetCreateSourceHash() => _$favoriteProductsHash();
@$internal
@override
Favorites create() => Favorites();
FavoriteProducts create() => FavoriteProducts();
}
String _$favoritesHash() => r'fccd46f5cd1bbf2b58a13ea90c6d1644ece767b0';
String _$favoriteProductsHash() => r'd43c41db210259021df104f9fecdd00cf474d196';
/// Manages the favorites state for the current user
/// Manages favorite products with full Product data from wishlist API
///
/// Uses a Set<String> to store product IDs for efficient lookup.
/// Data is persisted to Hive for offline access.
/// This is the MAIN provider for the favorites feature.
/// Returns full Product objects with all data from the wishlist API.
///
/// Online-first: Fetches from API, caches locally
/// Offline: Returns cached products
///
/// Uses keepAlive to prevent unnecessary reloads.
/// Provides refresh() method for pull-to-refresh functionality.
///
/// AsyncNotifier pattern allows:
/// - Manual refresh capability
/// - Proper loading states during operations
/// - State updates after mutations
/// - Better error handling
abstract class _$Favorites extends $AsyncNotifier<Set<String>> {
FutureOr<Set<String>> build();
abstract class _$FavoriteProducts extends $AsyncNotifier<List<Product>> {
FutureOr<List<Product>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<Set<String>>, Set<String>>;
final ref = this.ref as $Ref<AsyncValue<List<Product>>, List<Product>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<Set<String>>, Set<String>>,
AsyncValue<Set<String>>,
AnyNotifier<AsyncValue<List<Product>>, List<Product>>,
AsyncValue<List<Product>>,
Object?,
Object?
>;
@@ -181,6 +271,7 @@ abstract class _$Favorites extends $AsyncNotifier<Set<String>> {
/// Check if a specific product is favorited
///
/// Derived from the favorite products list.
/// Returns true if the product is in the user's favorites, false otherwise.
/// Safe to use in build methods - will return false during loading/error states.
@@ -189,6 +280,7 @@ const isFavoriteProvider = IsFavoriteFamily._();
/// Check if a specific product is favorited
///
/// Derived from the favorite products list.
/// Returns true if the product is in the user's favorites, false otherwise.
/// Safe to use in build methods - will return false during loading/error states.
@@ -196,6 +288,7 @@ final class IsFavoriteProvider extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
/// Check if a specific product is favorited
///
/// Derived from the favorite products list.
/// Returns true if the product is in the user's favorites, false otherwise.
/// Safe to use in build methods - will return false during loading/error states.
const IsFavoriteProvider._({
@@ -249,10 +342,11 @@ final class IsFavoriteProvider extends $FunctionalProvider<bool, bool, bool>
}
}
String _$isFavoriteHash() => r'8d69e5efe981a3717eebdd7ee192fd75afe722d5';
String _$isFavoriteHash() => r'6e2f5a50d2350975e17d91f395595cd284b69c20';
/// Check if a specific product is favorited
///
/// Derived from the favorite products list.
/// Returns true if the product is in the user's favorites, false otherwise.
/// Safe to use in build methods - will return false during loading/error states.
@@ -269,6 +363,7 @@ final class IsFavoriteFamily extends $Family
/// Check if a specific product is favorited
///
/// Derived from the favorite products list.
/// Returns true if the product is in the user's favorites, false otherwise.
/// Safe to use in build methods - will return false during loading/error states.
@@ -281,6 +376,7 @@ final class IsFavoriteFamily extends $Family
/// Get the total count of favorites
///
/// Derived from the favorite products list.
/// Returns the number of products in the user's favorites.
/// Safe to use in build methods - will return 0 during loading/error states.
@@ -289,6 +385,7 @@ const favoriteCountProvider = FavoriteCountProvider._();
/// Get the total count of favorites
///
/// Derived from the favorite products list.
/// Returns the number of products in the user's favorites.
/// Safe to use in build methods - will return 0 during loading/error states.
@@ -296,6 +393,7 @@ final class FavoriteCountProvider extends $FunctionalProvider<int, int, int>
with $Provider<int> {
/// Get the total count of favorites
///
/// Derived from the favorite products list.
/// Returns the number of products in the user's favorites.
/// Safe to use in build methods - will return 0 during loading/error states.
const FavoriteCountProvider._()
@@ -331,10 +429,11 @@ final class FavoriteCountProvider extends $FunctionalProvider<int, int, int>
}
}
String _$favoriteCountHash() => r'1f147fe5ef28b1477034bd567cfc05ab3e8e90db';
String _$favoriteCountHash() => r'f6f9ab69653671dbc6085dc75b2cae35a47c31a5';
/// Get all favorite product IDs as a list
///
/// Derived from the favorite products list.
/// Useful for filtering product lists or bulk operations.
/// Returns an empty list during loading/error states.
@@ -343,6 +442,7 @@ const favoriteProductIdsProvider = FavoriteProductIdsProvider._();
/// Get all favorite product IDs as a list
///
/// Derived from the favorite products list.
/// Useful for filtering product lists or bulk operations.
/// Returns an empty list during loading/error states.
@@ -351,6 +451,7 @@ final class FavoriteProductIdsProvider
with $Provider<List<String>> {
/// Get all favorite product IDs as a list
///
/// Derived from the favorite products list.
/// Useful for filtering product lists or bulk operations.
/// Returns an empty list during loading/error states.
const FavoriteProductIdsProvider._()
@@ -387,57 +488,4 @@ final class FavoriteProductIdsProvider
}
String _$favoriteProductIdsHash() =>
r'a6814af9a1775b908b4101e64ce3056e1534b561';
/// Get actual Product entities for favorited product IDs
///
/// Combines favorites state with products data to return full Product objects.
/// This is useful for displaying favorite products with complete information.
@ProviderFor(favoriteProducts)
const favoriteProductsProvider = FavoriteProductsProvider._();
/// Get actual Product entities for favorited product IDs
///
/// Combines favorites state with products data to return full Product objects.
/// This is useful for displaying favorite products with complete information.
final class FavoriteProductsProvider
extends
$FunctionalProvider<
AsyncValue<List<Product>>,
List<Product>,
FutureOr<List<Product>>
>
with $FutureModifier<List<Product>>, $FutureProvider<List<Product>> {
/// Get actual Product entities for favorited product IDs
///
/// Combines favorites state with products data to return full Product objects.
/// This is useful for displaying favorite products with complete information.
const FavoriteProductsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'favoriteProductsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$favoriteProductsHash();
@$internal
@override
$FutureProviderElement<List<Product>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<Product>> create(Ref ref) {
return favoriteProducts(ref);
}
}
String _$favoriteProductsHash() => r'630acfbc403cc4deb486c7b0199f128252a8990b';
r'2e281e9a5dee122d326354afd515a68c7f0c4137';

View File

@@ -21,9 +21,9 @@ import 'package:worker/features/products/domain/entities/product.dart';
/// Displays product information in a card format with a favorite toggle button.
/// Used in the favorites grid view.
class FavoriteProductCard extends ConsumerWidget {
final Product product;
const FavoriteProductCard({super.key, required this.product});
final Product product;
String _formatPrice(double price) {
final formatter = NumberFormat('#,###', 'vi_VN');
@@ -59,7 +59,7 @@ class FavoriteProductCard extends ConsumerWidget {
if (confirmed == true && context.mounted) {
// Remove from favorites
await ref
.read(favoritesProvider.notifier)
.read(favoriteProductsProvider.notifier)
.removeFavorite(product.productId);
// Show snackbar
@@ -94,7 +94,7 @@ class FavoriteProductCard extends ConsumerWidget {
top: Radius.circular(ProductCardSpecs.borderRadius),
),
child: CachedNetworkImage(
imageUrl: product.imageUrl,
imageUrl: product.thumbnail,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,

View File

@@ -292,6 +292,59 @@ class ProductModel extends HiveObject {
);
}
/// Create ProductModel from Wishlist API JSON
///
/// The wishlist API returns a simplified product structure:
/// - name: Item code (e.g., "GIB20 G04")
/// - item_code: Item code (duplicate of name)
/// - item_name: Display name (e.g., "Gibellina GIB20 G04")
/// - item_group_name: Category (e.g., "OUTDOOR [20mm]")
/// - custom_link_360: 360 view link
/// - thumbnail: Thumbnail image URL
/// - price: Price (usually 0 from wishlist)
/// - currency: Currency code
/// - conversion_of_sm: Conversion factor
factory ProductModel.fromWishlistApi(Map<String, dynamic> json) {
final now = DateTime.now();
// Handle thumbnail URL
String thumbnailUrl = '';
if (json['thumbnail'] != null && (json['thumbnail'] as String).isNotEmpty) {
final thumbnailPath = json['thumbnail'] as String;
if (thumbnailPath.startsWith('http')) {
thumbnailUrl = thumbnailPath;
} else if (thumbnailPath.startsWith('/')) {
thumbnailUrl = '${ApiConstants.baseUrl}$thumbnailPath';
} else {
thumbnailUrl = '${ApiConstants.baseUrl}/$thumbnailPath';
}
}
return ProductModel(
productId: json['item_code'] as String? ?? json['name'] as String,
name: json['item_name'] as String? ?? json['name'] as String,
description: null, // Not provided by wishlist API
basePrice: (json['price'] as num?)?.toDouble() ?? 0.0,
images: null, // Not provided by wishlist API
thumbnail: thumbnailUrl,
imageCaptions: null,
customLink360: json['custom_link_360'] as String?,
specifications: null,
category: json['item_group_name'] as String?,
brand: null, // Not provided by wishlist API
unit: json['currency'] as String? ?? '',
conversionOfSm: json['conversion_of_sm'] != null
? (json['conversion_of_sm'] as num).toDouble()
: null,
introAttributes: null,
isActive: true, // Assume active if in wishlist
isFeatured: false,
erpnextItemCode: json['item_code'] as String? ?? json['name'] as String,
createdAt: now,
updatedAt: null,
);
}
/// Convert ProductModel to JSON
Map<String, dynamic> toJson() {
return {

View File

@@ -61,7 +61,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
void _toggleFavorite() async {
// Toggle favorite using favorites provider
await ref.read(favoritesProvider.notifier).toggleFavorite(widget.productId);
await ref.read(favoriteProductsProvider.notifier).toggleFavorite(widget.productId);
// Show feedback
final isFavorite = ref.read(isFavoriteProvider(widget.productId));

View File

@@ -5,8 +5,11 @@
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/address_model.dart';
import 'package:worker/features/account/data/models/audit_log_model.dart';
import 'package:worker/features/account/data/models/city_model.dart';
import 'package:worker/features/account/data/models/payment_reminder_model.dart';
import 'package:worker/features/account/data/models/ward_model.dart';
import 'package:worker/features/auth/data/models/business_unit_model.dart';
import 'package:worker/features/auth/data/models/user_model.dart';
import 'package:worker/features/auth/data/models/user_session_model.dart';
@@ -14,7 +17,6 @@ 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';
@@ -37,6 +39,7 @@ import 'package:worker/features/showrooms/data/models/showroom_product_model.dar
extension HiveRegistrar on HiveInterface {
void registerAdapters() {
registerAdapter(AddressModelAdapter());
registerAdapter(AuditLogModelAdapter());
registerAdapter(BusinessUnitModelAdapter());
registerAdapter(CachedDataAdapter());
@@ -44,13 +47,13 @@ extension HiveRegistrar on HiveInterface {
registerAdapter(CartModelAdapter());
registerAdapter(CategoryModelAdapter());
registerAdapter(ChatRoomModelAdapter());
registerAdapter(CityModelAdapter());
registerAdapter(ComplaintStatusAdapter());
registerAdapter(ContentTypeAdapter());
registerAdapter(DesignRequestModelAdapter());
registerAdapter(DesignStatusAdapter());
registerAdapter(EntrySourceAdapter());
registerAdapter(EntryTypeAdapter());
registerAdapter(FavoriteModelAdapter());
registerAdapter(GiftCatalogModelAdapter());
registerAdapter(GiftCategoryAdapter());
registerAdapter(GiftStatusAdapter());
@@ -88,11 +91,13 @@ extension HiveRegistrar on HiveInterface {
registerAdapter(UserRoleAdapter());
registerAdapter(UserSessionModelAdapter());
registerAdapter(UserStatusAdapter());
registerAdapter(WardModelAdapter());
}
}
extension IsolatedHiveRegistrar on IsolatedHiveInterface {
void registerAdapters() {
registerAdapter(AddressModelAdapter());
registerAdapter(AuditLogModelAdapter());
registerAdapter(BusinessUnitModelAdapter());
registerAdapter(CachedDataAdapter());
@@ -100,13 +105,13 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
registerAdapter(CartModelAdapter());
registerAdapter(CategoryModelAdapter());
registerAdapter(ChatRoomModelAdapter());
registerAdapter(CityModelAdapter());
registerAdapter(ComplaintStatusAdapter());
registerAdapter(ContentTypeAdapter());
registerAdapter(DesignRequestModelAdapter());
registerAdapter(DesignStatusAdapter());
registerAdapter(EntrySourceAdapter());
registerAdapter(EntryTypeAdapter());
registerAdapter(FavoriteModelAdapter());
registerAdapter(GiftCatalogModelAdapter());
registerAdapter(GiftCategoryAdapter());
registerAdapter(GiftStatusAdapter());
@@ -144,5 +149,6 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
registerAdapter(UserRoleAdapter());
registerAdapter(UserSessionModelAdapter());
registerAdapter(UserStatusAdapter());
registerAdapter(WardModelAdapter());
}
}

View File

@@ -369,6 +369,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
equatable:
dependency: "direct main"
description:
name: equatable
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
url: "https://pub.dev"
source: hosted
version: "2.0.7"
fake_async:
dependency: transitive
description:

View File

@@ -38,6 +38,7 @@ dependencies:
hooks_riverpod: ^3.0.0
flutter_hooks: ^0.21.3+1
riverpod_annotation: ^3.0.0
equatable: ^2.0.7
# Local Database
hive_ce: ^2.6.0