From c225144ad358b166699a32fe4a7a8400bc7b1c7f Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Fri, 24 Oct 2025 15:09:51 +0700 Subject: [PATCH] product --- lib/core/router/app_router.dart | 15 +- .../products_local_datasource.dart | 188 +++---- .../pages/product_detail_page.dart | 324 ++++++++++++ .../presentation/pages/products_page.dart | 51 +- .../presentation/widgets/product_card.dart | 97 +++- .../product_detail/image_gallery_section.dart | 437 ++++++++++++++++ .../product_detail/product_info_section.dart | 198 +++++++ .../product_detail/product_tabs_section.dart | 487 ++++++++++++++++++ .../product_detail/sticky_action_bar.dart | 177 +++++++ .../presentation/widgets/product_grid.dart | 2 +- .../widgets/product_search_bar.dart | 2 +- 11 files changed, 1805 insertions(+), 173 deletions(-) create mode 100644 lib/features/products/presentation/pages/product_detail_page.dart create mode 100644 lib/features/products/presentation/widgets/product_detail/image_gallery_section.dart create mode 100644 lib/features/products/presentation/widgets/product_detail/product_info_section.dart create mode 100644 lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart create mode 100644 lib/features/products/presentation/widgets/product_detail/sticky_action_bar.dart diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index a26bf25..55a4223 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -6,8 +6,8 @@ library; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:worker/features/home/domain/entities/promotion.dart'; import 'package:worker/features/main/presentation/pages/main_scaffold.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/promotions/presentation/pages/promotion_detail_page.dart'; @@ -47,6 +47,19 @@ class AppRouter { ), ), + // Product Detail Route + GoRoute( + path: RouteNames.productDetail, + name: RouteNames.productDetail, + pageBuilder: (context, state) { + final productId = state.pathParameters['id']; + return MaterialPage( + key: state.pageKey, + child: ProductDetailPage(productId: productId ?? ''), + ); + }, + ), + // Promotion Detail Route GoRoute( path: RouteNames.promotionDetail, diff --git a/lib/features/products/data/datasources/products_local_datasource.dart b/lib/features/products/data/datasources/products_local_datasource.dart index 8841ff0..72a36b9 100644 --- a/lib/features/products/data/datasources/products_local_datasource.dart +++ b/lib/features/products/data/datasources/products_local_datasource.dart @@ -72,154 +72,112 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource { /// Mock products data static final List> _productsJson = [ { - 'id': 'prod_001', - 'name': 'Gạch men cao cấp 60x60', - 'sku': 'GM-60-001', + 'product_id': 'prod_001', + 'name': 'Gạch Cát Tường 1200x1200', 'description': 'Gạch men bóng kiếng cao cấp, chống trượt, độ bền cao. Phù hợp cho phòng khách, phòng ngủ.', - 'price': 450000.0, + 'base_price': 450000.0, 'unit': 'm²', - 'imageUrl': 'https://images.unsplash.com/photo-1615971677499-5467cbfe1f10?w=400', - 'categoryId': 'floor_tiles', - 'inStock': true, - 'stockQuantity': 150, - 'createdAt': '2024-01-15T08:00:00Z', - 'salePrice': null, + 'images': ['https://www.eurotile.vn/pictures/catalog/product/0-gachkholon/cat-tuong/CAT-S01G-1.jpg'], + 'image_captions': {}, + 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US', + 'specifications': {}, + 'category': 'floor_tiles', 'brand': 'Eurotile', + 'is_active': true, + 'is_featured': false, + 'erpnext_item_code': null, + 'created_at': '2024-01-15T08:00:00Z', + 'updated_at': null, }, { - 'id': 'prod_002', + 'product_id': 'prod_002', 'name': 'Gạch granite nhập khẩu', - 'sku': 'GR-80-002', 'description': 'Gạch granite nhập khẩu Tây Ban Nha, vân đá tự nhiên, sang trọng. Kích thước 80x80cm.', - 'price': 680000.0, + 'base_price': 680000.0, 'unit': 'm²', - 'imageUrl': 'https://images.unsplash.com/photo-1565183928294-7d22e855a326?w=400', - 'categoryId': 'floor_tiles', - 'inStock': true, - 'stockQuantity': 80, - 'createdAt': '2024-01-20T10:30:00Z', - 'salePrice': 620000.0, + 'images': ['https://images.unsplash.com/photo-1565193566173-7a0ee3dbe261?w=300&h=300&fit=crop'], + 'image_captions': {}, + 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R&locale=en_US', + 'specifications': {}, + 'category': 'floor_tiles', 'brand': 'Vasta Stone', + 'is_active': true, + 'is_featured': false, + 'erpnext_item_code': null, + 'created_at': '2024-01-20T10:30:00Z', + 'updated_at': null, }, { - 'id': 'prod_003', + 'product_id': 'prod_003', 'name': 'Gạch mosaic trang trí', - 'sku': 'MS-30-003', 'description': 'Gạch mosaic thủy tinh màu sắc đa dạng, tạo điểm nhấn cho không gian. Kích thước 30x30cm.', - 'price': 320000.0, + 'base_price': 320000.0, 'unit': 'm²', - 'imageUrl': 'https://images.unsplash.com/photo-1604709177225-055f99402ea3?w=400', - 'categoryId': 'decorative_tiles', - 'inStock': true, - 'stockQuantity': 45, - 'createdAt': '2024-02-01T14:15:00Z', - 'salePrice': null, + 'images': ['https://images.unsplash.com/photo-1615971677499-5467cbab01c0?w=300&h=300&fit=crop'], + 'image_captions': {}, + 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US', + 'specifications': {}, + 'category': 'decorative_tiles', 'brand': 'Eurotile', + 'is_active': true, + 'is_featured': false, + 'erpnext_item_code': null, + 'created_at': '2024-02-01T14:15:00Z', + 'updated_at': null, }, { - 'id': 'prod_004', + 'product_id': 'prod_004', 'name': 'Gạch 3D họa tiết', - 'sku': '3D-60-004', 'description': 'Gạch 3D với họa tiết nổi độc đáo, tạo hiệu ứng thị giác ấn tượng cho tường phòng khách.', - 'price': 750000.0, + 'base_price': 750000.0, 'unit': 'm²', - 'imageUrl': 'https://images.unsplash.com/photo-1600585152220-90363fe7e115?w=400', - 'categoryId': 'wall_tiles', - 'inStock': true, - 'stockQuantity': 30, - 'createdAt': '2024-02-10T09:00:00Z', - 'salePrice': 680000.0, + 'images': ['https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=300&h=300&fit=crop'], + 'image_captions': {}, + 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US', + 'specifications': {}, + 'category': 'wall_tiles', 'brand': 'Vasta Stone', + 'is_active': true, + 'is_featured': false, + 'erpnext_item_code': null, + 'created_at': '2024-02-10T09:00:00Z', + 'updated_at': null, }, { - 'id': 'prod_005', + 'product_id': 'prod_005', 'name': 'Gạch ceramic chống trượt', - 'sku': 'CR-40-005', 'description': 'Gạch ceramic chống trượt cấp độ R11, an toàn cho phòng tắm và ban công. Kích thước 40x40cm.', - 'price': 380000.0, + 'base_price': 380000.0, 'unit': 'm²', - 'imageUrl': 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=400', - 'categoryId': 'outdoor_tiles', - 'inStock': true, - 'stockQuantity': 8, - 'createdAt': '2024-02-15T11:20:00Z', - 'salePrice': null, + 'images': ['https://images.unsplash.com/photo-1615874694520-474822394e73?w=300&h=300&fit=crop'], + 'image_captions': {}, + 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US', + 'specifications': {}, + 'category': 'outdoor_tiles', 'brand': 'Eurotile', + 'is_active': true, + 'is_featured': false, + 'erpnext_item_code': null, + 'created_at': '2024-02-15T11:20:00Z', + 'updated_at': null, }, { - 'id': 'prod_006', + 'product_id': 'prod_006', 'name': 'Gạch terrazzo đá mài', - 'sku': 'TZ-60-006', 'description': 'Gạch terrazzo phong cách retro, đá mài hạt màu, độc đáo và bền đẹp theo thời gian.', - 'price': 890000.0, + 'base_price': 890000.0, 'unit': 'm²', - 'imageUrl': 'https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=400', - 'categoryId': 'decorative_tiles', - 'inStock': true, - 'stockQuantity': 25, - 'createdAt': '2024-02-20T15:45:00Z', - 'salePrice': 820000.0, - 'brand': 'Vasta Stone', - }, - { - 'id': 'prod_007', - 'name': 'Gạch ốp tường bếp', - 'sku': 'OT-30-007', - 'description': 'Gạch ốp tường nhà bếp, dễ lau chùi, chống thấm tốt. Kích thước 30x60cm.', - 'price': 280000.0, - 'unit': 'm²', - 'imageUrl': 'https://images.unsplash.com/photo-1600047509807-ba8f99d2cdde?w=400', - 'categoryId': 'wall_tiles', - 'inStock': true, - 'stockQuantity': 120, - 'createdAt': '2024-03-01T08:30:00Z', - 'salePrice': null, - 'brand': 'Eurotile', - }, - { - 'id': 'prod_008', - 'name': 'Gạch sân vườn chống rêu', - 'sku': 'SV-50-008', - 'description': 'Gạch lát sân vườn chống rêu mốc, bền với thời tiết. Kích thước 50x50cm.', - 'price': 420000.0, - 'unit': 'm²', - 'imageUrl': 'https://images.unsplash.com/photo-1600566752355-35792bedcfea?w=400', - 'categoryId': 'outdoor_tiles', - 'inStock': true, - 'stockQuantity': 65, - 'createdAt': '2024-03-05T10:00:00Z', - 'salePrice': 380000.0, - 'brand': 'Vasta Stone', - }, - { - 'id': 'prod_009', - 'name': 'Keo dán gạch chuyên dụng', - 'sku': 'ACC-KD-009', - 'description': 'Keo dán gạch chất lượng cao, độ bám dính mạnh, chống thấm. Bao 25kg.', - 'price': 180000.0, - 'unit': 'bao', - 'imageUrl': 'https://images.unsplash.com/photo-1581094794329-c8112a89af12?w=400', - 'categoryId': 'accessories', - 'inStock': true, - 'stockQuantity': 200, - 'createdAt': '2024-03-10T13:15:00Z', - 'salePrice': null, - 'brand': 'Eurotile', - }, - { - 'id': 'prod_010', - 'name': 'Keo chà ron màu', - 'sku': 'ACC-KCR-010', - 'description': 'Keo chà ron gạch nhiều màu sắc, chống thấm, chống nấm mốc. Bao 5kg.', - 'price': 120000.0, - 'unit': 'bao', - 'imageUrl': 'https://images.unsplash.com/photo-1621905251918-48416bd8575a?w=400', - 'categoryId': 'accessories', - 'inStock': true, - 'stockQuantity': 150, - 'createdAt': '2024-03-15T09:45:00Z', - 'salePrice': 99000.0, + 'images': ['https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=300&h=300&fit=crop'], + 'image_captions': {}, + 'link_360': null, + 'specifications': {}, + 'category': 'decorative_tiles', 'brand': 'Vasta Stone', + 'is_active': true, + 'is_featured': false, + 'erpnext_item_code': null, + 'created_at': '2024-02-20T15:45:00Z', + 'updated_at': null, }, ]; diff --git a/lib/features/products/presentation/pages/product_detail_page.dart b/lib/features/products/presentation/pages/product_detail_page.dart new file mode 100644 index 0000000..6ffc583 --- /dev/null +++ b/lib/features/products/presentation/pages/product_detail_page.dart @@ -0,0 +1,324 @@ +/// Page: Product Detail Page +/// +/// Full product detail screen with image gallery, specifications, reviews, and purchase options. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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/product_detail/image_gallery_section.dart'; +import 'package:worker/features/products/presentation/widgets/product_detail/product_info_section.dart'; +import 'package:worker/features/products/presentation/widgets/product_detail/product_tabs_section.dart'; +import 'package:worker/features/products/presentation/widgets/product_detail/sticky_action_bar.dart'; + +/// Product Detail Page +/// +/// Displays comprehensive product information including: +/// - Image gallery with 360° view +/// - Product info (SKU, name, price, discount) +/// - Quick info cards (size, warranty, delivery) +/// - Tabbed content (description, specifications, reviews) +/// - Sticky bottom action bar (quantity selector + add to cart) +class ProductDetailPage extends ConsumerStatefulWidget { + final String productId; + + const ProductDetailPage({ + super.key, + required this.productId, + }); + + @override + ConsumerState createState() => _ProductDetailPageState(); +} + +class _ProductDetailPageState extends ConsumerState { + int _quantity = 1; + bool _isFavorite = false; + + void _increaseQuantity() { + setState(() { + _quantity++; + }); + } + + void _decreaseQuantity() { + if (_quantity > 1) { + setState(() { + _quantity--; + }); + } + } + + void _updateQuantity(int newQuantity) { + if (newQuantity >= 1) { + setState(() { + _quantity = newQuantity; + }); + } + } + + void _toggleFavorite() { + setState(() { + _isFavorite = !_isFavorite; + }); + + // Show feedback + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _isFavorite + ? 'Đã thêm vào yêu thích' + : 'Đã xóa khỏi yêu thích', + ), + duration: const Duration(seconds: 1), + ), + ); + } + + void _shareProduct(Product product) { + // Show share options + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => Container( + decoration: const BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.vertical( + top: Radius.circular(AppRadius.xl), + ), + ), + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.grey100, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: AppSpacing.lg), + + // Title + const Text( + 'Chia sẻ sản phẩm', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.lg), + + // Share options + ListTile( + leading: const Icon(Icons.chat, color: AppColors.primaryBlue), + title: const Text('Chia sẻ qua tin nhắn'), + onTap: () { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Đang phát triển tính năng chat'), + ), + ); + }, + ), + ListTile( + leading: const Icon(Icons.share, color: AppColors.primaryBlue), + title: const Text('Chia sẻ khác'), + onTap: () { + Navigator.pop(context); + // TODO: Use share plugin + }, + ), + ListTile( + leading: const Icon(Icons.copy, color: AppColors.primaryBlue), + title: const Text('Sao chép link'), + onTap: () { + Navigator.pop(context); + // TODO: Copy to clipboard + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Đã sao chép link sản phẩm!'), + ), + ); + }, + ), + ], + ), + ), + ); + } + + void _addToCart(Product product) { + // TODO: Add to cart logic + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Đã thêm $_quantity ${product.unit} ${product.name} vào giỏ hàng!', + ), + duration: const Duration(seconds: 2), + action: SnackBarAction( + label: 'Xem giỏ hàng', + onPressed: () { + // TODO: Navigate to cart + }, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final productsAsync = ref.watch(productsProvider); + + return Scaffold( + backgroundColor: const Color(0xFFF4F6F8), + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => context.pop(), + ), + title: const Text( + 'Chi tiết sản phẩm', + style: TextStyle(color: Colors.black), + ), + elevation: AppBarSpecs.elevation, + backgroundColor: AppColors.white, + foregroundColor: AppColors.grey900, + centerTitle: false, + actions: [ + // Share button + IconButton( + icon: const Icon(Icons.share, color: Colors.black), + onPressed: () { + productsAsync.whenData((products) { + final product = products.firstWhere( + (p) => p.productId == widget.productId, + orElse: () => products.first, + ); + _shareProduct(product); + }); + }, + ), + // Favorite button + IconButton( + icon: Icon( + _isFavorite ? Icons.favorite : Icons.favorite_border, + color: _isFavorite ? AppColors.danger : Colors.black, + ), + onPressed: _toggleFavorite, + ), + const SizedBox(width: AppSpacing.sm), + ], + ), + body: productsAsync.when( + data: (products) { + // Find the product by ID + final product = products.firstWhere( + (p) => p.productId == widget.productId, + orElse: () => products.first, // Fallback for demo + ); + + return Stack( + children: [ + // Scrollable content + SingleChildScrollView( + child: Column( + children: [ + // Image Gallery Section + ImageGallerySection(product: product), + + const SizedBox(height: AppSpacing.md), + + // Product Information Section + ProductInfoSection(product: product), + + const SizedBox(height: AppSpacing.md), + + // Product Tabs Section + ProductTabsSection(product: product), + + // Bottom padding for sticky action bar + const SizedBox(height: 88.0), + ], + ), + ), + + // Sticky Action Bar + Positioned( + bottom: 0, + left: 0, + right: 0, + child: StickyActionBar( + quantity: _quantity, + onIncrease: _increaseQuantity, + onDecrease: _decreaseQuantity, + onQuantityChanged: _updateQuantity, + onAddToCart: () => _addToCart(product), + isOutOfStock: !product.inStock, + ), + ), + ], + ); + }, + loading: () => const Center( + child: CircularProgressIndicator( + color: AppColors.primaryBlue, + ), + ), + error: (error, stack) => Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 80, + color: AppColors.danger, + ), + const SizedBox(height: AppSpacing.lg), + const Text( + 'Không thể tải thông tin sản phẩm', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.sm), + Text( + error.toString(), + style: const TextStyle( + fontSize: 14, + color: AppColors.grey500, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + ElevatedButton.icon( + onPressed: () async { + await ref.read(productsProvider.notifier).refresh(); + }, + icon: const Icon(Icons.refresh), + label: const Text('Thử lại'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: AppColors.white, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/products/presentation/pages/products_page.dart b/lib/features/products/presentation/pages/products_page.dart index 2fedaca..30271e1 100644 --- a/lib/features/products/presentation/pages/products_page.dart +++ b/lib/features/products/presentation/pages/products_page.dart @@ -33,7 +33,7 @@ class ProductsPage extends ConsumerWidget { final productsAsync = ref.watch(productsProvider); return Scaffold( - backgroundColor: AppColors.white, + backgroundColor: const Color(0xFFF4F6F8), // Match HTML background appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.black), @@ -69,6 +69,7 @@ class ProductsPage extends ConsumerWidget { body: Column( children: [ // Search Bar + const SizedBox(height: AppSpacing.sm), const ProductSearchBar(), const SizedBox(height: AppSpacing.sm), @@ -94,37 +95,27 @@ class ProductsPage extends ConsumerWidget { return _buildEmptyState(context, l10n); } - return RefreshIndicator( - onRefresh: () async { - await ref.read(productsProvider.notifier).refresh(); + return ProductGrid( + products: products, + onProductTap: (product) { + // Navigate to product detail page + context.push('/products/${product.productId}'); }, - child: ProductGrid( - products: products, - onProductTap: (product) { - // TODO: Navigate to product detail page - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(product.name), - duration: const Duration(seconds: 1), + onAddToCart: (product) { + // TODO: Add to cart logic + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${product.name} đã thêm vào giỏ hàng'), + duration: const Duration(seconds: 2), + action: SnackBarAction( + label: 'Xem', + onPressed: () { + // Navigate to cart + }, ), - ); - }, - onAddToCart: (product) { - // TODO: Add to cart logic - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('${product.name} ${l10n.addedToCart}'), - duration: const Duration(seconds: 2), - action: SnackBarAction( - label: l10n.viewDetails, - onPressed: () { - // Navigate to cart - }, - ), - ), - ); - }, - ), + ), + ); + }, ); }, loading: () => _buildLoadingState(), diff --git a/lib/features/products/presentation/widgets/product_card.dart b/lib/features/products/presentation/widgets/product_card.dart index 619d370..eedb2c5 100644 --- a/lib/features/products/presentation/widgets/product_card.dart +++ b/lib/features/products/presentation/widgets/product_card.dart @@ -168,34 +168,81 @@ class ProductCard extends StatelessWidget { const SizedBox(height: AppSpacing.sm), - // Add to Cart Button - Full Width - SizedBox( - width: double.infinity, - height: 36.0, - child: ElevatedButton.icon( - onPressed: product.inStock ? onAddToCart : null, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryBlue, - foregroundColor: AppColors.white, - disabledBackgroundColor: AppColors.grey100, - disabledForegroundColor: AppColors.grey500, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.button), - ), - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, + // Action Buttons + Column( + spacing: 8.0, + children: [ + // Add to Cart Button + SizedBox( + width: double.infinity, + height: 36.0, + child: ElevatedButton.icon( + onPressed: product.inStock ? onAddToCart : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: AppColors.white, + disabledBackgroundColor: AppColors.grey100, + disabledForegroundColor: AppColors.grey500, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + ), + ), + icon: const Icon(Icons.shopping_cart, size: 16.0), + label: Text( + product.inStock ? 'Thêm vào giỏ' : l10n.outOfStock, + style: const TextStyle( + fontSize: 12.0, + fontWeight: FontWeight.w600, + ), + ), ), ), - icon: const Icon(Icons.shopping_cart, size: 18.0), - label: Text( - product.inStock ? l10n.addToCart : l10n.outOfStock, - style: const TextStyle( - fontSize: 14.0, - fontWeight: FontWeight.w600, + + // 360° View Button (if available) + if (product.has360View) + SizedBox( + width: double.infinity, + height: 36.0, + child: OutlinedButton.icon( + onPressed: () { + // TODO: Open 360 view in browser + // For now, show a message + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Đang phát triển tính năng xem 360°'), + duration: Duration(seconds: 2), + ), + ); + }, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primaryBlue, + side: const BorderSide( + color: AppColors.primaryBlue, + width: 1.5, + ), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + ), + ), + icon: const Icon(Icons.threed_rotation, size: 16.0), + label: const Text( + 'Phối cảnh 360°', + style: TextStyle( + fontSize: 12.0, + fontWeight: FontWeight.w600, + ), + ), + ), ), - ), - ), + ], ), ], ), diff --git a/lib/features/products/presentation/widgets/product_detail/image_gallery_section.dart b/lib/features/products/presentation/widgets/product_detail/image_gallery_section.dart new file mode 100644 index 0000000..ec953e2 --- /dev/null +++ b/lib/features/products/presentation/widgets/product_detail/image_gallery_section.dart @@ -0,0 +1,437 @@ +/// Widget: Image Gallery Section +/// +/// Product image gallery with main image, 360° view button, indicators, and thumbnails. +library; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.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'; + +/// Image Gallery Section +/// +/// Displays: +/// - Main product image with 1:1 aspect ratio +/// - 360° view button overlay (if available) +/// - Image indicators (dots) +/// - Thumbnail gallery row (horizontal scroll) +class ImageGallerySection extends StatefulWidget { + final Product product; + + const ImageGallerySection({ + super.key, + required this.product, + }); + + @override + State createState() => _ImageGallerySectionState(); +} + +class _ImageGallerySectionState extends State { + int _currentImageIndex = 0; + final PageController _pageController = PageController(); + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _changeImage(int index) { + setState(() { + _currentImageIndex = index; + }); + _pageController.animateToPage( + index, + duration: AppDuration.medium, + curve: Curves.easeInOut, + ); + } + + void _open360View() { + if (widget.product.link360 != null) { + // TODO: Open in browser + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Đang phát triển tính năng xem 360°'), + duration: Duration(seconds: 2), + ), + ); + } + } + + void _openLightbox() { + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (context) => _ImageLightbox( + images: widget.product.images, + imageCaptions: widget.product.imageCaptions, + initialIndex: _currentImageIndex, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final images = widget.product.images; + + return Container( + color: AppColors.white, + child: Column( + children: [ + // Main Image with PageView + AspectRatio( + aspectRatio: 1.0, + child: Stack( + children: [ + // PageView for images + PageView.builder( + controller: _pageController, + itemCount: images.length, + onPageChanged: (index) { + setState(() { + _currentImageIndex = index; + }); + }, + itemBuilder: (context, index) { + return GestureDetector( + onTap: _openLightbox, + child: CachedNetworkImage( + imageUrl: images[index], + fit: BoxFit.cover, + placeholder: (context, url) => Shimmer.fromColors( + baseColor: AppColors.grey100, + highlightColor: AppColors.grey50, + child: Container(color: AppColors.grey100), + ), + errorWidget: (context, url, error) => Container( + color: AppColors.grey100, + child: const Icon( + Icons.image_not_supported, + size: 64, + color: AppColors.grey500, + ), + ), + ), + ); + }, + ), + + // 360° Button Overlay + if (widget.product.has360View) + Positioned( + top: 16, + right: 16, + child: Material( + color: Colors.black.withAlpha(204), // 0.8 opacity + borderRadius: BorderRadius.circular(20), + child: InkWell( + onTap: _open360View, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(seconds: 2), + builder: (context, double value, child) { + return Transform.rotate( + angle: value * 2 * 3.14159, + child: const Icon( + Icons.sync, + size: 10, + color: AppColors.white, + ), + ); + }, + onEnd: () { + // Loop animation + setState(() {}); + }, + ), + const SizedBox(width: 6), + const Text( + '360°', + style: TextStyle( + color: AppColors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ), + + // Image Indicators + if (images.length > 1) + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + images.length, + (index) => Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _currentImageIndex == index + ? AppColors.white + : AppColors.white.withAlpha(128), + ), + ), + ), + ), + ), + ], + ), + ), + + // Thumbnail Gallery + if (images.length > 1) + Container( + height: 92, + padding: const EdgeInsets.all(16), + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: images.length, + itemBuilder: (context, index) { + final isActive = _currentImageIndex == index; + return GestureDetector( + onTap: () => _changeImage(index), + child: Container( + width: 60, + height: 60, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isActive + ? AppColors.primaryBlue + : Colors.transparent, + width: 2, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: images[index], + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: AppColors.grey100, + ), + errorWidget: (context, url, error) => Container( + color: AppColors.grey100, + child: const Icon( + Icons.image_not_supported, + size: 20, + color: AppColors.grey500, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +/// Image Lightbox for full-screen image viewing +class _ImageLightbox extends StatefulWidget { + final List images; + final Map imageCaptions; + final int initialIndex; + + const _ImageLightbox({ + required this.images, + required this.imageCaptions, + required this.initialIndex, + }); + + @override + State<_ImageLightbox> createState() => _ImageLightboxState(); +} + +class _ImageLightboxState extends State<_ImageLightbox> { + late int _currentIndex; + late PageController _pageController; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + _pageController = PageController(initialPage: widget.initialIndex); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _nextImage() { + if (_currentIndex < widget.images.length - 1) { + _pageController.nextPage( + duration: AppDuration.medium, + curve: Curves.easeInOut, + ); + } + } + + void _previousImage() { + if (_currentIndex > 0) { + _pageController.previousPage( + duration: AppDuration.medium, + curve: Curves.easeInOut, + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: AppColors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.close, size: 32), + onPressed: () => Navigator.of(context).pop(), + ), + title: Text( + '${_currentIndex + 1} / ${widget.images.length}', + style: const TextStyle( + color: AppColors.white, + fontSize: 16, + ), + ), + ), + body: Stack( + children: [ + // Image PageView + PageView.builder( + controller: _pageController, + itemCount: widget.images.length, + onPageChanged: (index) { + setState(() { + _currentIndex = index; + }); + }, + itemBuilder: (context, index) { + return Center( + child: InteractiveViewer( + minScale: 0.5, + maxScale: 4.0, + child: CachedNetworkImage( + imageUrl: widget.images[index], + fit: BoxFit.contain, + errorWidget: (context, url, error) => const Icon( + Icons.error_outline, + color: AppColors.white, + size: 64, + ), + ), + ), + ); + }, + ), + + // Navigation Arrows + if (widget.images.length > 1) ...[ + // Previous button + if (_currentIndex > 0) + Positioned( + left: 16, + top: 0, + bottom: 0, + child: Center( + child: IconButton( + icon: const Icon( + Icons.chevron_left, + color: AppColors.white, + size: 32, + ), + onPressed: _previousImage, + style: IconButton.styleFrom( + backgroundColor: Colors.white.withAlpha(51), + ), + ), + ), + ), + + // Next button + if (_currentIndex < widget.images.length - 1) + Positioned( + right: 16, + top: 0, + bottom: 0, + child: Center( + child: IconButton( + icon: const Icon( + Icons.chevron_right, + color: AppColors.white, + size: 32, + ), + onPressed: _nextImage, + style: IconButton.styleFrom( + backgroundColor: Colors.white.withAlpha(51), + ), + ), + ), + ), + ], + + // Caption (if available) + if (widget.imageCaptions.isNotEmpty) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withAlpha(128), + Colors.transparent, + ], + ), + ), + child: Text( + widget.imageCaptions[widget.images[_currentIndex]] ?? '', + style: const TextStyle( + color: AppColors.white, + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ); + } +} 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 new file mode 100644 index 0000000..beacf36 --- /dev/null +++ b/lib/features/products/presentation/widgets/product_detail/product_info_section.dart @@ -0,0 +1,198 @@ +/// Widget: Product Info Section +/// +/// Displays SKU, product name, pricing, and quick info cards. +library; + +import 'package:flutter/material.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'; + +/// Product Info Section +/// +/// Displays: +/// - SKU text (small, gray) +/// - Product title (large, bold) +/// - Pricing row (current price, original price, discount badge) +/// - Quick info cards (3 cards: Size, Warranty, Delivery) +class ProductInfoSection extends StatelessWidget { + final Product product; + + const ProductInfoSection({ + super.key, + required this.product, + }); + + String _formatPrice(double price) { + final formatter = NumberFormat('#,###', 'vi_VN'); + return '${formatter.format(price)} VND'; + } + + @override + Widget build(BuildContext context) { + return Container( + color: AppColors.white, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // SKU + Text( + 'SKU: ${product.erpnextItemCode ?? product.productId}', + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + ), + + const SizedBox(height: 8), + + // Product Title + Text( + product.name, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + color: AppColors.grey900, + height: 1.3, + ), + ), + + const SizedBox(height: 16), + + // Pricing Row + Row( + children: [ + // Current Price + Text( + '${_formatPrice(product.basePrice)}/${product.unit ?? 'm²'}', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.primaryBlue, + ), + ), + + // Original Price (if on sale) + if (product.isOnSale) ...[ + const SizedBox(width: 12), + Text( + _formatPrice(product.basePrice * 1.12), // Mock original price + style: const TextStyle( + fontSize: 16, + color: AppColors.grey500, + decoration: TextDecoration.lineThrough, + ), + ), + + const SizedBox(width: 12), + + // Discount Badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.danger, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '-${product.discountPercentage}%', + style: const TextStyle( + color: AppColors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + + const SizedBox(height: 16), + + // Quick Info Cards + Row( + children: [ + // Size Info + Expanded( + child: _QuickInfoCard( + icon: Icons.straighten, + label: 'Kích thước', + value: product.getSpecification('size') ?? '1200x1200', + ), + ), + const SizedBox(width: 12), + + // Warranty Info + Expanded( + child: _QuickInfoCard( + icon: Icons.shield, + label: 'Bảo hành', + value: product.getSpecification('warranty') ?? '15 năm', + ), + ), + const SizedBox(width: 12), + + // Delivery Info + Expanded( + child: _QuickInfoCard( + icon: Icons.local_shipping, + label: 'Giao hàng', + value: '2-3 ngày', + ), + ), + ], + ), + ], + ), + ); + } +} + +/// 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, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Icon( + icon, + color: AppColors.primaryBlue, + size: 24, + ), + const SizedBox(height: 4), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 2), + Text( + value, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + textAlign: TextAlign.center, + ), + ], + ); + } +} 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 new file mode 100644 index 0000000..502dc05 --- /dev/null +++ b/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart @@ -0,0 +1,487 @@ +/// Widget: Product Tabs Section +/// +/// Tabbed content section with Description, Specifications, and Reviews tabs. +library; + +import 'package:flutter/material.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'; + +/// Product Tabs Section +/// +/// Displays tabbed content: +/// - Description: Rich text with features list +/// - 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, + }); + + @override + State createState() => _ProductTabsSectionState(); +} + +class _ProductTabsSectionState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + color: AppColors.white, + child: Column( + children: [ + // Tab Navigation + Container( + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide( + color: Color(0xFFe0e0e0), + width: 1, + ), + ), + ), + child: TabBar( + controller: _tabController, + labelColor: AppColors.primaryBlue, + unselectedLabelColor: AppColors.grey500, + labelStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + unselectedLabelStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + indicatorColor: AppColors.primaryBlue, + indicatorWeight: 2, + tabs: const [ + Tab(text: 'Mô tả'), + Tab(text: 'Thông số'), + Tab(text: 'Đánh giá'), + ], + ), + ), + + // Tab Content + SizedBox( + height: 400, // Fixed height for tab content + child: TabBarView( + controller: _tabController, + children: [ + _DescriptionTab(product: widget.product), + _SpecificationsTab(product: widget.product), + const _ReviewsTab(), + ], + ), + ), + ], + ), + ); + } +} + +/// Description Tab Content +class _DescriptionTab extends StatelessWidget { + final Product product; + + const _DescriptionTab({required this.product}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Main description + const Text( + 'Bộ sưu tập Cao cấp', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + ), + const SizedBox(height: 12), + + Text( + product.description ?? + 'Sản phẩm gạch cao cấp với chất lượng vượt trội, mang đến vẻ đẹp tự nhiên và sang trọng cho không gian của bạn. Với bề mặt có texture tinh tế, sản phẩm tạo nên những đường vân tự nhiên chân thực.', + style: const TextStyle( + fontSize: 14, + height: 1.6, + color: AppColors.grey900, + ), + ), + + const SizedBox(height: 20), + + // Features heading + const Text( + 'Đặc điểm nổi bật:', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + ), + const SizedBox(height: 12), + + // Features list + ...[ + 'Bề mặt chống trầy xước cao', + 'Khả năng chống thấm nước tốt', + 'Màu sắc bền đẹp theo thời gian', + 'Dễ dàng vệ sinh và bảo trì', + 'Thân thiện với môi trường', + ].map((feature) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.check_circle, + size: 18, + color: AppColors.success, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + feature, + style: const TextStyle( + fontSize: 14, + color: AppColors.grey900, + ), + ), + ), + ], + ), + )), + + const SizedBox(height: 20), + + // Application section + const Text( + 'Ứng dụng:', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + ), + const SizedBox(height: 12), + + const Text( + 'Phù hợp cho phòng khách, phòng ngủ, hành lang, văn phòng và các không gian thương mại. Đặc biệt phù hợp với phong cách nội thất hiện đại, tối giản và Scandinavian.', + style: TextStyle( + fontSize: 14, + height: 1.6, + color: AppColors.grey900, + ), + ), + ], + ), + ); + } +} + +/// Specifications Tab Content +class _SpecificationsTab extends StatelessWidget { + final Product product; + + const _SpecificationsTab({required this.product}); + + @override + Widget build(BuildContext context) { + // Default specifications if not available + final specs = product.specifications.isNotEmpty + ? product.specifications + : { + 'Kích thước': '60cm x 60cm', + 'Độ dày': '9.5mm', + 'Bề mặt': 'Matt (Nhám)', + 'Loại men': 'Granite kỹ thuật số', + 'Độ hấp thụ nước': '< 0.5%', + 'Độ chống mài mòn': 'PEI 4', + 'Chức năng': 'Lát nền, Ốp tường', + 'Xuất xứ': 'Việt Nam', + 'Bảo hành': '15 năm', + '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( + children: specs.entries.map((entry) { + final isLast = entry == specs.entries.last; + return Container( + decoration: BoxDecoration( + border: isLast + ? null + : const Border( + bottom: BorderSide( + color: Color(0xFFe0e0e0), + ), + ), + ), + child: Row( + children: [ + // Label + Expanded( + child: Container( + padding: const EdgeInsets.all(12), + color: const Color(0xFFF4F6F8), + child: Text( + entry.key, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.grey900, + ), + ), + ), + ), + + // Divider + Container( + width: 1, + height: 44, + color: const Color(0xFFe0e0e0), + ), + + // Value + Expanded( + child: Container( + padding: const EdgeInsets.all(12), + child: Text( + entry.value.toString(), + style: const TextStyle( + fontSize: 14, + color: AppColors.grey900, + ), + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ); + } +} + +/// Reviews Tab Content +class _ReviewsTab extends StatelessWidget { + const _ReviewsTab(); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Rating Overview + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFF4F6F8), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + // Rating Score + const Text( + '4.8', + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w700, + color: AppColors.primaryBlue, + ), + ), + + const SizedBox(width: 16), + + // Rating Details + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Stars + Row( + children: List.generate( + 5, + (index) => Icon( + index < 4 + ? Icons.star + : Icons.star_half, + color: const Color(0xFFffc107), + size: 18, + ), + ), + ), + + const SizedBox(height: 4), + + // Review count + const Text( + '125 đánh giá', + style: TextStyle( + fontSize: 14, + color: AppColors.grey500, + ), + ), + ], + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Review Items + ..._mockReviews.map((review) => _ReviewItem(review: review)), + ], + ), + ); + } +} + +/// Review Item Widget +class _ReviewItem extends StatelessWidget { + final Map review; + + const _ReviewItem({required this.review}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only(bottom: 16), + margin: const EdgeInsets.only(bottom: 16), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide( + color: Color(0xFFe0e0e0), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Reviewer Info + Row( + children: [ + // Avatar + Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFF4F6F8), + ), + child: const Icon( + Icons.person, + color: AppColors.grey500, + size: 20, + ), + ), + + const SizedBox(width: 12), + + // Name and Date + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + review['name'], + style: const TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + ), + Text( + review['date'], + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 8), + + // Rating Stars + Row( + children: List.generate( + 5, + (index) => Icon( + index < review['rating'] + ? Icons.star + : Icons.star_border, + color: const Color(0xFFffc107), + size: 14, + ), + ), + ), + + const SizedBox(height: 8), + + // Review Text + Text( + review['text'], + style: const TextStyle( + fontSize: 14, + height: 1.5, + color: AppColors.grey900, + ), + ), + ], + ), + ); + } +} + +// Mock review data +final _mockReviews = [ + { + 'name': 'Nguyễn Văn A', + 'date': '2 tuần trước', + 'rating': 5, + 'text': + 'Sản phẩm chất lượng tốt, màu sắc đẹp và dễ lắp đặt. Rất hài lòng với lựa chọn này cho ngôi nhà của gia đình.', + }, + { + 'name': 'Trần Thị B', + 'date': '1 tháng trước', + 'rating': 4, + 'text': 'Gạch đẹp, vân gỗ rất chân thực. Giao hàng nhanh chóng và đóng gói cẩn thận.', + }, +]; 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 new file mode 100644 index 0000000..36f1049 --- /dev/null +++ b/lib/features/products/presentation/widgets/product_detail/sticky_action_bar.dart @@ -0,0 +1,177 @@ +/// Widget: Sticky Action Bar +/// +/// Fixed bottom action bar with quantity controls and add to cart button. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; + +/// Sticky Action Bar +/// +/// Fixed at the bottom of the screen with: +/// - Quantity controls (-, input, +) +/// - Add to cart button (full width, primary blue) +class StickyActionBar extends StatelessWidget { + final int quantity; + final VoidCallback onIncrease; + final VoidCallback onDecrease; + final ValueChanged onQuantityChanged; + final VoidCallback onAddToCart; + final bool isOutOfStock; + + const StickyActionBar({ + super.key, + required this.quantity, + required this.onIncrease, + required this.onDecrease, + required this.onQuantityChanged, + required this.onAddToCart, + this.isOutOfStock = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.white, + border: Border( + top: BorderSide( + color: const Color(0xFFe0e0e0), + width: 1, + ), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(26), // 0.1 opacity + offset: const Offset(0, -4), + blurRadius: 15, + ), + ], + ), + padding: const EdgeInsets.all(16), + child: SafeArea( + top: false, + child: Row( + children: [ + // Quantity Controls + Container( + decoration: BoxDecoration( + border: Border.all( + color: const Color(0xFFe0e0e0), + ), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + // Decrease Button + _QuantityButton( + icon: Icons.remove, + onPressed: quantity > 1 ? onDecrease : null, + ), + + // Quantity Input + SizedBox( + width: 60, + child: TextField( + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + controller: TextEditingController( + text: quantity.toString(), + ), + onChanged: (value) { + final newQuantity = int.tryParse(value); + if (newQuantity != null && newQuantity >= 1) { + onQuantityChanged(newQuantity); + } + }, + ), + ), + + // Increase Button + _QuantityButton( + icon: Icons.add, + onPressed: onIncrease, + ), + ], + ), + ), + + const SizedBox(width: 16), + + // Add to Cart Button + Expanded( + child: ElevatedButton.icon( + onPressed: isOutOfStock ? null : 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, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + icon: const Icon(Icons.shopping_cart, size: 20), + label: Text( + isOutOfStock ? 'Hết hàng' : 'Thêm vào giỏ hàng', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Quantity Button Widget +class _QuantityButton extends StatelessWidget { + final IconData icon; + final VoidCallback? onPressed; + + const _QuantityButton({ + required this.icon, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 40, + height: 40, + child: Material( + color: const Color(0xFFF4F6F8), + child: InkWell( + onTap: onPressed, + child: Icon( + icon, + size: 20, + color: onPressed != null ? AppColors.grey900 : AppColors.grey500, + ), + ), + ), + ); + } +} diff --git a/lib/features/products/presentation/widgets/product_grid.dart b/lib/features/products/presentation/widgets/product_grid.dart index b3b9baf..9d47412 100644 --- a/lib/features/products/presentation/widgets/product_grid.dart +++ b/lib/features/products/presentation/widgets/product_grid.dart @@ -31,7 +31,7 @@ class ProductGrid extends StatelessWidget { crossAxisCount: GridSpecs.productGridColumns, crossAxisSpacing: AppSpacing.xs, mainAxisSpacing: AppSpacing.xs, - childAspectRatio: 0.7, // Width / Height ratio + childAspectRatio: 0.62, // Width / Height ratio (adjusted for 2 buttons) ), itemCount: products.length, itemBuilder: (context, index) { diff --git a/lib/features/products/presentation/widgets/product_search_bar.dart b/lib/features/products/presentation/widgets/product_search_bar.dart index fb9d2d9..4b892ed 100644 --- a/lib/features/products/presentation/widgets/product_search_bar.dart +++ b/lib/features/products/presentation/widgets/product_search_bar.dart @@ -83,7 +83,7 @@ class _ProductSearchBarState extends ConsumerState { ) : null, filled: true, - fillColor: const Color(0xFFF5F5F5), + fillColor: Colors.white, border: OutlineInputBorder( borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius), borderSide: BorderSide.none,