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/products/presentation/pages/product_detail_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/quotes/presentation/pages/quotes_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 ?? ''),
);
},
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
@@ -459,6 +474,7 @@ class RouteNames {
static const String home = '/';
static const String products = '/products';
static const String productDetail = '/products/:id';
static const String writeReview = 'write-review';
static const String cart = '/cart';
static const String favorites = '/favorites';
static const String checkout = '/checkout';

View File

@@ -11,6 +11,7 @@ library;
import 'package:flutter/material.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart';

View File

@@ -11,6 +11,7 @@ library;
import 'package:flutter/material.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:hooks_riverpod/hooks_riverpod.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_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.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:
/// - 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()
/// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation
@@ -24,7 +24,7 @@ const cartProvider = CartProvider._();
///
/// Manages cart state with API integration:
/// - 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()
/// - Local-only operations: selection, warehouse, calculations
/// - 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:
/// - 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()
/// - Local-only operations: selection, warehouse, calculations
/// - 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
///
/// Manages cart state with API integration:
/// - 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()
/// - Local-only operations: selection, warehouse, calculations
/// - 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_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';

View File

@@ -184,13 +184,20 @@ class ProductsRemoteDataSource {
/// Get products by category
///
/// Filters products by category.
/// For now, we fetch all products and filter locally.
/// Filters products by category with pagination support.
/// For now, we fetch products with pagination and filter locally.
/// In the future, the API might support category filtering.
Future<List<ProductModel>> getProductsByCategory(String categoryId) async {
// For now, fetch all products and filter locally
Future<List<ProductModel>> getProductsByCategory(
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
final allProducts = await getAllProducts();
final allProducts = await getAllProducts(
limitStart: limitStart,
limitPageLength: limitPageLength,
);
if (categoryId == 'all') {
return allProducts;

View File

@@ -30,6 +30,7 @@ class ProductModel extends HiveObject {
this.brand,
this.unit,
this.conversionOfSm,
this.introAttributes,
required this.isActive,
required this.isFeatured,
this.erpnextItemCode,
@@ -91,6 +92,12 @@ class ProductModel extends HiveObject {
@HiveField(17)
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
@HiveField(12)
final bool isActive;
@@ -137,6 +144,9 @@ class ProductModel extends HiveObject {
conversionOfSm: json['conversion_of_sm'] != null
? (json['conversion_of_sm'] as num).toDouble()
: null,
introAttributes: json['intro_attributes'] != null
? jsonEncode(json['intro_attributes'])
: null,
isActive: json['is_active'] as bool? ?? true,
isFeatured: json['is_featured'] as bool? ?? false,
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();
// 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
? (json['conversion_of_sm'] as num).toDouble()
: null,
introAttributes: introAttributesList.isNotEmpty
? jsonEncode(introAttributesList)
: null,
isActive: (json['disabled'] as int?) == 0, // Frappe uses 'disabled' field
isFeatured: false, // Not provided by API, default to false
erpnextItemCode: json['name'] as String, // Store item code for reference
@@ -283,6 +312,9 @@ class ProductModel extends HiveObject {
'brand': brand,
'unit': unit,
'conversion_of_sm': conversionOfSm,
'intro_attributes': introAttributes != null
? jsonDecode(introAttributes!)
: null,
'is_active': isActive,
'is_featured': isFeatured,
'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
String get formattedPrice {
return '${basePrice.toStringAsFixed(0)}đ';
@@ -363,6 +427,7 @@ class ProductModel extends HiveObject {
String? brand,
String? unit,
double? conversionOfSm,
String? introAttributes,
bool? isActive,
bool? isFeatured,
String? erpnextItemCode,
@@ -383,6 +448,7 @@ class ProductModel extends HiveObject {
brand: brand ?? this.brand,
unit: unit ?? this.unit,
conversionOfSm: conversionOfSm ?? this.conversionOfSm,
introAttributes: introAttributes ?? this.introAttributes,
isActive: isActive ?? this.isActive,
isFeatured: isFeatured ?? this.isFeatured,
erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode,
@@ -426,6 +492,7 @@ class ProductModel extends HiveObject {
brand: brand,
unit: unit,
conversionOfSm: conversionOfSm,
introAttributes: introAttributesList,
isActive: isActive,
isFeatured: isFeatured,
erpnextItemCode: erpnextItemCode,

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ class Product {
this.brand,
this.unit,
this.conversionOfSm,
this.introAttributes,
required this.isActive,
required this.isFeatured,
this.erpnextItemCode,
@@ -70,6 +71,11 @@ class Product {
/// Used to calculate: Số viên = Số lượng × 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
final bool isActive;
@@ -116,15 +122,25 @@ class Product {
/// TODO: Implement stock tracking when backend supports it
bool get isLowStock => false;
/// Check if product is in stock
/// Currently using isActive as proxy
bool get inStock => isActive;
/// Get specification value by key
String? getSpecification(String key) {
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
Product copyWith({
String? productId,
@@ -140,6 +156,7 @@ class Product {
String? brand,
String? unit,
double? conversionOfSm,
List<Map<String, String>>? introAttributes,
bool? isActive,
bool? isFeatured,
String? erpnextItemCode,
@@ -160,6 +177,7 @@ class Product {
brand: brand ?? this.brand,
unit: unit ?? this.unit,
conversionOfSm: conversionOfSm ?? this.conversionOfSm,
introAttributes: introAttributes ?? this.introAttributes,
isActive: isActive ?? this.isActive,
isFeatured: isFeatured ?? this.isFeatured,
erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode,

View File

@@ -14,9 +14,14 @@ import 'package:worker/features/products/domain/entities/product.dart';
abstract class ProductsRepository {
/// 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.
Future<List<Product>> getAllProducts();
Future<List<Product>> getAllProducts({
int limitStart = 0,
int limitPageLength = 12,
});
/// Search products by query
///
@@ -27,8 +32,14 @@ abstract class ProductsRepository {
/// Get products by category
///
/// [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.
Future<List<Product>> getProductsByCategory(String categoryId);
Future<List<Product>> getProductsByCategory(
String categoryId, {
int limitStart = 0,
int limitPageLength = 12,
});
/// Get product by ID
///

View File

@@ -17,12 +17,25 @@ class GetProducts {
/// Execute the use case
///
/// [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)
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') {
return await repository.getAllProducts();
return await repository.getAllProducts(
limitStart: limitStart,
limitPageLength: limitPageLength,
);
} 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(
quantity: _quantity,
unit: product.unit ?? '',
conversionOfSm: product.conversionOfSm,
uomFromIntroAttributes: product.getIntroAttribute('UOM'),
onIncrease: _increaseQuantity,
onDecrease: _decreaseQuantity,
onQuantityChanged: _updateQuantity,
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/theme/colors.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/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_grid.dart';
import 'package:worker/features/products/presentation/widgets/product_search_bar.dart';
@@ -34,7 +33,6 @@ class ProductsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final categoriesAsync = ref.watch(categoriesProvider);
final productsAsync = ref.watch(productsProvider);
final cartItemCount = ref.watch(cartItemCountProvider);
@@ -75,46 +73,35 @@ class ProductsPage extends ConsumerWidget {
children: [
// Search Bar with Filter Button
Padding(
padding: const EdgeInsets.all(AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.sm),
child: Row(
children: [
// Search Bar (Expanded)
const Expanded(child: ProductSearchBar()),
const SizedBox(width: 8),
// Filter Button
SizedBox(
Container(
height: InputFieldSpecs.height,
child: OutlinedButton.icon(
width: InputFieldSpecs.height,
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
),
child: IconButton(
onPressed: () {
// Open filter drawer from right
Scaffold.of(scaffoldContext).openEndDrawer();
},
icon: const FaIcon(FontAwesomeIcons.sliders, size: 18),
label: const Text('Lọc', style: TextStyle(fontSize: 12)),
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),
),
),
color: AppColors.grey900,
),
),
],
),
),
// Category Filter Chips
categoriesAsync.when(
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),
// Brand Filter Chips
const BrandFilterChips(),
// Products Grid
Expanded(
@@ -124,8 +111,16 @@ class ProductsPage extends ConsumerWidget {
return _buildEmptyState(context, l10n);
}
final productsNotifier = ref.read(productsProvider.notifier);
final hasMore = productsNotifier.hasMore;
return ProductGrid(
products: products,
hasMore: hasMore,
isLoadingMore: false,
onLoadMore: () async {
await productsNotifier.loadMore();
},
onProductTap: (product) {
// Navigate to product detail page
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/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/selected_brand_provider.dart';
import 'package:worker/features/products/presentation/providers/search_query_provider.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.
/// 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:
/// ```dart
@@ -64,38 +64,108 @@ Future<ProductsRepository> productsRepository(Ref ref) async {
/// ```
@riverpod
class Products extends _$Products {
static const int pageSize = 12;
int _currentPage = 0;
bool _hasMore = true;
@override
Future<List<Product>> build() async {
// Reset pagination when dependencies change
_currentPage = 0;
_hasMore = true;
// Watch dependencies
final selectedCategory = ref.watch(selectedCategoryProvider);
final selectedBrand = ref.watch(selectedBrandProvider);
final searchQuery = ref.watch(searchQueryProvider);
// Get repository with injected data sources
final repository = await ref.watch(productsRepositoryProvider.future);
// Apply filters
// Fetch first page of products
List<Product> products;
if (searchQuery.isNotEmpty) {
// Search takes precedence over category filter
// Search takes precedence over brand filter
final searchUseCase = SearchProducts(repository);
products = await searchUseCase(searchQuery);
// If a category is selected, filter search results by category
if (selectedCategory != 'all') {
// If a brand is selected, filter search results by brand
if (selectedBrand != 'all') {
products = products
.where((product) => product.categoryId == selectedCategory)
.where((product) => product.brand == selectedBrand)
.toList();
}
// For search, we fetch all results at once, so no more pages
_hasMore = false;
} else {
// No search query, use category filter
// No search query, fetch all products with pagination
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;
}
/// 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
///
/// 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.
/// 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:
/// ```dart
@@ -179,7 +179,7 @@ const productsProvider = ProductsProvider._();
///
/// Fetches and filters products based on selected category and search query.
/// 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:
/// ```dart
@@ -197,7 +197,7 @@ final class ProductsProvider
///
/// Fetches and filters products based on selected category and search query.
/// 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:
/// ```dart
@@ -228,13 +228,13 @@ final class ProductsProvider
Products create() => Products();
}
String _$productsHash() => r'b892402a88484d301cdabd1fde5822ddd29538bf';
String _$productsHash() => r'5fe0fdb46c3a6845327221ff26ba5f3624fcf3bf';
/// Products Provider
///
/// Fetches and filters products based on selected category and search query.
/// 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:
/// ```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,
height: 36.0,
child: ElevatedButton.icon(
onPressed: !product.inStock ? onAddToCart : null,
onPressed: onAddToCart,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
@@ -256,9 +254,9 @@ class ProductCard extends ConsumerWidget {
),
),
icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 14.0),
label: Text(
!product.inStock ? 'Thêm vào giỏ' : l10n.outOfStock,
style: const TextStyle(
label: const Text(
'Thêm vào giỏ',
style: TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.w600,
),

View File

@@ -4,8 +4,8 @@
library;
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.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';
@@ -15,12 +15,13 @@ import 'package:worker/features/products/domain/entities/product.dart';
/// - SKU text (small, gray)
/// - Product title (large, bold)
/// - 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 {
final Product product;
const ProductInfoSection({super.key, required this.product});
final Product product;
String _formatPrice(double price) {
final formatter = NumberFormat('#,###', 'vi_VN');
return '${formatter.format(price)} VND';
@@ -107,57 +108,114 @@ class ProductInfoSection extends StatelessWidget {
const SizedBox(height: 16),
// Quick Info Cards
Row(
// Rating & Reviews Section
const Row(
children: [
// Size Info
Expanded(
child: _QuickInfoCard(
icon: Icons.straighten, // expand icon
label: 'Kích thước',
value: product.getSpecification('size') ?? '1200x1200',
),
// Rating Stars
Row(
children: [
Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
SizedBox(width: 2),
Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
SizedBox(width: 2),
Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
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
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),
SizedBox(width: 12),
// Delivery Info
Expanded(
child: _QuickInfoCard(
icon: Icons.local_shipping_outlined, // truck icon
label: 'Giao hàng',
value: '2-3 Ngày',
// Rating Text
Text(
'4.8 (125 đánh giá)',
style: TextStyle(
fontSize: 14,
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
class _QuickInfoCard extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const _QuickInfoCard({
required this.icon,
required this.label,
required this.value,
});
final IconData icon;
final String label;
final String value;
@override
Widget build(BuildContext context) {
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/theme/colors.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
///
@@ -16,9 +17,9 @@ import 'package:worker/features/products/domain/entities/product.dart';
/// - Specifications: Table of product specs
/// - Reviews: Rating overview and review items
class ProductTabsSection extends StatefulWidget {
final Product product;
const ProductTabsSection({super.key, required this.product});
final Product product;
@override
State<ProductTabsSection> createState() => _ProductTabsSectionState();
@@ -33,6 +34,9 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
super.initState();
// Start with Specifications tab (index 0)
_tabController = TabController(length: 2, vsync: this, initialIndex: 0);
_tabController.addListener(() {
setState(() {}); // Update IndexedStack when tab changes
});
}
@override
@@ -75,16 +79,13 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
),
),
// Tab Content
SizedBox(
height: 400, // Fixed height for tab content
child: TabBarView(
controller: _tabController,
children: [
_SpecificationsTab(product: widget.product),
const _ReviewsTab(),
],
),
// Tab Content (expands to fit content)
IndexedStack(
index: _tabController.index,
children: [
_SpecificationsTab(product: widget.product),
_ReviewsTab(productId: widget.product.productId),
],
),
],
),
@@ -94,9 +95,9 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
/// Description Tab Content
class _DescriptionTab extends StatelessWidget {
final Product product;
const _DescriptionTab({required this.product});
final Product product;
@override
Widget build(BuildContext context) {
@@ -201,9 +202,9 @@ class _DescriptionTab extends StatelessWidget {
/// Specifications Tab Content
class _SpecificationsTab extends StatelessWidget {
final Product product;
const _SpecificationsTab({required this.product});
final Product product;
@override
Widget build(BuildContext context) {
@@ -223,14 +224,13 @@ class _SpecificationsTab extends StatelessWidget {
'Tiêu chuẩn': 'TCVN 9081:2012, ISO 13006',
};
return SingleChildScrollView(
child: Container(
margin: const EdgeInsets.all(20),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFe0e0e0)),
borderRadius: BorderRadius.circular(8),
),
child: Column(
return Container(
margin: const EdgeInsets.all(20),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFe0e0e0)),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: specs.entries.map((entry) {
final isLast = entry == specs.entries.last;
return Container(
@@ -283,7 +283,6 @@ class _SpecificationsTab extends StatelessWidget {
),
);
}).toList(),
),
),
);
}
@@ -291,14 +290,20 @@ class _SpecificationsTab extends StatelessWidget {
/// Reviews Tab Content
class _ReviewsTab extends StatelessWidget {
const _ReviewsTab();
const _ReviewsTab({required this.productId});
final String productId;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Write Review Button
WriteReviewButton(productId: productId),
// Rating Overview
Container(
padding: const EdgeInsets.all(20),
@@ -353,6 +358,7 @@ class _ReviewsTab extends StatelessWidget {
// Review Items
..._mockReviews.map((review) => _ReviewItem(review: review)),
const SizedBox(height: 48),
],
),
);
@@ -361,9 +367,9 @@ class _ReviewsTab extends StatelessWidget {
/// Review Item Widget
class _ReviewItem extends StatelessWidget {
final Map<String, dynamic> review;
const _ReviewItem({required this.review});
final Map<String, dynamic> review;
@override
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
/// - Add to cart button
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({
super.key,
@@ -30,15 +23,40 @@ class StickyActionBar extends StatelessWidget {
required this.onDecrease,
required this.onQuantityChanged,
required this.onAddToCart,
this.isOutOfStock = false,
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() {
// 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';
if (conversionOfSm == null || conversionOfSm == 0) {
return ''; // No conversion data available
}
// 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
@@ -46,8 +64,8 @@ class StickyActionBar extends StatelessWidget {
return Container(
decoration: BoxDecoration(
color: AppColors.white,
border: Border(
top: BorderSide(color: const Color(0xFFe0e0e0), width: 1),
border: const Border(
top: BorderSide(color: Color(0xFFe0e0e0), width: 1),
),
boxShadow: [
BoxShadow(
@@ -131,40 +149,39 @@ class StickyActionBar extends StatelessWidget {
const SizedBox(height: 4),
// Conversion Text
Text(
_getConversionText(),
style: const TextStyle(
fontSize: 11,
color: AppColors.grey500,
if (_getConversionText().isNotEmpty)
Text(
_getConversionText(),
style: const TextStyle(
fontSize: 11,
color: AppColors.grey500,
),
),
),
],
),
const SizedBox(width: 16),
const SizedBox(width: 8),
// Add to Cart Button
Expanded(
child: ElevatedButton.icon(
onPressed: isOutOfStock ? null : onAddToCart,
onPressed: onAddToCart,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
elevation: 0,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
horizontal: 12,
vertical: 8,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 18),
label: Text(
isOutOfStock ? 'Hết hàng' : 'Thêm vào giỏ hàng',
style: const TextStyle(
label: const Text(
'Thêm vào giỏ hàng',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
@@ -180,10 +197,10 @@ class StickyActionBar extends StatelessWidget {
/// Quantity Button Widget
class _QuantityButton extends StatelessWidget {
final IconData icon;
final VoidCallback? onPressed;
const _QuantityButton({required this.icon, this.onPressed});
final IconData icon;
final VoidCallback? onPressed;
@override
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
///
/// Grid view displaying product cards.
/// Grid view displaying product cards with pagination support.
library;
import 'package:flutter/material.dart';
@@ -10,22 +10,59 @@ import 'package:worker/features/products/presentation/widgets/product_card.dart'
/// Product Grid Widget
///
/// Displays products in a 2-column grid layout.
class ProductGrid extends StatelessWidget {
/// Displays products in a 2-column grid layout with scroll-to-load-more.
class ProductGrid extends StatefulWidget {
final List<Product> products;
final void Function(Product)? onProductTap;
final void Function(Product)? onAddToCart;
final VoidCallback? onLoadMore;
final bool hasMore;
final bool isLoadingMore;
const ProductGrid({
super.key,
required this.products,
this.onProductTap,
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
Widget build(BuildContext context) {
return GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(AppSpacing.xs),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: GridSpecs.productGridColumns,
@@ -33,14 +70,24 @@ class ProductGrid extends StatelessWidget {
mainAxisSpacing: AppSpacing.xs,
childAspectRatio: 0.62, // Width / Height ratio (adjusted for 2 buttons)
),
itemCount: products.length,
itemCount: widget.products.length + (widget.hasMore ? 1 : 0),
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(
product: product,
onTap: onProductTap != null ? () => onProductTap!(product) : null,
onAddToCart: onAddToCart != null ? () => onAddToCart!(product) : null,
onTap: widget.onProductTap != null ? () => widget.onProductTap!(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,
),
),
),
],
);
}
}