fix news,
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ?? 'm²',
|
||||||
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'),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 = '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
|
@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
|
||||||
|
|||||||
32
pubspec.lock
32
pubspec.lock
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user