fix product

This commit is contained in:
Phuoc Nguyen
2025-11-17 15:07:53 +07:00
parent 0828ff1355
commit ff3629d6d1
14 changed files with 1087 additions and 110 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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