add news detail page

This commit is contained in:
Phuoc Nguyen
2025-11-03 13:37:33 +07:00
parent ea485d8c3a
commit 56c470baa1
9 changed files with 1614 additions and 94 deletions

View File

@@ -22,6 +22,7 @@ import 'package:worker/features/promotions/presentation/pages/promotion_detail_p
import 'package:worker/features/quotes/presentation/pages/quotes_page.dart';
import 'package:worker/features/price_policy/price_policy.dart';
import 'package:worker/features/news/presentation/pages/news_list_page.dart';
import 'package:worker/features/news/presentation/pages/news_detail_page.dart';
/// App Router
///
@@ -43,20 +44,16 @@ class AppRouter {
GoRoute(
path: RouteNames.home,
name: RouteNames.home,
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const MainScaffold(),
),
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const MainScaffold()),
),
// Products Route (full screen, no bottom nav)
GoRoute(
path: RouteNames.products,
name: RouteNames.products,
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const ProductsPage(),
),
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const ProductsPage()),
),
// Product Detail Route
@@ -89,60 +86,48 @@ class AppRouter {
GoRoute(
path: RouteNames.cart,
name: RouteNames.cart,
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const CartPage(),
),
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const CartPage()),
),
// Favorites Route
GoRoute(
path: RouteNames.favorites,
name: RouteNames.favorites,
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const FavoritesPage(),
),
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const FavoritesPage()),
),
// Loyalty Route
GoRoute(
path: RouteNames.loyalty,
name: RouteNames.loyalty,
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const LoyaltyPage(),
),
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const LoyaltyPage()),
),
// Loyalty Rewards Route
GoRoute(
path: '/loyalty/rewards',
name: 'loyalty_rewards',
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const RewardsPage(),
),
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const RewardsPage()),
),
// Points History Route
GoRoute(
path: RouteNames.pointsHistory,
name: 'loyalty_points_history',
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const PointsHistoryPage(),
),
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const PointsHistoryPage()),
),
// Orders Route
GoRoute(
path: RouteNames.orders,
name: RouteNames.orders,
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const OrdersPage(),
),
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const OrdersPage()),
),
// Order Detail Route
@@ -162,10 +147,8 @@ class AppRouter {
GoRoute(
path: RouteNames.payments,
name: RouteNames.payments,
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const PaymentsPage(),
),
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const PaymentsPage()),
),
// Payment Detail Route
@@ -185,30 +168,37 @@ class AppRouter {
GoRoute(
path: RouteNames.quotes,
name: RouteNames.quotes,
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const QuotesPage(),
),
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const QuotesPage()),
),
// Price Policy Route
GoRoute(
path: RouteNames.pricePolicy,
name: RouteNames.pricePolicy,
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const PricePolicyPage(),
),
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const PricePolicyPage()),
),
// News Route
GoRoute(
path: RouteNames.news,
name: RouteNames.news,
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const NewsListPage(),
),
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const NewsListPage()),
),
// News Detail Route
GoRoute(
path: RouteNames.newsDetail,
name: RouteNames.newsDetail,
pageBuilder: (context, state) {
final articleId = state.pathParameters['id'];
return MaterialPage(
key: state.pageKey,
child: NewsDetailPage(articleId: articleId ?? ''),
);
},
),
// TODO: Add more routes as features are implemented
@@ -218,18 +208,12 @@ class AppRouter {
errorPageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: Scaffold(
appBar: AppBar(
title: const Text('Không tìm thấy trang'),
),
appBar: AppBar(title: const Text('Không tìm thấy trang')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: Colors.red,
),
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
const Text(
'Trang không tồn tại',

View File

@@ -98,12 +98,54 @@ class NewsLocalDataSource {
///
/// This data will be replaced with real API data in production.
static final List<NewsArticleModel> _mockArticles = [
// Featured article
// Featured article with full content
const NewsArticleModel(
id: 'featured-1',
title: '5 xu hướng gạch men phòng tắm được ưa chuộng năm 2024',
excerpt:
'Khám phá những mẫu gạch men hiện đại, sang trọng cho không gian phòng tắm. Từ những tone màu trung tính đến các họa tiết độc đáo, cùng tìm hiểu các xu hướng đang được yêu thích nhất.',
content: '''
<p>Năm 2024 đánh dấu sự trở lại mạnh mẽ của các thiết kế phòng tắm hiện đại với những xu hướng gạch men đột phá. Không chỉ đơn thuần là vật liệu ốp lát, gạch men ngày nay đã trở thành yếu tố quyết định phong cách và cảm xúc của không gian.</p>
<h2>1. Gạch men họa tiết đá tự nhiên</h2>
<p>Xu hướng bắt chước kết cấu và màu sắc của đá tự nhiên đang trở nên cực kỳ phổ biến. Các sản phẩm gạch men mô phỏng đá marble, granite hay travertine mang đến vẻ đẹp sang trọng mà vẫn đảm bảo tính thực tiễn cao.</p>
<highlight type="tip">Chọn gạch men vân đá với kích thước lớn (60x120cm trở lên) để tạo cảm giác không gian rộng rãi và giảm số đường nối.</highlight>
<h2>2. Tone màu trung tính và earth tone</h2>
<p>Các gam màu trung tính như be, xám nhạt, và các tone đất đang thống trị xu hướng thiết kế. Những màu sắc này không chỉ tạo cảm giác thư giãn mà còn dễ dàng kết hợp với nhiều phong cách nội thất khác nhau.</p>
<ul>
<li>Beige và cream: Tạo cảm giác ấm áp, thân thiện</li>
<li>Xám nhạt: Hiện đại, tinh tế và sang trọng</li>
<li>Nâu đất: Gần gũi với thiên nhiên, tạo cảm giác thư thái</li>
</ul>
<h2>3. Kích thước lớn và định dạng dài</h2>
<p>Gạch men kích thước lớn (60x120cm, 75x150cm) và định dạng dài đang được ưa chuộng vì khả năng tạo ra không gian liền mạch, giảm đường nối và dễ vệ sinh.</p>
<blockquote>"Việc sử dụng gạch men kích thước lớn không chỉ tạo vẻ hiện đại mà còn giúp phòng tắm nhỏ trông rộng rãi hơn đáng kể" - KTS Nguyễn Minh Tuấn</blockquote>
<h2>4. Bề mặt texture và 3D</h2>
<p>Các loại gạch men với bề mặt có texture hoặc hiệu ứng 3D đang tạo nên điểm nhấn thú vị cho không gian phòng tắm. Từ các họa tiết geometric đến surface sần sùi tự nhiên.</p>
<h3>Các loại texture phổ biến:</h3>
<ol>
<li>Matt finish: Bề mặt nhám, chống trượt tốt</li>
<li>Structured surface: Có kết cấu sần sùi như đá tự nhiên</li>
<li>3D geometric: Họa tiết nổi tạo hiệu ứng thị giác</li>
</ol>
<h2>5. Gạch men màu đen và tương phản cao</h2>
<p>Xu hướng sử dụng gạch men màu đen hoặc tạo tương phản mạnh đang được nhiều gia chủ lựa chọn để tạo điểm nhấn đặc biệt cho phòng tắm.</p>
<highlight type="warning">Gạch men màu tối dễ để lại vết ố từ nước cứng và xà phòng. Cần vệ sinh thường xuyên và sử dụng sản phẩm chống thấm phù hợp.</highlight>
<h2>Kết luận</h2>
<p>Xu hướng gạch men phòng tắm năm 2024 hướng tới sự kết hợp hoàn hảo giữa thẩm mỹ và tính năng. Việc lựa chọn đúng loại gạch men không chỉ tăng giá trị thẩm mỹ mà còn đảm bảo độ bền và dễ bảo trì trong thời gian dài.</p>
<p>Hãy tham khảo ý kiến của chuyên gia và cân nhắc kỹ về điều kiện sử dụng thực tế để đưa ra lựa chọn phù hợp nhất cho không gian của bạn.</p>
''',
imageUrl:
'https://images.unsplash.com/photo-1503387762-592deb58ef4e?w=400&h=200&fit=crop',
category: 'news',
@@ -111,6 +153,17 @@ class NewsLocalDataSource {
viewCount: 2300,
readingTimeMinutes: 5,
isFeatured: true,
tags: [
'#gạch-men',
'#phòng-tắm',
'#xu-hướng-2024',
'#thiết-kế-nội-thất',
'#đá-tự-nhiên',
'#tone-trung-tính',
],
likeCount: 156,
commentCount: 23,
shareCount: 45,
),
// Latest articles

View File

@@ -49,6 +49,18 @@ class NewsArticleModel {
/// Author avatar URL (optional)
final String? authorAvatar;
/// Tags/keywords for the article
final List<String> tags;
/// Like count
final int likeCount;
/// Comment count
final int commentCount;
/// Share count
final int shareCount;
/// Constructor
const NewsArticleModel({
required this.id,
@@ -63,6 +75,10 @@ class NewsArticleModel {
this.isFeatured = false,
this.authorName,
this.authorAvatar,
this.tags = const [],
this.likeCount = 0,
this.commentCount = 0,
this.shareCount = 0,
});
/// Create model from JSON
@@ -80,6 +96,12 @@ class NewsArticleModel {
isFeatured: json['is_featured'] as bool? ?? false,
authorName: json['author_name'] as String?,
authorAvatar: json['author_avatar'] as String?,
tags:
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ??
const [],
likeCount: json['like_count'] as int? ?? 0,
commentCount: json['comment_count'] as int? ?? 0,
shareCount: json['share_count'] as int? ?? 0,
);
}
@@ -98,6 +120,10 @@ class NewsArticleModel {
'is_featured': isFeatured,
'author_name': authorName,
'author_avatar': authorAvatar,
'tags': tags,
'like_count': likeCount,
'comment_count': commentCount,
'share_count': shareCount,
};
}
@@ -116,6 +142,10 @@ class NewsArticleModel {
isFeatured: isFeatured,
authorName: authorName,
authorAvatar: authorAvatar,
tags: tags,
likeCount: likeCount,
commentCount: commentCount,
shareCount: shareCount,
);
}
@@ -134,6 +164,10 @@ class NewsArticleModel {
isFeatured: entity.isFeatured,
authorName: entity.authorName,
authorAvatar: entity.authorAvatar,
tags: entity.tags,
likeCount: entity.likeCount,
commentCount: entity.commentCount,
shareCount: entity.shareCount,
);
}

View File

@@ -45,6 +45,18 @@ class NewsArticle {
/// Author avatar URL (optional)
final String? authorAvatar;
/// Tags/keywords for the article
final List<String> tags;
/// Like count
final int likeCount;
/// Comment count
final int commentCount;
/// Share count
final int shareCount;
/// Constructor
const NewsArticle({
required this.id,
@@ -59,6 +71,10 @@ class NewsArticle {
this.isFeatured = false,
this.authorName,
this.authorAvatar,
this.tags = const [],
this.likeCount = 0,
this.commentCount = 0,
this.shareCount = 0,
});
/// Get formatted publication date (dd/MM/yyyy)
@@ -93,6 +109,10 @@ class NewsArticle {
bool? isFeatured,
String? authorName,
String? authorAvatar,
List<String>? tags,
int? likeCount,
int? commentCount,
int? shareCount,
}) {
return NewsArticle(
id: id ?? this.id,
@@ -107,6 +127,10 @@ class NewsArticle {
isFeatured: isFeatured ?? this.isFeatured,
authorName: authorName ?? this.authorName,
authorAvatar: authorAvatar ?? this.authorAvatar,
tags: tags ?? this.tags,
likeCount: likeCount ?? this.likeCount,
commentCount: commentCount ?? this.commentCount,
shareCount: shareCount ?? this.shareCount,
);
}
@@ -115,38 +139,13 @@ class NewsArticle {
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is NewsArticle &&
other.id == id &&
other.title == title &&
other.excerpt == excerpt &&
other.content == content &&
other.imageUrl == imageUrl &&
other.category == category &&
other.publishedDate == publishedDate &&
other.viewCount == viewCount &&
other.readingTimeMinutes == readingTimeMinutes &&
other.isFeatured == isFeatured &&
other.authorName == authorName &&
other.authorAvatar == authorAvatar;
return other is NewsArticle && other.id == id;
}
/// Hash code
@override
int get hashCode {
return Object.hash(
id,
title,
excerpt,
content,
imageUrl,
category,
publishedDate,
viewCount,
readingTimeMinutes,
isFeatured,
authorName,
authorAvatar,
);
return id.hashCode;
}
/// String representation

View File

@@ -0,0 +1,750 @@
/// News Detail Page
///
/// Displays full article content with images, HTML rendering, and interactions.
/// Matches HTML design at html/news-detail.html
library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
import 'package:worker/features/news/presentation/providers/news_provider.dart';
import 'package:worker/features/news/presentation/widgets/highlight_box.dart';
import 'package:worker/features/news/presentation/widgets/related_article_card.dart';
/// News Detail Page
///
/// Features:
/// - AppBar with back, share, and bookmark buttons
/// - Hero image (250px height)
/// - Article metadata (category, date, reading time, views)
/// - Title and excerpt
/// - Full article body with HTML rendering
/// - Tags section
/// - Social engagement stats and action buttons
/// - Related articles section
/// - Loading and error states
class NewsDetailPage extends ConsumerStatefulWidget {
/// Article ID to display
final String articleId;
/// Constructor
const NewsDetailPage({super.key, required this.articleId});
@override
ConsumerState<NewsDetailPage> createState() => _NewsDetailPageState();
}
class _NewsDetailPageState extends ConsumerState<NewsDetailPage> {
bool _isBookmarked = false;
bool _isLiked = false;
@override
Widget build(BuildContext context) {
final articleAsync = ref.watch(newsArticleByIdProvider(widget.articleId));
return Scaffold(
backgroundColor: Colors.white,
appBar: _buildAppBar(context),
body: articleAsync.when(
data: (article) {
if (article == null) {
return _buildNotFoundState();
}
return _buildContent(context, article);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => _buildErrorState(error.toString()),
),
);
}
/// Build AppBar
PreferredSizeWidget _buildAppBar(BuildContext context) {
return AppBar(
backgroundColor: AppColors.white,
elevation: AppBarSpecs.elevation,
title: Text(
'Chi tiết bài viết',
style: const TextStyle(color: Colors.black),
),
centerTitle: false,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => context.pop(),
),
actions: [
// Share button
IconButton(
icon: const Icon(Icons.share, color: Colors.black),
onPressed: _onShareTap,
),
// Bookmark button
IconButton(
icon: Icon(
_isBookmarked ? Icons.bookmark : Icons.bookmark_border,
color: _isBookmarked ? AppColors.warning : Colors.black,
),
onPressed: _onBookmarkTap,
),
const SizedBox(width: AppSpacing.sm),
],
);
}
/// Build content
Widget _buildContent(BuildContext context, NewsArticle article) {
final relatedArticles = ref
.watch(filteredNewsArticlesProvider)
.value
?.where((a) => a.id != article.id && a.category == article.category)
.take(3)
.toList();
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Hero Image
CachedNetworkImage(
imageUrl: article.imageUrl,
width: double.infinity,
height: 250,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
height: 250,
color: AppColors.grey100,
child: const Center(child: CircularProgressIndicator()),
),
errorWidget: (context, url, error) => Container(
height: 250,
color: AppColors.grey100,
child: const Icon(
Icons.image_outlined,
size: 48,
color: AppColors.grey500,
),
),
),
// Article Content
Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Metadata
_buildMetadata(article),
const SizedBox(height: 16),
// Title
Text(
article.title,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: Color(0xFF1E293B),
height: 1.3,
),
),
const SizedBox(height: 16),
// Excerpt
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
border: const Border(
left: BorderSide(color: AppColors.primaryBlue, width: 4),
),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
child: Text(
article.excerpt,
style: const TextStyle(
fontSize: 16,
color: Color(0xFF64748B),
fontStyle: FontStyle.italic,
height: 1.5,
),
),
),
const SizedBox(height: 24),
// Article Body
if (article.content != null)
_buildArticleBody(article.content!),
const SizedBox(height: 32),
// Tags Section
if (article.tags.isNotEmpty) _buildTagsSection(article.tags),
const SizedBox(height: 32),
// Social Actions
_buildSocialActions(article),
const SizedBox(height: 32),
// Related Articles
if (relatedArticles != null && relatedArticles.isNotEmpty)
_buildRelatedArticles(relatedArticles),
],
),
),
],
),
);
}
/// Build metadata
Widget _buildMetadata(NewsArticle article) {
return Wrap(
spacing: 16,
runSpacing: 8,
children: [
// Category badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primaryBlue,
borderRadius: BorderRadius.circular(16),
),
child: Text(
article.category.displayName,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
// Date
_buildMetaItem(Icons.calendar_today, article.formattedDate),
// Reading time
_buildMetaItem(Icons.schedule, article.readingTimeText),
// Views
_buildMetaItem(
Icons.visibility,
'${article.formattedViewCount} lượt xem',
),
],
);
}
/// Build metadata item
Widget _buildMetaItem(IconData icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: const Color(0xFF64748B)),
const SizedBox(width: 4),
Text(
text,
style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
),
],
);
}
/// 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
Widget _buildTagsSection(List<String> tags) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Thẻ liên quan',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: tags
.map(
(tag) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE2E8F0)),
borderRadius: BorderRadius.circular(16),
),
child: Text(
tag,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF64748B),
),
),
),
)
.toList(),
),
],
),
);
}
/// Build social actions section
Widget _buildSocialActions(NewsArticle article) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(color: const Color(0xFFE2E8F0)),
),
),
child: Column(
children: [
// Engagement stats
Wrap(
spacing: 16,
runSpacing: 8,
children: [
_buildStatItem(Icons.favorite, '${article.likeCount} lượt thích'),
_buildStatItem(
Icons.comment,
'${article.commentCount} bình luận',
),
_buildStatItem(Icons.share, '${article.shareCount} lượt chia sẻ'),
],
),
const SizedBox(height: 16),
// Action buttons
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildActionButton(
icon: _isLiked ? Icons.favorite : Icons.favorite_border,
onPressed: _onLikeTap,
color: _isLiked ? Colors.red : null,
),
const SizedBox(width: 8),
_buildActionButton(
icon: _isBookmarked ? Icons.bookmark : Icons.bookmark_border,
onPressed: _onBookmarkTap,
color: _isBookmarked ? AppColors.warning : null,
),
const SizedBox(width: 8),
_buildActionButton(icon: Icons.share, onPressed: _onShareTap),
],
),
],
),
);
}
/// Build stat item
Widget _buildStatItem(IconData icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: const Color(0xFF64748B)),
const SizedBox(width: 4),
Text(
text,
style: const TextStyle(fontSize: 14, color: Color(0xFF64748B)),
),
],
);
}
/// Build action button
Widget _buildActionButton({
required IconData icon,
required VoidCallback onPressed,
Color? color,
}) {
return OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.all(12),
side: BorderSide(color: color ?? const Color(0xFFE2E8F0), width: 2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: Icon(icon, size: 20, color: color ?? const Color(0xFF64748B)),
);
}
/// Build related articles section
Widget _buildRelatedArticles(List<NewsArticle> articles) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Bài viết liên quan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
const SizedBox(height: 16),
...articles.map(
(article) => RelatedArticleCard(
article: article,
onTap: () {
// Navigate to related article
context.push('/news/${article.id}');
},
),
),
],
),
);
}
/// Build not found state
Widget _buildNotFoundState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.article_outlined, size: 64, color: AppColors.grey500),
const SizedBox(height: 16),
const Text(
'Không tìm thấy bài viết',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
const SizedBox(height: 8),
const Text(
'Bài viết này không tồn tại hoặc đã bị xóa',
style: TextStyle(fontSize: 14, color: Color(0xFF64748B)),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.pop(),
child: const Text('Quay lại'),
),
],
),
);
}
/// Build error state
Widget _buildErrorState(String error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: AppColors.danger),
const SizedBox(height: 16),
const Text(
'Không thể tải bài viết',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
error,
style: const TextStyle(fontSize: 14, color: Color(0xFF64748B)),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.pop(),
child: const Text('Quay lại'),
),
],
),
);
}
/// Handle like tap
void _onLikeTap() {
setState(() {
_isLiked = !_isLiked;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isLiked ? 'Đã thích bài viết!' : 'Đã bỏ thích bài viết!',
),
duration: const Duration(seconds: 1),
),
);
}
/// Handle bookmark tap
void _onBookmarkTap() {
setState(() {
_isBookmarked = !_isBookmarked;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isBookmarked ? 'Đã lưu bài viết!' : 'Đã bỏ lưu bài viết!',
),
duration: const Duration(seconds: 1),
),
);
}
/// Handle share tap
void _onShareTap() {
// Copy link to clipboard
Clipboard.setData(
ClipboardData(text: 'https://worker.app/news/${widget.articleId}'),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Đã sao chép link bài viết!'),
duration: Duration(seconds: 2),
),
);
// TODO: Implement native share when share_plus package is added
// Share.share(
// 'Xem bài viết: ${article.title}\nhttps://worker.app/news/${article.id}',
// subject: article.title,
// );
}
}
/// Provider for getting article by ID
final newsArticleByIdProvider = FutureProvider.family<NewsArticle?, String>((
ref,
id,
) async {
final articles = await ref.watch(newsArticlesProvider.future);
try {
return articles.firstWhere((article) => article.id == id);
} catch (e) {
return null;
}
});

View File

@@ -6,6 +6,7 @@ library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
@@ -261,15 +262,7 @@ class NewsListPage extends ConsumerWidget {
/// Handle article tap
void _onArticleTap(BuildContext context, NewsArticle article) {
// TODO: Navigate to article detail page when implemented
// context.push('/news/${article.id}');
// For now, show a snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Xem bài viết: ${article.title}'),
duration: const Duration(seconds: 2),
),
);
// Navigate to article detail page
context.push('/news/${article.id}');
}
}

View File

@@ -0,0 +1,106 @@
/// Highlight Box Widget
///
/// A highlighted information box for tips and warnings in article content.
/// Used to emphasize important information in news articles.
library;
import 'package:flutter/material.dart';
import 'package:worker/core/constants/ui_constants.dart';
/// Highlight type enum
enum HighlightType {
/// Tip (lightbulb icon)
tip,
/// Warning (exclamation icon)
warning,
}
/// Highlight Box
///
/// Features:
/// - Gradient background (yellow/orange for both types)
/// - Icon based on type (lightbulb or exclamation)
/// - Title and content text
/// - Rounded corners
/// - Brown text color for contrast
class HighlightBox extends StatelessWidget {
/// Highlight type
final HighlightType type;
/// Highlight title
final String title;
/// Highlight content/text
final String content;
/// Constructor
const HighlightBox({
super.key,
required this.type,
required this.title,
required this.content,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFEF3C7), // light yellow
Color(0xFFFED7AA), // light orange
],
),
border: Border.all(color: const Color(0xFFF59E0B)),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title with icon
Row(
children: [
Icon(_getIcon(), size: 20, color: const Color(0xFF92400E)),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF92400E),
),
),
],
),
const SizedBox(height: 8),
// Content text
Text(
content,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF92400E),
height: 1.5,
),
),
],
),
);
}
/// Get icon based on type
IconData _getIcon() {
switch (type) {
case HighlightType.tip:
return Icons.lightbulb;
case HighlightType.warning:
return Icons.error_outline;
}
}
}

View File

@@ -0,0 +1,116 @@
/// Related Article Card Widget
///
/// Compact horizontal card for displaying related articles.
/// Used in the news detail page to show similar content.
library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
/// Related Article Card
///
/// Features:
/// - Horizontal layout
/// - 60x60 thumbnail
/// - Title (max 2 lines)
/// - Metadata: date and view count
/// - OnTap handler for navigation
class RelatedArticleCard extends StatelessWidget {
/// Article to display
final NewsArticle article;
/// Callback when card is tapped
final VoidCallback? onTap;
/// Constructor
const RelatedArticleCard({super.key, required this.article, this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
// Thumbnail (60x60)
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.md),
child: CachedNetworkImage(
imageUrl: article.imageUrl,
width: 60,
height: 60,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 60,
height: 60,
color: AppColors.grey100,
child: const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
errorWidget: (context, url, error) => Container(
width: 60,
height: 60,
color: AppColors.grey100,
child: const Icon(
Icons.image_outlined,
size: 20,
color: AppColors.grey500,
),
),
),
),
const SizedBox(width: AppSpacing.md),
// Content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title (max 2 lines)
Text(
article.title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
height: 1.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
// Metadata
Text(
'${article.formattedDate}${article.formattedViewCount} lượt xem',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF64748B),
),
),
],
),
),
],
),
),
);
}
}