/// 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 createState() => _NewsDetailPageState(); } class _NewsDetailPageState extends ConsumerState { 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 _parseHTMLContent(String content) { final List 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('

') && trimmed.endsWith('

')) { final text = trimmed.substring(4, trimmed.length - 5); widgets.add(_buildH2(text)); } // H3 heading else if (trimmed.startsWith('

') && trimmed.endsWith('

')) { final text = trimmed.substring(4, trimmed.length - 5); widgets.add(_buildH3(text)); } // Paragraph else if (trimmed.startsWith('

') && trimmed.endsWith('

')) { final text = trimmed.substring(3, trimmed.length - 4); widgets.add(_buildParagraph(text)); } // Unordered list start else if (trimmed == '
    ') { // Collect list items final listItems = []; continue; } // List item else if (trimmed.startsWith('
  • ') && trimmed.endsWith('
  • ')) { 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('
    ') && trimmed.endsWith('
    ')) { final text = trimmed.substring(12, trimmed.length - 13); widgets.add(_buildBlockquote(text)); } // Highlight box (custom tag) else if (trimmed.startsWith('(.*)').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 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 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(( ref, id, ) async { final articles = await ref.watch(newsArticlesProvider.future); try { return articles.firstWhere((article) => article.id == id); } catch (e) { return null; } });