Compare commits

..

2 Commits

Author SHA1 Message Date
Phuoc Nguyen
ff3629d6d1 fix product 2025-11-17 15:07:53 +07:00
Phuoc Nguyen
0828ff1355 fix product page 2025-11-17 11:03:51 +07:00
29 changed files with 1553 additions and 170 deletions

View File

@@ -37,6 +37,7 @@ import 'package:worker/features/orders/presentation/pages/payments_page.dart';
import 'package:worker/features/price_policy/price_policy.dart'; import 'package:worker/features/price_policy/price_policy.dart';
import 'package:worker/features/products/presentation/pages/product_detail_page.dart'; import 'package:worker/features/products/presentation/pages/product_detail_page.dart';
import 'package:worker/features/products/presentation/pages/products_page.dart'; import 'package:worker/features/products/presentation/pages/products_page.dart';
import 'package:worker/features/products/presentation/pages/write_review_page.dart';
import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart'; import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart';
import 'package:worker/features/quotes/presentation/pages/quotes_page.dart'; import 'package:worker/features/quotes/presentation/pages/quotes_page.dart';
import 'package:worker/features/showrooms/presentation/pages/design_request_create_page.dart'; import 'package:worker/features/showrooms/presentation/pages/design_request_create_page.dart';
@@ -181,6 +182,20 @@ final routerProvider = Provider<GoRouter>((ref) {
child: ProductDetailPage(productId: productId ?? ''), child: ProductDetailPage(productId: productId ?? ''),
); );
}, },
routes: [
// Write Review Route (nested under product detail)
GoRoute(
path: 'write-review',
name: RouteNames.writeReview,
pageBuilder: (context, state) {
final productId = state.pathParameters['id'];
return MaterialPage(
key: state.pageKey,
child: WriteReviewPage(productId: productId ?? ''),
);
},
),
],
), ),
// Promotion Detail Route // Promotion Detail Route
@@ -459,6 +474,7 @@ class RouteNames {
static const String home = '/'; static const String home = '/';
static const String products = '/products'; static const String products = '/products';
static const String productDetail = '/products/:id'; static const String productDetail = '/products/:id';
static const String writeReview = 'write-review';
static const String cart = '/cart'; static const String cart = '/cart';
static const String favorites = '/favorites'; static const String favorites = '/favorites';
static const String checkout = '/checkout'; static const String checkout = '/checkout';

View File

@@ -11,6 +11,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';

View File

@@ -11,6 +11,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';

View File

@@ -12,6 +12,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';

View File

@@ -12,7 +12,7 @@ part of 'cart_provider.dart';
/// ///
/// Manages cart state with API integration: /// Manages cart state with API integration:
/// - Adding/removing items (syncs with API) /// - Adding/removing items (syncs with API)
/// - Updating quantities (syncs with API with 5s debounce) /// - Updating quantities (syncs with API with 3s debounce)
/// - Loading cart from API via initialize() /// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations /// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation /// - keepAlive: true to maintain cart state across navigation
@@ -24,7 +24,7 @@ const cartProvider = CartProvider._();
/// ///
/// Manages cart state with API integration: /// Manages cart state with API integration:
/// - Adding/removing items (syncs with API) /// - Adding/removing items (syncs with API)
/// - Updating quantities (syncs with API with 5s debounce) /// - Updating quantities (syncs with API with 3s debounce)
/// - Loading cart from API via initialize() /// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations /// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation /// - keepAlive: true to maintain cart state across navigation
@@ -33,7 +33,7 @@ final class CartProvider extends $NotifierProvider<Cart, CartState> {
/// ///
/// Manages cart state with API integration: /// Manages cart state with API integration:
/// - Adding/removing items (syncs with API) /// - Adding/removing items (syncs with API)
/// - Updating quantities (syncs with API with 5s debounce) /// - Updating quantities (syncs with API with 3s debounce)
/// - Loading cart from API via initialize() /// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations /// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation /// - keepAlive: true to maintain cart state across navigation
@@ -64,13 +64,13 @@ final class CartProvider extends $NotifierProvider<Cart, CartState> {
} }
} }
String _$cartHash() => r'3bb1372a0e87268e35c7c8d424d2d8315b4d09b2'; String _$cartHash() => r'706de28734e7059b2e9484f3b1d94226a0e90bb9';
/// Cart Notifier /// Cart Notifier
/// ///
/// Manages cart state with API integration: /// Manages cart state with API integration:
/// - Adding/removing items (syncs with API) /// - Adding/removing items (syncs with API)
/// - Updating quantities (syncs with API with 5s debounce) /// - Updating quantities (syncs with API with 3s debounce)
/// - Loading cart from API via initialize() /// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations /// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation /// - keepAlive: true to maintain cart state across navigation

View File

@@ -10,6 +10,7 @@ library;
import 'package:flutter/material.dart' hide Notification; import 'package:flutter/material.dart' hide Notification;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';

View File

@@ -184,13 +184,20 @@ class ProductsRemoteDataSource {
/// Get products by category /// Get products by category
/// ///
/// Filters products by category. /// Filters products by category with pagination support.
/// For now, we fetch all products and filter locally. /// For now, we fetch products with pagination and filter locally.
/// In the future, the API might support category filtering. /// In the future, the API might support category filtering.
Future<List<ProductModel>> getProductsByCategory(String categoryId) async { Future<List<ProductModel>> getProductsByCategory(
// For now, fetch all products and filter locally String categoryId, {
int limitStart = 0,
int limitPageLength = 12,
}) async {
// Fetch products with pagination and filter locally
// TODO: Implement server-side category filtering if API supports it // TODO: Implement server-side category filtering if API supports it
final allProducts = await getAllProducts(); final allProducts = await getAllProducts(
limitStart: limitStart,
limitPageLength: limitPageLength,
);
if (categoryId == 'all') { if (categoryId == 'all') {
return allProducts; return allProducts;

View File

@@ -30,6 +30,7 @@ class ProductModel extends HiveObject {
this.brand, this.brand,
this.unit, this.unit,
this.conversionOfSm, this.conversionOfSm,
this.introAttributes,
required this.isActive, required this.isActive,
required this.isFeatured, required this.isFeatured,
this.erpnextItemCode, this.erpnextItemCode,
@@ -91,6 +92,12 @@ class ProductModel extends HiveObject {
@HiveField(17) @HiveField(17)
final double? conversionOfSm; final double? conversionOfSm;
/// Intro attributes (JSON encoded list)
/// Quick reference attributes from API: Size, Colour, UOM
/// Example: [{"code": "Size", "value": "120x120"}, {"code": "UOM", "value": "2 viên/hộp"}]
@HiveField(18)
final String? introAttributes;
/// Whether product is active /// Whether product is active
@HiveField(12) @HiveField(12)
final bool isActive; final bool isActive;
@@ -137,6 +144,9 @@ class ProductModel extends HiveObject {
conversionOfSm: json['conversion_of_sm'] != null conversionOfSm: json['conversion_of_sm'] != null
? (json['conversion_of_sm'] as num).toDouble() ? (json['conversion_of_sm'] as num).toDouble()
: null, : null,
introAttributes: json['intro_attributes'] != null
? jsonEncode(json['intro_attributes'])
: null,
isActive: json['is_active'] as bool? ?? true, isActive: json['is_active'] as bool? ?? true,
isFeatured: json['is_featured'] as bool? ?? false, isFeatured: json['is_featured'] as bool? ?? false,
erpnextItemCode: json['erpnext_item_code'] as String?, erpnextItemCode: json['erpnext_item_code'] as String?,
@@ -223,6 +233,22 @@ class ProductModel extends HiveObject {
} }
} }
// Parse intro_attributes array for quick reference
final List<Map<String, String>> introAttributesList = [];
if (json['intro_attributes'] != null && json['intro_attributes'] is List) {
final introAttrsData = json['intro_attributes'] as List;
for (final attr in introAttrsData) {
if (attr is Map<String, dynamic> &&
attr['code'] != null &&
attr['value'] != null) {
introAttributesList.add({
'code': attr['code'] as String,
'value': attr['value'] as String,
});
}
}
}
final now = DateTime.now(); final now = DateTime.now();
// Handle price from both product detail (price) and product list (standard_rate) // Handle price from both product detail (price) and product list (standard_rate)
@@ -251,6 +277,9 @@ class ProductModel extends HiveObject {
conversionOfSm: json['conversion_of_sm'] != null conversionOfSm: json['conversion_of_sm'] != null
? (json['conversion_of_sm'] as num).toDouble() ? (json['conversion_of_sm'] as num).toDouble()
: null, : null,
introAttributes: introAttributesList.isNotEmpty
? jsonEncode(introAttributesList)
: null,
isActive: (json['disabled'] as int?) == 0, // Frappe uses 'disabled' field isActive: (json['disabled'] as int?) == 0, // Frappe uses 'disabled' field
isFeatured: false, // Not provided by API, default to false isFeatured: false, // Not provided by API, default to false
erpnextItemCode: json['name'] as String, // Store item code for reference erpnextItemCode: json['name'] as String, // Store item code for reference
@@ -283,6 +312,9 @@ class ProductModel extends HiveObject {
'brand': brand, 'brand': brand,
'unit': unit, 'unit': unit,
'conversion_of_sm': conversionOfSm, 'conversion_of_sm': conversionOfSm,
'intro_attributes': introAttributes != null
? jsonDecode(introAttributes!)
: null,
'is_active': isActive, 'is_active': isActive,
'is_featured': isFeatured, 'is_featured': isFeatured,
'erpnext_item_code': erpnextItemCode, 'erpnext_item_code': erpnextItemCode,
@@ -336,6 +368,38 @@ class ProductModel extends HiveObject {
} }
} }
/// Get intro attributes as List
List<Map<String, String>>? get introAttributesList {
if (introAttributes == null) return null;
try {
final decoded = jsonDecode(introAttributes!) as List;
return decoded.map((e) {
final map = e as Map<String, dynamic>;
return {
'code': map['code'].toString(),
'value': map['value'].toString(),
};
}).toList();
} catch (e) {
return null;
}
}
/// Get specific intro attribute value by code
String? getIntroAttribute(String code) {
final attrs = introAttributesList;
if (attrs == null) return null;
try {
final attr = attrs.firstWhere(
(attr) => attr['code']?.toLowerCase() == code.toLowerCase(),
);
return attr['value'];
} catch (e) {
return null;
}
}
/// Get formatted price with currency /// Get formatted price with currency
String get formattedPrice { String get formattedPrice {
return '${basePrice.toStringAsFixed(0)}đ'; return '${basePrice.toStringAsFixed(0)}đ';
@@ -363,6 +427,7 @@ class ProductModel extends HiveObject {
String? brand, String? brand,
String? unit, String? unit,
double? conversionOfSm, double? conversionOfSm,
String? introAttributes,
bool? isActive, bool? isActive,
bool? isFeatured, bool? isFeatured,
String? erpnextItemCode, String? erpnextItemCode,
@@ -383,6 +448,7 @@ class ProductModel extends HiveObject {
brand: brand ?? this.brand, brand: brand ?? this.brand,
unit: unit ?? this.unit, unit: unit ?? this.unit,
conversionOfSm: conversionOfSm ?? this.conversionOfSm, conversionOfSm: conversionOfSm ?? this.conversionOfSm,
introAttributes: introAttributes ?? this.introAttributes,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
isFeatured: isFeatured ?? this.isFeatured, isFeatured: isFeatured ?? this.isFeatured,
erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode, erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode,
@@ -426,6 +492,7 @@ class ProductModel extends HiveObject {
brand: brand, brand: brand,
unit: unit, unit: unit,
conversionOfSm: conversionOfSm, conversionOfSm: conversionOfSm,
introAttributes: introAttributesList,
isActive: isActive, isActive: isActive,
isFeatured: isFeatured, isFeatured: isFeatured,
erpnextItemCode: erpnextItemCode, erpnextItemCode: erpnextItemCode,

View File

@@ -30,6 +30,7 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
brand: fields[10] as String?, brand: fields[10] as String?,
unit: fields[11] as String?, unit: fields[11] as String?,
conversionOfSm: (fields[17] as num?)?.toDouble(), conversionOfSm: (fields[17] as num?)?.toDouble(),
introAttributes: fields[18] as String?,
isActive: fields[12] as bool, isActive: fields[12] as bool,
isFeatured: fields[13] as bool, isFeatured: fields[13] as bool,
erpnextItemCode: fields[14] as String?, erpnextItemCode: fields[14] as String?,
@@ -41,7 +42,7 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
@override @override
void write(BinaryWriter writer, ProductModel obj) { void write(BinaryWriter writer, ProductModel obj) {
writer writer
..writeByte(18) ..writeByte(19)
..writeByte(0) ..writeByte(0)
..write(obj.productId) ..write(obj.productId)
..writeByte(1) ..writeByte(1)
@@ -77,7 +78,9 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
..writeByte(16) ..writeByte(16)
..write(obj.updatedAt) ..write(obj.updatedAt)
..writeByte(17) ..writeByte(17)
..write(obj.conversionOfSm); ..write(obj.conversionOfSm)
..writeByte(18)
..write(obj.introAttributes);
} }
@override @override

View File

@@ -25,10 +25,16 @@ class ProductsRepositoryImpl implements ProductsRepository {
}); });
@override @override
Future<List<Product>> getAllProducts() async { Future<List<Product>> getAllProducts({
int limitStart = 0,
int limitPageLength = 12,
}) async {
try { try {
// Fetch from Frappe API // Fetch from Frappe API with pagination
final productModels = await remoteDataSource.getAllProducts(); final productModels = await remoteDataSource.getAllProducts(
limitStart: limitStart,
limitPageLength: limitPageLength,
);
return productModels.map((model) => model.toEntity()).toList(); return productModels.map((model) => model.toEntity()).toList();
} catch (e) { } catch (e) {
print('[ProductsRepository] Error getting products: $e'); print('[ProductsRepository] Error getting products: $e');
@@ -49,11 +55,17 @@ class ProductsRepositoryImpl implements ProductsRepository {
} }
@override @override
Future<List<Product>> getProductsByCategory(String categoryId) async { Future<List<Product>> getProductsByCategory(
String categoryId, {
int limitStart = 0,
int limitPageLength = 12,
}) async {
try { try {
// Filter by category via remote API // Filter by category via remote API with pagination
final productModels = await remoteDataSource.getProductsByCategory( final productModels = await remoteDataSource.getProductsByCategory(
categoryId, categoryId,
limitStart: limitStart,
limitPageLength: limitPageLength,
); );
return productModels.map((model) => model.toEntity()).toList(); return productModels.map((model) => model.toEntity()).toList();
} catch (e) { } catch (e) {

View File

@@ -24,6 +24,7 @@ class Product {
this.brand, this.brand,
this.unit, this.unit,
this.conversionOfSm, this.conversionOfSm,
this.introAttributes,
required this.isActive, required this.isActive,
required this.isFeatured, required this.isFeatured,
this.erpnextItemCode, this.erpnextItemCode,
@@ -70,6 +71,11 @@ class Product {
/// Used to calculate: Số viên = Số lượng × conversionOfSm /// Used to calculate: Số viên = Số lượng × conversionOfSm
final double? conversionOfSm; final double? conversionOfSm;
/// Intro attributes (quick reference)
/// List of maps with 'code' and 'value' keys
/// Example: [{"code": "Size", "value": "120x120"}, {"code": "UOM", "value": "2 viên/hộp"}]
final List<Map<String, String>>? introAttributes;
/// Product is active /// Product is active
final bool isActive; final bool isActive;
@@ -116,15 +122,25 @@ class Product {
/// TODO: Implement stock tracking when backend supports it /// TODO: Implement stock tracking when backend supports it
bool get isLowStock => false; bool get isLowStock => false;
/// Check if product is in stock
/// Currently using isActive as proxy
bool get inStock => isActive;
/// Get specification value by key /// Get specification value by key
String? getSpecification(String key) { String? getSpecification(String key) {
return specifications[key]?.toString(); return specifications[key]?.toString();
} }
/// Get intro attribute value by code
String? getIntroAttribute(String code) {
if (introAttributes == null) return null;
try {
final attr = introAttributes!.firstWhere(
(attr) => attr['code']?.toLowerCase() == code.toLowerCase(),
);
return attr['value'];
} catch (e) {
return null;
}
}
/// Copy with method for creating modified copies /// Copy with method for creating modified copies
Product copyWith({ Product copyWith({
String? productId, String? productId,
@@ -140,6 +156,7 @@ class Product {
String? brand, String? brand,
String? unit, String? unit,
double? conversionOfSm, double? conversionOfSm,
List<Map<String, String>>? introAttributes,
bool? isActive, bool? isActive,
bool? isFeatured, bool? isFeatured,
String? erpnextItemCode, String? erpnextItemCode,
@@ -160,6 +177,7 @@ class Product {
brand: brand ?? this.brand, brand: brand ?? this.brand,
unit: unit ?? this.unit, unit: unit ?? this.unit,
conversionOfSm: conversionOfSm ?? this.conversionOfSm, conversionOfSm: conversionOfSm ?? this.conversionOfSm,
introAttributes: introAttributes ?? this.introAttributes,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
isFeatured: isFeatured ?? this.isFeatured, isFeatured: isFeatured ?? this.isFeatured,
erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode, erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode,

View File

@@ -14,9 +14,14 @@ import 'package:worker/features/products/domain/entities/product.dart';
abstract class ProductsRepository { abstract class ProductsRepository {
/// Get all products /// Get all products
/// ///
/// Returns a list of all available products. /// Returns a list of all available products with pagination support.
/// [limitStart] - Starting index for pagination (default: 0)
/// [limitPageLength] - Number of items per page (default: 12)
/// Throws an exception if the operation fails. /// Throws an exception if the operation fails.
Future<List<Product>> getAllProducts(); Future<List<Product>> getAllProducts({
int limitStart = 0,
int limitPageLength = 12,
});
/// Search products by query /// Search products by query
/// ///
@@ -27,8 +32,14 @@ abstract class ProductsRepository {
/// Get products by category /// Get products by category
/// ///
/// [categoryId] - Category ID to filter by /// [categoryId] - Category ID to filter by
/// [limitStart] - Starting index for pagination (default: 0)
/// [limitPageLength] - Number of items per page (default: 12)
/// Returns list of products in the specified category. /// Returns list of products in the specified category.
Future<List<Product>> getProductsByCategory(String categoryId); Future<List<Product>> getProductsByCategory(
String categoryId, {
int limitStart = 0,
int limitPageLength = 12,
});
/// Get product by ID /// Get product by ID
/// ///

View File

@@ -17,12 +17,25 @@ class GetProducts {
/// Execute the use case /// Execute the use case
/// ///
/// [categoryId] - Optional category ID to filter products /// [categoryId] - Optional category ID to filter products
/// [limitStart] - Starting index for pagination (default: 0)
/// [limitPageLength] - Number of items per page (default: 12)
/// Returns list of products (all or filtered by category) /// Returns list of products (all or filtered by category)
Future<List<Product>> call({String? categoryId}) async { Future<List<Product>> call({
String? categoryId,
int limitStart = 0,
int limitPageLength = 12,
}) async {
if (categoryId == null || categoryId == 'all') { if (categoryId == null || categoryId == 'all') {
return await repository.getAllProducts(); return await repository.getAllProducts(
limitStart: limitStart,
limitPageLength: limitPageLength,
);
} else { } else {
return await repository.getProductsByCategory(categoryId); return await repository.getProductsByCategory(
categoryId,
limitStart: limitStart,
limitPageLength: limitPageLength,
);
} }
} }
} }

View File

@@ -246,11 +246,12 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
child: StickyActionBar( child: StickyActionBar(
quantity: _quantity, quantity: _quantity,
unit: product.unit ?? '', unit: product.unit ?? '',
conversionOfSm: product.conversionOfSm,
uomFromIntroAttributes: product.getIntroAttribute('UOM'),
onIncrease: _increaseQuantity, onIncrease: _increaseQuantity,
onDecrease: _decreaseQuantity, onDecrease: _decreaseQuantity,
onQuantityChanged: _updateQuantity, onQuantityChanged: _updateQuantity,
onAddToCart: () => _addToCart(product), onAddToCart: () => _addToCart(product),
isOutOfStock: !product.inStock,
), ),
), ),
], ],

View File

@@ -11,10 +11,9 @@ import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart'; import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/cart/presentation/providers/cart_provider.dart'; import 'package:worker/features/cart/presentation/providers/cart_provider.dart';
import 'package:worker/features/products/presentation/providers/categories_provider.dart';
import 'package:worker/features/products/presentation/providers/product_filter_options_provider.dart'; import 'package:worker/features/products/presentation/providers/product_filter_options_provider.dart';
import 'package:worker/features/products/presentation/providers/products_provider.dart'; import 'package:worker/features/products/presentation/providers/products_provider.dart';
import 'package:worker/features/products/presentation/widgets/category_filter_chips.dart'; import 'package:worker/features/products/presentation/widgets/brand_filter_chips.dart';
import 'package:worker/features/products/presentation/widgets/product_filter_drawer.dart'; import 'package:worker/features/products/presentation/widgets/product_filter_drawer.dart';
import 'package:worker/features/products/presentation/widgets/product_grid.dart'; import 'package:worker/features/products/presentation/widgets/product_grid.dart';
import 'package:worker/features/products/presentation/widgets/product_search_bar.dart'; import 'package:worker/features/products/presentation/widgets/product_search_bar.dart';
@@ -34,7 +33,6 @@ class ProductsPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final categoriesAsync = ref.watch(categoriesProvider);
final productsAsync = ref.watch(productsProvider); final productsAsync = ref.watch(productsProvider);
final cartItemCount = ref.watch(cartItemCountProvider); final cartItemCount = ref.watch(cartItemCountProvider);
@@ -75,46 +73,35 @@ class ProductsPage extends ConsumerWidget {
children: [ children: [
// Search Bar with Filter Button // Search Bar with Filter Button
Padding( Padding(
padding: const EdgeInsets.all(AppSpacing.md), padding: const EdgeInsets.all(AppSpacing.sm),
child: Row( child: Row(
children: [ children: [
// Search Bar (Expanded) // Search Bar (Expanded)
const Expanded(child: ProductSearchBar()), const Expanded(child: ProductSearchBar()),
const SizedBox(width: 8), const SizedBox(width: 8),
// Filter Button // Filter Button
SizedBox( Container(
height: InputFieldSpecs.height, height: InputFieldSpecs.height,
child: OutlinedButton.icon( width: InputFieldSpecs.height,
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
),
child: IconButton(
onPressed: () { onPressed: () {
// Open filter drawer from right // Open filter drawer from right
Scaffold.of(scaffoldContext).openEndDrawer(); Scaffold.of(scaffoldContext).openEndDrawer();
}, },
icon: const FaIcon(FontAwesomeIcons.sliders, size: 18), icon: const FaIcon(FontAwesomeIcons.sliders, size: 18),
label: const Text('Lọc', style: TextStyle(fontSize: 12)), color: AppColors.grey900,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.grey900,
side: const BorderSide(color: AppColors.white, width: 0),
backgroundColor: AppColors.white,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
),
),
), ),
), ),
], ],
), ),
), ),
// Category Filter Chips // Brand Filter Chips
categoriesAsync.when( const BrandFilterChips(),
data: (categories) => CategoryFilterChips(categories: categories),
loading: () =>
const SizedBox(height: 48.0, child: Center(child: CircularProgressIndicator(strokeWidth: 2.0))),
error: (error, stack) => const SizedBox.shrink(),
),
const SizedBox(height: AppSpacing.sm),
// Products Grid // Products Grid
Expanded( Expanded(
@@ -124,8 +111,16 @@ class ProductsPage extends ConsumerWidget {
return _buildEmptyState(context, l10n); return _buildEmptyState(context, l10n);
} }
final productsNotifier = ref.read(productsProvider.notifier);
final hasMore = productsNotifier.hasMore;
return ProductGrid( return ProductGrid(
products: products, products: products,
hasMore: hasMore,
isLoadingMore: false,
onLoadMore: () async {
await productsNotifier.loadMore();
},
onProductTap: (product) { onProductTap: (product) {
// Navigate to product detail page // Navigate to product detail page
context.push('/products/${product.productId}'); context.push('/products/${product.productId}');

View File

@@ -0,0 +1,504 @@
/// Page: Write Review
///
/// Form page for users to write product reviews with star rating.
library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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/products/domain/entities/product.dart';
import 'package:worker/features/products/presentation/providers/products_provider.dart';
import 'package:worker/features/products/presentation/widgets/write_review/review_guidelines_card.dart';
import 'package:worker/features/products/presentation/widgets/write_review/star_rating_selector.dart';
/// Write Review Page
///
/// Allows users to write a review for a product:
/// - Product info card (read-only)
/// - Star rating selector (1-5, required)
/// - Review content (textarea, min 20 chars, max 1000)
/// - Character counter (red if < 20)
/// - Review guidelines
/// - Submit button with loading state
class WriteReviewPage extends ConsumerStatefulWidget {
/// Product ID to review
final String productId;
const WriteReviewPage({super.key, required this.productId});
@override
ConsumerState<WriteReviewPage> createState() => _WriteReviewPageState();
}
class _WriteReviewPageState extends ConsumerState<WriteReviewPage> {
// Form state
int _selectedRating = 0;
final _contentController = TextEditingController();
bool _isSubmitting = false;
// Validation errors
String? _ratingError;
String? _contentError;
// Constants
static const int _minContentLength = 20;
static const int _maxContentLength = 1000;
@override
void dispose() {
_contentController.dispose();
super.dispose();
}
/// Validate form fields
bool _validateForm() {
bool isValid = true;
// Reset errors
setState(() {
_ratingError = null;
_contentError = null;
});
// Rating validation
if (_selectedRating == 0) {
setState(() => _ratingError = 'Vui lòng chọn số sao đánh giá');
isValid = false;
}
// Content validation
final content = _contentController.text.trim();
if (content.length < _minContentLength) {
setState(() =>
_contentError = 'Nội dung đánh giá phải có ít nhất $_minContentLength ký tự');
isValid = false;
}
return isValid;
}
/// Submit review
Future<void> _submitReview() async {
if (!_validateForm()) return;
setState(() => _isSubmitting = true);
// Simulate API call (TODO: Replace with actual API integration)
await Future.delayed(const Duration(seconds: 1));
if (mounted) {
// Show success message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Row(
children: [
Icon(FontAwesomeIcons.circleCheck, color: AppColors.white),
SizedBox(width: 12),
Expanded(
child: Text('Đánh giá của bạn đã được gửi thành công!'),
),
],
),
backgroundColor: AppColors.success,
behavior: SnackBarBehavior.floating,
),
);
// Navigate back
context.pop();
}
}
@override
Widget build(BuildContext context) {
final productAsync = ref.watch(productDetailProvider(productId: widget.productId));
return Scaffold(
backgroundColor: AppColors.white,
// Standard AppBar
appBar: AppBar(
backgroundColor: AppColors.white,
elevation: AppBarSpecs.elevation,
centerTitle: false,
leading: IconButton(
icon: const Icon(
Icons.arrow_back,
color: AppColors.grey900,
),
onPressed: () => context.pop(),
),
title: const Text(
'Viết đánh giá sản phẩm',
style: TextStyle(
color: AppColors.grey900,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
body: productAsync.when(
data: (product) => _buildForm(product),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
FontAwesomeIcons.circleExclamation,
size: 48,
color: AppColors.danger,
),
const SizedBox(height: 16),
Text(
'Không thể tải thông tin sản phẩm',
style: const TextStyle(
fontSize: 16,
color: AppColors.grey900,
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.pop(),
child: const Text('Quay lại'),
),
],
),
),
),
);
}
Widget _buildForm(Product product) {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Product Info Card (Read-only)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.white,
border: Border.all(color: const Color(0xFFe0e0e0), width: 2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
// Product Image
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: product.images.isNotEmpty
? product.images.first
: '',
width: 80,
height: 80,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 80,
height: 80,
color: AppColors.grey100,
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
),
errorWidget: (context, url, error) => Container(
width: 80,
height: 80,
color: AppColors.grey100,
child: const Icon(
FontAwesomeIcons.image,
color: AppColors.grey500,
),
),
),
),
const SizedBox(width: 16),
// Product Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
'Mã: ${product.productId}',
style: const TextStyle(
fontSize: 13,
color: AppColors.grey500,
),
),
],
),
),
],
),
),
const SizedBox(height: 28),
// Rating Section (Required)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: const TextSpan(
text: 'Xếp hạng của bạn',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
children: [
TextSpan(
text: ' *',
style: TextStyle(color: AppColors.danger),
),
],
),
),
const SizedBox(height: 8),
const Text(
'Bấm vào ngôi sao để chọn đánh giá',
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
),
const SizedBox(height: 16),
// Star Rating Selector
StarRatingSelector(
rating: _selectedRating,
onRatingChanged: (rating) {
setState(() {
_selectedRating = rating;
_ratingError = null;
});
},
),
// Rating Error
if (_ratingError != null) ...[
const SizedBox(height: 8),
Row(
children: [
const Icon(
FontAwesomeIcons.triangleExclamation,
size: 14,
color: AppColors.danger,
),
const SizedBox(width: 6),
Text(
_ratingError!,
style: const TextStyle(
fontSize: 13,
color: AppColors.danger,
),
),
],
),
],
],
),
const SizedBox(height: 28),
// Review Content (Required)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: const TextSpan(
text: 'Nội dung đánh giá',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
children: [
TextSpan(
text: ' *',
style: TextStyle(color: AppColors.danger),
),
],
),
),
const SizedBox(height: 8),
// Textarea
TextField(
controller: _contentController,
maxLines: 6,
maxLength: _maxContentLength,
decoration: InputDecoration(
hintText:
'Chia sẻ trải nghiệm của bạn về sản phẩm này...',
hintStyle: const TextStyle(
fontSize: 15,
color: AppColors.grey500,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFFe0e0e0),
width: 2,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFFe0e0e0),
width: 2,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
width: 2,
),
),
contentPadding: const EdgeInsets.all(14),
counterText: '', // Hide default counter
),
style: const TextStyle(
fontSize: 15,
height: 1.6,
color: AppColors.grey900,
),
onChanged: (value) {
setState(() {
_contentError = null;
});
},
),
const SizedBox(height: 6),
// Character Counter
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'${_contentController.text.length}',
style: TextStyle(
fontSize: 13,
color: _contentController.text.length < _minContentLength
? AppColors.danger
: AppColors.grey500,
),
),
Text(
' / $_maxContentLength ký tự',
style: const TextStyle(
fontSize: 13,
color: AppColors.grey500,
),
),
],
),
// Content Error
if (_contentError != null) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(
FontAwesomeIcons.triangleExclamation,
size: 14,
color: AppColors.danger,
),
const SizedBox(width: 6),
Expanded(
child: Text(
_contentError!,
style: const TextStyle(
fontSize: 13,
color: AppColors.danger,
),
),
),
],
),
],
],
),
const SizedBox(height: 24),
// Guidelines
const ReviewGuidelinesCard(),
const SizedBox(height: 24),
// Submit Button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isSubmitting ? null : _submitReview,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
disabledBackgroundColor: const Color(0xFFe0e0e0),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 0,
),
child: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(AppColors.white),
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
FontAwesomeIcons.paperPlane,
size: 16,
color: AppColors.white,
),
const SizedBox(width: 10),
const Text(
'Gửi đánh giá',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.white,
),
),
],
),
),
),
const SizedBox(height: 24),
],
),
),
);
}
}

View File

@@ -16,7 +16,7 @@ import 'package:worker/features/products/domain/repositories/products_repository
import 'package:worker/features/products/domain/usecases/get_products.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/search_products.dart';
import 'package:worker/features/products/domain/usecases/get_product_detail.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/selected_brand_provider.dart';
import 'package:worker/features/products/presentation/providers/search_query_provider.dart'; import 'package:worker/features/products/presentation/providers/search_query_provider.dart';
part 'products_provider.g.dart'; part 'products_provider.g.dart';
@@ -50,7 +50,7 @@ Future<ProductsRepository> productsRepository(Ref ref) async {
/// ///
/// Fetches and filters products based on selected category and search query. /// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes. /// Automatically updates when category or search query changes.
/// Data is fetched from Frappe ERPNext API. /// Data is fetched from Frappe ERPNext API with pagination support.
/// ///
/// Usage: /// Usage:
/// ```dart /// ```dart
@@ -64,38 +64,108 @@ Future<ProductsRepository> productsRepository(Ref ref) async {
/// ``` /// ```
@riverpod @riverpod
class Products extends _$Products { class Products extends _$Products {
static const int pageSize = 12;
int _currentPage = 0;
bool _hasMore = true;
@override @override
Future<List<Product>> build() async { Future<List<Product>> build() async {
// Reset pagination when dependencies change
_currentPage = 0;
_hasMore = true;
// Watch dependencies // Watch dependencies
final selectedCategory = ref.watch(selectedCategoryProvider); final selectedBrand = ref.watch(selectedBrandProvider);
final searchQuery = ref.watch(searchQueryProvider); final searchQuery = ref.watch(searchQueryProvider);
// Get repository with injected data sources // Get repository with injected data sources
final repository = await ref.watch(productsRepositoryProvider.future); final repository = await ref.watch(productsRepositoryProvider.future);
// Apply filters // Fetch first page of products
List<Product> products; List<Product> products;
if (searchQuery.isNotEmpty) { if (searchQuery.isNotEmpty) {
// Search takes precedence over category filter // Search takes precedence over brand filter
final searchUseCase = SearchProducts(repository); final searchUseCase = SearchProducts(repository);
products = await searchUseCase(searchQuery); products = await searchUseCase(searchQuery);
// If a category is selected, filter search results by category // If a brand is selected, filter search results by brand
if (selectedCategory != 'all') { if (selectedBrand != 'all') {
products = products products = products
.where((product) => product.categoryId == selectedCategory) .where((product) => product.brand == selectedBrand)
.toList(); .toList();
} }
// For search, we fetch all results at once, so no more pages
_hasMore = false;
} else { } else {
// No search query, use category filter // No search query, fetch all products with pagination
final getProductsUseCase = GetProducts(repository); final getProductsUseCase = GetProducts(repository);
products = await getProductsUseCase(categoryId: selectedCategory); products = await getProductsUseCase(
limitStart: 0,
limitPageLength: pageSize,
);
// Filter by brand if not 'all'
if (selectedBrand != 'all') {
products = products
.where((product) => product.brand == selectedBrand)
.toList();
} }
// If we got less than pageSize, there are no more products
_hasMore = products.length >= pageSize;
}
_currentPage = 1;
return products; return products;
} }
/// Load more products (next page)
Future<void> loadMore() async {
if (!_hasMore) return;
// Watch dependencies to get current filters
final selectedBrand = ref.read(selectedBrandProvider);
final searchQuery = ref.read(searchQueryProvider);
// Don't paginate search results (already fetched all)
if (searchQuery.isNotEmpty) return;
// Get repository
final repository = await ref.read(productsRepositoryProvider.future);
// Calculate pagination parameters
final limitStart = _currentPage * pageSize;
// Fetch next page from API
final getProductsUseCase = GetProducts(repository);
var newProducts = await getProductsUseCase(
limitStart: limitStart,
limitPageLength: pageSize,
);
// Filter by brand if not 'all'
if (selectedBrand != 'all') {
newProducts = newProducts
.where((product) => product.brand == selectedBrand)
.toList();
}
// If we got less than pageSize, there are no more products
_hasMore = newProducts.length >= pageSize;
// Increment page counter
_currentPage++;
// Append new products to existing list
final currentProducts = state.value ?? [];
state = AsyncValue.data([...currentProducts, ...newProducts]);
}
/// Check if there are more products to load
bool get hasMore => _hasMore;
/// Refresh products data /// Refresh products data
/// ///
/// Forces a refresh from the datasource. /// Forces a refresh from the datasource.

View File

@@ -159,7 +159,7 @@ String _$productsRepositoryHash() =>
/// ///
/// Fetches and filters products based on selected category and search query. /// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes. /// Automatically updates when category or search query changes.
/// Data is fetched from Frappe ERPNext API. /// Data is fetched from Frappe ERPNext API with pagination support.
/// ///
/// Usage: /// Usage:
/// ```dart /// ```dart
@@ -179,7 +179,7 @@ const productsProvider = ProductsProvider._();
/// ///
/// Fetches and filters products based on selected category and search query. /// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes. /// Automatically updates when category or search query changes.
/// Data is fetched from Frappe ERPNext API. /// Data is fetched from Frappe ERPNext API with pagination support.
/// ///
/// Usage: /// Usage:
/// ```dart /// ```dart
@@ -197,7 +197,7 @@ final class ProductsProvider
/// ///
/// Fetches and filters products based on selected category and search query. /// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes. /// Automatically updates when category or search query changes.
/// Data is fetched from Frappe ERPNext API. /// Data is fetched from Frappe ERPNext API with pagination support.
/// ///
/// Usage: /// Usage:
/// ```dart /// ```dart
@@ -228,13 +228,13 @@ final class ProductsProvider
Products create() => Products(); Products create() => Products();
} }
String _$productsHash() => r'b892402a88484d301cdabd1fde5822ddd29538bf'; String _$productsHash() => r'5fe0fdb46c3a6845327221ff26ba5f3624fcf3bf';
/// Products Provider /// Products Provider
/// ///
/// Fetches and filters products based on selected category and search query. /// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes. /// Automatically updates when category or search query changes.
/// Data is fetched from Frappe ERPNext API. /// Data is fetched from Frappe ERPNext API with pagination support.
/// ///
/// Usage: /// Usage:
/// ```dart /// ```dart

View File

@@ -0,0 +1,39 @@
/// Provider: Selected Brand Provider
///
/// Manages the currently selected brand filter state.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'selected_brand_provider.g.dart';
/// Selected Brand Provider
///
/// Stores the currently selected brand ID for filtering products.
/// Default: 'all' (no brand filter)
///
/// Usage:
/// ```dart
/// // Watch selected brand
/// final selectedBrand = ref.watch(selectedBrandProvider);
///
/// // Update selected brand
/// ref.read(selectedBrandProvider.notifier).updateBrand('VASTA');
/// ```
@riverpod
class SelectedBrand extends _$SelectedBrand {
@override
String build() {
return 'all'; // Default: show all brands
}
/// Update the selected brand
void updateBrand(String brandId) {
state = brandId;
}
/// Reset to default (all brands)
void reset() {
state = 'all';
}
}

View File

@@ -0,0 +1,116 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'selected_brand_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Selected Brand Provider
///
/// Stores the currently selected brand ID for filtering products.
/// Default: 'all' (no brand filter)
///
/// Usage:
/// ```dart
/// // Watch selected brand
/// final selectedBrand = ref.watch(selectedBrandProvider);
///
/// // Update selected brand
/// ref.read(selectedBrandProvider.notifier).updateBrand('VASTA');
/// ```
@ProviderFor(SelectedBrand)
const selectedBrandProvider = SelectedBrandProvider._();
/// Selected Brand Provider
///
/// Stores the currently selected brand ID for filtering products.
/// Default: 'all' (no brand filter)
///
/// Usage:
/// ```dart
/// // Watch selected brand
/// final selectedBrand = ref.watch(selectedBrandProvider);
///
/// // Update selected brand
/// ref.read(selectedBrandProvider.notifier).updateBrand('VASTA');
/// ```
final class SelectedBrandProvider
extends $NotifierProvider<SelectedBrand, String> {
/// Selected Brand Provider
///
/// Stores the currently selected brand ID for filtering products.
/// Default: 'all' (no brand filter)
///
/// Usage:
/// ```dart
/// // Watch selected brand
/// final selectedBrand = ref.watch(selectedBrandProvider);
///
/// // Update selected brand
/// ref.read(selectedBrandProvider.notifier).updateBrand('VASTA');
/// ```
const SelectedBrandProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedBrandProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedBrandHash();
@$internal
@override
SelectedBrand create() => SelectedBrand();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String>(value),
);
}
}
String _$selectedBrandHash() => r'1295bffdcea67b78f7d55ce42f476603e042b19b';
/// Selected Brand Provider
///
/// Stores the currently selected brand ID for filtering products.
/// Default: 'all' (no brand filter)
///
/// Usage:
/// ```dart
/// // Watch selected brand
/// final selectedBrand = ref.watch(selectedBrandProvider);
///
/// // Update selected brand
/// ref.read(selectedBrandProvider.notifier).updateBrand('VASTA');
/// ```
abstract class _$SelectedBrand extends $Notifier<String> {
String build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String, String>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String, String>,
String,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,92 @@
/// Widget: Brand Filter Chips
///
/// Horizontal scrolling filter chips for product brands.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/products/presentation/providers/product_filter_options_provider.dart';
import 'package:worker/features/products/presentation/providers/selected_brand_provider.dart';
/// Brand Filter Chips Widget
///
/// Displays brands as horizontally scrolling chips.
/// Updates selected brand when tapped.
class BrandFilterChips extends ConsumerWidget {
const BrandFilterChips({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedBrand = ref.watch(selectedBrandProvider);
final filterOptionsAsync = ref.watch(productFilterOptionsProvider);
return filterOptionsAsync.when(
data: (options) {
// Add "All" option at the beginning
final allBrands = [
const FilterOption(value: 'all', label: 'Tất cả'),
...options.brands,
];
return SizedBox(
height: 48.0,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
itemCount: allBrands.length,
separatorBuilder: (context, index) =>
const SizedBox(width: AppSpacing.sm),
itemBuilder: (context, index) {
final brand = allBrands[index];
final isSelected = selectedBrand == brand.value;
return FilterChip(
label: Text(
brand.label,
style: TextStyle(
fontSize: 14.0,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? AppColors.white : AppColors.grey900,
),
),
selected: isSelected,
onSelected: (selected) {
if (selected) {
ref
.read(selectedBrandProvider.notifier)
.updateBrand(brand.value);
}
},
backgroundColor: AppColors.white,
selectedColor: AppColors.primaryBlue,
checkmarkColor: AppColors.white,
side: BorderSide(
color: isSelected ? AppColors.primaryBlue : AppColors.grey100,
width: isSelected ? 2.0 : 1.0,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
elevation: isSelected ? AppElevation.low : 0,
showCheckmark: false,
);
},
),
);
},
loading: () => const SizedBox(
height: 48.0,
child: Center(
child: CircularProgressIndicator(strokeWidth: 2.0),
),
),
error: (error, stack) => const SizedBox.shrink(),
);
}
}

View File

@@ -239,12 +239,10 @@ class ProductCard extends ConsumerWidget {
width: double.infinity, width: double.infinity,
height: 36.0, height: 36.0,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: !product.inStock ? onAddToCart : null, onPressed: onAddToCart,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue, backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white, foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
@@ -256,9 +254,9 @@ class ProductCard extends ConsumerWidget {
), ),
), ),
icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 14.0), icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 14.0),
label: Text( label: const Text(
!product.inStock ? 'Thêm vào giỏ' : l10n.outOfStock, 'Thêm vào giỏ',
style: const TextStyle( style: TextStyle(
fontSize: 12.0, fontSize: 12.0,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),

View File

@@ -4,8 +4,8 @@
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/products/domain/entities/product.dart'; import 'package:worker/features/products/domain/entities/product.dart';
@@ -15,12 +15,13 @@ import 'package:worker/features/products/domain/entities/product.dart';
/// - SKU text (small, gray) /// - SKU text (small, gray)
/// - Product title (large, bold) /// - Product title (large, bold)
/// - Pricing row (current price, original price, discount badge) /// - Pricing row (current price, original price, discount badge)
/// - Quick info cards (3 cards: Size, Warranty, Delivery) /// - Rating section (5 stars with review count)
/// - Dynamic intro attribute cards (shown only if non-null)
class ProductInfoSection extends StatelessWidget { class ProductInfoSection extends StatelessWidget {
final Product product;
const ProductInfoSection({super.key, required this.product}); const ProductInfoSection({super.key, required this.product});
final Product product;
String _formatPrice(double price) { String _formatPrice(double price) {
final formatter = NumberFormat('#,###', 'vi_VN'); final formatter = NumberFormat('#,###', 'vi_VN');
return '${formatter.format(price)} VND'; return '${formatter.format(price)} VND';
@@ -107,57 +108,114 @@ class ProductInfoSection extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
// Quick Info Cards // Rating & Reviews Section
const Row(
children: [
// Rating Stars
Row( Row(
children: [ children: [
// Size Info Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
Expanded( SizedBox(width: 2),
child: _QuickInfoCard( Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
icon: Icons.straighten, // expand icon SizedBox(width: 2),
label: 'Kích thước', Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
value: product.getSpecification('size') ?? '1200x1200', SizedBox(width: 2),
Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
SizedBox(width: 2),
Icon(FontAwesomeIcons.starHalfStroke, color: Color(0xFFffc107), size: 16),
],
), ),
),
const SizedBox(width: 12),
// Packaging Info SizedBox(width: 12),
Expanded(
child: _QuickInfoCard(
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),
// Delivery Info // Rating Text
Expanded( Text(
child: _QuickInfoCard( '4.8 (125 đánh giá)',
icon: Icons.local_shipping_outlined, // truck icon style: TextStyle(
label: 'Giao hàng', fontSize: 14,
value: '2-3 Ngày', color: AppColors.grey500,
), ),
), ),
], ],
), ),
const SizedBox(height: 16),
// Intro attributes quick info cards (dynamic based on non-null values)
if (_buildIntroAttributeCards(product).isNotEmpty)
Row(
children: _buildIntroAttributeCards(product),
),
], ],
), ),
); );
} }
/// Build intro attribute cards dynamically based on non-null values
List<Widget> _buildIntroAttributeCards(Product product) {
final cards = <Widget>[];
// Define available intro attributes with their display info
final availableAttributes = [
{
'code': 'Size',
'icon': FontAwesomeIcons.expand,
'label': 'Kích thước',
},
{
'code': 'Colour',
'icon': FontAwesomeIcons.palette,
'label': 'Màu sắc',
},
{
'code': 'UOM',
'icon': FontAwesomeIcons.boxArchive,
'label': 'Đóng gói',
},
];
// Build cards only for non-null values
for (final attr in availableAttributes) {
final value = product.getIntroAttribute(attr['code'] as String);
if (value != null && value.isNotEmpty) {
cards.add(
Expanded(
child: _QuickInfoCard(
icon: attr['icon'] as IconData,
label: attr['label'] as String,
value: value,
),
),
);
// Add spacing between cards (except after the last one)
if (cards.length < availableAttributes.length) {
cards.add(const SizedBox(width: 8));
}
}
}
// Remove trailing spacer if it exists
if (cards.isNotEmpty && cards.last is SizedBox) {
cards.removeLast();
}
return cards;
}
} }
/// Quick Info Card Widget /// Quick Info Card Widget
class _QuickInfoCard extends StatelessWidget { class _QuickInfoCard extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const _QuickInfoCard({ const _QuickInfoCard({
required this.icon, required this.icon,
required this.label, required this.label,
required this.value, required this.value,
}); });
final IconData icon;
final String label;
final String value;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(

View File

@@ -8,6 +8,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/products/domain/entities/product.dart'; import 'package:worker/features/products/domain/entities/product.dart';
import 'package:worker/features/products/presentation/widgets/product_detail/write_review_button.dart';
/// Product Tabs Section /// Product Tabs Section
/// ///
@@ -16,9 +17,9 @@ import 'package:worker/features/products/domain/entities/product.dart';
/// - Specifications: Table of product specs /// - Specifications: Table of product specs
/// - Reviews: Rating overview and review items /// - Reviews: Rating overview and review items
class ProductTabsSection extends StatefulWidget { class ProductTabsSection extends StatefulWidget {
final Product product;
const ProductTabsSection({super.key, required this.product}); const ProductTabsSection({super.key, required this.product});
final Product product;
@override @override
State<ProductTabsSection> createState() => _ProductTabsSectionState(); State<ProductTabsSection> createState() => _ProductTabsSectionState();
@@ -33,6 +34,9 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
super.initState(); super.initState();
// Start with Specifications tab (index 0) // Start with Specifications tab (index 0)
_tabController = TabController(length: 2, vsync: this, initialIndex: 0); _tabController = TabController(length: 2, vsync: this, initialIndex: 0);
_tabController.addListener(() {
setState(() {}); // Update IndexedStack when tab changes
});
} }
@override @override
@@ -75,17 +79,14 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
), ),
), ),
// Tab Content // Tab Content (expands to fit content)
SizedBox( IndexedStack(
height: 400, // Fixed height for tab content index: _tabController.index,
child: TabBarView(
controller: _tabController,
children: [ children: [
_SpecificationsTab(product: widget.product), _SpecificationsTab(product: widget.product),
const _ReviewsTab(), _ReviewsTab(productId: widget.product.productId),
], ],
), ),
),
], ],
), ),
); );
@@ -94,9 +95,9 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
/// Description Tab Content /// Description Tab Content
class _DescriptionTab extends StatelessWidget { class _DescriptionTab extends StatelessWidget {
final Product product;
const _DescriptionTab({required this.product}); const _DescriptionTab({required this.product});
final Product product;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -201,9 +202,9 @@ class _DescriptionTab extends StatelessWidget {
/// Specifications Tab Content /// Specifications Tab Content
class _SpecificationsTab extends StatelessWidget { class _SpecificationsTab extends StatelessWidget {
final Product product;
const _SpecificationsTab({required this.product}); const _SpecificationsTab({required this.product});
final Product product;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -223,8 +224,7 @@ class _SpecificationsTab extends StatelessWidget {
'Tiêu chuẩn': 'TCVN 9081:2012, ISO 13006', 'Tiêu chuẩn': 'TCVN 9081:2012, ISO 13006',
}; };
return SingleChildScrollView( return Container(
child: Container(
margin: const EdgeInsets.all(20), margin: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFe0e0e0)), border: Border.all(color: const Color(0xFFe0e0e0)),
@@ -284,21 +284,26 @@ class _SpecificationsTab extends StatelessWidget {
); );
}).toList(), }).toList(),
), ),
),
); );
} }
} }
/// Reviews Tab Content /// Reviews Tab Content
class _ReviewsTab extends StatelessWidget { class _ReviewsTab extends StatelessWidget {
const _ReviewsTab();
const _ReviewsTab({required this.productId});
final String productId;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( return Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Write Review Button
WriteReviewButton(productId: productId),
// Rating Overview // Rating Overview
Container( Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
@@ -353,6 +358,7 @@ class _ReviewsTab extends StatelessWidget {
// Review Items // Review Items
..._mockReviews.map((review) => _ReviewItem(review: review)), ..._mockReviews.map((review) => _ReviewItem(review: review)),
const SizedBox(height: 48),
], ],
), ),
); );
@@ -361,9 +367,9 @@ class _ReviewsTab extends StatelessWidget {
/// Review Item Widget /// Review Item Widget
class _ReviewItem extends StatelessWidget { class _ReviewItem extends StatelessWidget {
final Map<String, dynamic> review;
const _ReviewItem({required this.review}); const _ReviewItem({required this.review});
final Map<String, dynamic> review;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -15,13 +15,6 @@ import 'package:worker/core/theme/colors.dart';
/// - Quantity section with label, controls, and conversion text /// - Quantity section with label, controls, and conversion text
/// - Add to cart button /// - Add to cart button
class StickyActionBar extends StatelessWidget { class StickyActionBar extends StatelessWidget {
final int quantity;
final VoidCallback onIncrease;
final VoidCallback onDecrease;
final ValueChanged<int> onQuantityChanged;
final VoidCallback onAddToCart;
final bool isOutOfStock;
final String unit;
const StickyActionBar({ const StickyActionBar({
super.key, super.key,
@@ -30,15 +23,40 @@ class StickyActionBar extends StatelessWidget {
required this.onDecrease, required this.onDecrease,
required this.onQuantityChanged, required this.onQuantityChanged,
required this.onAddToCart, required this.onAddToCart,
this.isOutOfStock = false,
this.unit = '', this.unit = '',
this.conversionOfSm,
this.uomFromIntroAttributes,
}); });
final int quantity;
final VoidCallback onIncrease;
final VoidCallback onDecrease;
final ValueChanged<int> onQuantityChanged;
final VoidCallback onAddToCart;
final String unit;
final double? conversionOfSm;
final String? uomFromIntroAttributes;
String _getConversionText() { String _getConversionText() {
// Calculate conversion: each m² ≈ 0.36 boxes, each box = varies if (conversionOfSm == null || conversionOfSm == 0) {
final pieces = (quantity / 0.36).ceil(); return ''; // No conversion data available
final actualArea = (pieces * 0.36).toStringAsFixed(2); }
return 'Tương đương: $pieces viên / $actualArea';
// Calculate boxes needed using API conversion factor
// Formula: boxes_needed = quantity_m² / conversion_of_sm
final boxesNeeded = (quantity / conversionOfSm!).ceil();
// Extract pieces per box from UOM if available (e.g., "2 viên/hộp" -> 2)
int piecesPerBox = 1;
if (uomFromIntroAttributes != null) {
final match = RegExp(r'(\d+)\s*viên').firstMatch(uomFromIntroAttributes!);
if (match != null) {
piecesPerBox = int.tryParse(match.group(1) ?? '1') ?? 1;
}
}
final totalPieces = boxesNeeded * piecesPerBox;
return 'Tương đương: $boxesNeeded hộp / $totalPieces viên';
} }
@override @override
@@ -46,8 +64,8 @@ class StickyActionBar extends StatelessWidget {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.white, color: AppColors.white,
border: Border( border: const Border(
top: BorderSide(color: const Color(0xFFe0e0e0), width: 1), top: BorderSide(color: Color(0xFFe0e0e0), width: 1),
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
@@ -131,6 +149,7 @@ class StickyActionBar extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
// Conversion Text // Conversion Text
if (_getConversionText().isNotEmpty)
Text( Text(
_getConversionText(), _getConversionText(),
style: const TextStyle( style: const TextStyle(
@@ -141,30 +160,28 @@ class StickyActionBar extends StatelessWidget {
], ],
), ),
const SizedBox(width: 16), const SizedBox(width: 8),
// Add to Cart Button // Add to Cart Button
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: isOutOfStock ? null : onAddToCart, onPressed: onAddToCart,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue, backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white, foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
elevation: 0, elevation: 0,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 20, horizontal: 12,
vertical: 12, vertical: 8,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
), ),
icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 18), icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 18),
label: Text( label: const Text(
isOutOfStock ? 'Hết hàng' : 'Thêm vào giỏ hàng', 'Thêm vào giỏ hàng',
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -180,10 +197,10 @@ class StickyActionBar extends StatelessWidget {
/// Quantity Button Widget /// Quantity Button Widget
class _QuantityButton extends StatelessWidget { class _QuantityButton extends StatelessWidget {
final IconData icon;
final VoidCallback? onPressed;
const _QuantityButton({required this.icon, this.onPressed}); const _QuantityButton({required this.icon, this.onPressed});
final IconData icon;
final VoidCallback? onPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -0,0 +1,67 @@
/// Widget: Write Review Button
///
/// Button to navigate to the write review page from product detail.
library;
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/theme/colors.dart';
/// Write Review Button
///
/// Displays a prominent button for users to write a review:
/// - Primary blue background
/// - Edit icon
/// - Text: "Viết đánh giá của bạn"
/// - Navigates to WriteReviewPage with productId
class WriteReviewButton extends StatelessWidget {
/// Product ID to review
final String productId;
const WriteReviewButton({
super.key,
required this.productId,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 20),
child: ElevatedButton(
onPressed: () {
// Navigate to write review page
context.push('/products/$productId/write-review');
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 28),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 0,
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
FontAwesomeIcons.penToSquare,
size: 16,
color: AppColors.white,
),
SizedBox(width: 10),
Text(
'Viết đánh giá của bạn',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.white,
),
),
],
),
),
);
}
}

View File

@@ -1,6 +1,6 @@
/// Widget: Product Grid /// Widget: Product Grid
/// ///
/// Grid view displaying product cards. /// Grid view displaying product cards with pagination support.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -10,22 +10,59 @@ import 'package:worker/features/products/presentation/widgets/product_card.dart'
/// Product Grid Widget /// Product Grid Widget
/// ///
/// Displays products in a 2-column grid layout. /// Displays products in a 2-column grid layout with scroll-to-load-more.
class ProductGrid extends StatelessWidget { class ProductGrid extends StatefulWidget {
final List<Product> products; final List<Product> products;
final void Function(Product)? onProductTap; final void Function(Product)? onProductTap;
final void Function(Product)? onAddToCart; final void Function(Product)? onAddToCart;
final VoidCallback? onLoadMore;
final bool hasMore;
final bool isLoadingMore;
const ProductGrid({ const ProductGrid({
super.key, super.key,
required this.products, required this.products,
this.onProductTap, this.onProductTap,
this.onAddToCart, this.onAddToCart,
this.onLoadMore,
this.hasMore = false,
this.isLoadingMore = false,
}); });
@override
State<ProductGrid> createState() => _ProductGridState();
}
class _ProductGridState extends State<ProductGrid> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
// Load more when 200px from bottom
if (widget.hasMore && !widget.isLoadingMore && widget.onLoadMore != null) {
widget.onLoadMore!();
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GridView.builder( return GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(AppSpacing.xs), padding: const EdgeInsets.all(AppSpacing.xs),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: GridSpecs.productGridColumns, crossAxisCount: GridSpecs.productGridColumns,
@@ -33,14 +70,24 @@ class ProductGrid extends StatelessWidget {
mainAxisSpacing: AppSpacing.xs, mainAxisSpacing: AppSpacing.xs,
childAspectRatio: 0.62, // Width / Height ratio (adjusted for 2 buttons) childAspectRatio: 0.62, // Width / Height ratio (adjusted for 2 buttons)
), ),
itemCount: products.length, itemCount: widget.products.length + (widget.hasMore ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final product = products[index]; // Show loading indicator at the end
if (index == widget.products.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(AppSpacing.md),
child: CircularProgressIndicator(),
),
);
}
final product = widget.products[index];
return ProductCard( return ProductCard(
product: product, product: product,
onTap: onProductTap != null ? () => onProductTap!(product) : null, onTap: widget.onProductTap != null ? () => widget.onProductTap!(product) : null,
onAddToCart: onAddToCart != null ? () => onAddToCart!(product) : null, onAddToCart: widget.onAddToCart != null ? () => widget.onAddToCart!(product) : null,
); );
}, },
); );

View File

@@ -0,0 +1,105 @@
/// Widget: Review Guidelines Card
///
/// Information card with tips for writing good reviews.
library;
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:worker/core/theme/colors.dart';
/// Review Guidelines Card Widget
///
/// Displays helpful tips for writing product reviews:
/// - Light blue background (#f0f7ff)
/// - Blue left border
/// - Lightbulb icon
/// - 4 bullet points with guidelines
class ReviewGuidelinesCard extends StatelessWidget {
const ReviewGuidelinesCard({super.key});
// Guidelines background color
static const Color _backgroundColor = Color(0xFFF0F7FF);
// Guidelines list
static const List<String> _guidelines = [
'Chia sẻ trải nghiệm thực tế của bạn về sản phẩm',
'Đề cập đến chất lượng, màu sắc, độ bền của sản phẩm',
'Nêu rõ điểm tốt và điểm chưa tốt (nếu có)',
'Tránh spam, nội dung không phù hợp hoặc vi phạm',
];
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _backgroundColor,
borderRadius: BorderRadius.circular(8),
border: const Border(
left: BorderSide(
color: AppColors.primaryBlue,
width: 4,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
const Row(
children: [
Icon(
FontAwesomeIcons.lightbulb,
size: 16,
color: AppColors.primaryBlue,
),
SizedBox(width: 8),
Text(
'Gợi ý viết đánh giá tốt',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.primaryBlue,
),
),
],
),
const SizedBox(height: 12),
// Guidelines List
...List.generate(_guidelines.length, (index) {
return Padding(
padding: EdgeInsets.only(
bottom: index < _guidelines.length - 1 ? 6 : 0,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'',
style: TextStyle(
fontSize: 14,
color: AppColors.grey900,
height: 1.6,
),
),
Expanded(
child: Text(
_guidelines[index],
style: const TextStyle(
fontSize: 14,
color: AppColors.grey900,
height: 1.6,
),
),
),
],
),
);
}),
],
),
);
}
}

View File

@@ -0,0 +1,117 @@
/// Widget: Star Rating Selector
///
/// Interactive 5-star rating widget with hover effects and visual feedback.
library;
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
/// Star Rating Selector Widget
///
/// Displays 5 clickable stars with:
/// - Empty stars by default (FontAwesomeIcons.star)
/// - Filled stars when selected (FontAwesomeIcons.solidStar)
/// - Hover effects (scale 1.1)
/// - Rating label below showing text
/// - Callback when rating changes
class StarRatingSelector extends StatefulWidget {
/// Current selected rating (0-5)
final int rating;
/// Callback when rating changes
final ValueChanged<int> onRatingChanged;
const StarRatingSelector({
super.key,
required this.rating,
required this.onRatingChanged,
});
@override
State<StarRatingSelector> createState() => _StarRatingSelectorState();
}
class _StarRatingSelectorState extends State<StarRatingSelector> {
// Rating label text mapping
static const Map<int, String> _ratingLabels = {
0: 'Chưa chọn đánh giá',
1: 'Rất không hài lòng',
2: 'Không hài lòng',
3: 'Bình thường',
4: 'Hài lòng',
5: 'Rất hài lòng',
};
// Colors
static const Color _starUnselected = Color(0xFFe0e0e0);
static const Color _starHover = Color(0xFFffc107);
static const Color _starSelected = Color(0xFFff9800);
static const Color _labelBackgroundSelected = Color(0xFFfff3e0);
int? _hoverRating;
@override
Widget build(BuildContext context) {
return Column(
children: [
// Stars Row
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(5, (index) {
final starIndex = index + 1;
final isSelected = starIndex <= widget.rating;
final isHovered = _hoverRating != null && starIndex <= _hoverRating!;
return MouseRegion(
onEnter: (_) => setState(() => _hoverRating = starIndex),
onExit: (_) => setState(() => _hoverRating = null),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => widget.onRatingChanged(starIndex),
child: AnimatedScale(
scale: isHovered ? 1.1 : 1.0,
duration: AppDuration.short,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Icon(
isSelected
? FontAwesomeIcons.solidStar
: FontAwesomeIcons.star,
size: 36,
color: isHovered
? _starHover
: (isSelected ? _starSelected : _starUnselected),
),
),
),
),
);
}),
),
const SizedBox(height: 12),
// Rating Label
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
decoration: BoxDecoration(
color: widget.rating > 0
? _labelBackgroundSelected
: AppColors.grey50,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_ratingLabels[widget.rating] ?? '',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: widget.rating > 0 ? _starSelected : AppColors.grey500,
),
),
),
],
);
}
}