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');
}