add favorite

This commit is contained in:
Phuoc Nguyen
2025-11-18 11:23:07 +07:00
parent 192c322816
commit a5eb95fa64
25 changed files with 2506 additions and 978 deletions

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> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="assets/css/style.css"> <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"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head> </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> <body>
<div class="page-wrapper"> <div class="page-wrapper">
<!-- Header --> <!-- Header -->
@@ -15,12 +73,38 @@
<a href="account.html" class="back-button"> <a href="account.html" class="back-button">
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
</a> </a>
<h1 class="header-title">Địa chỉ đã lưu</h1> <h1 class="header-title">Địa chỉ của bạn</h1>
<button class="back-button" onclick="addAddress()"> <button class="back-button" onclick="openInfoModal()">
<i class="fas fa-plus"></i> <i class="fas fa-info-circle"></i>
</button> </button>
</div> </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"> <div class="container">
<!-- Address List --> <!-- Address List -->
<div class="address-list"> <div class="address-list">
@@ -93,7 +177,7 @@
</div> </div>
<!-- Add New Address Button --> <!-- 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> <i class="fas fa-plus"></i>
Thêm địa chỉ mới Thêm địa chỉ mới
</button> </button>
@@ -133,6 +217,25 @@
alert('Đã đặt làm địa chỉ mặc định'); 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> </script>
</body> </body>
</html> </html>

View File

@@ -4,12 +4,13 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Đặt hàng - EuroTile Worker</title> <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="assets/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head> </head>
<body> <body class="bg-gray-50">
<div class="page-wrapper"> <div class="page-wrapper">
<!-- Header -->
<!-- Header --> <!-- Header -->
<div class="header"> <div class="header">
<a href="cart.html" class="back-button"> <a href="cart.html" class="back-button">
@@ -19,312 +20,383 @@
<div style="width: 32px;"></div> <div style="width: 32px;"></div>
</div> </div>
<div class="container"> <div class="container max-w-4xl mx-auto px-4 py-6" style="padding-bottom: 120px;">
<!-- Delivery Info -->
<div class="card"> <!-- Card 1: Thông tin giao hàng -->
<h3 class="card-title">Thông tin giao hàng</h3> <div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<div class="form-group"> <h3 class="text-base font-semibold text-gray-900 mb-4 flex items-center gap-2">
<label class="form-label">Họ và tên người nhận</label> <i class="fas fa-shipping-fast text-blue-600"></i>
<input type="text" class="form-input" value="La Nguyen Quynh"> Thông tin giao hàng
</div> </h3>
<div class="form-group">
<label class="form-label">Số điện thoại</label> <!-- Address Section -->
<input type="tel" class="form-input" value="0983441099"> <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>
<!--<div class="form-group"> <!-- Pickup Date -->
<label class="form-label">Địa chỉ giao hàng</label> <div class="mb-4">
<textarea class="form-input" rows="3">123 Nguyễn Trãi, Quận 1, TP.HCM</textarea> <label class="block text-sm font-medium text-gray-700 mb-2">
</div>--> Ngày lấy hàng
<div class="form-group"> </label>
<label class="form-label">Tỉnh/Thành phố</label> <div class="relative">
<select class="form-input" id="provinceSelect"> <i class="fas fa-calendar-alt absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<option value="">Chọn tỉnh/thành phố</option> <input type="date"
<option value="hcm" selected>TP. Hồ Chí Minh</option> id="pickupDate"
<option value="hanoi">Hà Nội</option> 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">
<option value="danang">Đà Nẵng</option> </div>
<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">
</div> </div>
<div class="form-group"> <!-- Note -->
<label class="form-label">Ghi chú</label> <div>
<input type="text" class="form-input" placeholder="Ví dụ: Thời gian yêu cầu giao hàng"> <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>
</div> </div>
<!-- Card 2: Phát hành hóa đơn -->
<!-- Invoice Information --> <div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<div class="card"> <div class="flex items-center justify-between mb-4">
<div class="form-group" style="height:24px;"> <h3 class="text-base font-semibold text-gray-900 flex items-center gap-2">
<label class="checkbox-label" style="font-size:16px;"> <i class="fas fa-file-invoice text-blue-600"></i>
<input type="checkbox" id="invoiceCheckbox" onchange="toggleInvoiceInfo()">
<span class="checkmark"></span>
Phát hành hóa đơn 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> </label>
</div> </div>
<div id="invoiceInfoCard" class="invoice-info-card" style="display: none;"> <!-- Invoice Information (Hidden by default) -->
<h4 class="invoice-title">Thông tin hóa đơn</h4> <div id="invoiceInfoCard" class="hidden">
<div class="form-group"> <div class="border-t border-gray-200 pt-4">
<label class="form-label">Tên người mua</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">
<input type="text" class="form-input" id="buyerName" placeholder="Tên công ty/cá nhân"> <div class="flex items-start justify-between">
</div> <div class="flex-1">
<div class="form-group"> <div class="font-semibold text-gray-900 mb-1">Công ty TNHH Xây dựng Minh Long</div>
<label class="form-label">Mã số thuế</label> <div class="text-sm text-gray-600 mb-0.5">Mã số thuế: 0134000687</div>
<input type="text" class="form-input" id="taxCode" placeholder="Mã số thuế"> <div class="text-sm text-gray-600 mb-0.5">Số điện thoại: 0339797979</div>
</div> <div class="text-sm text-gray-600 mb-0.5">Email: minhlong.org@gmail.com</div>
<!--<div class="form-group"> <div class="text-sm text-gray-600">
<label class="form-label">Tên công ty</label> Địa chỉ: 11 Đường Hoàng Hữu Nam, Phường Linh Chiểu,
<input type="text" class="form-input" id="companyName" placeholder="Tên công ty/tổ chức"> Thành phố Thủ Đức, TP.HCM
</div>--> </div>
<div class="form-group"> </div>
<label class="form-label">Địa chỉ</label> <i class="fas fa-chevron-right text-gray-400 group-hover:text-blue-600 mt-1"></i>
<input type="text" class="form-input" id="companyAddress" placeholder="Địa chỉ"> </div>
</div> </a>
<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ệ">
</div> </div>
</div> </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 --> <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">
<div class="card"> <input type="radio" name="payment" value="full" checked class="w-4 h-4 text-blue-600 focus:ring-blue-500">
<h3 class="card-title">Phương thức thanh toán</h3> <div class="ml-3 flex-1">
<label class="list-item" style="cursor: pointer;"> <div class="flex items-center gap-2">
<input type="radio" name="payment" checked style="margin-right: 12px;"> <i class="fas fa-money-check-alt text-gray-600"></i>
<div class="list-item-icon"> <div class="font-medium text-gray-900">Thanh toán hoàn toàn</div>
<i class="fas fa-money-check-alt"></i> </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 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>
</div> </div>
</label> </label>
<label class="list-item" style="cursor: pointer;">
<input type="radio" name="payment" style="margin-right: 12px;"> <label class="flex items-center p-3 border border-gray-200 rounded-lg cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition">
<div class="list-item-icon"> <input type="radio" name="payment" value="partial" class="w-4 h-4 text-blue-600 focus:ring-blue-500">
<i class="fas fa-hand-holding-usd"></i> <div class="ml-3 flex-1">
</div> <div class="flex items-center gap-2">
<div class="list-item-content"> <i class="fas fa-hand-holding-usd text-gray-600"></i>
<div class="list-item-title">Thanh toán một phần</div> <div class="font-medium text-gray-900">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> </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> </div>
</label> </label>
</div> </div>
<!-- Discount Code --> <!-- Card 4: Mã giảm giá -->
<div class="card"> <div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<div class="form-group" style="margin-bottom: 8px;"> <h3 class="text-base font-semibold text-gray-900 mb-3 flex items-center gap-2">
<label class="form-label">Mã giảm giá</label> <i class="fas fa-ticket-alt text-blue-600"></i>
<div style="display: flex; gap: 8px;"> Mã giảm giá
<input type="text" class="form-input" style="flex: 1;" placeholder="Nhập mã giảm giá"> </h3>
<button class="btn btn-primary">Áp dụng</button>
<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>
</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 --> <!-- Summary -->
<div class="card"> <div class="space-y-2 pt-3 border-t border-gray-200">
<h3 class="card-title">Tóm tắt đơn hàng</h3> <div class="flex justify-between text-sm">
<div class="d-flex justify-between mb-2"> <span class="text-gray-600">Tạm tính</span>
<div> <span class="text-gray-900">17.107.200đ</span>
<div>Gạch men cao cấp</div> </div>
<div class="text-small text-muted">10 m² (28 viên / 10.08 m²)</div> <div class="flex justify-between text-sm">
</div> <span class="text-gray-600">Giảm giá Diamond</span>
<span>4.536.000đ</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>
<div class="d-flex justify-between mb-2">
<div> <!-- Total -->
<div>Gạch granite nhập khẩu 1200x1200</div> <div class="flex justify-between items-center pt-4 mt-4 border-t-2 border-gray-300">
<div class="text-small text-muted">(11 viên / 15.84 m²)</div> <span class="text-lg font-semibold text-gray-900">Tổng thanh toán</span>
</div> <span class="text-2xl font-bold text-blue-600">14.541.120đ</span>
<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>
</div> </div>
</div> </div>
<!-- Card 6: Tùy chọn đàm phán giá -->
<!-- Price Negotiation --> <div class="bg-yellow-50 border-2 border-yellow-300 rounded-lg p-4 mb-4">
<div class="negotiation-checkbox"> <label class="flex items-start cursor-pointer">
<label class="checkbox-label"> <input type="checkbox"
<input type="checkbox" id="negotiationCheckbox" onchange="toggleNegotiation()"> id="negotiationCheckbox"
<span>Yêu cầu đàm phán giá</span> 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> </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> </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 --> <!-- Sticky Footer -->
<div style="margin-bottom: 24px;"> <div class="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-gray-200 shadow-lg z-50">
<a href="payment-qr.html" class="btn btn-primary btn-block btn-submit"> <div class="max-w-4xl mx-auto px-4 py-4">
<i class="fas fa-check-circle"></i> Hoàn tất đặt hàng <button id="submitBtn"
</a> onclick="handleSubmit()"
<p class="text-center text-small text-muted mt-2"> 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">
Bằng cách đặt hàng, bạn đồng ý với <i class="fas fa-check-circle text-xl"></i>
<a href="#" class="text-primary">Điều khoản & Điều kiện</a> <span id="submitBtnText">Hoàn tất đặt hàng</span>
</p> </button>
</div> </div>
</div> </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> <script>
// Set default pickup date to tomorrow // Toggle invoice info
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;
});
function toggleInvoiceInfo() { function toggleInvoiceInfo() {
const checkbox = document.getElementById('invoiceCheckbox'); const checkbox = document.getElementById('invoiceCheckbox');
const invoiceCard = document.getElementById('invoiceInfoCard'); const invoiceCard = document.getElementById('invoiceInfoCard');
if (checkbox.checked) { if (checkbox.checked) {
invoiceCard.style.display = 'block'; invoiceCard.classList.remove('hidden');
invoiceCard.classList.add('animate-slideDown');
} else { } else {
invoiceCard.style.display = 'none'; invoiceCard.classList.add('hidden');
invoiceCard.classList.remove('animate-slideDown');
} }
} }
// Toggle negotiation
function toggleNegotiation() { function toggleNegotiation() {
const checkbox = document.getElementById('negotiationCheckbox'); const checkbox = document.getElementById('negotiationCheckbox');
const paymentSection = document.querySelector('.card:has(.list-item)'); const paymentMethodCard = document.getElementById('paymentMethodCard');
// Payment method section const submitBtnText = document.getElementById('submitBtnText');
const submitBtn = document.querySelector('.btn-submit');
if (checkbox.checked) { if (checkbox.checked) {
paymentSection.classList.add('hidden'); paymentMethodCard.classList.add('opacity-50', 'pointer-events-none');
submitBtn.innerHTML = '<i class="fas fa-handshake"></i> Gửi Yêu cầu & Đàm phán'; submitBtnText.textContent = 'Gửi Yêu cầu & Đàm phán';
} else { } else {
paymentSection.classList.remove('hidden'); paymentMethodCard.classList.remove('opacity-50', 'pointer-events-none');
submitBtn.innerHTML = '<i class="fas fa-check-circle"></i> Hoàn tất đặt hàng'; submitBtnText.textContent = 'Hoàn tất đặt hàng';
} }
} }
function toggleNegotiation() { // Handle submit
const checkbox = document.getElementById('negotiationCheckbox'); function handleSubmit() {
const paymentMethods = document.querySelectorAll('.card')[2]; // Payment method section is 3rd card const negotiationCheckbox = document.getElementById('negotiationCheckbox');
const submitBtn = document.querySelector('.btn-submit');
if (checkbox.checked) { if (negotiationCheckbox.checked) {
paymentMethods.style.display = 'none'; // Navigate to negotiation page
submitBtn.innerHTML = '<i class="fas fa-handshake"></i> Gửi Yêu cầu & Đàm phán'; showToast('Đang gửi yêu cầu đàm phán...', 'info');
submitBtn.href = '#'; // Don't redirect to order success setTimeout(() => {
submitBtn.onclick = function(e) { window.location.href = 'order-success.html?type=negotiation';
e.preventDefault(); }, 1000);
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';
};
} else { } else {
paymentMethods.style.display = 'block'; // Navigate to payment page
submitBtn.innerHTML = '<i class="fas fa-check-circle"></i> Hoàn tất đặt hàng'; showToast('Đang xử lý đơn hàng...', 'info');
submitBtn.href = 'payment-qr.html'; setTimeout(() => {
submitBtn.onclick = null; 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> </script>
</body> </body>
</html> </html>

View File

@@ -145,6 +145,25 @@ class ApiConstants {
/// Body: { "method": "whatsapp|telegram|sms" } /// Body: { "method": "whatsapp|telegram|sms" }
static const String shareReferral = '/loyalty/referral/share'; 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 // Product Endpoints
// ============================================================================ // ============================================================================

View File

@@ -51,8 +51,8 @@ class HiveBoxNames {
/// Address book /// Address book
static const String addressBox = 'address_box'; static const String addressBox = 'address_box';
/// Favorite products /// Favorite products data (cached from wishlist API)
static const String favoriteBox = 'favorite_box'; static const String favoriteProductsBox = 'favorite_products_box';
/// Offline request queue for failed API calls /// Offline request queue for failed API calls
static const String offlineQueueBox = 'offline_queue_box'; static const String offlineQueueBox = 'offline_queue_box';
@@ -72,7 +72,7 @@ class HiveBoxNames {
syncStateBox, syncStateBox,
notificationBox, notificationBox,
addressBox, addressBox,
favoriteBox, favoriteProductsBox,
offlineQueueBox, offlineQueueBox,
]; ];
} }

View File

@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart'; 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/database_manager.dart';
import 'package:worker/core/database/hive_service.dart'; import 'package:worker/core/database/hive_service.dart';
@@ -53,6 +54,9 @@ class HiveInitializer {
final dbManager = DatabaseManager(); final dbManager = DatabaseManager();
// Migration: Delete old favoriteBox (deprecated, replaced with favoriteProductsBox)
await _deleteLegacyFavoriteBox(verbose);
// Clear expired cache on app start // Clear expired cache on app start
await dbManager.clearExpiredCache(); await dbManager.clearExpiredCache();
@@ -97,6 +101,33 @@ class HiveInitializer {
await hiveService.clearUserData(); 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 /// Get database statistics
/// ///
/// Returns statistics about all Hive boxes. /// Returns statistics about all Hive boxes.

View File

@@ -156,8 +156,8 @@ class HiveService {
// Notification box (non-sensitive) // Notification box (non-sensitive)
Hive.openBox<dynamic>(HiveBoxNames.notificationBox), Hive.openBox<dynamic>(HiveBoxNames.notificationBox),
// Favorites box (non-sensitive) // Favorite products box (non-sensitive) - caches Product entities from wishlist API
Hive.openBox<dynamic>(HiveBoxNames.favoriteBox), Hive.openBox<dynamic>(HiveBoxNames.favoriteProductsBox),
]); ]);
// Open potentially encrypted boxes (sensitive data) // Open potentially encrypted boxes (sensitive data)

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

@@ -1,50 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'favorite_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class FavoriteModelAdapter extends TypeAdapter<FavoriteModel> {
@override
final typeId = 28;
@override
FavoriteModel 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,
);
}
@override
void write(BinaryWriter writer, FavoriteModel obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.favoriteId)
..writeByte(1)
..write(obj.productId)
..writeByte(2)
..write(obj.userId)
..writeByte(3)
..write(obj.createdAt);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is FavoriteModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

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) { if (confirmed == true && context.mounted) {
// Clear all favorites // TODO: Implement clear all functionality
await ref.read(favoritesProvider.notifier).clearAll(); // For now, we would need to remove each product individually
// or add a clearAll method to the repository
// Show snackbar // Show snackbar
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( 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), duration: Duration(seconds: 2),
), ),
); );
@@ -79,6 +80,9 @@ class FavoritesPage extends ConsumerWidget {
final favoriteProductsAsync = ref.watch(favoriteProductsProvider); final favoriteProductsAsync = ref.watch(favoriteProductsProvider);
final favoriteCount = ref.watch(favoriteCountProvider); 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( return Scaffold(
backgroundColor: const Color(0xFFF4F6F8), backgroundColor: const Color(0xFFF4F6F8),
appBar: AppBar( appBar: AppBar(
@@ -121,26 +125,148 @@ class FavoritesPage extends ConsumerWidget {
body: SafeArea( body: SafeArea(
child: favoriteProductsAsync.when( child: favoriteProductsAsync.when(
data: (products) { 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(); return const _EmptyState();
} }
// If products is empty but we haven't loaded yet, show loading
if (products.isEmpty && !hasLoadedOnce) {
return const _LoadingState();
}
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
ref.invalidate(favoritesProvider); // Use the new refresh method from AsyncNotifier
ref.invalidate(favoriteProductsProvider); await ref.read(favoriteProductsProvider.notifier).refresh();
}, },
child: _FavoritesGrid(products: products), child: _FavoritesGrid(products: products),
); );
}, },
loading: () => const _LoadingState(), loading: () {
error: (error, stackTrace) => _ErrorState( // IMPORTANT: Check for previous data first to prevent empty state flash
error: error, final previousValue = favoriteProductsAsync.hasValue
onRetry: () { ? favoriteProductsAsync.value
ref.invalidate(favoritesProvider); : null;
ref.invalidate(favoriteProductsProvider);
}, // 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), const SizedBox(height: AppSpacing.sm),
// Subtext // Subtext
Text( const Text(
'Thêm sản phẩm vào danh sách yêu thích để xem lại sau', 'Thêm sản phẩm vào danh sách yêu thích để xem lại sau',
style: TextStyle(fontSize: 14.0, color: AppColors.grey500), style: TextStyle(fontSize: 14.0, color: AppColors.grey500),
textAlign: TextAlign.center, textAlign: TextAlign.center,
@@ -269,9 +395,9 @@ class _ShimmerCard extends StatelessWidget {
// Image placeholder // Image placeholder
Expanded( Expanded(
child: Container( child: Container(
decoration: BoxDecoration( decoration: const BoxDecoration(
color: AppColors.grey100, color: AppColors.grey100,
borderRadius: const BorderRadius.vertical( borderRadius: BorderRadius.vertical(
top: Radius.circular(ProductCardSpecs.borderRadius), top: Radius.circular(ProductCardSpecs.borderRadius),
), ),
), ),
@@ -347,11 +473,11 @@ class _ShimmerCard extends StatelessWidget {
/// ///
/// Displayed when there's an error loading favorites. /// Displayed when there's an error loading favorites.
class _ErrorState extends StatelessWidget { class _ErrorState extends StatelessWidget {
final Object error;
final VoidCallback onRetry;
const _ErrorState({required this.error, required this.onRetry}); const _ErrorState({required this.error, required this.onRetry});
final Object error;
final Future<void> Function() onRetry;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Center(
@@ -428,10 +554,10 @@ class _ErrorState extends StatelessWidget {
/// ///
/// Displays favorite products in a grid layout. /// Displays favorite products in a grid layout.
class _FavoritesGrid extends StatelessWidget { class _FavoritesGrid extends StatelessWidget {
final List<Product> products;
const _FavoritesGrid({required this.products}); const _FavoritesGrid({required this.products});
final List<Product> products;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GridView.builder( return GridView.builder(

View File

@@ -1,148 +1,141 @@
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/features/favorites/data/datasources/favorites_local_datasource.dart'; import 'package:worker/core/network/dio_client.dart';
import 'package:worker/features/favorites/data/models/favorite_model.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/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'; part 'favorites_provider.g.dart';
// ============================================================================ // ============================================================================
// DATASOURCE PROVIDER // DATASOURCE PROVIDERS
// ============================================================================ // ============================================================================
/// Provides instance of FavoritesLocalDataSource /// Provides instance of FavoritesRemoteDataSource
@riverpod @riverpod
FavoritesLocalDataSource favoritesLocalDataSource(Ref ref) { Future<FavoritesRemoteDataSource> favoritesRemoteDataSource(Ref ref) async {
return FavoritesLocalDataSource(); 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 /// Provides instance of FavoritesRepository with online-first approach
///
/// TODO: Replace with actual auth provider integration
/// For now, using hardcoded userId for development
@riverpod @riverpod
String currentUserId(Ref ref) { Future<FavoritesRepository> favoritesRepository(Ref ref) async {
// TODO: Integrate with actual auth provider when available final remoteDataSource = await ref.watch(favoritesRemoteDataSourceProvider.future);
// Example: return ref.watch(authProvider).user?.id ?? 'user_001'; final productsLocalDataSource = ref.watch(favoriteProductsLocalDataSourceProvider);
return 'user_001'; 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. /// This is the MAIN provider for the favorites feature.
/// Data is persisted to Hive for offline access. /// Returns full Product objects with all data from the wishlist API.
@riverpod ///
class Favorites extends _$Favorites { /// Online-first: Fetches from API, caches locally
late FavoritesLocalDataSource _dataSource; /// Offline: Returns cached products
late String _userId; ///
/// 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 @override
Future<Set<String>> build() async { Future<List<Product>> build() async {
_dataSource = ref.read(favoritesLocalDataSourceProvider); _repository = await ref.read(favoritesRepositoryProvider.future);
_userId = ref.read(currentUserIdProvider); return await _loadProducts();
// Load favorites from Hive
return await _loadFavorites();
} }
// ========================================================================== // ==========================================================================
// PRIVATE METHODS // PRIVATE METHODS
// ========================================================================== // ==========================================================================
/// Load favorites from Hive database /// Load favorite products from repository
Future<Set<String>> _loadFavorites() async { ///
/// Online-first: Fetches from API, caches locally
/// Falls back to local cache on network failure
Future<List<Product>> _loadProducts() async {
try { try {
final favorites = await _dataSource.getAllFavorites(_userId); final products = await _repository.getFavoriteProducts();
final productIds = favorites.map((fav) => fav.productId).toSet(); _debugPrint('Loaded ${products.length} favorite products');
return products;
debugPrint('Loaded ${productIds.length} favorites for user: $_userId');
return productIds;
} catch (e) { } catch (e) {
debugPrint('Error loading favorites: $e'); _debugPrint('Error loading favorite products: $e');
return {}; 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 // PUBLIC METHODS
// ========================================================================== // ==========================================================================
/// Add a product to favorites /// Add a product to favorites
/// ///
/// Creates a new favorite entry and persists it to Hive. /// Calls API to add to wishlist, then refreshes the products list.
/// If the product is already favorited, this operation is a no-op. /// No userId needed - the API uses the authenticated session.
Future<void> addFavorite(String productId) async { Future<void> addFavorite(String productId) async {
try { try {
// Check if already favorited _debugPrint('Adding product to favorites: $productId');
final currentState = state.value ?? <String>{};
if (currentState.contains(productId)) {
debugPrint('Product $productId is already favorited');
return;
}
// Create favorite model // Call repository to add to favorites (uses auth token from session)
final favorite = FavoriteModel( await _repository.addFavorite(productId);
favoriteId: _generateFavoriteId(productId),
productId: productId,
userId: _userId,
createdAt: DateTime.now(),
);
// Persist to Hive // Refresh the products list after successful addition
await _dataSource.addFavorite(favorite); await refresh();
// Update state _debugPrint('Successfully added favorite: $productId');
final newState = <String>{...currentState, productId}; } catch (e) {
state = AsyncValue.data(newState); _debugPrint('Error adding favorite: $e');
rethrow;
debugPrint('Added favorite: $productId');
} catch (e, stackTrace) {
debugPrint('Error adding favorite: $e');
state = AsyncValue.error(e, stackTrace);
} }
} }
/// Remove a product from favorites /// Remove a product from favorites
/// ///
/// Removes the favorite entry from Hive. /// Calls API to remove from wishlist, then refreshes the products list.
/// If the product is not favorited, this operation is a no-op. /// No userId needed - the API uses the authenticated session.
Future<void> removeFavorite(String productId) async { Future<void> removeFavorite(String productId) async {
try { try {
// Check if favorited _debugPrint('Removing product from favorites: $productId');
final currentState = state.value ?? <String>{};
if (!currentState.contains(productId)) {
debugPrint('Product $productId is not favorited');
return;
}
// Remove from Hive // Call repository to remove from favorites (uses auth token from session)
await _dataSource.removeFavorite(productId, _userId); await _repository.removeFavorite(productId);
// Update state // Refresh the products list after successful removal
final newState = <String>{...currentState}; await refresh();
newState.remove(productId);
state = AsyncValue.data(newState);
debugPrint('Removed favorite: $productId'); _debugPrint('Successfully removed favorite: $productId');
} catch (e, stackTrace) { } catch (e) {
debugPrint('Error removing favorite: $e'); _debugPrint('Error removing favorite: $e');
state = AsyncValue.error(e, stackTrace); rethrow;
} }
} }
@@ -151,38 +144,26 @@ class Favorites extends _$Favorites {
/// If the product is favorited, it will be removed. /// If the product is favorited, it will be removed.
/// If the product is not favorited, it will be added. /// If the product is not favorited, it will be added.
Future<void> toggleFavorite(String productId) async { 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); await removeFavorite(productId);
} else { } else {
await addFavorite(productId); 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 { Future<void> refresh() async {
state = const AsyncValue.loading(); state = const AsyncValue.loading();
state = await AsyncValue.guard(() async { 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 /// 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. /// 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. /// Safe to use in build methods - will return false during loading/error states.
@riverpod @riverpod
bool isFavorite(Ref ref, String productId) { bool isFavorite(Ref ref, String productId) {
final favoritesAsync = ref.watch(favoritesProvider); final favoriteProductsAsync = ref.watch(favoriteProductsProvider);
return favoritesAsync.when( return favoriteProductsAsync.when(
data: (favorites) => favorites.contains(productId), data: (products) => products.any((p) => p.productId == productId),
loading: () => false, loading: () => false,
error: (_, __) => false, error: (_, __) => false,
); );
@@ -206,14 +188,15 @@ bool isFavorite(Ref ref, String productId) {
/// Get the total count of favorites /// Get the total count of favorites
/// ///
/// Derived from the favorite products list.
/// Returns the number of products in the user's favorites. /// Returns the number of products in the user's favorites.
/// Safe to use in build methods - will return 0 during loading/error states. /// Safe to use in build methods - will return 0 during loading/error states.
@riverpod @riverpod
int favoriteCount(Ref ref) { int favoriteCount(Ref ref) {
final favoritesAsync = ref.watch(favoritesProvider); final favoriteProductsAsync = ref.watch(favoriteProductsProvider);
return favoritesAsync.when( return favoriteProductsAsync.when(
data: (favorites) => favorites.length, data: (products) => products.length,
loading: () => 0, loading: () => 0,
error: (_, __) => 0, error: (_, __) => 0,
); );
@@ -221,51 +204,26 @@ int favoriteCount(Ref ref) {
/// Get all favorite product IDs as a list /// Get all favorite product IDs as a list
/// ///
/// Derived from the favorite products list.
/// Useful for filtering product lists or bulk operations. /// Useful for filtering product lists or bulk operations.
/// Returns an empty list during loading/error states. /// Returns an empty list during loading/error states.
@riverpod @riverpod
List<String> favoriteProductIds(Ref ref) { List<String> favoriteProductIds(Ref ref) {
final favoritesAsync = ref.watch(favoritesProvider); final favoriteProductsAsync = ref.watch(favoriteProductsProvider);
return favoritesAsync.when( return favoriteProductsAsync.when(
data: (favorites) => favorites.toList(), data: (products) => products.map((p) => p.productId).toList(),
loading: () => <String>[], loading: () => <String>[],
error: (_, __) => <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 UTILITIES
// ============================================================================ // ============================================================================
/// Debug print helper /// Debug print helper
void debugPrint(String message) { void _debugPrint(String message) {
// ignore: avoid_print
print('[FavoritesProvider] $message'); print('[FavoritesProvider] $message');
} }

View File

@@ -8,170 +8,260 @@ part of 'favorites_provider.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning // ignore_for_file: type=lint, type=warning
/// Provides instance of FavoritesLocalDataSource /// Provides instance of FavoritesRemoteDataSource
@ProviderFor(favoritesLocalDataSource) @ProviderFor(favoritesRemoteDataSource)
const favoritesLocalDataSourceProvider = FavoritesLocalDataSourceProvider._(); const favoritesRemoteDataSourceProvider = FavoritesRemoteDataSourceProvider._();
/// Provides instance of FavoritesLocalDataSource /// Provides instance of FavoritesRemoteDataSource
final class FavoritesLocalDataSourceProvider final class FavoritesRemoteDataSourceProvider
extends extends
$FunctionalProvider< $FunctionalProvider<
FavoritesLocalDataSource, AsyncValue<FavoritesRemoteDataSource>,
FavoritesLocalDataSource, FavoritesRemoteDataSource,
FavoritesLocalDataSource FutureOr<FavoritesRemoteDataSource>
> >
with $Provider<FavoritesLocalDataSource> { with
/// Provides instance of FavoritesLocalDataSource $FutureModifier<FavoritesRemoteDataSource>,
const FavoritesLocalDataSourceProvider._() $FutureProvider<FavoritesRemoteDataSource> {
/// Provides instance of FavoritesRemoteDataSource
const FavoritesRemoteDataSourceProvider._()
: super( : super(
from: null, from: null,
argument: null, argument: null,
retry: null, retry: null,
name: r'favoritesLocalDataSourceProvider', name: r'favoritesRemoteDataSourceProvider',
isAutoDispose: true, isAutoDispose: true,
dependencies: null, dependencies: null,
$allTransitiveDependencies: null, $allTransitiveDependencies: null,
); );
@override @override
String debugGetCreateSourceHash() => _$favoritesLocalDataSourceHash(); String debugGetCreateSourceHash() => _$favoritesRemoteDataSourceHash();
@$internal @$internal
@override @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, $ProviderPointer pointer,
) => $ProviderElement(pointer); ) => $ProviderElement(pointer);
@override @override
FavoritesLocalDataSource create(Ref ref) { FavoriteProductsLocalDataSource create(Ref ref) {
return favoritesLocalDataSource(ref); return favoriteProductsLocalDataSource(ref);
} }
/// {@macro riverpod.override_with_value} /// {@macro riverpod.override_with_value}
Override overrideWithValue(FavoritesLocalDataSource value) { Override overrideWithValue(FavoriteProductsLocalDataSource value) {
return $ProviderOverride( return $ProviderOverride(
origin: this, origin: this,
providerOverride: $SyncValueProvider<FavoritesLocalDataSource>(value), providerOverride: $SyncValueProvider<FavoriteProductsLocalDataSource>(
value,
),
); );
} }
} }
String _$favoritesLocalDataSourceHash() => String _$favoriteProductsLocalDataSourceHash() =>
r'2f6ff99042b7cc1087d8cfdad517f448952c25be'; r'852ae8132f466b3fa6549c26880821ea31e00092';
/// Provides the current logged-in user's ID /// Provides instance of FavoritesRepository with online-first approach
///
/// TODO: Replace with actual auth provider integration
/// For now, using hardcoded userId for development
@ProviderFor(currentUserId) @ProviderFor(favoritesRepository)
const currentUserIdProvider = CurrentUserIdProvider._(); const favoritesRepositoryProvider = FavoritesRepositoryProvider._();
/// Provides the current logged-in user's ID /// Provides instance of FavoritesRepository with online-first approach
///
/// TODO: Replace with actual auth provider integration
/// For now, using hardcoded userId for development
final class CurrentUserIdProvider final class FavoritesRepositoryProvider
extends $FunctionalProvider<String, String, String> extends
with $Provider<String> { $FunctionalProvider<
/// Provides the current logged-in user's ID AsyncValue<FavoritesRepository>,
/// FavoritesRepository,
/// TODO: Replace with actual auth provider integration FutureOr<FavoritesRepository>
/// For now, using hardcoded userId for development >
const CurrentUserIdProvider._() with
$FutureModifier<FavoritesRepository>,
$FutureProvider<FavoritesRepository> {
/// Provides instance of FavoritesRepository with online-first approach
const FavoritesRepositoryProvider._()
: super( : super(
from: null, from: null,
argument: null, argument: null,
retry: null, retry: null,
name: r'currentUserIdProvider', name: r'favoritesRepositoryProvider',
isAutoDispose: true, isAutoDispose: true,
dependencies: null, dependencies: null,
$allTransitiveDependencies: null, $allTransitiveDependencies: null,
); );
@override @override
String debugGetCreateSourceHash() => _$currentUserIdHash(); String debugGetCreateSourceHash() => _$favoritesRepositoryHash();
@$internal @$internal
@override @override
$ProviderElement<String> $createElement($ProviderPointer pointer) => $FutureProviderElement<FavoritesRepository> $createElement(
$ProviderElement(pointer); $ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override @override
String create(Ref ref) { FutureOr<FavoritesRepository> create(Ref ref) {
return currentUserId(ref); return favoritesRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String>(value),
);
} }
} }
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. /// This is the MAIN provider for the favorites feature.
/// Data is persisted to Hive for offline access. /// Returns full Product objects with all data from the wishlist API.
@ProviderFor(Favorites)
const favoritesProvider = FavoritesProvider._();
/// Manages the favorites state for the current user
/// ///
/// Uses a Set<String> to store product IDs for efficient lookup. /// Online-first: Fetches from API, caches locally
/// Data is persisted to Hive for offline access. /// Offline: Returns cached products
final class FavoritesProvider ///
extends $AsyncNotifierProvider<Favorites, Set<String>> { /// Uses keepAlive to prevent unnecessary reloads.
/// Manages the favorites state for the current user /// 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. /// This is the MAIN provider for the favorites feature.
/// Data is persisted to Hive for offline access. /// Returns full Product objects with all data from the wishlist API.
const FavoritesProvider._() ///
/// 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( : super(
from: null, from: null,
argument: null, argument: null,
retry: null, retry: null,
name: r'favoritesProvider', name: r'favoriteProductsProvider',
isAutoDispose: true, isAutoDispose: false,
dependencies: null, dependencies: null,
$allTransitiveDependencies: null, $allTransitiveDependencies: null,
); );
@override @override
String debugGetCreateSourceHash() => _$favoritesHash(); String debugGetCreateSourceHash() => _$favoriteProductsHash();
@$internal @$internal
@override @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. /// This is the MAIN provider for the favorites feature.
/// Data is persisted to Hive for offline access. /// 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>> { abstract class _$FavoriteProducts extends $AsyncNotifier<List<Product>> {
FutureOr<Set<String>> build(); FutureOr<List<Product>> build();
@$mustCallSuper @$mustCallSuper
@override @override
void runBuild() { void runBuild() {
final created = build(); 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 = final element =
ref.element ref.element
as $ClassProviderElement< as $ClassProviderElement<
AnyNotifier<AsyncValue<Set<String>>, Set<String>>, AnyNotifier<AsyncValue<List<Product>>, List<Product>>,
AsyncValue<Set<String>>, AsyncValue<List<Product>>,
Object?, Object?,
Object? Object?
>; >;
@@ -181,6 +271,7 @@ abstract class _$Favorites extends $AsyncNotifier<Set<String>> {
/// Check if a specific product is favorited /// 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. /// 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. /// 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 /// 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. /// 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. /// 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> { with $Provider<bool> {
/// Check if a specific product is favorited /// 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. /// 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. /// Safe to use in build methods - will return false during loading/error states.
const IsFavoriteProvider._({ 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 /// 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. /// 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. /// 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 /// 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. /// 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. /// 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 /// Get the total count of favorites
/// ///
/// Derived from the favorite products list.
/// Returns the number of products in the user's favorites. /// Returns the number of products in the user's favorites.
/// Safe to use in build methods - will return 0 during loading/error states. /// 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 /// Get the total count of favorites
/// ///
/// Derived from the favorite products list.
/// Returns the number of products in the user's favorites. /// Returns the number of products in the user's favorites.
/// Safe to use in build methods - will return 0 during loading/error states. /// 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> { with $Provider<int> {
/// Get the total count of favorites /// Get the total count of favorites
/// ///
/// Derived from the favorite products list.
/// Returns the number of products in the user's favorites. /// Returns the number of products in the user's favorites.
/// Safe to use in build methods - will return 0 during loading/error states. /// Safe to use in build methods - will return 0 during loading/error states.
const FavoriteCountProvider._() 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 /// Get all favorite product IDs as a list
/// ///
/// Derived from the favorite products list.
/// Useful for filtering product lists or bulk operations. /// Useful for filtering product lists or bulk operations.
/// Returns an empty list during loading/error states. /// Returns an empty list during loading/error states.
@@ -343,6 +442,7 @@ const favoriteProductIdsProvider = FavoriteProductIdsProvider._();
/// Get all favorite product IDs as a list /// Get all favorite product IDs as a list
/// ///
/// Derived from the favorite products list.
/// Useful for filtering product lists or bulk operations. /// Useful for filtering product lists or bulk operations.
/// Returns an empty list during loading/error states. /// Returns an empty list during loading/error states.
@@ -351,6 +451,7 @@ final class FavoriteProductIdsProvider
with $Provider<List<String>> { with $Provider<List<String>> {
/// Get all favorite product IDs as a list /// Get all favorite product IDs as a list
/// ///
/// Derived from the favorite products list.
/// Useful for filtering product lists or bulk operations. /// Useful for filtering product lists or bulk operations.
/// Returns an empty list during loading/error states. /// Returns an empty list during loading/error states.
const FavoriteProductIdsProvider._() const FavoriteProductIdsProvider._()
@@ -387,57 +488,4 @@ final class FavoriteProductIdsProvider
} }
String _$favoriteProductIdsHash() => String _$favoriteProductIdsHash() =>
r'a6814af9a1775b908b4101e64ce3056e1534b561'; r'2e281e9a5dee122d326354afd515a68c7f0c4137';
/// 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';

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. /// Displays product information in a card format with a favorite toggle button.
/// Used in the favorites grid view. /// Used in the favorites grid view.
class FavoriteProductCard extends ConsumerWidget { class FavoriteProductCard extends ConsumerWidget {
final Product product;
const FavoriteProductCard({super.key, required this.product}); const FavoriteProductCard({super.key, required this.product});
final Product product;
String _formatPrice(double price) { String _formatPrice(double price) {
final formatter = NumberFormat('#,###', 'vi_VN'); final formatter = NumberFormat('#,###', 'vi_VN');
@@ -59,7 +59,7 @@ class FavoriteProductCard extends ConsumerWidget {
if (confirmed == true && context.mounted) { if (confirmed == true && context.mounted) {
// Remove from favorites // Remove from favorites
await ref await ref
.read(favoritesProvider.notifier) .read(favoriteProductsProvider.notifier)
.removeFavorite(product.productId); .removeFavorite(product.productId);
// Show snackbar // Show snackbar
@@ -94,7 +94,7 @@ class FavoriteProductCard extends ConsumerWidget {
top: Radius.circular(ProductCardSpecs.borderRadius), top: Radius.circular(ProductCardSpecs.borderRadius),
), ),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: product.imageUrl, imageUrl: product.thumbnail,
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
fit: BoxFit.cover, 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 /// Convert ProductModel to JSON
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {

View File

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

View File

@@ -14,7 +14,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/cart/data/models/cart_model.dart';
import 'package:worker/features/chat/data/models/chat_room_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/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/member_card_model.dart';
import 'package:worker/features/home/data/models/promotion_model.dart'; import 'package:worker/features/home/data/models/promotion_model.dart';
import 'package:worker/features/loyalty/data/models/gift_catalog_model.dart'; import 'package:worker/features/loyalty/data/models/gift_catalog_model.dart';
@@ -50,7 +49,6 @@ extension HiveRegistrar on HiveInterface {
registerAdapter(DesignStatusAdapter()); registerAdapter(DesignStatusAdapter());
registerAdapter(EntrySourceAdapter()); registerAdapter(EntrySourceAdapter());
registerAdapter(EntryTypeAdapter()); registerAdapter(EntryTypeAdapter());
registerAdapter(FavoriteModelAdapter());
registerAdapter(GiftCatalogModelAdapter()); registerAdapter(GiftCatalogModelAdapter());
registerAdapter(GiftCategoryAdapter()); registerAdapter(GiftCategoryAdapter());
registerAdapter(GiftStatusAdapter()); registerAdapter(GiftStatusAdapter());
@@ -106,7 +104,6 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
registerAdapter(DesignStatusAdapter()); registerAdapter(DesignStatusAdapter());
registerAdapter(EntrySourceAdapter()); registerAdapter(EntrySourceAdapter());
registerAdapter(EntryTypeAdapter()); registerAdapter(EntryTypeAdapter());
registerAdapter(FavoriteModelAdapter());
registerAdapter(GiftCatalogModelAdapter()); registerAdapter(GiftCatalogModelAdapter());
registerAdapter(GiftCategoryAdapter()); registerAdapter(GiftCategoryAdapter());
registerAdapter(GiftStatusAdapter()); registerAdapter(GiftStatusAdapter());