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/price_policy/price_policy.dart';
|
||||||
import 'package:worker/features/products/presentation/pages/product_detail_page.dart';
|
import 'package:worker/features/products/presentation/pages/product_detail_page.dart';
|
||||||
import 'package:worker/features/products/presentation/pages/products_page.dart';
|
import 'package:worker/features/products/presentation/pages/products_page.dart';
|
||||||
|
import 'package:worker/features/products/presentation/pages/write_review_page.dart';
|
||||||
import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart';
|
import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart';
|
||||||
import 'package:worker/features/quotes/presentation/pages/quotes_page.dart';
|
import 'package:worker/features/quotes/presentation/pages/quotes_page.dart';
|
||||||
import 'package:worker/features/showrooms/presentation/pages/design_request_create_page.dart';
|
import 'package:worker/features/showrooms/presentation/pages/design_request_create_page.dart';
|
||||||
@@ -181,6 +182,20 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
child: ProductDetailPage(productId: productId ?? ''),
|
child: ProductDetailPage(productId: productId ?? ''),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
routes: [
|
||||||
|
// Write Review Route (nested under product detail)
|
||||||
|
GoRoute(
|
||||||
|
path: 'write-review',
|
||||||
|
name: RouteNames.writeReview,
|
||||||
|
pageBuilder: (context, state) {
|
||||||
|
final productId = state.pathParameters['id'];
|
||||||
|
return MaterialPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: WriteReviewPage(productId: productId ?? ''),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Promotion Detail Route
|
// Promotion Detail Route
|
||||||
@@ -459,6 +474,7 @@ class RouteNames {
|
|||||||
static const String home = '/';
|
static const String home = '/';
|
||||||
static const String products = '/products';
|
static const String products = '/products';
|
||||||
static const String productDetail = '/products/:id';
|
static const String productDetail = '/products/:id';
|
||||||
|
static const String writeReview = 'write-review';
|
||||||
static const String cart = '/cart';
|
static const String cart = '/cart';
|
||||||
static const String favorites = '/favorites';
|
static const String favorites = '/favorites';
|
||||||
static const String checkout = '/checkout';
|
static const String checkout = '/checkout';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ library;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:worker/core/constants/ui_constants.dart';
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ library;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:worker/core/constants/ui_constants.dart';
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ part of 'cart_provider.dart';
|
|||||||
///
|
///
|
||||||
/// Manages cart state with API integration:
|
/// Manages cart state with API integration:
|
||||||
/// - Adding/removing items (syncs with API)
|
/// - Adding/removing items (syncs with API)
|
||||||
/// - Updating quantities (syncs with API with 5s debounce)
|
/// - Updating quantities (syncs with API with 3s debounce)
|
||||||
/// - Loading cart from API via initialize()
|
/// - Loading cart from API via initialize()
|
||||||
/// - Local-only operations: selection, warehouse, calculations
|
/// - Local-only operations: selection, warehouse, calculations
|
||||||
/// - keepAlive: true to maintain cart state across navigation
|
/// - keepAlive: true to maintain cart state across navigation
|
||||||
@@ -24,7 +24,7 @@ const cartProvider = CartProvider._();
|
|||||||
///
|
///
|
||||||
/// Manages cart state with API integration:
|
/// Manages cart state with API integration:
|
||||||
/// - Adding/removing items (syncs with API)
|
/// - Adding/removing items (syncs with API)
|
||||||
/// - Updating quantities (syncs with API with 5s debounce)
|
/// - Updating quantities (syncs with API with 3s debounce)
|
||||||
/// - Loading cart from API via initialize()
|
/// - Loading cart from API via initialize()
|
||||||
/// - Local-only operations: selection, warehouse, calculations
|
/// - Local-only operations: selection, warehouse, calculations
|
||||||
/// - keepAlive: true to maintain cart state across navigation
|
/// - keepAlive: true to maintain cart state across navigation
|
||||||
@@ -33,7 +33,7 @@ final class CartProvider extends $NotifierProvider<Cart, CartState> {
|
|||||||
///
|
///
|
||||||
/// Manages cart state with API integration:
|
/// Manages cart state with API integration:
|
||||||
/// - Adding/removing items (syncs with API)
|
/// - Adding/removing items (syncs with API)
|
||||||
/// - Updating quantities (syncs with API with 5s debounce)
|
/// - Updating quantities (syncs with API with 3s debounce)
|
||||||
/// - Loading cart from API via initialize()
|
/// - Loading cart from API via initialize()
|
||||||
/// - Local-only operations: selection, warehouse, calculations
|
/// - Local-only operations: selection, warehouse, calculations
|
||||||
/// - keepAlive: true to maintain cart state across navigation
|
/// - keepAlive: true to maintain cart state across navigation
|
||||||
@@ -64,13 +64,13 @@ final class CartProvider extends $NotifierProvider<Cart, CartState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$cartHash() => r'3bb1372a0e87268e35c7c8d424d2d8315b4d09b2';
|
String _$cartHash() => r'706de28734e7059b2e9484f3b1d94226a0e90bb9';
|
||||||
|
|
||||||
/// Cart Notifier
|
/// Cart Notifier
|
||||||
///
|
///
|
||||||
/// Manages cart state with API integration:
|
/// Manages cart state with API integration:
|
||||||
/// - Adding/removing items (syncs with API)
|
/// - Adding/removing items (syncs with API)
|
||||||
/// - Updating quantities (syncs with API with 5s debounce)
|
/// - Updating quantities (syncs with API with 3s debounce)
|
||||||
/// - Loading cart from API via initialize()
|
/// - Loading cart from API via initialize()
|
||||||
/// - Local-only operations: selection, warehouse, calculations
|
/// - Local-only operations: selection, warehouse, calculations
|
||||||
/// - keepAlive: true to maintain cart state across navigation
|
/// - keepAlive: true to maintain cart state across navigation
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ library;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart' hide Notification;
|
import 'package:flutter/material.dart' hide Notification;
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:worker/core/constants/ui_constants.dart';
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/theme/colors.dart';
|
import 'package:worker/core/theme/colors.dart';
|
||||||
|
|||||||
@@ -184,13 +184,20 @@ class ProductsRemoteDataSource {
|
|||||||
|
|
||||||
/// Get products by category
|
/// Get products by category
|
||||||
///
|
///
|
||||||
/// Filters products by category.
|
/// Filters products by category with pagination support.
|
||||||
/// For now, we fetch all products and filter locally.
|
/// For now, we fetch products with pagination and filter locally.
|
||||||
/// In the future, the API might support category filtering.
|
/// In the future, the API might support category filtering.
|
||||||
Future<List<ProductModel>> getProductsByCategory(String categoryId) async {
|
Future<List<ProductModel>> getProductsByCategory(
|
||||||
// For now, fetch all products and filter locally
|
String categoryId, {
|
||||||
|
int limitStart = 0,
|
||||||
|
int limitPageLength = 12,
|
||||||
|
}) async {
|
||||||
|
// Fetch products with pagination and filter locally
|
||||||
// TODO: Implement server-side category filtering if API supports it
|
// TODO: Implement server-side category filtering if API supports it
|
||||||
final allProducts = await getAllProducts();
|
final allProducts = await getAllProducts(
|
||||||
|
limitStart: limitStart,
|
||||||
|
limitPageLength: limitPageLength,
|
||||||
|
);
|
||||||
|
|
||||||
if (categoryId == 'all') {
|
if (categoryId == 'all') {
|
||||||
return allProducts;
|
return allProducts;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class ProductModel extends HiveObject {
|
|||||||
this.brand,
|
this.brand,
|
||||||
this.unit,
|
this.unit,
|
||||||
this.conversionOfSm,
|
this.conversionOfSm,
|
||||||
|
this.introAttributes,
|
||||||
required this.isActive,
|
required this.isActive,
|
||||||
required this.isFeatured,
|
required this.isFeatured,
|
||||||
this.erpnextItemCode,
|
this.erpnextItemCode,
|
||||||
@@ -91,6 +92,12 @@ class ProductModel extends HiveObject {
|
|||||||
@HiveField(17)
|
@HiveField(17)
|
||||||
final double? conversionOfSm;
|
final double? conversionOfSm;
|
||||||
|
|
||||||
|
/// Intro attributes (JSON encoded list)
|
||||||
|
/// Quick reference attributes from API: Size, Colour, UOM
|
||||||
|
/// Example: [{"code": "Size", "value": "120x120"}, {"code": "UOM", "value": "2 viên/hộp"}]
|
||||||
|
@HiveField(18)
|
||||||
|
final String? introAttributes;
|
||||||
|
|
||||||
/// Whether product is active
|
/// Whether product is active
|
||||||
@HiveField(12)
|
@HiveField(12)
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
@@ -137,6 +144,9 @@ class ProductModel extends HiveObject {
|
|||||||
conversionOfSm: json['conversion_of_sm'] != null
|
conversionOfSm: json['conversion_of_sm'] != null
|
||||||
? (json['conversion_of_sm'] as num).toDouble()
|
? (json['conversion_of_sm'] as num).toDouble()
|
||||||
: null,
|
: null,
|
||||||
|
introAttributes: json['intro_attributes'] != null
|
||||||
|
? jsonEncode(json['intro_attributes'])
|
||||||
|
: null,
|
||||||
isActive: json['is_active'] as bool? ?? true,
|
isActive: json['is_active'] as bool? ?? true,
|
||||||
isFeatured: json['is_featured'] as bool? ?? false,
|
isFeatured: json['is_featured'] as bool? ?? false,
|
||||||
erpnextItemCode: json['erpnext_item_code'] as String?,
|
erpnextItemCode: json['erpnext_item_code'] as String?,
|
||||||
@@ -223,6 +233,22 @@ class ProductModel extends HiveObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse intro_attributes array for quick reference
|
||||||
|
final List<Map<String, String>> introAttributesList = [];
|
||||||
|
if (json['intro_attributes'] != null && json['intro_attributes'] is List) {
|
||||||
|
final introAttrsData = json['intro_attributes'] as List;
|
||||||
|
for (final attr in introAttrsData) {
|
||||||
|
if (attr is Map<String, dynamic> &&
|
||||||
|
attr['code'] != null &&
|
||||||
|
attr['value'] != null) {
|
||||||
|
introAttributesList.add({
|
||||||
|
'code': attr['code'] as String,
|
||||||
|
'value': attr['value'] as String,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
// Handle price from both product detail (price) and product list (standard_rate)
|
// Handle price from both product detail (price) and product list (standard_rate)
|
||||||
@@ -251,6 +277,9 @@ class ProductModel extends HiveObject {
|
|||||||
conversionOfSm: json['conversion_of_sm'] != null
|
conversionOfSm: json['conversion_of_sm'] != null
|
||||||
? (json['conversion_of_sm'] as num).toDouble()
|
? (json['conversion_of_sm'] as num).toDouble()
|
||||||
: null,
|
: null,
|
||||||
|
introAttributes: introAttributesList.isNotEmpty
|
||||||
|
? jsonEncode(introAttributesList)
|
||||||
|
: null,
|
||||||
isActive: (json['disabled'] as int?) == 0, // Frappe uses 'disabled' field
|
isActive: (json['disabled'] as int?) == 0, // Frappe uses 'disabled' field
|
||||||
isFeatured: false, // Not provided by API, default to false
|
isFeatured: false, // Not provided by API, default to false
|
||||||
erpnextItemCode: json['name'] as String, // Store item code for reference
|
erpnextItemCode: json['name'] as String, // Store item code for reference
|
||||||
@@ -283,6 +312,9 @@ class ProductModel extends HiveObject {
|
|||||||
'brand': brand,
|
'brand': brand,
|
||||||
'unit': unit,
|
'unit': unit,
|
||||||
'conversion_of_sm': conversionOfSm,
|
'conversion_of_sm': conversionOfSm,
|
||||||
|
'intro_attributes': introAttributes != null
|
||||||
|
? jsonDecode(introAttributes!)
|
||||||
|
: null,
|
||||||
'is_active': isActive,
|
'is_active': isActive,
|
||||||
'is_featured': isFeatured,
|
'is_featured': isFeatured,
|
||||||
'erpnext_item_code': erpnextItemCode,
|
'erpnext_item_code': erpnextItemCode,
|
||||||
@@ -336,6 +368,38 @@ class ProductModel extends HiveObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get intro attributes as List
|
||||||
|
List<Map<String, String>>? get introAttributesList {
|
||||||
|
if (introAttributes == null) return null;
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(introAttributes!) as List;
|
||||||
|
return decoded.map((e) {
|
||||||
|
final map = e as Map<String, dynamic>;
|
||||||
|
return {
|
||||||
|
'code': map['code'].toString(),
|
||||||
|
'value': map['value'].toString(),
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get specific intro attribute value by code
|
||||||
|
String? getIntroAttribute(String code) {
|
||||||
|
final attrs = introAttributesList;
|
||||||
|
if (attrs == null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final attr = attrs.firstWhere(
|
||||||
|
(attr) => attr['code']?.toLowerCase() == code.toLowerCase(),
|
||||||
|
);
|
||||||
|
return attr['value'];
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get formatted price with currency
|
/// Get formatted price with currency
|
||||||
String get formattedPrice {
|
String get formattedPrice {
|
||||||
return '${basePrice.toStringAsFixed(0)}đ';
|
return '${basePrice.toStringAsFixed(0)}đ';
|
||||||
@@ -363,6 +427,7 @@ class ProductModel extends HiveObject {
|
|||||||
String? brand,
|
String? brand,
|
||||||
String? unit,
|
String? unit,
|
||||||
double? conversionOfSm,
|
double? conversionOfSm,
|
||||||
|
String? introAttributes,
|
||||||
bool? isActive,
|
bool? isActive,
|
||||||
bool? isFeatured,
|
bool? isFeatured,
|
||||||
String? erpnextItemCode,
|
String? erpnextItemCode,
|
||||||
@@ -383,6 +448,7 @@ class ProductModel extends HiveObject {
|
|||||||
brand: brand ?? this.brand,
|
brand: brand ?? this.brand,
|
||||||
unit: unit ?? this.unit,
|
unit: unit ?? this.unit,
|
||||||
conversionOfSm: conversionOfSm ?? this.conversionOfSm,
|
conversionOfSm: conversionOfSm ?? this.conversionOfSm,
|
||||||
|
introAttributes: introAttributes ?? this.introAttributes,
|
||||||
isActive: isActive ?? this.isActive,
|
isActive: isActive ?? this.isActive,
|
||||||
isFeatured: isFeatured ?? this.isFeatured,
|
isFeatured: isFeatured ?? this.isFeatured,
|
||||||
erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode,
|
erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode,
|
||||||
@@ -426,6 +492,7 @@ class ProductModel extends HiveObject {
|
|||||||
brand: brand,
|
brand: brand,
|
||||||
unit: unit,
|
unit: unit,
|
||||||
conversionOfSm: conversionOfSm,
|
conversionOfSm: conversionOfSm,
|
||||||
|
introAttributes: introAttributesList,
|
||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
isFeatured: isFeatured,
|
isFeatured: isFeatured,
|
||||||
erpnextItemCode: erpnextItemCode,
|
erpnextItemCode: erpnextItemCode,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
|
|||||||
brand: fields[10] as String?,
|
brand: fields[10] as String?,
|
||||||
unit: fields[11] as String?,
|
unit: fields[11] as String?,
|
||||||
conversionOfSm: (fields[17] as num?)?.toDouble(),
|
conversionOfSm: (fields[17] as num?)?.toDouble(),
|
||||||
|
introAttributes: fields[18] as String?,
|
||||||
isActive: fields[12] as bool,
|
isActive: fields[12] as bool,
|
||||||
isFeatured: fields[13] as bool,
|
isFeatured: fields[13] as bool,
|
||||||
erpnextItemCode: fields[14] as String?,
|
erpnextItemCode: fields[14] as String?,
|
||||||
@@ -41,7 +42,7 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
|
|||||||
@override
|
@override
|
||||||
void write(BinaryWriter writer, ProductModel obj) {
|
void write(BinaryWriter writer, ProductModel obj) {
|
||||||
writer
|
writer
|
||||||
..writeByte(18)
|
..writeByte(19)
|
||||||
..writeByte(0)
|
..writeByte(0)
|
||||||
..write(obj.productId)
|
..write(obj.productId)
|
||||||
..writeByte(1)
|
..writeByte(1)
|
||||||
@@ -77,7 +78,9 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
|
|||||||
..writeByte(16)
|
..writeByte(16)
|
||||||
..write(obj.updatedAt)
|
..write(obj.updatedAt)
|
||||||
..writeByte(17)
|
..writeByte(17)
|
||||||
..write(obj.conversionOfSm);
|
..write(obj.conversionOfSm)
|
||||||
|
..writeByte(18)
|
||||||
|
..write(obj.introAttributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -25,10 +25,16 @@ class ProductsRepositoryImpl implements ProductsRepository {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Product>> getAllProducts() async {
|
Future<List<Product>> getAllProducts({
|
||||||
|
int limitStart = 0,
|
||||||
|
int limitPageLength = 12,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Fetch from Frappe API
|
// Fetch from Frappe API with pagination
|
||||||
final productModels = await remoteDataSource.getAllProducts();
|
final productModels = await remoteDataSource.getAllProducts(
|
||||||
|
limitStart: limitStart,
|
||||||
|
limitPageLength: limitPageLength,
|
||||||
|
);
|
||||||
return productModels.map((model) => model.toEntity()).toList();
|
return productModels.map((model) => model.toEntity()).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[ProductsRepository] Error getting products: $e');
|
print('[ProductsRepository] Error getting products: $e');
|
||||||
@@ -49,11 +55,17 @@ class ProductsRepositoryImpl implements ProductsRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Product>> getProductsByCategory(String categoryId) async {
|
Future<List<Product>> getProductsByCategory(
|
||||||
|
String categoryId, {
|
||||||
|
int limitStart = 0,
|
||||||
|
int limitPageLength = 12,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Filter by category via remote API
|
// Filter by category via remote API with pagination
|
||||||
final productModels = await remoteDataSource.getProductsByCategory(
|
final productModels = await remoteDataSource.getProductsByCategory(
|
||||||
categoryId,
|
categoryId,
|
||||||
|
limitStart: limitStart,
|
||||||
|
limitPageLength: limitPageLength,
|
||||||
);
|
);
|
||||||
return productModels.map((model) => model.toEntity()).toList();
|
return productModels.map((model) => model.toEntity()).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class Product {
|
|||||||
this.brand,
|
this.brand,
|
||||||
this.unit,
|
this.unit,
|
||||||
this.conversionOfSm,
|
this.conversionOfSm,
|
||||||
|
this.introAttributes,
|
||||||
required this.isActive,
|
required this.isActive,
|
||||||
required this.isFeatured,
|
required this.isFeatured,
|
||||||
this.erpnextItemCode,
|
this.erpnextItemCode,
|
||||||
@@ -70,6 +71,11 @@ class Product {
|
|||||||
/// Used to calculate: Số viên = Số lượng × conversionOfSm
|
/// Used to calculate: Số viên = Số lượng × conversionOfSm
|
||||||
final double? conversionOfSm;
|
final double? conversionOfSm;
|
||||||
|
|
||||||
|
/// Intro attributes (quick reference)
|
||||||
|
/// List of maps with 'code' and 'value' keys
|
||||||
|
/// Example: [{"code": "Size", "value": "120x120"}, {"code": "UOM", "value": "2 viên/hộp"}]
|
||||||
|
final List<Map<String, String>>? introAttributes;
|
||||||
|
|
||||||
/// Product is active
|
/// Product is active
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
|
|
||||||
@@ -116,15 +122,25 @@ class Product {
|
|||||||
/// TODO: Implement stock tracking when backend supports it
|
/// TODO: Implement stock tracking when backend supports it
|
||||||
bool get isLowStock => false;
|
bool get isLowStock => false;
|
||||||
|
|
||||||
/// Check if product is in stock
|
|
||||||
/// Currently using isActive as proxy
|
|
||||||
bool get inStock => isActive;
|
|
||||||
|
|
||||||
/// Get specification value by key
|
/// Get specification value by key
|
||||||
String? getSpecification(String key) {
|
String? getSpecification(String key) {
|
||||||
return specifications[key]?.toString();
|
return specifications[key]?.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get intro attribute value by code
|
||||||
|
String? getIntroAttribute(String code) {
|
||||||
|
if (introAttributes == null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final attr = introAttributes!.firstWhere(
|
||||||
|
(attr) => attr['code']?.toLowerCase() == code.toLowerCase(),
|
||||||
|
);
|
||||||
|
return attr['value'];
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Copy with method for creating modified copies
|
/// Copy with method for creating modified copies
|
||||||
Product copyWith({
|
Product copyWith({
|
||||||
String? productId,
|
String? productId,
|
||||||
@@ -140,6 +156,7 @@ class Product {
|
|||||||
String? brand,
|
String? brand,
|
||||||
String? unit,
|
String? unit,
|
||||||
double? conversionOfSm,
|
double? conversionOfSm,
|
||||||
|
List<Map<String, String>>? introAttributes,
|
||||||
bool? isActive,
|
bool? isActive,
|
||||||
bool? isFeatured,
|
bool? isFeatured,
|
||||||
String? erpnextItemCode,
|
String? erpnextItemCode,
|
||||||
@@ -160,6 +177,7 @@ class Product {
|
|||||||
brand: brand ?? this.brand,
|
brand: brand ?? this.brand,
|
||||||
unit: unit ?? this.unit,
|
unit: unit ?? this.unit,
|
||||||
conversionOfSm: conversionOfSm ?? this.conversionOfSm,
|
conversionOfSm: conversionOfSm ?? this.conversionOfSm,
|
||||||
|
introAttributes: introAttributes ?? this.introAttributes,
|
||||||
isActive: isActive ?? this.isActive,
|
isActive: isActive ?? this.isActive,
|
||||||
isFeatured: isFeatured ?? this.isFeatured,
|
isFeatured: isFeatured ?? this.isFeatured,
|
||||||
erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode,
|
erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode,
|
||||||
|
|||||||
@@ -14,9 +14,14 @@ import 'package:worker/features/products/domain/entities/product.dart';
|
|||||||
abstract class ProductsRepository {
|
abstract class ProductsRepository {
|
||||||
/// Get all products
|
/// Get all products
|
||||||
///
|
///
|
||||||
/// Returns a list of all available products.
|
/// Returns a list of all available products with pagination support.
|
||||||
|
/// [limitStart] - Starting index for pagination (default: 0)
|
||||||
|
/// [limitPageLength] - Number of items per page (default: 12)
|
||||||
/// Throws an exception if the operation fails.
|
/// Throws an exception if the operation fails.
|
||||||
Future<List<Product>> getAllProducts();
|
Future<List<Product>> getAllProducts({
|
||||||
|
int limitStart = 0,
|
||||||
|
int limitPageLength = 12,
|
||||||
|
});
|
||||||
|
|
||||||
/// Search products by query
|
/// Search products by query
|
||||||
///
|
///
|
||||||
@@ -27,8 +32,14 @@ abstract class ProductsRepository {
|
|||||||
/// Get products by category
|
/// Get products by category
|
||||||
///
|
///
|
||||||
/// [categoryId] - Category ID to filter by
|
/// [categoryId] - Category ID to filter by
|
||||||
|
/// [limitStart] - Starting index for pagination (default: 0)
|
||||||
|
/// [limitPageLength] - Number of items per page (default: 12)
|
||||||
/// Returns list of products in the specified category.
|
/// Returns list of products in the specified category.
|
||||||
Future<List<Product>> getProductsByCategory(String categoryId);
|
Future<List<Product>> getProductsByCategory(
|
||||||
|
String categoryId, {
|
||||||
|
int limitStart = 0,
|
||||||
|
int limitPageLength = 12,
|
||||||
|
});
|
||||||
|
|
||||||
/// Get product by ID
|
/// Get product by ID
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -17,12 +17,25 @@ class GetProducts {
|
|||||||
/// Execute the use case
|
/// Execute the use case
|
||||||
///
|
///
|
||||||
/// [categoryId] - Optional category ID to filter products
|
/// [categoryId] - Optional category ID to filter products
|
||||||
|
/// [limitStart] - Starting index for pagination (default: 0)
|
||||||
|
/// [limitPageLength] - Number of items per page (default: 12)
|
||||||
/// Returns list of products (all or filtered by category)
|
/// Returns list of products (all or filtered by category)
|
||||||
Future<List<Product>> call({String? categoryId}) async {
|
Future<List<Product>> call({
|
||||||
|
String? categoryId,
|
||||||
|
int limitStart = 0,
|
||||||
|
int limitPageLength = 12,
|
||||||
|
}) async {
|
||||||
if (categoryId == null || categoryId == 'all') {
|
if (categoryId == null || categoryId == 'all') {
|
||||||
return await repository.getAllProducts();
|
return await repository.getAllProducts(
|
||||||
|
limitStart: limitStart,
|
||||||
|
limitPageLength: limitPageLength,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return await repository.getProductsByCategory(categoryId);
|
return await repository.getProductsByCategory(
|
||||||
|
categoryId,
|
||||||
|
limitStart: limitStart,
|
||||||
|
limitPageLength: limitPageLength,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,11 +246,12 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
|||||||
child: StickyActionBar(
|
child: StickyActionBar(
|
||||||
quantity: _quantity,
|
quantity: _quantity,
|
||||||
unit: product.unit ?? 'm²',
|
unit: product.unit ?? 'm²',
|
||||||
|
conversionOfSm: product.conversionOfSm,
|
||||||
|
uomFromIntroAttributes: product.getIntroAttribute('UOM'),
|
||||||
onIncrease: _increaseQuantity,
|
onIncrease: _increaseQuantity,
|
||||||
onDecrease: _decreaseQuantity,
|
onDecrease: _decreaseQuantity,
|
||||||
onQuantityChanged: _updateQuantity,
|
onQuantityChanged: _updateQuantity,
|
||||||
onAddToCart: () => _addToCart(product),
|
onAddToCart: () => _addToCart(product),
|
||||||
isOutOfStock: !product.inStock,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ import 'package:worker/core/constants/ui_constants.dart';
|
|||||||
import 'package:worker/core/router/app_router.dart';
|
import 'package:worker/core/router/app_router.dart';
|
||||||
import 'package:worker/core/theme/colors.dart';
|
import 'package:worker/core/theme/colors.dart';
|
||||||
import 'package:worker/features/cart/presentation/providers/cart_provider.dart';
|
import 'package:worker/features/cart/presentation/providers/cart_provider.dart';
|
||||||
import 'package:worker/features/products/presentation/providers/categories_provider.dart';
|
|
||||||
import 'package:worker/features/products/presentation/providers/product_filter_options_provider.dart';
|
import 'package:worker/features/products/presentation/providers/product_filter_options_provider.dart';
|
||||||
import 'package:worker/features/products/presentation/providers/products_provider.dart';
|
import 'package:worker/features/products/presentation/providers/products_provider.dart';
|
||||||
import 'package:worker/features/products/presentation/widgets/category_filter_chips.dart';
|
import 'package:worker/features/products/presentation/widgets/brand_filter_chips.dart';
|
||||||
import 'package:worker/features/products/presentation/widgets/product_filter_drawer.dart';
|
import 'package:worker/features/products/presentation/widgets/product_filter_drawer.dart';
|
||||||
import 'package:worker/features/products/presentation/widgets/product_grid.dart';
|
import 'package:worker/features/products/presentation/widgets/product_grid.dart';
|
||||||
import 'package:worker/features/products/presentation/widgets/product_search_bar.dart';
|
import 'package:worker/features/products/presentation/widgets/product_search_bar.dart';
|
||||||
@@ -34,7 +33,6 @@ class ProductsPage extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final categoriesAsync = ref.watch(categoriesProvider);
|
|
||||||
final productsAsync = ref.watch(productsProvider);
|
final productsAsync = ref.watch(productsProvider);
|
||||||
final cartItemCount = ref.watch(cartItemCountProvider);
|
final cartItemCount = ref.watch(cartItemCountProvider);
|
||||||
|
|
||||||
@@ -75,46 +73,35 @@ class ProductsPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Search Bar with Filter Button
|
// Search Bar with Filter Button
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Search Bar (Expanded)
|
// Search Bar (Expanded)
|
||||||
const Expanded(child: ProductSearchBar()),
|
const Expanded(child: ProductSearchBar()),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
// Filter Button
|
// Filter Button
|
||||||
SizedBox(
|
Container(
|
||||||
height: InputFieldSpecs.height,
|
height: InputFieldSpecs.height,
|
||||||
child: OutlinedButton.icon(
|
width: InputFieldSpecs.height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// Open filter drawer from right
|
// Open filter drawer from right
|
||||||
Scaffold.of(scaffoldContext).openEndDrawer();
|
Scaffold.of(scaffoldContext).openEndDrawer();
|
||||||
},
|
},
|
||||||
icon: const FaIcon(FontAwesomeIcons.sliders, size: 18),
|
icon: const FaIcon(FontAwesomeIcons.sliders, size: 18),
|
||||||
label: const Text('Lọc', style: TextStyle(fontSize: 12)),
|
color: AppColors.grey900,
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.grey900,
|
|
||||||
side: const BorderSide(color: AppColors.white, width: 0),
|
|
||||||
backgroundColor: AppColors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Category Filter Chips
|
// Brand Filter Chips
|
||||||
categoriesAsync.when(
|
const BrandFilterChips(),
|
||||||
data: (categories) => CategoryFilterChips(categories: categories),
|
|
||||||
loading: () =>
|
|
||||||
const SizedBox(height: 48.0, child: Center(child: CircularProgressIndicator(strokeWidth: 2.0))),
|
|
||||||
error: (error, stack) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: AppSpacing.sm),
|
|
||||||
|
|
||||||
// Products Grid
|
// Products Grid
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -124,8 +111,16 @@ class ProductsPage extends ConsumerWidget {
|
|||||||
return _buildEmptyState(context, l10n);
|
return _buildEmptyState(context, l10n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final productsNotifier = ref.read(productsProvider.notifier);
|
||||||
|
final hasMore = productsNotifier.hasMore;
|
||||||
|
|
||||||
return ProductGrid(
|
return ProductGrid(
|
||||||
products: products,
|
products: products,
|
||||||
|
hasMore: hasMore,
|
||||||
|
isLoadingMore: false,
|
||||||
|
onLoadMore: () async {
|
||||||
|
await productsNotifier.loadMore();
|
||||||
|
},
|
||||||
onProductTap: (product) {
|
onProductTap: (product) {
|
||||||
// Navigate to product detail page
|
// Navigate to product detail page
|
||||||
context.push('/products/${product.productId}');
|
context.push('/products/${product.productId}');
|
||||||
|
|||||||
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/get_products.dart';
|
||||||
import 'package:worker/features/products/domain/usecases/search_products.dart';
|
import 'package:worker/features/products/domain/usecases/search_products.dart';
|
||||||
import 'package:worker/features/products/domain/usecases/get_product_detail.dart';
|
import 'package:worker/features/products/domain/usecases/get_product_detail.dart';
|
||||||
import 'package:worker/features/products/presentation/providers/selected_category_provider.dart';
|
import 'package:worker/features/products/presentation/providers/selected_brand_provider.dart';
|
||||||
import 'package:worker/features/products/presentation/providers/search_query_provider.dart';
|
import 'package:worker/features/products/presentation/providers/search_query_provider.dart';
|
||||||
|
|
||||||
part 'products_provider.g.dart';
|
part 'products_provider.g.dart';
|
||||||
@@ -50,7 +50,7 @@ Future<ProductsRepository> productsRepository(Ref ref) async {
|
|||||||
///
|
///
|
||||||
/// Fetches and filters products based on selected category and search query.
|
/// Fetches and filters products based on selected category and search query.
|
||||||
/// Automatically updates when category or search query changes.
|
/// Automatically updates when category or search query changes.
|
||||||
/// Data is fetched from Frappe ERPNext API.
|
/// Data is fetched from Frappe ERPNext API with pagination support.
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
@@ -64,38 +64,108 @@ Future<ProductsRepository> productsRepository(Ref ref) async {
|
|||||||
/// ```
|
/// ```
|
||||||
@riverpod
|
@riverpod
|
||||||
class Products extends _$Products {
|
class Products extends _$Products {
|
||||||
|
static const int pageSize = 12;
|
||||||
|
int _currentPage = 0;
|
||||||
|
bool _hasMore = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Product>> build() async {
|
Future<List<Product>> build() async {
|
||||||
|
// Reset pagination when dependencies change
|
||||||
|
_currentPage = 0;
|
||||||
|
_hasMore = true;
|
||||||
|
|
||||||
// Watch dependencies
|
// Watch dependencies
|
||||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
final selectedBrand = ref.watch(selectedBrandProvider);
|
||||||
final searchQuery = ref.watch(searchQueryProvider);
|
final searchQuery = ref.watch(searchQueryProvider);
|
||||||
|
|
||||||
// Get repository with injected data sources
|
// Get repository with injected data sources
|
||||||
final repository = await ref.watch(productsRepositoryProvider.future);
|
final repository = await ref.watch(productsRepositoryProvider.future);
|
||||||
|
|
||||||
// Apply filters
|
// Fetch first page of products
|
||||||
List<Product> products;
|
List<Product> products;
|
||||||
|
|
||||||
if (searchQuery.isNotEmpty) {
|
if (searchQuery.isNotEmpty) {
|
||||||
// Search takes precedence over category filter
|
// Search takes precedence over brand filter
|
||||||
final searchUseCase = SearchProducts(repository);
|
final searchUseCase = SearchProducts(repository);
|
||||||
products = await searchUseCase(searchQuery);
|
products = await searchUseCase(searchQuery);
|
||||||
|
|
||||||
// If a category is selected, filter search results by category
|
// If a brand is selected, filter search results by brand
|
||||||
if (selectedCategory != 'all') {
|
if (selectedBrand != 'all') {
|
||||||
products = products
|
products = products
|
||||||
.where((product) => product.categoryId == selectedCategory)
|
.where((product) => product.brand == selectedBrand)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For search, we fetch all results at once, so no more pages
|
||||||
|
_hasMore = false;
|
||||||
} else {
|
} else {
|
||||||
// No search query, use category filter
|
// No search query, fetch all products with pagination
|
||||||
final getProductsUseCase = GetProducts(repository);
|
final getProductsUseCase = GetProducts(repository);
|
||||||
products = await getProductsUseCase(categoryId: selectedCategory);
|
products = await getProductsUseCase(
|
||||||
|
limitStart: 0,
|
||||||
|
limitPageLength: pageSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter by brand if not 'all'
|
||||||
|
if (selectedBrand != 'all') {
|
||||||
|
products = products
|
||||||
|
.where((product) => product.brand == selectedBrand)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we got less than pageSize, there are no more products
|
||||||
|
_hasMore = products.length >= pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPage = 1;
|
||||||
return products;
|
return products;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load more products (next page)
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (!_hasMore) return;
|
||||||
|
|
||||||
|
// Watch dependencies to get current filters
|
||||||
|
final selectedBrand = ref.read(selectedBrandProvider);
|
||||||
|
final searchQuery = ref.read(searchQueryProvider);
|
||||||
|
|
||||||
|
// Don't paginate search results (already fetched all)
|
||||||
|
if (searchQuery.isNotEmpty) return;
|
||||||
|
|
||||||
|
// Get repository
|
||||||
|
final repository = await ref.read(productsRepositoryProvider.future);
|
||||||
|
|
||||||
|
// Calculate pagination parameters
|
||||||
|
final limitStart = _currentPage * pageSize;
|
||||||
|
|
||||||
|
// Fetch next page from API
|
||||||
|
final getProductsUseCase = GetProducts(repository);
|
||||||
|
var newProducts = await getProductsUseCase(
|
||||||
|
limitStart: limitStart,
|
||||||
|
limitPageLength: pageSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter by brand if not 'all'
|
||||||
|
if (selectedBrand != 'all') {
|
||||||
|
newProducts = newProducts
|
||||||
|
.where((product) => product.brand == selectedBrand)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got less than pageSize, there are no more products
|
||||||
|
_hasMore = newProducts.length >= pageSize;
|
||||||
|
|
||||||
|
// Increment page counter
|
||||||
|
_currentPage++;
|
||||||
|
|
||||||
|
// Append new products to existing list
|
||||||
|
final currentProducts = state.value ?? [];
|
||||||
|
state = AsyncValue.data([...currentProducts, ...newProducts]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if there are more products to load
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
|
||||||
/// Refresh products data
|
/// Refresh products data
|
||||||
///
|
///
|
||||||
/// Forces a refresh from the datasource.
|
/// Forces a refresh from the datasource.
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ String _$productsRepositoryHash() =>
|
|||||||
///
|
///
|
||||||
/// Fetches and filters products based on selected category and search query.
|
/// Fetches and filters products based on selected category and search query.
|
||||||
/// Automatically updates when category or search query changes.
|
/// Automatically updates when category or search query changes.
|
||||||
/// Data is fetched from Frappe ERPNext API.
|
/// Data is fetched from Frappe ERPNext API with pagination support.
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
@@ -179,7 +179,7 @@ const productsProvider = ProductsProvider._();
|
|||||||
///
|
///
|
||||||
/// Fetches and filters products based on selected category and search query.
|
/// Fetches and filters products based on selected category and search query.
|
||||||
/// Automatically updates when category or search query changes.
|
/// Automatically updates when category or search query changes.
|
||||||
/// Data is fetched from Frappe ERPNext API.
|
/// Data is fetched from Frappe ERPNext API with pagination support.
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
@@ -197,7 +197,7 @@ final class ProductsProvider
|
|||||||
///
|
///
|
||||||
/// Fetches and filters products based on selected category and search query.
|
/// Fetches and filters products based on selected category and search query.
|
||||||
/// Automatically updates when category or search query changes.
|
/// Automatically updates when category or search query changes.
|
||||||
/// Data is fetched from Frappe ERPNext API.
|
/// Data is fetched from Frappe ERPNext API with pagination support.
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
@@ -228,13 +228,13 @@ final class ProductsProvider
|
|||||||
Products create() => Products();
|
Products create() => Products();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$productsHash() => r'b892402a88484d301cdabd1fde5822ddd29538bf';
|
String _$productsHash() => r'5fe0fdb46c3a6845327221ff26ba5f3624fcf3bf';
|
||||||
|
|
||||||
/// Products Provider
|
/// Products Provider
|
||||||
///
|
///
|
||||||
/// Fetches and filters products based on selected category and search query.
|
/// Fetches and filters products based on selected category and search query.
|
||||||
/// Automatically updates when category or search query changes.
|
/// Automatically updates when category or search query changes.
|
||||||
/// Data is fetched from Frappe ERPNext API.
|
/// Data is fetched from Frappe ERPNext API with pagination support.
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
|
|||||||
@@ -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,
|
width: double.infinity,
|
||||||
height: 36.0,
|
height: 36.0,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: !product.inStock ? onAddToCart : null,
|
onPressed: onAddToCart,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: AppColors.primaryBlue,
|
||||||
foregroundColor: AppColors.white,
|
foregroundColor: AppColors.white,
|
||||||
disabledBackgroundColor: AppColors.grey100,
|
|
||||||
disabledForegroundColor: AppColors.grey500,
|
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
@@ -256,9 +254,9 @@ class ProductCard extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 14.0),
|
icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 14.0),
|
||||||
label: Text(
|
label: const Text(
|
||||||
!product.inStock ? 'Thêm vào giỏ' : l10n.outOfStock,
|
'Thêm vào giỏ',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12.0,
|
fontSize: 12.0,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:worker/core/constants/ui_constants.dart';
|
|
||||||
import 'package:worker/core/theme/colors.dart';
|
import 'package:worker/core/theme/colors.dart';
|
||||||
import 'package:worker/features/products/domain/entities/product.dart';
|
import 'package:worker/features/products/domain/entities/product.dart';
|
||||||
|
|
||||||
@@ -15,12 +15,13 @@ import 'package:worker/features/products/domain/entities/product.dart';
|
|||||||
/// - SKU text (small, gray)
|
/// - SKU text (small, gray)
|
||||||
/// - Product title (large, bold)
|
/// - Product title (large, bold)
|
||||||
/// - Pricing row (current price, original price, discount badge)
|
/// - Pricing row (current price, original price, discount badge)
|
||||||
/// - Quick info cards (3 cards: Size, Warranty, Delivery)
|
/// - Rating section (5 stars with review count)
|
||||||
|
/// - Dynamic intro attribute cards (shown only if non-null)
|
||||||
class ProductInfoSection extends StatelessWidget {
|
class ProductInfoSection extends StatelessWidget {
|
||||||
final Product product;
|
|
||||||
|
|
||||||
const ProductInfoSection({super.key, required this.product});
|
const ProductInfoSection({super.key, required this.product});
|
||||||
|
|
||||||
|
final Product product;
|
||||||
|
|
||||||
String _formatPrice(double price) {
|
String _formatPrice(double price) {
|
||||||
final formatter = NumberFormat('#,###', 'vi_VN');
|
final formatter = NumberFormat('#,###', 'vi_VN');
|
||||||
return '${formatter.format(price)} VND';
|
return '${formatter.format(price)} VND';
|
||||||
@@ -107,57 +108,114 @@ class ProductInfoSection extends StatelessWidget {
|
|||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Quick Info Cards
|
// Rating & Reviews Section
|
||||||
|
const Row(
|
||||||
|
children: [
|
||||||
|
// Rating Stars
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// Size Info
|
Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
|
||||||
Expanded(
|
SizedBox(width: 2),
|
||||||
child: _QuickInfoCard(
|
Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
|
||||||
icon: Icons.straighten, // expand icon
|
SizedBox(width: 2),
|
||||||
label: 'Kích thước',
|
Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
|
||||||
value: product.getSpecification('size') ?? '1200x1200',
|
SizedBox(width: 2),
|
||||||
|
Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
|
||||||
|
SizedBox(width: 2),
|
||||||
|
Icon(FontAwesomeIcons.starHalfStroke, color: Color(0xFFffc107), size: 16),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
|
|
||||||
// Packaging Info
|
SizedBox(width: 12),
|
||||||
Expanded(
|
|
||||||
child: _QuickInfoCard(
|
|
||||||
icon: Icons.inventory_2_outlined, // cube/box icon
|
|
||||||
label: 'Đóng gói',
|
|
||||||
value: product.getSpecification('packaging') ?? '2 viên/thùng',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
|
|
||||||
// Delivery Info
|
// Rating Text
|
||||||
Expanded(
|
Text(
|
||||||
child: _QuickInfoCard(
|
'4.8 (125 đánh giá)',
|
||||||
icon: Icons.local_shipping_outlined, // truck icon
|
style: TextStyle(
|
||||||
label: 'Giao hàng',
|
fontSize: 14,
|
||||||
value: '2-3 Ngày',
|
color: AppColors.grey500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Intro attributes quick info cards (dynamic based on non-null values)
|
||||||
|
if (_buildIntroAttributeCards(product).isNotEmpty)
|
||||||
|
Row(
|
||||||
|
children: _buildIntroAttributeCards(product),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build intro attribute cards dynamically based on non-null values
|
||||||
|
List<Widget> _buildIntroAttributeCards(Product product) {
|
||||||
|
final cards = <Widget>[];
|
||||||
|
|
||||||
|
// Define available intro attributes with their display info
|
||||||
|
final availableAttributes = [
|
||||||
|
{
|
||||||
|
'code': 'Size',
|
||||||
|
'icon': FontAwesomeIcons.expand,
|
||||||
|
'label': 'Kích thước',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'Colour',
|
||||||
|
'icon': FontAwesomeIcons.palette,
|
||||||
|
'label': 'Màu sắc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'UOM',
|
||||||
|
'icon': FontAwesomeIcons.boxArchive,
|
||||||
|
'label': 'Đóng gói',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build cards only for non-null values
|
||||||
|
for (final attr in availableAttributes) {
|
||||||
|
final value = product.getIntroAttribute(attr['code'] as String);
|
||||||
|
if (value != null && value.isNotEmpty) {
|
||||||
|
cards.add(
|
||||||
|
Expanded(
|
||||||
|
child: _QuickInfoCard(
|
||||||
|
icon: attr['icon'] as IconData,
|
||||||
|
label: attr['label'] as String,
|
||||||
|
value: value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add spacing between cards (except after the last one)
|
||||||
|
if (cards.length < availableAttributes.length) {
|
||||||
|
cards.add(const SizedBox(width: 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing spacer if it exists
|
||||||
|
if (cards.isNotEmpty && cards.last is SizedBox) {
|
||||||
|
cards.removeLast();
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quick Info Card Widget
|
/// Quick Info Card Widget
|
||||||
class _QuickInfoCard extends StatelessWidget {
|
class _QuickInfoCard extends StatelessWidget {
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
const _QuickInfoCard({
|
const _QuickInfoCard({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.value,
|
required this.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|||||||
import 'package:worker/core/constants/ui_constants.dart';
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/theme/colors.dart';
|
import 'package:worker/core/theme/colors.dart';
|
||||||
import 'package:worker/features/products/domain/entities/product.dart';
|
import 'package:worker/features/products/domain/entities/product.dart';
|
||||||
|
import 'package:worker/features/products/presentation/widgets/product_detail/write_review_button.dart';
|
||||||
|
|
||||||
/// Product Tabs Section
|
/// Product Tabs Section
|
||||||
///
|
///
|
||||||
@@ -16,9 +17,9 @@ import 'package:worker/features/products/domain/entities/product.dart';
|
|||||||
/// - Specifications: Table of product specs
|
/// - Specifications: Table of product specs
|
||||||
/// - Reviews: Rating overview and review items
|
/// - Reviews: Rating overview and review items
|
||||||
class ProductTabsSection extends StatefulWidget {
|
class ProductTabsSection extends StatefulWidget {
|
||||||
final Product product;
|
|
||||||
|
|
||||||
const ProductTabsSection({super.key, required this.product});
|
const ProductTabsSection({super.key, required this.product});
|
||||||
|
final Product product;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ProductTabsSection> createState() => _ProductTabsSectionState();
|
State<ProductTabsSection> createState() => _ProductTabsSectionState();
|
||||||
@@ -33,6 +34,9 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
|
|||||||
super.initState();
|
super.initState();
|
||||||
// Start with Specifications tab (index 0)
|
// Start with Specifications tab (index 0)
|
||||||
_tabController = TabController(length: 2, vsync: this, initialIndex: 0);
|
_tabController = TabController(length: 2, vsync: this, initialIndex: 0);
|
||||||
|
_tabController.addListener(() {
|
||||||
|
setState(() {}); // Update IndexedStack when tab changes
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -75,17 +79,14 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Tab Content
|
// Tab Content (expands to fit content)
|
||||||
SizedBox(
|
IndexedStack(
|
||||||
height: 400, // Fixed height for tab content
|
index: _tabController.index,
|
||||||
child: TabBarView(
|
|
||||||
controller: _tabController,
|
|
||||||
children: [
|
children: [
|
||||||
_SpecificationsTab(product: widget.product),
|
_SpecificationsTab(product: widget.product),
|
||||||
const _ReviewsTab(),
|
_ReviewsTab(productId: widget.product.productId),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -94,9 +95,9 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
|
|||||||
|
|
||||||
/// Description Tab Content
|
/// Description Tab Content
|
||||||
class _DescriptionTab extends StatelessWidget {
|
class _DescriptionTab extends StatelessWidget {
|
||||||
final Product product;
|
|
||||||
|
|
||||||
const _DescriptionTab({required this.product});
|
const _DescriptionTab({required this.product});
|
||||||
|
final Product product;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -201,9 +202,9 @@ class _DescriptionTab extends StatelessWidget {
|
|||||||
|
|
||||||
/// Specifications Tab Content
|
/// Specifications Tab Content
|
||||||
class _SpecificationsTab extends StatelessWidget {
|
class _SpecificationsTab extends StatelessWidget {
|
||||||
final Product product;
|
|
||||||
|
|
||||||
const _SpecificationsTab({required this.product});
|
const _SpecificationsTab({required this.product});
|
||||||
|
final Product product;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -223,8 +224,7 @@ class _SpecificationsTab extends StatelessWidget {
|
|||||||
'Tiêu chuẩn': 'TCVN 9081:2012, ISO 13006',
|
'Tiêu chuẩn': 'TCVN 9081:2012, ISO 13006',
|
||||||
};
|
};
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return Container(
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.all(20),
|
margin: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: const Color(0xFFe0e0e0)),
|
border: Border.all(color: const Color(0xFFe0e0e0)),
|
||||||
@@ -284,21 +284,26 @@ class _SpecificationsTab extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reviews Tab Content
|
/// Reviews Tab Content
|
||||||
class _ReviewsTab extends StatelessWidget {
|
class _ReviewsTab extends StatelessWidget {
|
||||||
const _ReviewsTab();
|
|
||||||
|
const _ReviewsTab({required this.productId});
|
||||||
|
final String productId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SingleChildScrollView(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Write Review Button
|
||||||
|
WriteReviewButton(productId: productId),
|
||||||
|
|
||||||
// Rating Overview
|
// Rating Overview
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
@@ -353,6 +358,7 @@ class _ReviewsTab extends StatelessWidget {
|
|||||||
|
|
||||||
// Review Items
|
// Review Items
|
||||||
..._mockReviews.map((review) => _ReviewItem(review: review)),
|
..._mockReviews.map((review) => _ReviewItem(review: review)),
|
||||||
|
const SizedBox(height: 48),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -361,9 +367,9 @@ class _ReviewsTab extends StatelessWidget {
|
|||||||
|
|
||||||
/// Review Item Widget
|
/// Review Item Widget
|
||||||
class _ReviewItem extends StatelessWidget {
|
class _ReviewItem extends StatelessWidget {
|
||||||
final Map<String, dynamic> review;
|
|
||||||
|
|
||||||
const _ReviewItem({required this.review});
|
const _ReviewItem({required this.review});
|
||||||
|
final Map<String, dynamic> review;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@@ -15,13 +15,6 @@ import 'package:worker/core/theme/colors.dart';
|
|||||||
/// - Quantity section with label, controls, and conversion text
|
/// - Quantity section with label, controls, and conversion text
|
||||||
/// - Add to cart button
|
/// - Add to cart button
|
||||||
class StickyActionBar extends StatelessWidget {
|
class StickyActionBar extends StatelessWidget {
|
||||||
final int quantity;
|
|
||||||
final VoidCallback onIncrease;
|
|
||||||
final VoidCallback onDecrease;
|
|
||||||
final ValueChanged<int> onQuantityChanged;
|
|
||||||
final VoidCallback onAddToCart;
|
|
||||||
final bool isOutOfStock;
|
|
||||||
final String unit;
|
|
||||||
|
|
||||||
const StickyActionBar({
|
const StickyActionBar({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -30,15 +23,40 @@ class StickyActionBar extends StatelessWidget {
|
|||||||
required this.onDecrease,
|
required this.onDecrease,
|
||||||
required this.onQuantityChanged,
|
required this.onQuantityChanged,
|
||||||
required this.onAddToCart,
|
required this.onAddToCart,
|
||||||
this.isOutOfStock = false,
|
|
||||||
this.unit = 'm²',
|
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() {
|
String _getConversionText() {
|
||||||
// Calculate conversion: each m² ≈ 0.36 boxes, each box = varies
|
if (conversionOfSm == null || conversionOfSm == 0) {
|
||||||
final pieces = (quantity / 0.36).ceil();
|
return ''; // No conversion data available
|
||||||
final actualArea = (pieces * 0.36).toStringAsFixed(2);
|
}
|
||||||
return 'Tương đương: $pieces viên / $actualArea m²';
|
|
||||||
|
// Calculate boxes needed using API conversion factor
|
||||||
|
// Formula: boxes_needed = quantity_m² / conversion_of_sm
|
||||||
|
final boxesNeeded = (quantity / conversionOfSm!).ceil();
|
||||||
|
|
||||||
|
// Extract pieces per box from UOM if available (e.g., "2 viên/hộp" -> 2)
|
||||||
|
int piecesPerBox = 1;
|
||||||
|
if (uomFromIntroAttributes != null) {
|
||||||
|
final match = RegExp(r'(\d+)\s*viên').firstMatch(uomFromIntroAttributes!);
|
||||||
|
if (match != null) {
|
||||||
|
piecesPerBox = int.tryParse(match.group(1) ?? '1') ?? 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final totalPieces = boxesNeeded * piecesPerBox;
|
||||||
|
|
||||||
|
return 'Tương đương: $boxesNeeded hộp / $totalPieces viên';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -46,8 +64,8 @@ class StickyActionBar extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.white,
|
color: AppColors.white,
|
||||||
border: Border(
|
border: const Border(
|
||||||
top: BorderSide(color: const Color(0xFFe0e0e0), width: 1),
|
top: BorderSide(color: Color(0xFFe0e0e0), width: 1),
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -131,6 +149,7 @@ class StickyActionBar extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
// Conversion Text
|
// Conversion Text
|
||||||
|
if (_getConversionText().isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
_getConversionText(),
|
_getConversionText(),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -141,30 +160,28 @@ class StickyActionBar extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
// Add to Cart Button
|
// Add to Cart Button
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: isOutOfStock ? null : onAddToCart,
|
onPressed: onAddToCart,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: AppColors.primaryBlue,
|
||||||
foregroundColor: AppColors.white,
|
foregroundColor: AppColors.white,
|
||||||
disabledBackgroundColor: AppColors.grey100,
|
|
||||||
disabledForegroundColor: AppColors.grey500,
|
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 20,
|
horizontal: 12,
|
||||||
vertical: 12,
|
vertical: 8,
|
||||||
),
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 18),
|
icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 18),
|
||||||
label: Text(
|
label: const Text(
|
||||||
isOutOfStock ? 'Hết hàng' : 'Thêm vào giỏ hàng',
|
'Thêm vào giỏ hàng',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
@@ -180,10 +197,10 @@ class StickyActionBar extends StatelessWidget {
|
|||||||
|
|
||||||
/// Quantity Button Widget
|
/// Quantity Button Widget
|
||||||
class _QuantityButton extends StatelessWidget {
|
class _QuantityButton extends StatelessWidget {
|
||||||
final IconData icon;
|
|
||||||
final VoidCallback? onPressed;
|
|
||||||
|
|
||||||
const _QuantityButton({required this.icon, this.onPressed});
|
const _QuantityButton({required this.icon, this.onPressed});
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@@ -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
|
/// Widget: Product Grid
|
||||||
///
|
///
|
||||||
/// Grid view displaying product cards.
|
/// Grid view displaying product cards with pagination support.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -10,22 +10,59 @@ import 'package:worker/features/products/presentation/widgets/product_card.dart'
|
|||||||
|
|
||||||
/// Product Grid Widget
|
/// Product Grid Widget
|
||||||
///
|
///
|
||||||
/// Displays products in a 2-column grid layout.
|
/// Displays products in a 2-column grid layout with scroll-to-load-more.
|
||||||
class ProductGrid extends StatelessWidget {
|
class ProductGrid extends StatefulWidget {
|
||||||
final List<Product> products;
|
final List<Product> products;
|
||||||
final void Function(Product)? onProductTap;
|
final void Function(Product)? onProductTap;
|
||||||
final void Function(Product)? onAddToCart;
|
final void Function(Product)? onAddToCart;
|
||||||
|
final VoidCallback? onLoadMore;
|
||||||
|
final bool hasMore;
|
||||||
|
final bool isLoadingMore;
|
||||||
|
|
||||||
const ProductGrid({
|
const ProductGrid({
|
||||||
super.key,
|
super.key,
|
||||||
required this.products,
|
required this.products,
|
||||||
this.onProductTap,
|
this.onProductTap,
|
||||||
this.onAddToCart,
|
this.onAddToCart,
|
||||||
|
this.onLoadMore,
|
||||||
|
this.hasMore = false,
|
||||||
|
this.isLoadingMore = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProductGrid> createState() => _ProductGridState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProductGridState extends State<ProductGrid> {
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_scrollController.position.pixels >=
|
||||||
|
_scrollController.position.maxScrollExtent - 200) {
|
||||||
|
// Load more when 200px from bottom
|
||||||
|
if (widget.hasMore && !widget.isLoadingMore && widget.onLoadMore != null) {
|
||||||
|
widget.onLoadMore!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.all(AppSpacing.xs),
|
padding: const EdgeInsets.all(AppSpacing.xs),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: GridSpecs.productGridColumns,
|
crossAxisCount: GridSpecs.productGridColumns,
|
||||||
@@ -33,14 +70,24 @@ class ProductGrid extends StatelessWidget {
|
|||||||
mainAxisSpacing: AppSpacing.xs,
|
mainAxisSpacing: AppSpacing.xs,
|
||||||
childAspectRatio: 0.62, // Width / Height ratio (adjusted for 2 buttons)
|
childAspectRatio: 0.62, // Width / Height ratio (adjusted for 2 buttons)
|
||||||
),
|
),
|
||||||
itemCount: products.length,
|
itemCount: widget.products.length + (widget.hasMore ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final product = products[index];
|
// Show loading indicator at the end
|
||||||
|
if (index == widget.products.length) {
|
||||||
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final product = widget.products[index];
|
||||||
|
|
||||||
return ProductCard(
|
return ProductCard(
|
||||||
product: product,
|
product: product,
|
||||||
onTap: onProductTap != null ? () => onProductTap!(product) : null,
|
onTap: widget.onProductTap != null ? () => widget.onProductTap!(product) : null,
|
||||||
onAddToCart: onAddToCart != null ? () => onAddToCart!(product) : null,
|
onAddToCart: widget.onAddToCart != null ? () => widget.onAddToCart!(product) : null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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