diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index a3fcad2..ecf9936 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -37,6 +37,7 @@ import 'package:worker/features/orders/presentation/pages/payments_page.dart'; import 'package:worker/features/price_policy/price_policy.dart'; import 'package:worker/features/products/presentation/pages/product_detail_page.dart'; import 'package:worker/features/products/presentation/pages/products_page.dart'; +import 'package:worker/features/products/presentation/pages/write_review_page.dart'; import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart'; import 'package:worker/features/quotes/presentation/pages/quotes_page.dart'; import 'package:worker/features/showrooms/presentation/pages/design_request_create_page.dart'; @@ -181,6 +182,20 @@ final routerProvider = Provider((ref) { child: ProductDetailPage(productId: productId ?? ''), ); }, + routes: [ + // Write Review Route (nested under product detail) + GoRoute( + path: 'write-review', + name: RouteNames.writeReview, + pageBuilder: (context, state) { + final productId = state.pathParameters['id']; + return MaterialPage( + key: state.pageKey, + child: WriteReviewPage(productId: productId ?? ''), + ); + }, + ), + ], ), // Promotion Detail Route @@ -459,6 +474,7 @@ class RouteNames { static const String home = '/'; static const String products = '/products'; static const String productDetail = '/products/:id'; + static const String writeReview = 'write-review'; static const String cart = '/cart'; static const String favorites = '/favorites'; static const String checkout = '/checkout'; diff --git a/lib/features/products/data/models/product_model.dart b/lib/features/products/data/models/product_model.dart index 77c5278..7778122 100644 --- a/lib/features/products/data/models/product_model.dart +++ b/lib/features/products/data/models/product_model.dart @@ -30,6 +30,7 @@ class ProductModel extends HiveObject { this.brand, this.unit, this.conversionOfSm, + this.introAttributes, required this.isActive, required this.isFeatured, this.erpnextItemCode, @@ -91,6 +92,12 @@ class ProductModel extends HiveObject { @HiveField(17) final double? conversionOfSm; + /// Intro attributes (JSON encoded list) + /// Quick reference attributes from API: Size, Colour, UOM + /// Example: [{"code": "Size", "value": "120x120"}, {"code": "UOM", "value": "2 viên/hộp"}] + @HiveField(18) + final String? introAttributes; + /// Whether product is active @HiveField(12) final bool isActive; @@ -137,6 +144,9 @@ class ProductModel extends HiveObject { conversionOfSm: json['conversion_of_sm'] != null ? (json['conversion_of_sm'] as num).toDouble() : null, + introAttributes: json['intro_attributes'] != null + ? jsonEncode(json['intro_attributes']) + : null, isActive: json['is_active'] as bool? ?? true, isFeatured: json['is_featured'] as bool? ?? false, erpnextItemCode: json['erpnext_item_code'] as String?, @@ -223,6 +233,22 @@ class ProductModel extends HiveObject { } } + // Parse intro_attributes array for quick reference + final List> 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 && + attr['code'] != null && + attr['value'] != null) { + introAttributesList.add({ + 'code': attr['code'] as String, + 'value': attr['value'] as String, + }); + } + } + } + final now = DateTime.now(); // Handle price from both product detail (price) and product list (standard_rate) @@ -251,6 +277,9 @@ class ProductModel extends HiveObject { conversionOfSm: json['conversion_of_sm'] != null ? (json['conversion_of_sm'] as num).toDouble() : null, + introAttributes: introAttributesList.isNotEmpty + ? jsonEncode(introAttributesList) + : null, isActive: (json['disabled'] as int?) == 0, // Frappe uses 'disabled' field isFeatured: false, // Not provided by API, default to false erpnextItemCode: json['name'] as String, // Store item code for reference @@ -283,6 +312,9 @@ class ProductModel extends HiveObject { 'brand': brand, 'unit': unit, 'conversion_of_sm': conversionOfSm, + 'intro_attributes': introAttributes != null + ? jsonDecode(introAttributes!) + : null, 'is_active': isActive, 'is_featured': isFeatured, 'erpnext_item_code': erpnextItemCode, @@ -336,6 +368,38 @@ class ProductModel extends HiveObject { } } + /// Get intro attributes as List + List>? get introAttributesList { + if (introAttributes == null) return null; + try { + final decoded = jsonDecode(introAttributes!) as List; + return decoded.map((e) { + final map = e as Map; + return { + 'code': map['code'].toString(), + 'value': map['value'].toString(), + }; + }).toList(); + } catch (e) { + return null; + } + } + + /// Get specific intro attribute value by code + String? getIntroAttribute(String code) { + final attrs = introAttributesList; + if (attrs == null) return null; + + try { + final attr = attrs.firstWhere( + (attr) => attr['code']?.toLowerCase() == code.toLowerCase(), + ); + return attr['value']; + } catch (e) { + return null; + } + } + /// Get formatted price with currency String get formattedPrice { return '${basePrice.toStringAsFixed(0)}đ'; @@ -363,6 +427,7 @@ class ProductModel extends HiveObject { String? brand, String? unit, double? conversionOfSm, + String? introAttributes, bool? isActive, bool? isFeatured, String? erpnextItemCode, @@ -383,6 +448,7 @@ class ProductModel extends HiveObject { brand: brand ?? this.brand, unit: unit ?? this.unit, conversionOfSm: conversionOfSm ?? this.conversionOfSm, + introAttributes: introAttributes ?? this.introAttributes, isActive: isActive ?? this.isActive, isFeatured: isFeatured ?? this.isFeatured, erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode, @@ -426,6 +492,7 @@ class ProductModel extends HiveObject { brand: brand, unit: unit, conversionOfSm: conversionOfSm, + introAttributes: introAttributesList, isActive: isActive, isFeatured: isFeatured, erpnextItemCode: erpnextItemCode, diff --git a/lib/features/products/data/models/product_model.g.dart b/lib/features/products/data/models/product_model.g.dart index f4012ac..e06bf48 100644 --- a/lib/features/products/data/models/product_model.g.dart +++ b/lib/features/products/data/models/product_model.g.dart @@ -30,6 +30,7 @@ class ProductModelAdapter extends TypeAdapter { brand: fields[10] as String?, unit: fields[11] as String?, conversionOfSm: (fields[17] as num?)?.toDouble(), + introAttributes: fields[18] as String?, isActive: fields[12] as bool, isFeatured: fields[13] as bool, erpnextItemCode: fields[14] as String?, @@ -41,7 +42,7 @@ class ProductModelAdapter extends TypeAdapter { @override void write(BinaryWriter writer, ProductModel obj) { writer - ..writeByte(18) + ..writeByte(19) ..writeByte(0) ..write(obj.productId) ..writeByte(1) @@ -77,7 +78,9 @@ class ProductModelAdapter extends TypeAdapter { ..writeByte(16) ..write(obj.updatedAt) ..writeByte(17) - ..write(obj.conversionOfSm); + ..write(obj.conversionOfSm) + ..writeByte(18) + ..write(obj.introAttributes); } @override diff --git a/lib/features/products/domain/entities/product.dart b/lib/features/products/domain/entities/product.dart index b8c99ec..e6c16af 100644 --- a/lib/features/products/domain/entities/product.dart +++ b/lib/features/products/domain/entities/product.dart @@ -24,6 +24,7 @@ class Product { this.brand, this.unit, this.conversionOfSm, + this.introAttributes, required this.isActive, required this.isFeatured, this.erpnextItemCode, @@ -70,6 +71,11 @@ class Product { /// Used to calculate: Số viên = Số lượng × conversionOfSm final double? conversionOfSm; + /// Intro attributes (quick reference) + /// List of maps with 'code' and 'value' keys + /// Example: [{"code": "Size", "value": "120x120"}, {"code": "UOM", "value": "2 viên/hộp"}] + final List>? introAttributes; + /// Product is active final bool isActive; @@ -116,15 +122,25 @@ class Product { /// TODO: Implement stock tracking when backend supports it bool get isLowStock => false; - /// Check if product is in stock - /// Currently using isActive as proxy - bool get inStock => isActive; - /// Get specification value by key String? getSpecification(String key) { return specifications[key]?.toString(); } + /// Get intro attribute value by code + String? getIntroAttribute(String code) { + if (introAttributes == null) return null; + + try { + final attr = introAttributes!.firstWhere( + (attr) => attr['code']?.toLowerCase() == code.toLowerCase(), + ); + return attr['value']; + } catch (e) { + return null; + } + } + /// Copy with method for creating modified copies Product copyWith({ String? productId, @@ -140,6 +156,7 @@ class Product { String? brand, String? unit, double? conversionOfSm, + List>? introAttributes, bool? isActive, bool? isFeatured, String? erpnextItemCode, @@ -160,6 +177,7 @@ class Product { brand: brand ?? this.brand, unit: unit ?? this.unit, conversionOfSm: conversionOfSm ?? this.conversionOfSm, + introAttributes: introAttributes ?? this.introAttributes, isActive: isActive ?? this.isActive, isFeatured: isFeatured ?? this.isFeatured, erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode, diff --git a/lib/features/products/presentation/pages/product_detail_page.dart b/lib/features/products/presentation/pages/product_detail_page.dart index 535489d..91a2379 100644 --- a/lib/features/products/presentation/pages/product_detail_page.dart +++ b/lib/features/products/presentation/pages/product_detail_page.dart @@ -246,11 +246,12 @@ class _ProductDetailPageState extends ConsumerState { child: StickyActionBar( quantity: _quantity, unit: product.unit ?? 'm²', + conversionOfSm: product.conversionOfSm, + uomFromIntroAttributes: product.getIntroAttribute('UOM'), onIncrease: _increaseQuantity, onDecrease: _decreaseQuantity, onQuantityChanged: _updateQuantity, onAddToCart: () => _addToCart(product), - isOutOfStock: !product.inStock, ), ), ], diff --git a/lib/features/products/presentation/pages/write_review_page.dart b/lib/features/products/presentation/pages/write_review_page.dart new file mode 100644 index 0000000..35a5896 --- /dev/null +++ b/lib/features/products/presentation/pages/write_review_page.dart @@ -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 createState() => _WriteReviewPageState(); +} + +class _WriteReviewPageState extends ConsumerState { + // 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 _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(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), + ], + ), + ), + ); + } +} diff --git a/lib/features/products/presentation/widgets/product_card.dart b/lib/features/products/presentation/widgets/product_card.dart index a520c64..bda955e 100644 --- a/lib/features/products/presentation/widgets/product_card.dart +++ b/lib/features/products/presentation/widgets/product_card.dart @@ -239,12 +239,10 @@ class ProductCard extends ConsumerWidget { width: double.infinity, height: 36.0, child: ElevatedButton.icon( - onPressed: !product.inStock ? onAddToCart : null, + onPressed: onAddToCart, style: ElevatedButton.styleFrom( backgroundColor: AppColors.primaryBlue, foregroundColor: AppColors.white, - disabledBackgroundColor: AppColors.grey100, - disabledForegroundColor: AppColors.grey500, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( @@ -256,9 +254,9 @@ class ProductCard extends ConsumerWidget { ), ), icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 14.0), - label: Text( - !product.inStock ? 'Thêm vào giỏ' : l10n.outOfStock, - style: const TextStyle( + label: const Text( + 'Thêm vào giỏ', + style: TextStyle( fontSize: 12.0, fontWeight: FontWeight.w600, ), diff --git a/lib/features/products/presentation/widgets/product_detail/product_info_section.dart b/lib/features/products/presentation/widgets/product_detail/product_info_section.dart index 2227036..75ef6d0 100644 --- a/lib/features/products/presentation/widgets/product_detail/product_info_section.dart +++ b/lib/features/products/presentation/widgets/product_detail/product_info_section.dart @@ -4,8 +4,8 @@ library; import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:intl/intl.dart'; -import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/theme/colors.dart'; import 'package:worker/features/products/domain/entities/product.dart'; @@ -15,12 +15,13 @@ import 'package:worker/features/products/domain/entities/product.dart'; /// - SKU text (small, gray) /// - Product title (large, bold) /// - Pricing row (current price, original price, discount badge) -/// - Quick info cards (3 cards: Size, Warranty, Delivery) +/// - Rating section (5 stars with review count) +/// - Dynamic intro attribute cards (shown only if non-null) class ProductInfoSection extends StatelessWidget { - final Product product; - const ProductInfoSection({super.key, required this.product}); + final Product product; + String _formatPrice(double price) { final formatter = NumberFormat('#,###', 'vi_VN'); return '${formatter.format(price)} VND'; @@ -107,57 +108,114 @@ class ProductInfoSection extends StatelessWidget { const SizedBox(height: 16), - // Quick Info Cards - Row( + // Rating & Reviews Section + const Row( children: [ - // Size Info - Expanded( - child: _QuickInfoCard( - icon: Icons.straighten, // expand icon - label: 'Kích thước', - value: product.getSpecification('size') ?? '1200x1200', - ), + // Rating Stars + Row( + children: [ + Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16), + SizedBox(width: 2), + Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16), + SizedBox(width: 2), + Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16), + SizedBox(width: 2), + Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16), + SizedBox(width: 2), + Icon(FontAwesomeIcons.starHalfStroke, color: Color(0xFFffc107), size: 16), + ], ), - const SizedBox(width: 12), - // Packaging Info - Expanded( - child: _QuickInfoCard( - icon: Icons.inventory_2_outlined, // cube/box icon - label: 'Đóng gói', - value: product.getSpecification('packaging') ?? '2 viên/thùng', - ), - ), - const SizedBox(width: 12), + SizedBox(width: 12), - // Delivery Info - Expanded( - child: _QuickInfoCard( - icon: Icons.local_shipping_outlined, // truck icon - label: 'Giao hàng', - value: '2-3 Ngày', + // Rating Text + Text( + '4.8 (125 đánh giá)', + style: TextStyle( + fontSize: 14, + color: AppColors.grey500, ), ), ], ), + + const SizedBox(height: 16), + + // Intro attributes quick info cards (dynamic based on non-null values) + if (_buildIntroAttributeCards(product).isNotEmpty) + Row( + children: _buildIntroAttributeCards(product), + ), ], ), ); } + + /// Build intro attribute cards dynamically based on non-null values + List _buildIntroAttributeCards(Product product) { + final cards = []; + + // Define available intro attributes with their display info + final availableAttributes = [ + { + 'code': 'Size', + 'icon': FontAwesomeIcons.expand, + 'label': 'Kích thước', + }, + { + 'code': 'Colour', + 'icon': FontAwesomeIcons.palette, + 'label': 'Màu sắc', + }, + { + 'code': 'UOM', + 'icon': FontAwesomeIcons.boxArchive, + 'label': 'Đóng gói', + }, + ]; + + // Build cards only for non-null values + for (final attr in availableAttributes) { + final value = product.getIntroAttribute(attr['code'] as String); + if (value != null && value.isNotEmpty) { + cards.add( + Expanded( + child: _QuickInfoCard( + icon: attr['icon'] as IconData, + label: attr['label'] as String, + value: value, + ), + ), + ); + + // Add spacing between cards (except after the last one) + if (cards.length < availableAttributes.length) { + cards.add(const SizedBox(width: 8)); + } + } + } + + // Remove trailing spacer if it exists + if (cards.isNotEmpty && cards.last is SizedBox) { + cards.removeLast(); + } + + return cards; + } } /// Quick Info Card Widget class _QuickInfoCard extends StatelessWidget { - final IconData icon; - final String label; - final String value; - const _QuickInfoCard({ required this.icon, required this.label, required this.value, }); + final IconData icon; + final String label; + final String value; + @override Widget build(BuildContext context) { return Column( diff --git a/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart b/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart index 7f247af..985f23d 100644 --- a/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart +++ b/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart @@ -8,6 +8,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/theme/colors.dart'; import 'package:worker/features/products/domain/entities/product.dart'; +import 'package:worker/features/products/presentation/widgets/product_detail/write_review_button.dart'; /// Product Tabs Section /// @@ -16,9 +17,9 @@ import 'package:worker/features/products/domain/entities/product.dart'; /// - Specifications: Table of product specs /// - Reviews: Rating overview and review items class ProductTabsSection extends StatefulWidget { - final Product product; const ProductTabsSection({super.key, required this.product}); + final Product product; @override State createState() => _ProductTabsSectionState(); @@ -33,6 +34,9 @@ class _ProductTabsSectionState extends State super.initState(); // Start with Specifications tab (index 0) _tabController = TabController(length: 2, vsync: this, initialIndex: 0); + _tabController.addListener(() { + setState(() {}); // Update IndexedStack when tab changes + }); } @override @@ -75,16 +79,13 @@ class _ProductTabsSectionState extends State ), ), - // Tab Content - SizedBox( - height: 400, // Fixed height for tab content - child: TabBarView( - controller: _tabController, - children: [ - _SpecificationsTab(product: widget.product), - const _ReviewsTab(), - ], - ), + // Tab Content (expands to fit content) + IndexedStack( + index: _tabController.index, + children: [ + _SpecificationsTab(product: widget.product), + _ReviewsTab(productId: widget.product.productId), + ], ), ], ), @@ -94,9 +95,9 @@ class _ProductTabsSectionState extends State /// Description Tab Content class _DescriptionTab extends StatelessWidget { - final Product product; const _DescriptionTab({required this.product}); + final Product product; @override Widget build(BuildContext context) { @@ -201,9 +202,9 @@ class _DescriptionTab extends StatelessWidget { /// Specifications Tab Content class _SpecificationsTab extends StatelessWidget { - final Product product; const _SpecificationsTab({required this.product}); + final Product product; @override Widget build(BuildContext context) { @@ -223,14 +224,13 @@ class _SpecificationsTab extends StatelessWidget { 'Tiêu chuẩn': 'TCVN 9081:2012, ISO 13006', }; - return SingleChildScrollView( - child: Container( - margin: const EdgeInsets.all(20), - decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFe0e0e0)), - borderRadius: BorderRadius.circular(8), - ), - child: Column( + return Container( + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFe0e0e0)), + borderRadius: BorderRadius.circular(8), + ), + child: Column( children: specs.entries.map((entry) { final isLast = entry == specs.entries.last; return Container( @@ -283,7 +283,6 @@ class _SpecificationsTab extends StatelessWidget { ), ); }).toList(), - ), ), ); } @@ -291,14 +290,20 @@ class _SpecificationsTab extends StatelessWidget { /// Reviews Tab Content class _ReviewsTab extends StatelessWidget { - const _ReviewsTab(); + + const _ReviewsTab({required this.productId}); + final String productId; @override Widget build(BuildContext context) { - return SingleChildScrollView( + return Padding( padding: const EdgeInsets.all(20), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Write Review Button + WriteReviewButton(productId: productId), + // Rating Overview Container( padding: const EdgeInsets.all(20), @@ -353,6 +358,7 @@ class _ReviewsTab extends StatelessWidget { // Review Items ..._mockReviews.map((review) => _ReviewItem(review: review)), + const SizedBox(height: 48), ], ), ); @@ -361,9 +367,9 @@ class _ReviewsTab extends StatelessWidget { /// Review Item Widget class _ReviewItem extends StatelessWidget { - final Map review; const _ReviewItem({required this.review}); + final Map review; @override Widget build(BuildContext context) { diff --git a/lib/features/products/presentation/widgets/product_detail/sticky_action_bar.dart b/lib/features/products/presentation/widgets/product_detail/sticky_action_bar.dart index 2fa2ec0..296a9bc 100644 --- a/lib/features/products/presentation/widgets/product_detail/sticky_action_bar.dart +++ b/lib/features/products/presentation/widgets/product_detail/sticky_action_bar.dart @@ -15,13 +15,6 @@ import 'package:worker/core/theme/colors.dart'; /// - Quantity section with label, controls, and conversion text /// - Add to cart button class StickyActionBar extends StatelessWidget { - final int quantity; - final VoidCallback onIncrease; - final VoidCallback onDecrease; - final ValueChanged onQuantityChanged; - final VoidCallback onAddToCart; - final bool isOutOfStock; - final String unit; const StickyActionBar({ super.key, @@ -30,15 +23,40 @@ class StickyActionBar extends StatelessWidget { required this.onDecrease, required this.onQuantityChanged, required this.onAddToCart, - this.isOutOfStock = false, this.unit = 'm²', + this.conversionOfSm, + this.uomFromIntroAttributes, }); + final int quantity; + final VoidCallback onIncrease; + final VoidCallback onDecrease; + final ValueChanged onQuantityChanged; + final VoidCallback onAddToCart; + final String unit; + final double? conversionOfSm; + final String? uomFromIntroAttributes; String _getConversionText() { - // Calculate conversion: each m² ≈ 0.36 boxes, each box = varies - final pieces = (quantity / 0.36).ceil(); - final actualArea = (pieces * 0.36).toStringAsFixed(2); - return 'Tương đương: $pieces viên / $actualArea m²'; + if (conversionOfSm == null || conversionOfSm == 0) { + return ''; // No conversion data available + } + + // Calculate boxes needed using API conversion factor + // Formula: boxes_needed = quantity_m² / conversion_of_sm + final boxesNeeded = (quantity / conversionOfSm!).ceil(); + + // Extract pieces per box from UOM if available (e.g., "2 viên/hộp" -> 2) + int piecesPerBox = 1; + if (uomFromIntroAttributes != null) { + final match = RegExp(r'(\d+)\s*viên').firstMatch(uomFromIntroAttributes!); + if (match != null) { + piecesPerBox = int.tryParse(match.group(1) ?? '1') ?? 1; + } + } + + final totalPieces = boxesNeeded * piecesPerBox; + + return 'Tương đương: $boxesNeeded hộp / $totalPieces viên'; } @override @@ -46,8 +64,8 @@ class StickyActionBar extends StatelessWidget { return Container( decoration: BoxDecoration( color: AppColors.white, - border: Border( - top: BorderSide(color: const Color(0xFFe0e0e0), width: 1), + border: const Border( + top: BorderSide(color: Color(0xFFe0e0e0), width: 1), ), boxShadow: [ BoxShadow( @@ -131,40 +149,39 @@ class StickyActionBar extends StatelessWidget { const SizedBox(height: 4), // Conversion Text - Text( - _getConversionText(), - style: const TextStyle( - fontSize: 11, - color: AppColors.grey500, + if (_getConversionText().isNotEmpty) + Text( + _getConversionText(), + style: const TextStyle( + fontSize: 11, + color: AppColors.grey500, + ), ), - ), ], ), - const SizedBox(width: 16), + const SizedBox(width: 8), // Add to Cart Button Expanded( child: ElevatedButton.icon( - onPressed: isOutOfStock ? null : onAddToCart, + onPressed: onAddToCart, style: ElevatedButton.styleFrom( backgroundColor: AppColors.primaryBlue, foregroundColor: AppColors.white, - disabledBackgroundColor: AppColors.grey100, - disabledForegroundColor: AppColors.grey500, elevation: 0, padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, + horizontal: 12, + vertical: 8, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 18), - label: Text( - isOutOfStock ? 'Hết hàng' : 'Thêm vào giỏ hàng', - style: const TextStyle( + label: const Text( + 'Thêm vào giỏ hàng', + style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), @@ -180,10 +197,10 @@ class StickyActionBar extends StatelessWidget { /// Quantity Button Widget class _QuantityButton extends StatelessWidget { - final IconData icon; - final VoidCallback? onPressed; const _QuantityButton({required this.icon, this.onPressed}); + final IconData icon; + final VoidCallback? onPressed; @override Widget build(BuildContext context) { diff --git a/lib/features/products/presentation/widgets/product_detail/write_review_button.dart b/lib/features/products/presentation/widgets/product_detail/write_review_button.dart new file mode 100644 index 0000000..429c178 --- /dev/null +++ b/lib/features/products/presentation/widgets/product_detail/write_review_button.dart @@ -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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/products/presentation/widgets/write_review/review_guidelines_card.dart b/lib/features/products/presentation/widgets/write_review/review_guidelines_card.dart new file mode 100644 index 0000000..7cc087b --- /dev/null +++ b/lib/features/products/presentation/widgets/write_review/review_guidelines_card.dart @@ -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 _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, + ), + ), + ), + ], + ), + ); + }), + ], + ), + ); + } +} diff --git a/lib/features/products/presentation/widgets/write_review/star_rating_selector.dart b/lib/features/products/presentation/widgets/write_review/star_rating_selector.dart new file mode 100644 index 0000000..2cc05ab --- /dev/null +++ b/lib/features/products/presentation/widgets/write_review/star_rating_selector.dart @@ -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 onRatingChanged; + + const StarRatingSelector({ + super.key, + required this.rating, + required this.onRatingChanged, + }); + + @override + State createState() => _StarRatingSelectorState(); +} + +class _StarRatingSelectorState extends State { + // Rating label text mapping + static const Map _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, + ), + ), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index de384dc..e7a50da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -912,10 +912,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1437,26 +1437,26 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.11" timing: dependency: transitive description: