Files
worker/lib/features/news/presentation/pages/news_detail_page.dart
Phuoc Nguyen 19d9a3dc2d update loaing
2025-12-02 18:09:20 +07:00

605 lines
19 KiB
Dart

/// 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:worker/core/widgets/loading_indicator.dart';
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import 'package:worker/core/constants/api_constants.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/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 colorScheme = Theme.of(context).colorScheme;
final articleAsync = ref.watch(newsArticleByIdProvider(widget.articleId));
return Scaffold(
backgroundColor: colorScheme.surface,
appBar: _buildAppBar(context),
body: articleAsync.when(
data: (article) {
if (article == null) {
return _buildNotFoundState(context);
}
return _buildContent(context, article);
},
loading: () => const const CustomLoadingIndicator(),
error: (error, stack) => _buildErrorState(context, error.toString()),
),
);
}
/// Build AppBar
PreferredSizeWidget _buildAppBar(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return AppBar(
backgroundColor: colorScheme.surface,
elevation: AppBarSpecs.elevation,
title: Text(
'Chi tiết bài viết',
style: TextStyle(color: colorScheme.onSurface),
),
centerTitle: false,
leading: IconButton(
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
onPressed: () => context.pop(),
),
actions: [
// Share button
IconButton(
icon: FaIcon(FontAwesomeIcons.shareNodes, color: colorScheme.onSurface, size: 20),
onPressed: _onShareTap,
),
// Bookmark button
IconButton(
icon: FaIcon(
_isBookmarked ? FontAwesomeIcons.solidBookmark : FontAwesomeIcons.bookmark,
// Keep AppColors.warning for bookmarked state - semantic status color
color: _isBookmarked ? AppColors.warning : colorScheme.onSurface,
size: 20,
),
onPressed: _onBookmarkTap,
),
const SizedBox(width: AppSpacing.sm),
],
);
}
/// Build content
Widget _buildContent(BuildContext context, NewsArticle article) {
final colorScheme = Theme.of(context).colorScheme;
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: colorScheme.surfaceContainerHighest,
child: const const CustomLoadingIndicator(),
),
errorWidget: (context, url, error) => Container(
height: 250,
color: colorScheme.surfaceContainerHighest,
child: FaIcon(
FontAwesomeIcons.image,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
// Article Content
Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Metadata
_buildMetadata(context, article),
const SizedBox(height: 16),
// Title
Text(
article.title,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
height: 1.3,
),
),
const SizedBox(height: 8),
// Article Body - Render HTML content
if (article.content != null && article.content!.isNotEmpty)
Html(
data: article.content,
style: {
"body": Style(
margin: Margins.zero,
padding: HtmlPaddings.zero,
fontSize: FontSize(16),
lineHeight: const LineHeight(1.7),
color: colorScheme.onSurface,
),
"h2": Style(
fontSize: FontSize(20),
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
margin: Margins.only(top: 32, bottom: 16),
),
"h3": Style(
fontSize: FontSize(18),
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
margin: Margins.only(top: 24, bottom: 12),
),
"p": Style(
fontSize: FontSize(16),
color: colorScheme.onSurface,
lineHeight: const LineHeight(1.7),
margin: Margins.only(bottom: 16),
),
"strong": Style(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
"img": Style(
margin: Margins.symmetric(vertical: 16),
),
"ul": Style(
margin: Margins.only(left: 16, bottom: 16),
),
"ol": Style(
margin: Margins.only(left: 16, bottom: 16),
),
"li": Style(
fontSize: FontSize(16),
color: colorScheme.onSurface,
lineHeight: const LineHeight(1.5),
margin: Margins.only(bottom: 8),
),
"blockquote": Style(
backgroundColor: colorScheme.primaryContainer,
border: Border(
left: BorderSide(color: colorScheme.primary, width: 4),
),
padding: HtmlPaddings.all(16),
margin: Margins.symmetric(vertical: 24),
fontStyle: FontStyle.italic,
),
"div": Style(
margin: Margins.zero,
padding: HtmlPaddings.zero,
),
},
onLinkTap: (url, attributes, element) {
// Handle link taps if needed
if (url != null) {
debugPrint('Link tapped: $url');
}
},
),
const SizedBox(height: 32),
// Tags Section
if (article.tags.isNotEmpty) _buildTagsSection(context, article.tags),
const SizedBox(height: 32),
// Social Actions
_buildSocialActions(context, article),
const SizedBox(height: 32),
// Related Articles
if (relatedArticles != null && relatedArticles.isNotEmpty)
_buildRelatedArticles(context, relatedArticles),
],
),
),
],
),
);
}
/// Build metadata
Widget _buildMetadata(BuildContext context, NewsArticle article) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
spacing: 16,
children: [
// Category badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
child: Text(
article.category.displayName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onPrimary,
),
),
),
// Date
_buildMetaItem(context, FontAwesomeIcons.calendar, article.formattedDate),
// Reading time
// _buildMetaItem(context, Icons.schedule, article.readingTimeText),
// Views
// _buildMetaItem(
// context,
// Icons.visibility,
// '${article.formattedViewCount} lượt xem',
// ),
],
);
}
/// Build metadata item
Widget _buildMetaItem(BuildContext context, IconData icon, String text) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
FaIcon(icon, size: 12, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(
text,
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
),
],
);
}
/// Build tags section
Widget _buildTagsSection(BuildContext context, List<String> tags) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Thẻ liên quan',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
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: colorScheme.surface,
border: Border.all(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(16),
),
child: Text(
tag,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
),
)
.toList(),
),
],
),
);
}
/// Build social actions section
Widget _buildSocialActions(BuildContext context, NewsArticle article) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildActionButton(
context,
icon: _isLiked ? FontAwesomeIcons.solidHeart : FontAwesomeIcons.heart,
onPressed: _onLikeTap,
color: _isLiked ? Colors.red : null,
),
const SizedBox(width: 8),
_buildActionButton(
context,
icon: _isBookmarked ? FontAwesomeIcons.solidBookmark : FontAwesomeIcons.bookmark,
onPressed: _onBookmarkTap,
// Keep AppColors.warning for bookmarked state - semantic status color
color: _isBookmarked ? AppColors.warning : null,
),
const SizedBox(width: 8),
_buildActionButton(context, icon: FontAwesomeIcons.shareNodes, onPressed: _onShareTap),
],
),
);
}
/// Build stat item
Widget _buildStatItem(BuildContext context, IconData icon, String text) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(
text,
style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
),
],
);
}
/// Build action button
Widget _buildActionButton(
BuildContext context, {
required IconData icon,
required VoidCallback onPressed,
Color? color,
}) {
final colorScheme = Theme.of(context).colorScheme;
return OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.all(12),
side: BorderSide(color: color ?? colorScheme.outlineVariant, width: 2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: FaIcon(icon, size: 18, color: color ?? colorScheme.onSurfaceVariant),
);
}
/// Build related articles section
Widget _buildRelatedArticles(BuildContext context, List<NewsArticle> articles) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Bài viết liên quan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
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(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FaIcon(FontAwesomeIcons.fileLines, size: 64, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text(
'Không tìm thấy bài viết',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
'Bài viết này không tồn tại hoặc đã bị xóa',
style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.pop(),
child: const Text('Quay lại'),
),
],
),
);
}
/// Build error state
Widget _buildErrorState(BuildContext context, String error) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Keep AppColors.danger for error state - semantic status color
FaIcon(FontAwesomeIcons.circleExclamation, size: 64, color: AppColors.danger),
const SizedBox(height: 16),
Text(
'Không thể tải bài viết',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
error,
style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
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
SharePlus.instance.share(
ShareParams(text: 'Xem bài viết: ${ApiConstants.baseUrl}')
);
}
}