add news detail page
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
750
lib/features/news/presentation/pages/news_detail_page.dart
Normal file
750
lib/features/news/presentation/pages/news_detail_page.dart
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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}');
|
||||
}
|
||||
}
|
||||
|
||||
106
lib/features/news/presentation/widgets/highlight_box.dart
Normal file
106
lib/features/news/presentation/widgets/highlight_box.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
116
lib/features/news/presentation/widgets/related_article_card.dart
Normal file
116
lib/features/news/presentation/widgets/related_article_card.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user