From b5afeed534ae557dc1ce9334bf838d4ac9b0cd33 Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Tue, 11 Nov 2025 11:34:23 +0700 Subject: [PATCH] fix news, --- .../news/data/models/blog_post_model.dart | 10 +- .../presentation/pages/news_detail_page.dart | 263 +++++------------- .../news/presentation/widgets/news_card.dart | 4 +- .../domain/usecases/get_product_detail.dart | 31 +++ .../pages/product_detail_page.dart | 58 ++-- .../providers/products_provider.dart | 24 ++ .../providers/products_provider.g.dart | 148 ++++++++++ .../product_detail/product_info_section.dart | 14 +- .../product_detail/product_tabs_section.dart | 5 +- .../product_detail/sticky_action_bar.dart | 127 ++++++--- pubspec.lock | 32 +++ pubspec.yaml | 1 + 12 files changed, 439 insertions(+), 278 deletions(-) create mode 100644 lib/features/products/domain/usecases/get_product_detail.dart diff --git a/lib/features/news/data/models/blog_post_model.dart b/lib/features/news/data/models/blog_post_model.dart index 0868f62..ffb3d33 100644 --- a/lib/features/news/data/models/blog_post_model.dart +++ b/lib/features/news/data/models/blog_post_model.dart @@ -86,12 +86,12 @@ class BlogPostModel { } } - // Extract excerpt from blogIntro or metaDescription - final excerpt = blogIntro ?? metaDescription ?? ''; - // Use content_html preferentially, fall back to content final htmlContent = contentHtml ?? content; + // Excerpt is ONLY from blog_intro (plain text) + final excerpt = blogIntro ?? ''; + // Use meta image with full URL path String imageUrl; if (metaImage != null && metaImage!.isNotEmpty) { @@ -117,7 +117,9 @@ class BlogPostModel { return NewsArticle( id: name, title: title, - excerpt: excerpt.length > 200 ? '${excerpt.substring(0, 200)}...' : excerpt, + excerpt: excerpt.isNotEmpty + ? (excerpt.length > 300 ? '${excerpt.substring(0, 300)}...' : excerpt) + : 'Không có mô tả', content: htmlContent, imageUrl: imageUrl, category: category, diff --git a/lib/features/news/presentation/pages/news_detail_page.dart b/lib/features/news/presentation/pages/news_detail_page.dart index b0c9174..847dc41 100644 --- a/lib/features/news/presentation/pages/news_detail_page.dart +++ b/lib/features/news/presentation/pages/news_detail_page.dart @@ -7,6 +7,7 @@ library; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:worker/core/constants/ui_constants.dart'; @@ -181,9 +182,79 @@ class _NewsDetailPageState extends ConsumerState { const SizedBox(height: 24), - // Article Body - if (article.content != null) - _buildArticleBody(article.content!), + // Article Body - Render HTML content + if (article.content != null && article.content!.isNotEmpty) + Container( + // Wrap Html in Container to prevent rendering issues + child: Html( + data: article.content, + style: { + "body": Style( + margin: Margins.zero, + padding: HtmlPaddings.zero, + fontSize: FontSize(16), + lineHeight: const LineHeight(1.7), + color: const Color(0xFF1E293B), + ), + "h2": Style( + fontSize: FontSize(20), + fontWeight: FontWeight.w600, + color: const Color(0xFF1E293B), + margin: Margins.only(top: 32, bottom: 16), + ), + "h3": Style( + fontSize: FontSize(18), + fontWeight: FontWeight.w600, + color: const Color(0xFF1E293B), + margin: Margins.only(top: 24, bottom: 12), + ), + "p": Style( + fontSize: FontSize(16), + color: const Color(0xFF1E293B), + lineHeight: const LineHeight(1.7), + margin: Margins.only(bottom: 16), + ), + "strong": Style( + fontWeight: FontWeight.w600, + color: const Color(0xFF1E293B), + ), + "img": Style( + margin: Margins.symmetric(vertical: 16), + ), + "ul": Style( + margin: Margins.only(left: 16, bottom: 16), + ), + "ol": Style( + margin: Margins.only(left: 16, bottom: 16), + ), + "li": Style( + fontSize: FontSize(16), + color: const Color(0xFF1E293B), + lineHeight: const LineHeight(1.5), + margin: Margins.only(bottom: 8), + ), + "blockquote": Style( + backgroundColor: const Color(0xFFF0F9FF), + border: const Border( + left: BorderSide(color: AppColors.primaryBlue, width: 4), + ), + padding: HtmlPaddings.all(16), + margin: Margins.symmetric(vertical: 24), + fontStyle: FontStyle.italic, + ), + "div": Style( + margin: Margins.zero, + padding: HtmlPaddings.zero, + ), + }, + onLinkTap: (url, attributes, element) { + // Handle link taps if needed + if (url != null) { + debugPrint('Link tapped: $url'); + } + }, + ), + ), const SizedBox(height: 32), @@ -261,192 +332,6 @@ class _NewsDetailPageState extends ConsumerState { ); } - /// Build article body with simple HTML parsing - Widget _buildArticleBody(String content) { - final elements = _parseHTMLContent(content); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: elements, - ); - } - - /// Parse HTML-like content into widgets - List _parseHTMLContent(String content) { - final List widgets = []; - final lines = content.split('\n').where((line) => line.trim().isNotEmpty); - - for (final line in lines) { - final trimmed = line.trim(); - - // H2 heading - if (trimmed.startsWith('

') && trimmed.endsWith('

')) { - final text = trimmed.substring(4, trimmed.length - 5); - widgets.add(_buildH2(text)); - } - // H3 heading - else if (trimmed.startsWith('

') && trimmed.endsWith('

')) { - final text = trimmed.substring(4, trimmed.length - 5); - widgets.add(_buildH3(text)); - } - // Paragraph - else if (trimmed.startsWith('

') && trimmed.endsWith('

')) { - final text = trimmed.substring(3, trimmed.length - 4); - widgets.add(_buildParagraph(text)); - } - // Unordered list start - else if (trimmed == '
    ') { - // Collect list items - final listItems = []; - continue; - } - // List item - else if (trimmed.startsWith('
  • ') && trimmed.endsWith('
  • ')) { - final text = trimmed.substring(4, trimmed.length - 5); - widgets.add(_buildListItem(text, false)); - } - // Ordered list item (number prefix) - else if (RegExp(r'^\d+\.').hasMatch(trimmed)) { - widgets.add(_buildListItem(trimmed, true)); - } - // Blockquote - else if (trimmed.startsWith('
    ') && - trimmed.endsWith('
    ')) { - final text = trimmed.substring(12, trimmed.length - 13); - widgets.add(_buildBlockquote(text)); - } - // Highlight box (custom tag) - else if (trimmed.startsWith('(.*)').firstMatch(trimmed); - - if (typeMatch != null && contentMatch != null) { - final type = typeMatch.group(1); - final content = contentMatch.group(1); - - widgets.add( - HighlightBox( - type: type == 'tip' ? HighlightType.tip : HighlightType.warning, - title: type == 'tip' ? 'Mẹo từ chuyên gia' : 'Lưu ý khi sử dụng', - content: content ?? '', - ), - ); - } - } - } - - return widgets; - } - - /// Build H2 heading - Widget _buildH2(String text) { - return Padding( - padding: const EdgeInsets.only(top: 32, bottom: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - text, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: Color(0xFF1E293B), - ), - ), - const SizedBox(height: 8), - Container(height: 2, width: 60, color: AppColors.primaryBlue), - ], - ), - ); - } - - /// Build H3 heading - Widget _buildH3(String text) { - return Padding( - padding: const EdgeInsets.only(top: 24, bottom: 12), - child: Text( - text, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Color(0xFF1E293B), - ), - ), - ); - } - - /// Build paragraph - Widget _buildParagraph(String text) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Text( - text, - style: const TextStyle( - fontSize: 16, - color: Color(0xFF1E293B), - height: 1.7, - ), - ), - ); - } - - /// Build list item - Widget _buildListItem(String text, bool isOrdered) { - return Padding( - padding: const EdgeInsets.only(left: 16, bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isOrdered ? '' : '• ', - style: const TextStyle( - fontSize: 16, - color: AppColors.primaryBlue, - fontWeight: FontWeight.bold, - ), - ), - Expanded( - child: Text( - text, - style: const TextStyle( - fontSize: 16, - color: Color(0xFF1E293B), - height: 1.5, - ), - ), - ), - ], - ), - ); - } - - /// Build blockquote - Widget _buildBlockquote(String text) { - return Container( - margin: const EdgeInsets.symmetric(vertical: 24), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFF0F9FF), - border: const Border( - left: BorderSide(color: AppColors.primaryBlue, width: 4), - ), - borderRadius: const BorderRadius.only( - topRight: Radius.circular(8), - bottomRight: Radius.circular(8), - ), - ), - child: Text( - text, - style: const TextStyle( - fontSize: 16, - color: Color(0xFF1E293B), - fontStyle: FontStyle.italic, - height: 1.6, - ), - ), - ); - } - /// Build tags section Widget _buildTagsSection(List tags) { return Container( diff --git a/lib/features/news/presentation/widgets/news_card.dart b/lib/features/news/presentation/widgets/news_card.dart index cd8b458..c598180 100644 --- a/lib/features/news/presentation/widgets/news_card.dart +++ b/lib/features/news/presentation/widgets/news_card.dart @@ -116,10 +116,10 @@ class NewsCard extends StatelessWidget { Row( children: [ // Date - Icon( + const Icon( Icons.calendar_today, size: 12, - color: const Color(0xFF64748B), + color: Color(0xFF64748B), ), const SizedBox(width: 4), Text( diff --git a/lib/features/products/domain/usecases/get_product_detail.dart b/lib/features/products/domain/usecases/get_product_detail.dart new file mode 100644 index 0000000..9190968 --- /dev/null +++ b/lib/features/products/domain/usecases/get_product_detail.dart @@ -0,0 +1,31 @@ +/// Use Case: Get Product Detail +/// +/// Fetches a single product by its ID from the repository. +library; + +import 'package:worker/features/products/domain/entities/product.dart'; +import 'package:worker/features/products/domain/repositories/products_repository.dart'; + +/// Get Product Detail Use Case +/// +/// Fetches detailed information for a single product by ID. +/// +/// Usage: +/// ```dart +/// final getProductDetail = GetProductDetail(repository); +/// final product = await getProductDetail(productId: 'GIB20 G02'); +/// ``` +class GetProductDetail { + const GetProductDetail(this._repository); + + final ProductsRepository _repository; + + /// Execute the use case + /// + /// [productId] - The unique identifier of the product + /// Returns a [Product] entity + /// Throws [Exception] if the product is not found or on error + Future call({required String productId}) async { + return await _repository.getProductById(productId); + } +} diff --git a/lib/features/products/presentation/pages/product_detail_page.dart b/lib/features/products/presentation/pages/product_detail_page.dart index 718c7a4..cc2c55d 100644 --- a/lib/features/products/presentation/pages/product_detail_page.dart +++ b/lib/features/products/presentation/pages/product_detail_page.dart @@ -8,6 +8,7 @@ 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/favorites/presentation/providers/favorites_provider.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'; @@ -34,7 +35,6 @@ class ProductDetailPage extends ConsumerStatefulWidget { class _ProductDetailPageState extends ConsumerState { int _quantity = 1; - bool _isFavorite = false; void _increaseQuantity() { setState(() { @@ -58,20 +58,22 @@ class _ProductDetailPageState extends ConsumerState { } } - void _toggleFavorite() { - setState(() { - _isFavorite = !_isFavorite; - }); + void _toggleFavorite() async { + // Toggle favorite using favorites provider + await ref.read(favoritesProvider.notifier).toggleFavorite(widget.productId); // 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', + final isFavorite = ref.read(isFavoriteProvider(widget.productId)); + if (mounted) { + 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), ), - duration: const Duration(seconds: 1), - ), - ); + ); + } } void _shareProduct(Product product) { @@ -166,7 +168,11 @@ class _ProductDetailPageState extends ConsumerState { @override Widget build(BuildContext context) { - final productsAsync = ref.watch(productsProvider); + // Use productDetailProvider with productId parameter + final productAsync = ref.watch(productDetailProvider(productId: widget.productId)); + + // Watch favorite status from favorites provider + final isFavorite = ref.watch(isFavoriteProvider(widget.productId)); return Scaffold( backgroundColor: const Color(0xFFF4F6F8), @@ -188,11 +194,7 @@ class _ProductDetailPageState extends ConsumerState { 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, - ); + productAsync.whenData((product) { _shareProduct(product); }); }, @@ -200,22 +202,16 @@ class _ProductDetailPageState extends ConsumerState { // Favorite button IconButton( icon: Icon( - _isFavorite ? Icons.favorite : Icons.favorite_border, - color: _isFavorite ? AppColors.danger : Colors.black, + 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 - ); - + body: productAsync.when( + data: (product) { return Stack( children: [ // Scrollable content @@ -248,6 +244,7 @@ class _ProductDetailPageState extends ConsumerState { right: 0, child: StickyActionBar( quantity: _quantity, + unit: product.unit ?? 'm²', onIncrease: _increaseQuantity, onDecrease: _decreaseQuantity, onQuantityChanged: _updateQuantity, @@ -289,8 +286,9 @@ class _ProductDetailPageState extends ConsumerState { ), const SizedBox(height: AppSpacing.lg), ElevatedButton.icon( - onPressed: () async { - await ref.read(productsProvider.notifier).refresh(); + onPressed: () { + // Invalidate to trigger refetch + ref.invalidate(productDetailProvider(productId: widget.productId)); }, icon: const Icon(Icons.refresh), label: const Text('Thử lại'), diff --git a/lib/features/products/presentation/providers/products_provider.dart b/lib/features/products/presentation/providers/products_provider.dart index d883473..a2b1652 100644 --- a/lib/features/products/presentation/providers/products_provider.dart +++ b/lib/features/products/presentation/providers/products_provider.dart @@ -15,6 +15,7 @@ import 'package:worker/features/products/domain/entities/product.dart'; import 'package:worker/features/products/domain/repositories/products_repository.dart'; import 'package:worker/features/products/domain/usecases/get_products.dart'; import 'package:worker/features/products/domain/usecases/search_products.dart'; +import 'package:worker/features/products/domain/usecases/get_product_detail.dart'; import 'package:worker/features/products/presentation/providers/selected_category_provider.dart'; import 'package:worker/features/products/presentation/providers/search_query_provider.dart'; @@ -116,3 +117,26 @@ Future> allProducts(Ref ref) async { return await getProductsUseCase(); } + +/// Product Detail Provider +/// +/// Fetches a single product by ID from Frappe ERPNext API. +/// Uses getProductDetail endpoint for efficient single product fetch. +/// +/// Usage: +/// ```dart +/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02')); +/// +/// productAsync.when( +/// data: (product) => ProductDetailView(product: product), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` +@riverpod +Future productDetail(Ref ref, {required String productId}) async { + final repository = await ref.watch(productsRepositoryProvider.future); + final getProductDetailUseCase = GetProductDetail(repository); + + return await getProductDetailUseCase(productId: productId); +} diff --git a/lib/features/products/presentation/providers/products_provider.g.dart b/lib/features/products/presentation/providers/products_provider.g.dart index b3e0a74..3557ee1 100644 --- a/lib/features/products/presentation/providers/products_provider.g.dart +++ b/lib/features/products/presentation/providers/products_provider.g.dart @@ -321,3 +321,151 @@ final class AllProductsProvider } String _$allProductsHash() => r'402d7c6e8d119c7c7eab5e696fb8163831259def'; + +/// Product Detail Provider +/// +/// Fetches a single product by ID from Frappe ERPNext API. +/// Uses getProductDetail endpoint for efficient single product fetch. +/// +/// Usage: +/// ```dart +/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02')); +/// +/// productAsync.when( +/// data: (product) => ProductDetailView(product: product), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` + +@ProviderFor(productDetail) +const productDetailProvider = ProductDetailFamily._(); + +/// Product Detail Provider +/// +/// Fetches a single product by ID from Frappe ERPNext API. +/// Uses getProductDetail endpoint for efficient single product fetch. +/// +/// Usage: +/// ```dart +/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02')); +/// +/// productAsync.when( +/// data: (product) => ProductDetailView(product: product), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` + +final class ProductDetailProvider + extends $FunctionalProvider, Product, FutureOr> + with $FutureModifier, $FutureProvider { + /// Product Detail Provider + /// + /// Fetches a single product by ID from Frappe ERPNext API. + /// Uses getProductDetail endpoint for efficient single product fetch. + /// + /// Usage: + /// ```dart + /// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02')); + /// + /// productAsync.when( + /// data: (product) => ProductDetailView(product: product), + /// loading: () => CircularProgressIndicator(), + /// error: (error, stack) => ErrorWidget(error), + /// ); + /// ``` + const ProductDetailProvider._({ + required ProductDetailFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'productDetailProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$productDetailHash(); + + @override + String toString() { + return r'productDetailProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return productDetail(ref, productId: argument); + } + + @override + bool operator ==(Object other) { + return other is ProductDetailProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$productDetailHash() => r'ca219f1451f518c84ca1832aacb3c83920f4bfd2'; + +/// Product Detail Provider +/// +/// Fetches a single product by ID from Frappe ERPNext API. +/// Uses getProductDetail endpoint for efficient single product fetch. +/// +/// Usage: +/// ```dart +/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02')); +/// +/// productAsync.when( +/// data: (product) => ProductDetailView(product: product), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` + +final class ProductDetailFamily extends $Family + with $FunctionalFamilyOverride, String> { + const ProductDetailFamily._() + : super( + retry: null, + name: r'productDetailProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Product Detail Provider + /// + /// Fetches a single product by ID from Frappe ERPNext API. + /// Uses getProductDetail endpoint for efficient single product fetch. + /// + /// Usage: + /// ```dart + /// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02')); + /// + /// productAsync.when( + /// data: (product) => ProductDetailView(product: product), + /// loading: () => CircularProgressIndicator(), + /// error: (error, stack) => ErrorWidget(error), + /// ); + /// ``` + + ProductDetailProvider call({required String productId}) => + ProductDetailProvider._(argument: productId, from: this); + + @override + String toString() => r'productDetailProvider'; +} diff --git a/lib/features/products/presentation/widgets/product_detail/product_info_section.dart b/lib/features/products/presentation/widgets/product_detail/product_info_section.dart index d6b100d..2227036 100644 --- a/lib/features/products/presentation/widgets/product_detail/product_info_section.dart +++ b/lib/features/products/presentation/widgets/product_detail/product_info_section.dart @@ -113,19 +113,19 @@ class ProductInfoSection extends StatelessWidget { // Size Info Expanded( child: _QuickInfoCard( - icon: Icons.straighten, + icon: Icons.straighten, // expand icon label: 'Kích thước', value: product.getSpecification('size') ?? '1200x1200', ), ), const SizedBox(width: 12), - // Warranty Info + // Packaging Info Expanded( child: _QuickInfoCard( - icon: Icons.shield, - label: 'Bảo hành', - value: product.getSpecification('warranty') ?? '15 năm', + icon: Icons.inventory_2_outlined, // cube/box icon + label: 'Đóng gói', + value: product.getSpecification('packaging') ?? '2 viên/thùng', ), ), const SizedBox(width: 12), @@ -133,9 +133,9 @@ class ProductInfoSection extends StatelessWidget { // Delivery Info Expanded( child: _QuickInfoCard( - icon: Icons.local_shipping, + icon: Icons.local_shipping_outlined, // truck icon label: 'Giao hàng', - value: '2-3 ngày', + value: '2-3 Ngày', ), ), ], diff --git a/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart b/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart index 9494024..5ad8aaa 100644 --- a/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart +++ b/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart @@ -30,7 +30,8 @@ class _ProductTabsSectionState extends State @override void initState() { super.initState(); - _tabController = TabController(length: 3, vsync: this); + // Start with Specifications tab (index 0) + _tabController = TabController(length: 2, vsync: this, initialIndex: 0); } @override @@ -67,7 +68,6 @@ class _ProductTabsSectionState extends State indicatorColor: AppColors.primaryBlue, indicatorWeight: 2, tabs: const [ - Tab(text: 'Mô tả'), Tab(text: 'Thông số'), Tab(text: 'Đánh giá'), ], @@ -80,7 +80,6 @@ class _ProductTabsSectionState extends State child: TabBarView( controller: _tabController, children: [ - _DescriptionTab(product: widget.product), _SpecificationsTab(product: widget.product), const _ReviewsTab(), ], diff --git a/lib/features/products/presentation/widgets/product_detail/sticky_action_bar.dart b/lib/features/products/presentation/widgets/product_detail/sticky_action_bar.dart index 634896b..6f856e6 100644 --- a/lib/features/products/presentation/widgets/product_detail/sticky_action_bar.dart +++ b/lib/features/products/presentation/widgets/product_detail/sticky_action_bar.dart @@ -11,8 +11,8 @@ 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) +/// - Quantity section with label, controls, and conversion text +/// - Add to cart button class StickyActionBar extends StatelessWidget { final int quantity; final VoidCallback onIncrease; @@ -20,6 +20,7 @@ class StickyActionBar extends StatelessWidget { final ValueChanged onQuantityChanged; final VoidCallback onAddToCart; final bool isOutOfStock; + final String unit; const StickyActionBar({ super.key, @@ -29,8 +30,16 @@ class StickyActionBar extends StatelessWidget { required this.onQuantityChanged, required this.onAddToCart, this.isOutOfStock = false, + this.unit = 'm²', }); + String _getConversionText() { + // Calculate conversion: each m² ≈ 0.36 boxes, each box = varies + final pieces = (quantity / 0.36).ceil(); + final actualArea = (pieces * 0.36).toStringAsFixed(2); + return 'Tương đương: $pieces viên / $actualArea m²'; + } + @override Widget build(BuildContext context) { return Container( @@ -52,51 +61,83 @@ class StickyActionBar extends StatelessWidget { 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 Section + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Label + Text( + 'Số lượng ($unit)', + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, + fontWeight: FontWeight.w500, ), + ), - // 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); - } - }, - ), + const SizedBox(height: 8), + + // Quantity Controls + Container( + width: 142, + 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, + ), - // Increase Button - _QuantityButton(icon: Icons.add, onPressed: onIncrease), - ], - ), + // Quantity Input + Expanded( + 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(height: 4), + + // Conversion Text + Text( + _getConversionText(), + style: const TextStyle( + fontSize: 11, + color: AppColors.grey500, + ), + ), + ], ), const SizedBox(width: 16), diff --git a/pubspec.lock b/pubspec.lock index f527c16..effc660 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -273,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -475,6 +483,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.21.3+1" + flutter_html: + dependency: "direct main" + description: + name: flutter_html + sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d" + url: "https://pub.dev" + source: hosted + version: "3.0.0" flutter_lints: dependency: "direct dev" description: @@ -663,6 +679,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.3.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -836,6 +860,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + list_counter: + dependency: transitive + description: + name: list_counter + sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237 + url: "https://pub.dev" + source: hosted + version: "1.0.2" logging: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a0471c2..695c5a2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,6 +79,7 @@ dependencies: # Icons cupertino_icons: ^1.0.8 + flutter_html: ^3.0.0 dev_dependencies: flutter_test: