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

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

View File

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

View File

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

View File

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

View File

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

View File

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