This commit is contained in:
Phuoc Nguyen
2025-10-24 15:09:51 +07:00
parent 338d26a38a
commit c225144ad3
11 changed files with 1805 additions and 173 deletions

View File

@@ -6,8 +6,8 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.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/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/products/presentation/pages/products_page.dart';
import 'package:worker/features/promotions/presentation/pages/promotion_detail_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 // Promotion Detail Route
GoRoute( GoRoute(
path: RouteNames.promotionDetail, path: RouteNames.promotionDetail,

View File

@@ -72,154 +72,112 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource {
/// Mock products data /// Mock products data
static final List<Map<String, dynamic>> _productsJson = [ static final List<Map<String, dynamic>> _productsJson = [
{ {
'id': 'prod_001', 'product_id': 'prod_001',
'name': 'Gạch men cao cấp 60x60', 'name': 'Gạch Cát Tường 1200x1200',
'sku': 'GM-60-001',
'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ủ.', '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': '', 'unit': '',
'imageUrl': 'https://images.unsplash.com/photo-1615971677499-5467cbfe1f10?w=400', 'images': ['https://www.eurotile.vn/pictures/catalog/product/0-gachkholon/cat-tuong/CAT-S01G-1.jpg'],
'categoryId': 'floor_tiles', 'image_captions': {},
'inStock': true, 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US',
'stockQuantity': 150, 'specifications': {},
'createdAt': '2024-01-15T08:00:00Z', 'category': 'floor_tiles',
'salePrice': null,
'brand': 'Eurotile', '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', '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.', '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': '', 'unit': '',
'imageUrl': 'https://images.unsplash.com/photo-1565183928294-7d22e855a326?w=400', 'images': ['https://images.unsplash.com/photo-1565193566173-7a0ee3dbe261?w=300&h=300&fit=crop'],
'categoryId': 'floor_tiles', 'image_captions': {},
'inStock': true, 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R&locale=en_US',
'stockQuantity': 80, 'specifications': {},
'createdAt': '2024-01-20T10:30:00Z', 'category': 'floor_tiles',
'salePrice': 620000.0,
'brand': 'Vasta Stone', '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í', '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.', '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': '', 'unit': '',
'imageUrl': 'https://images.unsplash.com/photo-1604709177225-055f99402ea3?w=400', 'images': ['https://images.unsplash.com/photo-1615971677499-5467cbab01c0?w=300&h=300&fit=crop'],
'categoryId': 'decorative_tiles', 'image_captions': {},
'inStock': true, 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US',
'stockQuantity': 45, 'specifications': {},
'createdAt': '2024-02-01T14:15:00Z', 'category': 'decorative_tiles',
'salePrice': null,
'brand': 'Eurotile', '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', '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.', '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': '', 'unit': '',
'imageUrl': 'https://images.unsplash.com/photo-1600585152220-90363fe7e115?w=400', 'images': ['https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=300&h=300&fit=crop'],
'categoryId': 'wall_tiles', 'image_captions': {},
'inStock': true, 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US',
'stockQuantity': 30, 'specifications': {},
'createdAt': '2024-02-10T09:00:00Z', 'category': 'wall_tiles',
'salePrice': 680000.0,
'brand': 'Vasta Stone', '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', '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.', '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': '', 'unit': '',
'imageUrl': 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=400', 'images': ['https://images.unsplash.com/photo-1615874694520-474822394e73?w=300&h=300&fit=crop'],
'categoryId': 'outdoor_tiles', 'image_captions': {},
'inStock': true, 'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US',
'stockQuantity': 8, 'specifications': {},
'createdAt': '2024-02-15T11:20:00Z', 'category': 'outdoor_tiles',
'salePrice': null,
'brand': 'Eurotile', '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', '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.', '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': '', 'unit': '',
'imageUrl': 'https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=400', 'images': ['https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=300&h=300&fit=crop'],
'categoryId': 'decorative_tiles', 'image_captions': {},
'inStock': true, 'link_360': null,
'stockQuantity': 25, 'specifications': {},
'createdAt': '2024-02-20T15:45:00Z', 'category': 'decorative_tiles',
'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': '',
'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': '',
'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,
'brand': 'Vasta Stone', 'brand': 'Vasta Stone',
'is_active': true,
'is_featured': false,
'erpnext_item_code': null,
'created_at': '2024-02-20T15:45:00Z',
'updated_at': null,
}, },
]; ];

View File

@@ -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<ProductDetailPage> createState() => _ProductDetailPageState();
}
class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
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,
),
),
],
),
),
),
),
);
}
}

View File

@@ -33,7 +33,7 @@ class ProductsPage extends ConsumerWidget {
final productsAsync = ref.watch(productsProvider); final productsAsync = ref.watch(productsProvider);
return Scaffold( return Scaffold(
backgroundColor: AppColors.white, backgroundColor: const Color(0xFFF4F6F8), // Match HTML background
appBar: AppBar( appBar: AppBar(
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black), icon: const Icon(Icons.arrow_back, color: Colors.black),
@@ -69,6 +69,7 @@ class ProductsPage extends ConsumerWidget {
body: Column( body: Column(
children: [ children: [
// Search Bar // Search Bar
const SizedBox(height: AppSpacing.sm),
const ProductSearchBar(), const ProductSearchBar(),
const SizedBox(height: AppSpacing.sm), const SizedBox(height: AppSpacing.sm),
@@ -94,37 +95,27 @@ class ProductsPage extends ConsumerWidget {
return _buildEmptyState(context, l10n); return _buildEmptyState(context, l10n);
} }
return RefreshIndicator( return ProductGrid(
onRefresh: () async { products: products,
await ref.read(productsProvider.notifier).refresh(); onProductTap: (product) {
// Navigate to product detail page
context.push('/products/${product.productId}');
}, },
child: ProductGrid( onAddToCart: (product) {
products: products, // TODO: Add to cart logic
onProductTap: (product) { ScaffoldMessenger.of(context).showSnackBar(
// TODO: Navigate to product detail page SnackBar(
ScaffoldMessenger.of(context).showSnackBar( content: Text('${product.name} đã thêm vào giỏ hàng'),
SnackBar( duration: const Duration(seconds: 2),
content: Text(product.name), action: SnackBarAction(
duration: const Duration(seconds: 1), 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(), loading: () => _buildLoadingState(),

View File

@@ -168,34 +168,81 @@ class ProductCard extends StatelessWidget {
const SizedBox(height: AppSpacing.sm), const SizedBox(height: AppSpacing.sm),
// Add to Cart Button - Full Width // Action Buttons
SizedBox( Column(
width: double.infinity, spacing: 8.0,
height: 36.0, children: [
child: ElevatedButton.icon( // Add to Cart Button
onPressed: product.inStock ? onAddToCart : null, SizedBox(
style: ElevatedButton.styleFrom( width: double.infinity,
backgroundColor: AppColors.primaryBlue, height: 36.0,
foregroundColor: AppColors.white, child: ElevatedButton.icon(
disabledBackgroundColor: AppColors.grey100, onPressed: product.inStock ? onAddToCart : null,
disabledForegroundColor: AppColors.grey500, style: ElevatedButton.styleFrom(
elevation: 0, backgroundColor: AppColors.primaryBlue,
shape: RoundedRectangleBorder( foregroundColor: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.button), disabledBackgroundColor: AppColors.grey100,
), disabledForegroundColor: AppColors.grey500,
padding: const EdgeInsets.symmetric( elevation: 0,
horizontal: AppSpacing.sm, 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( // 360° View Button (if available)
product.inStock ? l10n.addToCart : l10n.outOfStock, if (product.has360View)
style: const TextStyle( SizedBox(
fontSize: 14.0, width: double.infinity,
fontWeight: FontWeight.w600, 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,
),
),
),
), ),
), ],
),
), ),
], ],
), ),

View File

@@ -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<ImageGallerySection> createState() => _ImageGallerySectionState();
}
class _ImageGallerySectionState extends State<ImageGallerySection> {
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<double>(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<String> images;
final Map<String, String> 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,
),
),
),
],
),
);
}
}

View File

@@ -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 ?? ''}',
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,
),
],
);
}
}

View File

@@ -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<ProductTabsSection> createState() => _ProductTabsSectionState();
}
class _ProductTabsSectionState extends State<ProductTabsSection>
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<String, dynamic> 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.',
},
];

View File

@@ -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<int> 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,
),
),
),
);
}
}

View File

@@ -31,7 +31,7 @@ class ProductGrid extends StatelessWidget {
crossAxisCount: GridSpecs.productGridColumns, crossAxisCount: GridSpecs.productGridColumns,
crossAxisSpacing: AppSpacing.xs, crossAxisSpacing: AppSpacing.xs,
mainAxisSpacing: AppSpacing.xs, mainAxisSpacing: AppSpacing.xs,
childAspectRatio: 0.7, // Width / Height ratio childAspectRatio: 0.62, // Width / Height ratio (adjusted for 2 buttons)
), ),
itemCount: products.length, itemCount: products.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {

View File

@@ -83,7 +83,7 @@ class _ProductSearchBarState extends ConsumerState<ProductSearchBar> {
) )
: null, : null,
filled: true, filled: true,
fillColor: const Color(0xFFF5F5F5), fillColor: Colors.white,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius), borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: BorderSide.none, borderSide: BorderSide.none,