product
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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': 'm²',
|
'unit': 'm²',
|
||||||
'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': 'm²',
|
'unit': 'm²',
|
||||||
'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': 'm²',
|
'unit': 'm²',
|
||||||
'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': 'm²',
|
'unit': 'm²',
|
||||||
'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': 'm²',
|
'unit': 'm²',
|
||||||
'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': 'm²',
|
'unit': 'm²',
|
||||||
'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': '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,
|
|
||||||
'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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user