favorite
This commit is contained in:
464
lib/features/favorites/presentation/pages/favorites_page.dart
Normal file
464
lib/features/favorites/presentation/pages/favorites_page.dart
Normal file
@@ -0,0 +1,464 @@
|
||||
/// Page: Favorites Page
|
||||
///
|
||||
/// Displays all favorited products in a grid layout.
|
||||
/// Allows users to view and manage their favorite products.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/favorites/presentation/providers/favorites_provider.dart';
|
||||
import 'package:worker/features/favorites/presentation/widgets/favorite_product_card.dart';
|
||||
import 'package:worker/features/products/domain/entities/product.dart';
|
||||
|
||||
/// Favorites Page
|
||||
///
|
||||
/// Shows all products that the user has marked as favorites.
|
||||
/// Features:
|
||||
/// - Grid layout of favorite products
|
||||
/// - Pull-to-refresh
|
||||
/// - Empty state when no favorites
|
||||
/// - Error state with retry
|
||||
/// - Clear all functionality
|
||||
class FavoritesPage extends ConsumerWidget {
|
||||
const FavoritesPage({super.key});
|
||||
|
||||
/// Show confirmation dialog before clearing all favorites
|
||||
Future<void> _showClearAllDialog(BuildContext context, WidgetRef ref, int count) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Xóa tất cả yêu thích?'),
|
||||
content: Text(
|
||||
'Bạn có chắc muốn xóa toàn bộ $count sản phẩm khỏi danh sách yêu thích?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Hủy'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.danger,
|
||||
foregroundColor: AppColors.white,
|
||||
),
|
||||
child: const Text('Xóa tất cả'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
// Clear all favorites
|
||||
await ref.read(favoritesProvider.notifier).clearAll();
|
||||
|
||||
// Show snackbar
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Đã xóa tất cả yêu thích'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch favorites and products
|
||||
final favoriteProductsAsync = ref.watch(favoriteProductsProvider);
|
||||
final favoriteCount = ref.watch(favoriteCountProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
appBar: AppBar(
|
||||
// backgroundColor: AppColors.white,
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 1,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: const Text('Yêu thích'),
|
||||
actions: [
|
||||
// Count badge
|
||||
if (favoriteCount > 0)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
'($favoriteCount)',
|
||||
style: const TextStyle(
|
||||
fontSize: 16.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Clear all button
|
||||
if (favoriteCount > 0)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Xóa tất cả',
|
||||
onPressed: () => _showClearAllDialog(context, ref, favoriteCount),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: favoriteProductsAsync.when(
|
||||
data: (products) {
|
||||
if (products.isEmpty) {
|
||||
return const _EmptyState();
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(favoritesProvider);
|
||||
ref.invalidate(favoriteProductsProvider);
|
||||
},
|
||||
child: _FavoritesGrid(products: products),
|
||||
);
|
||||
},
|
||||
loading: () => const _LoadingState(),
|
||||
error: (error, stackTrace) => _ErrorState(
|
||||
error: error,
|
||||
onRetry: () {
|
||||
ref.invalidate(favoritesProvider);
|
||||
ref.invalidate(favoriteProductsProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EMPTY STATE
|
||||
// ============================================================================
|
||||
|
||||
/// Empty State Widget
|
||||
///
|
||||
/// Displayed when there are no favorite products.
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Large icon
|
||||
Icon(
|
||||
Icons.favorite_border,
|
||||
size: 80.0,
|
||||
color: AppColors.grey500.withValues(alpha: 0.5),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Heading
|
||||
const Text(
|
||||
'Chưa có sản phẩm yêu thích',
|
||||
style: TextStyle(
|
||||
fontSize: 18.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
// Subtext
|
||||
Text(
|
||||
'Thêm sản phẩm vào danh sách yêu thích để xem lại sau',
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
|
||||
// Explore Products Button
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Navigate to products page
|
||||
context.pushReplacement('/products');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
foregroundColor: AppColors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xl,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Khám phá sản phẩm',
|
||||
style: TextStyle(
|
||||
fontSize: 16.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LOADING STATE
|
||||
// ============================================================================
|
||||
|
||||
/// Loading State Widget
|
||||
///
|
||||
/// Displayed while favorites are being loaded.
|
||||
class _LoadingState extends StatelessWidget {
|
||||
const _LoadingState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12.0,
|
||||
mainAxisSpacing: 12.0,
|
||||
childAspectRatio: 0.62,
|
||||
),
|
||||
itemCount: 6, // Show 6 shimmer cards
|
||||
itemBuilder: (context, index) => _ShimmerCard(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shimmer Card for Loading State
|
||||
class _ShimmerCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: ProductCardSpecs.elevation,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(ProductCardSpecs.borderRadius),
|
||||
),
|
||||
child: Shimmer.fromColors(
|
||||
baseColor: AppColors.grey100,
|
||||
highlightColor: AppColors.grey50,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Image placeholder
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.grey100,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(ProductCardSpecs.borderRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Info placeholder
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Name placeholder
|
||||
Container(
|
||||
height: 14.0,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.grey100,
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8.0),
|
||||
|
||||
// SKU placeholder
|
||||
Container(
|
||||
height: 12.0,
|
||||
width: 80.0,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.grey100,
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8.0),
|
||||
|
||||
// Price placeholder
|
||||
Container(
|
||||
height: 16.0,
|
||||
width: 100.0,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.grey100,
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12.0),
|
||||
|
||||
// Button placeholder
|
||||
Container(
|
||||
height: 36.0,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.grey100,
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ERROR STATE
|
||||
// ============================================================================
|
||||
|
||||
/// Error State Widget
|
||||
///
|
||||
/// 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,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Error icon
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 80.0,
|
||||
color: AppColors.danger.withValues(alpha: 0.7),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Title
|
||||
const Text(
|
||||
'Có lỗi xảy ra',
|
||||
style: TextStyle(
|
||||
fontSize: 18.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
// Error message
|
||||
Text(
|
||||
error.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
|
||||
// Retry Button
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
foregroundColor: AppColors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xl,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text(
|
||||
'Thử lại',
|
||||
style: TextStyle(
|
||||
fontSize: 16.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FAVORITES GRID
|
||||
// ============================================================================
|
||||
|
||||
/// Favorites Grid Widget
|
||||
///
|
||||
/// Displays favorite products in a grid layout.
|
||||
class _FavoritesGrid extends StatelessWidget {
|
||||
final List<Product> products;
|
||||
|
||||
const _FavoritesGrid({
|
||||
required this.products,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: GridSpecs.productGridColumns,
|
||||
crossAxisSpacing: 12.0,
|
||||
mainAxisSpacing: 12.0,
|
||||
childAspectRatio: 0.62, // Same as products page
|
||||
),
|
||||
itemCount: products.length,
|
||||
itemBuilder: (context, index) {
|
||||
final product = products[index];
|
||||
return RepaintBoundary(
|
||||
child: FavoriteProductCard(product: product),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
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/features/products/data/datasources/products_local_datasource.dart';
|
||||
import 'package:worker/features/products/data/repositories/products_repository_impl.dart';
|
||||
import 'package:worker/features/products/domain/entities/product.dart';
|
||||
import 'package:worker/features/products/domain/usecases/get_products.dart';
|
||||
|
||||
part 'favorites_provider.g.dart';
|
||||
|
||||
// ============================================================================
|
||||
// DATASOURCE PROVIDER
|
||||
// ============================================================================
|
||||
|
||||
/// Provides instance of FavoritesLocalDataSource
|
||||
@riverpod
|
||||
FavoritesLocalDataSource favoritesLocalDataSource(Ref ref) {
|
||||
return FavoritesLocalDataSource();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CURRENT USER ID PROVIDER
|
||||
// ============================================================================
|
||||
|
||||
/// Provides the current logged-in user's ID
|
||||
///
|
||||
/// TODO: Replace with actual auth provider integration
|
||||
/// For now, using hardcoded userId for development
|
||||
@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';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN FAVORITES PROVIDER
|
||||
// ============================================================================
|
||||
|
||||
/// Manages the favorites state for the current user
|
||||
///
|
||||
/// 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;
|
||||
|
||||
@override
|
||||
Future<Set<String>> build() async {
|
||||
_dataSource = ref.read(favoritesLocalDataSourceProvider);
|
||||
_userId = ref.read(currentUserIdProvider);
|
||||
|
||||
// Load favorites from Hive
|
||||
return await _loadFavorites();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// PRIVATE METHODS
|
||||
// ==========================================================================
|
||||
|
||||
/// Load favorites from Hive database
|
||||
Future<Set<String>> _loadFavorites() 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;
|
||||
} catch (e) {
|
||||
debugPrint('Error loading favorites: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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;
|
||||
}
|
||||
|
||||
// Create favorite model
|
||||
final favorite = FavoriteModel(
|
||||
favoriteId: _generateFavoriteId(productId),
|
||||
productId: productId,
|
||||
userId: _userId,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Persist to Hive
|
||||
await _dataSource.addFavorite(favorite);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a product from favorites
|
||||
///
|
||||
/// Removes the favorite entry from Hive.
|
||||
/// If the product is not favorited, this operation is a no-op.
|
||||
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;
|
||||
}
|
||||
|
||||
// Remove from Hive
|
||||
await _dataSource.removeFavorite(productId, _userId);
|
||||
|
||||
// Update state
|
||||
final newState = <String>{...currentState};
|
||||
newState.remove(productId);
|
||||
state = AsyncValue.data(newState);
|
||||
|
||||
debugPrint('Removed favorite: $productId');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('Error removing favorite: $e');
|
||||
state = AsyncValue.error(e, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle favorite status for a product
|
||||
///
|
||||
/// 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>{};
|
||||
|
||||
if (currentState.contains(productId)) {
|
||||
await removeFavorite(productId);
|
||||
} else {
|
||||
await addFavorite(productId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh favorites from database
|
||||
///
|
||||
/// Useful for syncing state after external changes or on app resume.
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
return await _loadFavorites();
|
||||
});
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER PROVIDERS
|
||||
// ============================================================================
|
||||
|
||||
/// Check if a specific product is favorited
|
||||
///
|
||||
/// 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);
|
||||
|
||||
return favoritesAsync.when(
|
||||
data: (favorites) => favorites.contains(productId),
|
||||
loading: () => false,
|
||||
error: (_, __) => false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the total count of favorites
|
||||
///
|
||||
/// 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);
|
||||
|
||||
return favoritesAsync.when(
|
||||
data: (favorites) => favorites.length,
|
||||
loading: () => 0,
|
||||
error: (_, __) => 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get all favorite product IDs as a 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);
|
||||
|
||||
return favoritesAsync.when(
|
||||
data: (favorites) => favorites.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 [];
|
||||
}
|
||||
|
||||
// Import products provider to get all products
|
||||
const productsRepository = ProductsRepositoryImpl(
|
||||
localDataSource: ProductsLocalDataSourceImpl(),
|
||||
);
|
||||
|
||||
const 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) {
|
||||
print('[FavoritesProvider] $message');
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'favorites_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Provides instance of FavoritesLocalDataSource
|
||||
|
||||
@ProviderFor(favoritesLocalDataSource)
|
||||
const favoritesLocalDataSourceProvider = FavoritesLocalDataSourceProvider._();
|
||||
|
||||
/// Provides instance of FavoritesLocalDataSource
|
||||
|
||||
final class FavoritesLocalDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
FavoritesLocalDataSource,
|
||||
FavoritesLocalDataSource,
|
||||
FavoritesLocalDataSource
|
||||
>
|
||||
with $Provider<FavoritesLocalDataSource> {
|
||||
/// Provides instance of FavoritesLocalDataSource
|
||||
const FavoritesLocalDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'favoritesLocalDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$favoritesLocalDataSourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<FavoritesLocalDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FavoritesLocalDataSource create(Ref ref) {
|
||||
return favoritesLocalDataSource(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(FavoritesLocalDataSource value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<FavoritesLocalDataSource>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$favoritesLocalDataSourceHash() =>
|
||||
r'2f6ff99042b7cc1087d8cfdad517f448952c25be';
|
||||
|
||||
/// Provides the current logged-in user's ID
|
||||
///
|
||||
/// TODO: Replace with actual auth provider integration
|
||||
/// For now, using hardcoded userId for development
|
||||
|
||||
@ProviderFor(currentUserId)
|
||||
const currentUserIdProvider = CurrentUserIdProvider._();
|
||||
|
||||
/// Provides the current logged-in user's ID
|
||||
///
|
||||
/// TODO: Replace with actual auth provider integration
|
||||
/// For now, using hardcoded userId for development
|
||||
|
||||
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._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'currentUserIdProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$currentUserIdHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<String> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$currentUserIdHash() => r'7f968e463454a4ad87bce0442f62ecc24a6f756e';
|
||||
|
||||
/// Manages the favorites state for the current user
|
||||
///
|
||||
/// 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
|
||||
///
|
||||
/// 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
|
||||
///
|
||||
/// Uses a Set<String> to store product IDs for efficient lookup.
|
||||
/// Data is persisted to Hive for offline access.
|
||||
const FavoritesProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'favoritesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$favoritesHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
Favorites create() => Favorites();
|
||||
}
|
||||
|
||||
String _$favoritesHash() => r'fccd46f5cd1bbf2b58a13ea90c6d1644ece767b0';
|
||||
|
||||
/// Manages the favorites state for the current user
|
||||
///
|
||||
/// Uses a Set<String> to store product IDs for efficient lookup.
|
||||
/// Data is persisted to Hive for offline access.
|
||||
|
||||
abstract class _$Favorites extends $AsyncNotifier<Set<String>> {
|
||||
FutureOr<Set<String>> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<AsyncValue<Set<String>>, Set<String>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<Set<String>>, Set<String>>,
|
||||
AsyncValue<Set<String>>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a specific product is favorited
|
||||
///
|
||||
/// 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.
|
||||
|
||||
@ProviderFor(isFavorite)
|
||||
const isFavoriteProvider = IsFavoriteFamily._();
|
||||
|
||||
/// Check if a specific product is favorited
|
||||
///
|
||||
/// 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.
|
||||
|
||||
final class IsFavoriteProvider extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
/// Check if a specific product is favorited
|
||||
///
|
||||
/// 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._({
|
||||
required IsFavoriteFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'isFavoriteProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$isFavoriteHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'isFavoriteProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return isFavorite(ref, argument);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is IsFavoriteProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$isFavoriteHash() => r'8d69e5efe981a3717eebdd7ee192fd75afe722d5';
|
||||
|
||||
/// Check if a specific product is favorited
|
||||
///
|
||||
/// 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.
|
||||
|
||||
final class IsFavoriteFamily extends $Family
|
||||
with $FunctionalFamilyOverride<bool, String> {
|
||||
const IsFavoriteFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'isFavoriteProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// Check if a specific product is favorited
|
||||
///
|
||||
/// 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.
|
||||
|
||||
IsFavoriteProvider call(String productId) =>
|
||||
IsFavoriteProvider._(argument: productId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'isFavoriteProvider';
|
||||
}
|
||||
|
||||
/// Get the total count of favorites
|
||||
///
|
||||
/// Returns the number of products in the user's favorites.
|
||||
/// Safe to use in build methods - will return 0 during loading/error states.
|
||||
|
||||
@ProviderFor(favoriteCount)
|
||||
const favoriteCountProvider = FavoriteCountProvider._();
|
||||
|
||||
/// Get the total count of favorites
|
||||
///
|
||||
/// Returns the number of products in the user's favorites.
|
||||
/// Safe to use in build methods - will return 0 during loading/error states.
|
||||
|
||||
final class FavoriteCountProvider extends $FunctionalProvider<int, int, int>
|
||||
with $Provider<int> {
|
||||
/// Get the total count of favorites
|
||||
///
|
||||
/// 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._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'favoriteCountProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$favoriteCountHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<int> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
int create(Ref ref) {
|
||||
return favoriteCount(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(int value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<int>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$favoriteCountHash() => r'1f147fe5ef28b1477034bd567cfc05ab3e8e90db';
|
||||
|
||||
/// Get all favorite product IDs as a list
|
||||
///
|
||||
/// Useful for filtering product lists or bulk operations.
|
||||
/// Returns an empty list during loading/error states.
|
||||
|
||||
@ProviderFor(favoriteProductIds)
|
||||
const favoriteProductIdsProvider = FavoriteProductIdsProvider._();
|
||||
|
||||
/// Get all favorite product IDs as a list
|
||||
///
|
||||
/// Useful for filtering product lists or bulk operations.
|
||||
/// Returns an empty list during loading/error states.
|
||||
|
||||
final class FavoriteProductIdsProvider
|
||||
extends $FunctionalProvider<List<String>, List<String>, List<String>>
|
||||
with $Provider<List<String>> {
|
||||
/// Get all favorite product IDs as a list
|
||||
///
|
||||
/// Useful for filtering product lists or bulk operations.
|
||||
/// Returns an empty list during loading/error states.
|
||||
const FavoriteProductIdsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'favoriteProductIdsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$favoriteProductIdsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<List<String>> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
List<String> create(Ref ref) {
|
||||
return favoriteProductIds(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(List<String> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<List<String>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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'cb3af4f84591c94e9eed3322b167fd8050a40aa1';
|
||||
@@ -0,0 +1,242 @@
|
||||
/// Widget: Favorite Product Card
|
||||
///
|
||||
/// Displays a favorited product in a card format with image, name, price,
|
||||
/// and favorite toggle button.
|
||||
library;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/favorites/presentation/providers/favorites_provider.dart';
|
||||
import 'package:worker/features/products/domain/entities/product.dart';
|
||||
|
||||
/// Favorite Product Card Widget
|
||||
///
|
||||
/// 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,
|
||||
});
|
||||
|
||||
String _formatPrice(double price) {
|
||||
final formatter = NumberFormat('#,###', 'vi_VN');
|
||||
return '${formatter.format(price)}đ';
|
||||
}
|
||||
|
||||
/// Show confirmation dialog before removing from favorites
|
||||
Future<void> _showRemoveDialog(BuildContext context, WidgetRef ref) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Xóa khỏi yêu thích?'),
|
||||
content: const Text(
|
||||
'Bạn có chắc muốn xóa sản phẩm này khỏi danh sách yêu thích?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Hủy'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.danger,
|
||||
foregroundColor: AppColors.white,
|
||||
),
|
||||
child: const Text('Xóa'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
// Remove from favorites
|
||||
await ref.read(favoritesProvider.notifier).removeFavorite(product.productId);
|
||||
|
||||
// Show snackbar
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Đã xóa khỏi yêu thích'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Card(
|
||||
elevation: ProductCardSpecs.elevation,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(ProductCardSpecs.borderRadius),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Product Image with Favorite Button
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Image
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(ProductCardSpecs.borderRadius),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: product.imageUrl,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: ImageSpecs.productImageCacheWidth,
|
||||
memCacheHeight: ImageSpecs.productImageCacheHeight,
|
||||
placeholder: (context, url) => Shimmer.fromColors(
|
||||
baseColor: AppColors.grey100,
|
||||
highlightColor: AppColors.grey50,
|
||||
child: Container(
|
||||
color: AppColors.grey100,
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: AppColors.grey100,
|
||||
child: const Icon(
|
||||
Icons.image_not_supported,
|
||||
size: 48.0,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Favorite Button (top-right)
|
||||
Positioned(
|
||||
top: AppSpacing.sm,
|
||||
right: AppSpacing.sm,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.favorite,
|
||||
color: AppColors.danger,
|
||||
size: 20.0,
|
||||
),
|
||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
),
|
||||
onPressed: () => _showRemoveDialog(context, ref),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Product Info
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Product Name
|
||||
Text(
|
||||
product.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
|
||||
// SKU (if available)
|
||||
if (product.erpnextItemCode != null)
|
||||
Text(
|
||||
'Mã: ${product.erpnextItemCode}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.0,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
|
||||
// Price
|
||||
Text(
|
||||
_formatPrice(product.effectivePrice),
|
||||
style: const TextStyle(
|
||||
fontSize: 16.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
// View Details Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 36.0,
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
// Navigate to product detail
|
||||
context.push('/products/${product.productId}');
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryBlue,
|
||||
side: const BorderSide(
|
||||
color: AppColors.primaryBlue,
|
||||
width: 1.5,
|
||||
),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Xem chi tiết',
|
||||
style: TextStyle(
|
||||
fontSize: 12.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user