Compare commits
2 Commits
49082026f5
...
ff3629d6d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff3629d6d1 | ||
|
|
0828ff1355 |
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
///
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,11 +246,12 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
child: StickyActionBar(
|
||||
quantity: _quantity,
|
||||
unit: product.unit ?? 'm²',
|
||||
conversionOfSm: product.conversionOfSm,
|
||||
uomFromIntroAttributes: product.getIntroAttribute('UOM'),
|
||||
onIncrease: _increaseQuantity,
|
||||
onDecrease: _decreaseQuantity,
|
||||
onQuantityChanged: _updateQuantity,
|
||||
onAddToCart: () => _addToCart(product),
|
||||
isOutOfStock: !product.inStock,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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}');
|
||||
|
||||
504
lib/features/products/presentation/pages/write_review_page.dart
Normal file
504
lib/features/products/presentation/pages/write_review_page.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = 'm²',
|
||||
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 m²';
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user