add news detail page
This commit is contained in:
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