fix news,
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
/// Use Case: Get Product Detail
|
||||
///
|
||||
/// Fetches a single product by its ID from the repository.
|
||||
library;
|
||||
|
||||
import 'package:worker/features/products/domain/entities/product.dart';
|
||||
import 'package:worker/features/products/domain/repositories/products_repository.dart';
|
||||
|
||||
/// Get Product Detail Use Case
|
||||
///
|
||||
/// Fetches detailed information for a single product by ID.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final getProductDetail = GetProductDetail(repository);
|
||||
/// final product = await getProductDetail(productId: 'GIB20 G02');
|
||||
/// ```
|
||||
class GetProductDetail {
|
||||
const GetProductDetail(this._repository);
|
||||
|
||||
final ProductsRepository _repository;
|
||||
|
||||
/// Execute the use case
|
||||
///
|
||||
/// [productId] - The unique identifier of the product
|
||||
/// Returns a [Product] entity
|
||||
/// Throws [Exception] if the product is not found or on error
|
||||
Future<Product> call({required String productId}) async {
|
||||
return await _repository.getProductById(productId);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.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';
|
||||
import 'package:worker/features/products/presentation/providers/products_provider.dart';
|
||||
import 'package:worker/features/products/presentation/widgets/product_detail/image_gallery_section.dart';
|
||||
@@ -34,7 +35,6 @@ class ProductDetailPage extends ConsumerStatefulWidget {
|
||||
|
||||
class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
int _quantity = 1;
|
||||
bool _isFavorite = false;
|
||||
|
||||
void _increaseQuantity() {
|
||||
setState(() {
|
||||
@@ -58,20 +58,22 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleFavorite() {
|
||||
setState(() {
|
||||
_isFavorite = !_isFavorite;
|
||||
});
|
||||
void _toggleFavorite() async {
|
||||
// Toggle favorite using favorites provider
|
||||
await ref.read(favoritesProvider.notifier).toggleFavorite(widget.productId);
|
||||
|
||||
// Show feedback
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isFavorite ? 'Đã thêm vào yêu thích' : 'Đã xóa khỏi yêu thích',
|
||||
final isFavorite = ref.read(isFavoriteProvider(widget.productId));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
isFavorite ? 'Đã thêm vào yêu thích' : 'Đã xóa khỏi yêu thích',
|
||||
),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _shareProduct(Product product) {
|
||||
@@ -166,7 +168,11 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final productsAsync = ref.watch(productsProvider);
|
||||
// Use productDetailProvider with productId parameter
|
||||
final productAsync = ref.watch(productDetailProvider(productId: widget.productId));
|
||||
|
||||
// Watch favorite status from favorites provider
|
||||
final isFavorite = ref.watch(isFavoriteProvider(widget.productId));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
@@ -188,11 +194,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share, color: Colors.black),
|
||||
onPressed: () {
|
||||
productsAsync.whenData((products) {
|
||||
final product = products.firstWhere(
|
||||
(p) => p.productId == widget.productId,
|
||||
orElse: () => products.first,
|
||||
);
|
||||
productAsync.whenData((product) {
|
||||
_shareProduct(product);
|
||||
});
|
||||
},
|
||||
@@ -200,22 +202,16 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
// Favorite button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: _isFavorite ? AppColors.danger : Colors.black,
|
||||
isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: isFavorite ? AppColors.danger : Colors.black,
|
||||
),
|
||||
onPressed: _toggleFavorite,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
),
|
||||
body: productsAsync.when(
|
||||
data: (products) {
|
||||
// Find the product by ID
|
||||
final product = products.firstWhere(
|
||||
(p) => p.productId == widget.productId,
|
||||
orElse: () => products.first, // Fallback for demo
|
||||
);
|
||||
|
||||
body: productAsync.when(
|
||||
data: (product) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Scrollable content
|
||||
@@ -248,6 +244,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
right: 0,
|
||||
child: StickyActionBar(
|
||||
quantity: _quantity,
|
||||
unit: product.unit ?? 'm²',
|
||||
onIncrease: _increaseQuantity,
|
||||
onDecrease: _decreaseQuantity,
|
||||
onQuantityChanged: _updateQuantity,
|
||||
@@ -289,8 +286,9 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
await ref.read(productsProvider.notifier).refresh();
|
||||
onPressed: () {
|
||||
// Invalidate to trigger refetch
|
||||
ref.invalidate(productDetailProvider(productId: widget.productId));
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Thử lại'),
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:worker/features/products/domain/entities/product.dart';
|
||||
import 'package:worker/features/products/domain/repositories/products_repository.dart';
|
||||
import 'package:worker/features/products/domain/usecases/get_products.dart';
|
||||
import 'package:worker/features/products/domain/usecases/search_products.dart';
|
||||
import 'package:worker/features/products/domain/usecases/get_product_detail.dart';
|
||||
import 'package:worker/features/products/presentation/providers/selected_category_provider.dart';
|
||||
import 'package:worker/features/products/presentation/providers/search_query_provider.dart';
|
||||
|
||||
@@ -116,3 +117,26 @@ Future<List<Product>> allProducts(Ref ref) async {
|
||||
|
||||
return await getProductsUseCase();
|
||||
}
|
||||
|
||||
/// Product Detail Provider
|
||||
///
|
||||
/// Fetches a single product by ID from Frappe ERPNext API.
|
||||
/// Uses getProductDetail endpoint for efficient single product fetch.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02'));
|
||||
///
|
||||
/// productAsync.when(
|
||||
/// data: (product) => ProductDetailView(product: product),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
@riverpod
|
||||
Future<Product> productDetail(Ref ref, {required String productId}) async {
|
||||
final repository = await ref.watch(productsRepositoryProvider.future);
|
||||
final getProductDetailUseCase = GetProductDetail(repository);
|
||||
|
||||
return await getProductDetailUseCase(productId: productId);
|
||||
}
|
||||
|
||||
@@ -321,3 +321,151 @@ final class AllProductsProvider
|
||||
}
|
||||
|
||||
String _$allProductsHash() => r'402d7c6e8d119c7c7eab5e696fb8163831259def';
|
||||
|
||||
/// Product Detail Provider
|
||||
///
|
||||
/// Fetches a single product by ID from Frappe ERPNext API.
|
||||
/// Uses getProductDetail endpoint for efficient single product fetch.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02'));
|
||||
///
|
||||
/// productAsync.when(
|
||||
/// data: (product) => ProductDetailView(product: product),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
@ProviderFor(productDetail)
|
||||
const productDetailProvider = ProductDetailFamily._();
|
||||
|
||||
/// Product Detail Provider
|
||||
///
|
||||
/// Fetches a single product by ID from Frappe ERPNext API.
|
||||
/// Uses getProductDetail endpoint for efficient single product fetch.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02'));
|
||||
///
|
||||
/// productAsync.when(
|
||||
/// data: (product) => ProductDetailView(product: product),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
final class ProductDetailProvider
|
||||
extends $FunctionalProvider<AsyncValue<Product>, Product, FutureOr<Product>>
|
||||
with $FutureModifier<Product>, $FutureProvider<Product> {
|
||||
/// Product Detail Provider
|
||||
///
|
||||
/// Fetches a single product by ID from Frappe ERPNext API.
|
||||
/// Uses getProductDetail endpoint for efficient single product fetch.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02'));
|
||||
///
|
||||
/// productAsync.when(
|
||||
/// data: (product) => ProductDetailView(product: product),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
const ProductDetailProvider._({
|
||||
required ProductDetailFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'productDetailProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$productDetailHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'productDetailProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<Product> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<Product> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return productDetail(ref, productId: argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ProductDetailProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$productDetailHash() => r'ca219f1451f518c84ca1832aacb3c83920f4bfd2';
|
||||
|
||||
/// Product Detail Provider
|
||||
///
|
||||
/// Fetches a single product by ID from Frappe ERPNext API.
|
||||
/// Uses getProductDetail endpoint for efficient single product fetch.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02'));
|
||||
///
|
||||
/// productAsync.when(
|
||||
/// data: (product) => ProductDetailView(product: product),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
final class ProductDetailFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<Product>, String> {
|
||||
const ProductDetailFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'productDetailProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// Product Detail Provider
|
||||
///
|
||||
/// Fetches a single product by ID from Frappe ERPNext API.
|
||||
/// Uses getProductDetail endpoint for efficient single product fetch.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02'));
|
||||
///
|
||||
/// productAsync.when(
|
||||
/// data: (product) => ProductDetailView(product: product),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
ProductDetailProvider call({required String productId}) =>
|
||||
ProductDetailProvider._(argument: productId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'productDetailProvider';
|
||||
}
|
||||
|
||||
@@ -113,19 +113,19 @@ class ProductInfoSection extends StatelessWidget {
|
||||
// Size Info
|
||||
Expanded(
|
||||
child: _QuickInfoCard(
|
||||
icon: Icons.straighten,
|
||||
icon: Icons.straighten, // expand icon
|
||||
label: 'Kích thước',
|
||||
value: product.getSpecification('size') ?? '1200x1200',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Warranty Info
|
||||
// Packaging Info
|
||||
Expanded(
|
||||
child: _QuickInfoCard(
|
||||
icon: Icons.shield,
|
||||
label: 'Bảo hành',
|
||||
value: product.getSpecification('warranty') ?? '15 năm',
|
||||
icon: Icons.inventory_2_outlined, // cube/box icon
|
||||
label: 'Đóng gói',
|
||||
value: product.getSpecification('packaging') ?? '2 viên/thùng',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -133,9 +133,9 @@ class ProductInfoSection extends StatelessWidget {
|
||||
// Delivery Info
|
||||
Expanded(
|
||||
child: _QuickInfoCard(
|
||||
icon: Icons.local_shipping,
|
||||
icon: Icons.local_shipping_outlined, // truck icon
|
||||
label: 'Giao hàng',
|
||||
value: '2-3 ngày',
|
||||
value: '2-3 Ngày',
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -30,7 +30,8 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
// Start with Specifications tab (index 0)
|
||||
_tabController = TabController(length: 2, vsync: this, initialIndex: 0);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -67,7 +68,6 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
|
||||
indicatorColor: AppColors.primaryBlue,
|
||||
indicatorWeight: 2,
|
||||
tabs: const [
|
||||
Tab(text: 'Mô tả'),
|
||||
Tab(text: 'Thông số'),
|
||||
Tab(text: 'Đánh giá'),
|
||||
],
|
||||
@@ -80,7 +80,6 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_DescriptionTab(product: widget.product),
|
||||
_SpecificationsTab(product: widget.product),
|
||||
const _ReviewsTab(),
|
||||
],
|
||||
|
||||
@@ -11,8 +11,8 @@ import 'package:worker/core/theme/colors.dart';
|
||||
/// Sticky Action Bar
|
||||
///
|
||||
/// Fixed at the bottom of the screen with:
|
||||
/// - Quantity controls (-, input, +)
|
||||
/// - Add to cart button (full width, primary blue)
|
||||
/// - Quantity section with label, controls, and conversion text
|
||||
/// - Add to cart button
|
||||
class StickyActionBar extends StatelessWidget {
|
||||
final int quantity;
|
||||
final VoidCallback onIncrease;
|
||||
@@ -20,6 +20,7 @@ class StickyActionBar extends StatelessWidget {
|
||||
final ValueChanged<int> onQuantityChanged;
|
||||
final VoidCallback onAddToCart;
|
||||
final bool isOutOfStock;
|
||||
final String unit;
|
||||
|
||||
const StickyActionBar({
|
||||
super.key,
|
||||
@@ -29,8 +30,16 @@ class StickyActionBar extends StatelessWidget {
|
||||
required this.onQuantityChanged,
|
||||
required this.onAddToCart,
|
||||
this.isOutOfStock = false,
|
||||
this.unit = 'm²',
|
||||
});
|
||||
|
||||
String _getConversionText() {
|
||||
// Calculate conversion: each m² ≈ 0.36 boxes, each box = varies
|
||||
final pieces = (quantity / 0.36).ceil();
|
||||
final actualArea = (pieces * 0.36).toStringAsFixed(2);
|
||||
return 'Tương đương: $pieces viên / $actualArea m²';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
@@ -52,51 +61,83 @@ class StickyActionBar extends StatelessWidget {
|
||||
top: false,
|
||||
child: Row(
|
||||
children: [
|
||||
// Quantity Controls
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFe0e0e0)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Decrease Button
|
||||
_QuantityButton(
|
||||
icon: Icons.remove,
|
||||
onPressed: quantity > 1 ? onDecrease : null,
|
||||
// Quantity Section
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Label
|
||||
Text(
|
||||
'Số lượng ($unit)',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
||||
// Quantity Input
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: TextField(
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
controller: TextEditingController(
|
||||
text: quantity.toString(),
|
||||
),
|
||||
onChanged: (value) {
|
||||
final newQuantity = int.tryParse(value);
|
||||
if (newQuantity != null && newQuantity >= 1) {
|
||||
onQuantityChanged(newQuantity);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Quantity Controls
|
||||
Container(
|
||||
width: 142,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFe0e0e0)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Decrease Button
|
||||
_QuantityButton(
|
||||
icon: Icons.remove,
|
||||
onPressed: quantity > 1 ? onDecrease : null,
|
||||
),
|
||||
|
||||
// Increase Button
|
||||
_QuantityButton(icon: Icons.add, onPressed: onIncrease),
|
||||
],
|
||||
),
|
||||
// Quantity Input
|
||||
Expanded(
|
||||
child: TextField(
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
controller: TextEditingController(
|
||||
text: quantity.toString(),
|
||||
),
|
||||
onChanged: (value) {
|
||||
final newQuantity = int.tryParse(value);
|
||||
if (newQuantity != null && newQuantity >= 1) {
|
||||
onQuantityChanged(newQuantity);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Increase Button
|
||||
_QuantityButton(icon: Icons.add, onPressed: onIncrease),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Conversion Text
|
||||
Text(
|
||||
_getConversionText(),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
Reference in New Issue
Block a user