fix news,

This commit is contained in:
Phuoc Nguyen
2025-11-11 11:34:23 +07:00
parent 47cdf71968
commit b5afeed534
12 changed files with 439 additions and 278 deletions

View File

@@ -86,12 +86,12 @@ class BlogPostModel {
} }
} }
// Extract excerpt from blogIntro or metaDescription
final excerpt = blogIntro ?? metaDescription ?? '';
// Use content_html preferentially, fall back to content // Use content_html preferentially, fall back to content
final htmlContent = contentHtml ?? content; final htmlContent = contentHtml ?? content;
// Excerpt is ONLY from blog_intro (plain text)
final excerpt = blogIntro ?? '';
// Use meta image with full URL path // Use meta image with full URL path
String imageUrl; String imageUrl;
if (metaImage != null && metaImage!.isNotEmpty) { if (metaImage != null && metaImage!.isNotEmpty) {
@@ -117,7 +117,9 @@ class BlogPostModel {
return NewsArticle( return NewsArticle(
id: name, id: name,
title: title, 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, content: htmlContent,
imageUrl: imageUrl, imageUrl: imageUrl,
category: category, category: category,

View File

@@ -7,6 +7,7 @@ library;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
@@ -181,9 +182,79 @@ class _NewsDetailPageState extends ConsumerState<NewsDetailPage> {
const SizedBox(height: 24), const SizedBox(height: 24),
// Article Body // Article Body - Render HTML content
if (article.content != null) if (article.content != null && article.content!.isNotEmpty)
_buildArticleBody(article.content!), 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), const SizedBox(height: 32),
@@ -261,192 +332,6 @@ class _NewsDetailPageState extends ConsumerState<NewsDetailPage> {
); );
} }
/// 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<Widget> _parseHTMLContent(String content) {
final List<Widget> 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('<h2>') && trimmed.endsWith('</h2>')) {
final text = trimmed.substring(4, trimmed.length - 5);
widgets.add(_buildH2(text));
}
// H3 heading
else if (trimmed.startsWith('<h3>') && trimmed.endsWith('</h3>')) {
final text = trimmed.substring(4, trimmed.length - 5);
widgets.add(_buildH3(text));
}
// Paragraph
else if (trimmed.startsWith('<p>') && trimmed.endsWith('</p>')) {
final text = trimmed.substring(3, trimmed.length - 4);
widgets.add(_buildParagraph(text));
}
// Unordered list start
else if (trimmed == '<ul>') {
// Collect list items
final listItems = <String>[];
continue;
}
// List item
else if (trimmed.startsWith('<li>') && trimmed.endsWith('</li>')) {
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('<blockquote>') &&
trimmed.endsWith('</blockquote>')) {
final text = trimmed.substring(12, trimmed.length - 13);
widgets.add(_buildBlockquote(text));
}
// Highlight box (custom tag)
else if (trimmed.startsWith('<highlight type="')) {
final typeMatch = RegExp(r'type="(\w+)"').firstMatch(trimmed);
final contentMatch = RegExp(r'>(.*)</highlight>').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 /// Build tags section
Widget _buildTagsSection(List<String> tags) { Widget _buildTagsSection(List<String> tags) {
return Container( return Container(

View File

@@ -116,10 +116,10 @@ class NewsCard extends StatelessWidget {
Row( Row(
children: [ children: [
// Date // Date
Icon( const Icon(
Icons.calendar_today, Icons.calendar_today,
size: 12, size: 12,
color: const Color(0xFF64748B), color: Color(0xFF64748B),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(

View File

@@ -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<Product> call({required String productId}) async {
return await _repository.getProductById(productId);
}
}

View File

@@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.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/domain/entities/product.dart';
import 'package:worker/features/products/presentation/providers/products_provider.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/image_gallery_section.dart';
@@ -34,7 +35,6 @@ class ProductDetailPage extends ConsumerStatefulWidget {
class _ProductDetailPageState extends ConsumerState<ProductDetailPage> { class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
int _quantity = 1; int _quantity = 1;
bool _isFavorite = false;
void _increaseQuantity() { void _increaseQuantity() {
setState(() { setState(() {
@@ -58,21 +58,23 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
} }
} }
void _toggleFavorite() { void _toggleFavorite() async {
setState(() { // Toggle favorite using favorites provider
_isFavorite = !_isFavorite; await ref.read(favoritesProvider.notifier).toggleFavorite(widget.productId);
});
// Show feedback // Show feedback
final isFavorite = ref.read(isFavoriteProvider(widget.productId));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
_isFavorite ? 'Đã thêm vào yêu thích' : 'Đã xóa khỏi yêu thích', 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) { void _shareProduct(Product product) {
// Show share options // Show share options
@@ -166,7 +168,11 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
@override @override
Widget build(BuildContext context) { 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( return Scaffold(
backgroundColor: const Color(0xFFF4F6F8), backgroundColor: const Color(0xFFF4F6F8),
@@ -188,11 +194,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
IconButton( IconButton(
icon: const Icon(Icons.share, color: Colors.black), icon: const Icon(Icons.share, color: Colors.black),
onPressed: () { onPressed: () {
productsAsync.whenData((products) { productAsync.whenData((product) {
final product = products.firstWhere(
(p) => p.productId == widget.productId,
orElse: () => products.first,
);
_shareProduct(product); _shareProduct(product);
}); });
}, },
@@ -200,22 +202,16 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
// Favorite button // Favorite button
IconButton( IconButton(
icon: Icon( icon: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border, isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite ? AppColors.danger : Colors.black, color: isFavorite ? AppColors.danger : Colors.black,
), ),
onPressed: _toggleFavorite, onPressed: _toggleFavorite,
), ),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
], ],
), ),
body: productsAsync.when( body: productAsync.when(
data: (products) { data: (product) {
// Find the product by ID
final product = products.firstWhere(
(p) => p.productId == widget.productId,
orElse: () => products.first, // Fallback for demo
);
return Stack( return Stack(
children: [ children: [
// Scrollable content // Scrollable content
@@ -248,6 +244,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
right: 0, right: 0,
child: StickyActionBar( child: StickyActionBar(
quantity: _quantity, quantity: _quantity,
unit: product.unit ?? '',
onIncrease: _increaseQuantity, onIncrease: _increaseQuantity,
onDecrease: _decreaseQuantity, onDecrease: _decreaseQuantity,
onQuantityChanged: _updateQuantity, onQuantityChanged: _updateQuantity,
@@ -289,8 +286,9 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () async { onPressed: () {
await ref.read(productsProvider.notifier).refresh(); // Invalidate to trigger refetch
ref.invalidate(productDetailProvider(productId: widget.productId));
}, },
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
label: const Text('Thử lại'), label: const Text('Thử lại'),

View File

@@ -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/repositories/products_repository.dart';
import 'package:worker/features/products/domain/usecases/get_products.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/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/selected_category_provider.dart';
import 'package:worker/features/products/presentation/providers/search_query_provider.dart'; import 'package:worker/features/products/presentation/providers/search_query_provider.dart';
@@ -116,3 +117,26 @@ Future<List<Product>> allProducts(Ref ref) async {
return await getProductsUseCase(); 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<Product> productDetail(Ref ref, {required String productId}) async {
final repository = await ref.watch(productsRepositoryProvider.future);
final getProductDetailUseCase = GetProductDetail(repository);
return await getProductDetailUseCase(productId: productId);
}

View File

@@ -321,3 +321,151 @@ final class AllProductsProvider
} }
String _$allProductsHash() => r'402d7c6e8d119c7c7eab5e696fb8163831259def'; 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<AsyncValue<Product>, Product, FutureOr<Product>>
with $FutureModifier<Product>, $FutureProvider<Product> {
/// 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<Product> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<Product> 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<FutureOr<Product>, 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';
}

View File

@@ -113,19 +113,19 @@ class ProductInfoSection extends StatelessWidget {
// Size Info // Size Info
Expanded( Expanded(
child: _QuickInfoCard( child: _QuickInfoCard(
icon: Icons.straighten, icon: Icons.straighten, // expand icon
label: 'Kích thước', label: 'Kích thước',
value: product.getSpecification('size') ?? '1200x1200', value: product.getSpecification('size') ?? '1200x1200',
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// Warranty Info // Packaging Info
Expanded( Expanded(
child: _QuickInfoCard( child: _QuickInfoCard(
icon: Icons.shield, icon: Icons.inventory_2_outlined, // cube/box icon
label: 'Bảo hành', label: 'Đóng gói',
value: product.getSpecification('warranty') ?? '15 năm', value: product.getSpecification('packaging') ?? '2 viên/thùng',
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -133,9 +133,9 @@ class ProductInfoSection extends StatelessWidget {
// Delivery Info // Delivery Info
Expanded( Expanded(
child: _QuickInfoCard( child: _QuickInfoCard(
icon: Icons.local_shipping, icon: Icons.local_shipping_outlined, // truck icon
label: 'Giao hàng', label: 'Giao hàng',
value: '2-3 ngày', value: '2-3 Ngày',
), ),
), ),
], ],

View File

@@ -30,7 +30,8 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this); // Start with Specifications tab (index 0)
_tabController = TabController(length: 2, vsync: this, initialIndex: 0);
} }
@override @override
@@ -67,7 +68,6 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
indicatorColor: AppColors.primaryBlue, indicatorColor: AppColors.primaryBlue,
indicatorWeight: 2, indicatorWeight: 2,
tabs: const [ tabs: const [
Tab(text: 'Mô tả'),
Tab(text: 'Thông số'), Tab(text: 'Thông số'),
Tab(text: 'Đánh giá'), Tab(text: 'Đánh giá'),
], ],
@@ -80,7 +80,6 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [
_DescriptionTab(product: widget.product),
_SpecificationsTab(product: widget.product), _SpecificationsTab(product: widget.product),
const _ReviewsTab(), const _ReviewsTab(),
], ],

View File

@@ -11,8 +11,8 @@ import 'package:worker/core/theme/colors.dart';
/// Sticky Action Bar /// Sticky Action Bar
/// ///
/// Fixed at the bottom of the screen with: /// Fixed at the bottom of the screen with:
/// - Quantity controls (-, input, +) /// - Quantity section with label, controls, and conversion text
/// - Add to cart button (full width, primary blue) /// - Add to cart button
class StickyActionBar extends StatelessWidget { class StickyActionBar extends StatelessWidget {
final int quantity; final int quantity;
final VoidCallback onIncrease; final VoidCallback onIncrease;
@@ -20,6 +20,7 @@ class StickyActionBar extends StatelessWidget {
final ValueChanged<int> onQuantityChanged; final ValueChanged<int> onQuantityChanged;
final VoidCallback onAddToCart; final VoidCallback onAddToCart;
final bool isOutOfStock; final bool isOutOfStock;
final String unit;
const StickyActionBar({ const StickyActionBar({
super.key, super.key,
@@ -29,8 +30,16 @@ class StickyActionBar extends StatelessWidget {
required this.onQuantityChanged, required this.onQuantityChanged,
required this.onAddToCart, required this.onAddToCart,
this.isOutOfStock = false, this.isOutOfStock = false,
this.unit = '',
}); });
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';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
@@ -52,8 +61,26 @@ class StickyActionBar extends StatelessWidget {
top: false, top: false,
child: Row( child: Row(
children: [ children: [
// 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,
),
),
const SizedBox(height: 8),
// Quantity Controls // Quantity Controls
Container( Container(
width: 142,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFe0e0e0)), border: Border.all(color: const Color(0xFFe0e0e0)),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -67,8 +94,7 @@ class StickyActionBar extends StatelessWidget {
), ),
// Quantity Input // Quantity Input
SizedBox( Expanded(
width: 60,
child: TextField( child: TextField(
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
@@ -76,7 +102,9 @@ class StickyActionBar extends StatelessWidget {
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly], inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: const InputDecoration( decoration: const InputDecoration(
border: InputBorder.none, border: InputBorder.none,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
@@ -99,6 +127,19 @@ class StickyActionBar extends StatelessWidget {
), ),
), ),
const SizedBox(height: 4),
// Conversion Text
Text(
_getConversionText(),
style: const TextStyle(
fontSize: 11,
color: AppColors.grey500,
),
),
],
),
const SizedBox(width: 16), const SizedBox(width: 16),
// Add to Cart Button // Add to Cart Button

View File

@@ -273,6 +273,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.6" version: "3.0.6"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -475,6 +483,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.21.3+1" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -663,6 +679,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.3.0" version: "4.3.0"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http: http:
dependency: transitive dependency: transitive
description: description:
@@ -836,6 +860,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.1" 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: logging:
dependency: transitive dependency: transitive
description: description:

View File

@@ -79,6 +79,7 @@ dependencies:
# Icons # Icons
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
flutter_html: ^3.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: