news page

This commit is contained in:
Phuoc Nguyen
2025-11-03 11:48:41 +07:00
parent 21c1c3372c
commit ea485d8c3a
14 changed files with 2017 additions and 13 deletions

View File

@@ -0,0 +1,275 @@
/// News List Page
///
/// Displays all news articles with category filtering and featured section.
/// Matches HTML design at html/news-list.html
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/category_filter_chips.dart';
import 'package:worker/features/news/presentation/widgets/featured_news_card.dart';
import 'package:worker/features/news/presentation/widgets/news_card.dart';
/// News List Page
///
/// Features:
/// - Standard AppBar with title "Tin tức & chuyên môn"
/// - Horizontal scrollable category chips (Tất cả, Tin tức, Chuyên môn, Dự án, Sự kiện, Khuyến mãi)
/// - Featured article section (large card)
/// - "Mới nhất" section with news cards list
/// - RefreshIndicator for pull-to-refresh
/// - Loading and error states
class NewsListPage extends ConsumerWidget {
const NewsListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch providers
final featuredArticleAsync = ref.watch(featuredArticleProvider);
final filteredArticlesAsync = ref.watch(filteredNewsArticlesProvider);
final selectedCategory = ref.watch(selectedNewsCategoryProvider);
return Scaffold(
backgroundColor: Colors.white,
appBar: _buildAppBar(context),
body: RefreshIndicator(
onRefresh: () async {
// Invalidate providers to trigger refresh
ref.invalidate(newsArticlesProvider);
ref.invalidate(featuredArticleProvider);
ref.invalidate(filteredNewsArticlesProvider);
},
child: CustomScrollView(
slivers: [
// Category Filter Chips
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 4, bottom: AppSpacing.md),
child: CategoryFilterChips(
selectedCategory: selectedCategory,
onCategorySelected: (category) {
ref
.read(selectedNewsCategoryProvider.notifier)
.setCategory(category);
},
),
),
),
// Featured Article Section
featuredArticleAsync.when(
data: (article) {
if (article == null) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section title "Nổi bật"
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
),
child: Row(
children: [
Icon(
Icons.star,
size: 18,
color: AppColors.primaryBlue,
),
const SizedBox(width: 8),
const Text(
'Nổi bật',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
],
),
),
const SizedBox(height: AppSpacing.md),
// Featured card
FeaturedNewsCard(
article: article,
onTap: () => _onArticleTap(context, article),
),
const SizedBox(height: 32),
],
),
);
},
loading: () => const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(AppSpacing.md),
child: Center(child: CircularProgressIndicator()),
),
),
error: (error, stack) =>
const SliverToBoxAdapter(child: SizedBox.shrink()),
),
// Latest News Section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Row(
children: [
Icon(
Icons.newspaper,
size: 18,
color: AppColors.primaryBlue,
),
const SizedBox(width: 8),
const Text(
'Mới nhất',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
],
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
// News List
filteredArticlesAsync.when(
data: (articles) {
if (articles.isEmpty) {
return SliverFillRemaining(child: _buildEmptyState());
}
return SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final article = articles[index];
return NewsCard(
article: article,
onTap: () => _onArticleTap(context, article),
);
}, childCount: articles.length),
),
);
},
loading: () => const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
error: (error, stack) => SliverFillRemaining(
child: _buildErrorState(error.toString()),
),
),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
],
),
),
);
}
/// Build standard AppBar
PreferredSizeWidget _buildAppBar(BuildContext context) {
return AppBar(
backgroundColor: AppColors.white,
elevation: AppBarSpecs.elevation,
title: const Text(
'Tin tức & chuyên môn',
style: TextStyle(
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
centerTitle: false,
actions: const [SizedBox(width: AppSpacing.sm)],
);
}
/// Build empty state
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.newspaper_outlined, size: 64, color: AppColors.grey500),
const SizedBox(height: 16),
const Text(
'Chưa có tin tức',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
const SizedBox(height: 8),
const Text(
'Hãy quay lại sau để xem các bài viết mới',
style: TextStyle(fontSize: 14, color: Color(0xFF64748B)),
textAlign: TextAlign.center,
),
],
),
);
}
/// 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 tin tức',
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,
),
),
],
),
);
}
/// 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),
),
);
}
}

View File

@@ -0,0 +1,101 @@
/// News Providers
///
/// State management for news articles using Riverpod.
/// Provides access to news data and filtering capabilities.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/features/news/data/datasources/news_local_datasource.dart';
import 'package:worker/features/news/data/repositories/news_repository_impl.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
import 'package:worker/features/news/domain/repositories/news_repository.dart';
part 'news_provider.g.dart';
/// News Local DataSource Provider
///
/// Provides instance of NewsLocalDataSource.
@riverpod
NewsLocalDataSource newsLocalDataSource(Ref ref) {
return NewsLocalDataSource();
}
/// News Repository Provider
///
/// Provides instance of NewsRepository implementation.
@riverpod
NewsRepository newsRepository(Ref ref) {
final localDataSource = ref.watch(newsLocalDataSourceProvider);
return NewsRepositoryImpl(localDataSource: localDataSource);
}
/// News Articles Provider
///
/// Fetches all news articles sorted by published date.
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
@riverpod
Future<List<NewsArticle>> newsArticles(Ref ref) async {
final repository = ref.watch(newsRepositoryProvider);
return repository.getAllArticles();
}
/// Featured Article Provider
///
/// Fetches the featured article for the top section.
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
@riverpod
Future<NewsArticle?> featuredArticle(Ref ref) async {
final repository = ref.watch(newsRepositoryProvider);
return repository.getFeaturedArticle();
}
/// Selected News Category Provider
///
/// Manages the currently selected category filter.
/// null means "All" is selected (show all categories).
@riverpod
class SelectedNewsCategory extends _$SelectedNewsCategory {
@override
NewsCategory? build() {
// Default: show all categories
return null;
}
/// Set selected category
void setCategory(NewsCategory? category) {
state = category;
}
/// Clear selection (show all)
void clearSelection() {
state = null;
}
}
/// Filtered News Articles Provider
///
/// Returns news articles filtered by selected category.
/// If no category is selected, returns all articles.
@riverpod
Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
final selectedCategory = ref.watch(selectedNewsCategoryProvider);
final repository = ref.watch(newsRepositoryProvider);
// If no category selected, return all articles
if (selectedCategory == null) {
return repository.getAllArticles();
}
// Filter by selected category
return repository.getArticlesByCategory(selectedCategory);
}
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Used for article detail page.
@riverpod
Future<NewsArticle?> newsArticleById(Ref ref, String articleId) async {
final repository = ref.watch(newsRepositoryProvider);
return repository.getArticleById(articleId);
}

View File

@@ -0,0 +1,455 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'news_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// News Local DataSource Provider
///
/// Provides instance of NewsLocalDataSource.
@ProviderFor(newsLocalDataSource)
const newsLocalDataSourceProvider = NewsLocalDataSourceProvider._();
/// News Local DataSource Provider
///
/// Provides instance of NewsLocalDataSource.
final class NewsLocalDataSourceProvider
extends
$FunctionalProvider<
NewsLocalDataSource,
NewsLocalDataSource,
NewsLocalDataSource
>
with $Provider<NewsLocalDataSource> {
/// News Local DataSource Provider
///
/// Provides instance of NewsLocalDataSource.
const NewsLocalDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'newsLocalDataSourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$newsLocalDataSourceHash();
@$internal
@override
$ProviderElement<NewsLocalDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
NewsLocalDataSource create(Ref ref) {
return newsLocalDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(NewsLocalDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<NewsLocalDataSource>(value),
);
}
}
String _$newsLocalDataSourceHash() =>
r'e7e7d71d20274fe8b498c7b15f8aeb9eb515af27';
/// News Repository Provider
///
/// Provides instance of NewsRepository implementation.
@ProviderFor(newsRepository)
const newsRepositoryProvider = NewsRepositoryProvider._();
/// News Repository Provider
///
/// Provides instance of NewsRepository implementation.
final class NewsRepositoryProvider
extends $FunctionalProvider<NewsRepository, NewsRepository, NewsRepository>
with $Provider<NewsRepository> {
/// News Repository Provider
///
/// Provides instance of NewsRepository implementation.
const NewsRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'newsRepositoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$newsRepositoryHash();
@$internal
@override
$ProviderElement<NewsRepository> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
NewsRepository create(Ref ref) {
return newsRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(NewsRepository value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<NewsRepository>(value),
);
}
}
String _$newsRepositoryHash() => r'1536188fae6934f147f022a8f5d7bd62ff9453b5';
/// News Articles Provider
///
/// Fetches all news articles sorted by published date.
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
@ProviderFor(newsArticles)
const newsArticlesProvider = NewsArticlesProvider._();
/// News Articles Provider
///
/// Fetches all news articles sorted by published date.
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
final class NewsArticlesProvider
extends
$FunctionalProvider<
AsyncValue<List<NewsArticle>>,
List<NewsArticle>,
FutureOr<List<NewsArticle>>
>
with
$FutureModifier<List<NewsArticle>>,
$FutureProvider<List<NewsArticle>> {
/// News Articles Provider
///
/// Fetches all news articles sorted by published date.
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
const NewsArticlesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'newsArticlesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$newsArticlesHash();
@$internal
@override
$FutureProviderElement<List<NewsArticle>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<NewsArticle>> create(Ref ref) {
return newsArticles(ref);
}
}
String _$newsArticlesHash() => r'24d70e49f7137c614c024dc93c97451c6e161ce6';
/// Featured Article Provider
///
/// Fetches the featured article for the top section.
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
@ProviderFor(featuredArticle)
const featuredArticleProvider = FeaturedArticleProvider._();
/// Featured Article Provider
///
/// Fetches the featured article for the top section.
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
final class FeaturedArticleProvider
extends
$FunctionalProvider<
AsyncValue<NewsArticle?>,
NewsArticle?,
FutureOr<NewsArticle?>
>
with $FutureModifier<NewsArticle?>, $FutureProvider<NewsArticle?> {
/// Featured Article Provider
///
/// Fetches the featured article for the top section.
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
const FeaturedArticleProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'featuredArticleProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$featuredArticleHash();
@$internal
@override
$FutureProviderElement<NewsArticle?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<NewsArticle?> create(Ref ref) {
return featuredArticle(ref);
}
}
String _$featuredArticleHash() => r'f7146600bc3bbaf5987ab6b09262135b1558f1c0';
/// Selected News Category Provider
///
/// Manages the currently selected category filter.
/// null means "All" is selected (show all categories).
@ProviderFor(SelectedNewsCategory)
const selectedNewsCategoryProvider = SelectedNewsCategoryProvider._();
/// Selected News Category Provider
///
/// Manages the currently selected category filter.
/// null means "All" is selected (show all categories).
final class SelectedNewsCategoryProvider
extends $NotifierProvider<SelectedNewsCategory, NewsCategory?> {
/// Selected News Category Provider
///
/// Manages the currently selected category filter.
/// null means "All" is selected (show all categories).
const SelectedNewsCategoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedNewsCategoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedNewsCategoryHash();
@$internal
@override
SelectedNewsCategory create() => SelectedNewsCategory();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(NewsCategory? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<NewsCategory?>(value),
);
}
}
String _$selectedNewsCategoryHash() =>
r'f1dca9a5d7de94cac90494d94ce05b727e6e4d5f';
/// Selected News Category Provider
///
/// Manages the currently selected category filter.
/// null means "All" is selected (show all categories).
abstract class _$SelectedNewsCategory extends $Notifier<NewsCategory?> {
NewsCategory? build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<NewsCategory?, NewsCategory?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<NewsCategory?, NewsCategory?>,
NewsCategory?,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Filtered News Articles Provider
///
/// Returns news articles filtered by selected category.
/// If no category is selected, returns all articles.
@ProviderFor(filteredNewsArticles)
const filteredNewsArticlesProvider = FilteredNewsArticlesProvider._();
/// Filtered News Articles Provider
///
/// Returns news articles filtered by selected category.
/// If no category is selected, returns all articles.
final class FilteredNewsArticlesProvider
extends
$FunctionalProvider<
AsyncValue<List<NewsArticle>>,
List<NewsArticle>,
FutureOr<List<NewsArticle>>
>
with
$FutureModifier<List<NewsArticle>>,
$FutureProvider<List<NewsArticle>> {
/// Filtered News Articles Provider
///
/// Returns news articles filtered by selected category.
/// If no category is selected, returns all articles.
const FilteredNewsArticlesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'filteredNewsArticlesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$filteredNewsArticlesHash();
@$internal
@override
$FutureProviderElement<List<NewsArticle>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<NewsArticle>> create(Ref ref) {
return filteredNewsArticles(ref);
}
}
String _$filteredNewsArticlesHash() =>
r'f40a737b74b44f2d4fa86977175314ed0da471fa';
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Used for article detail page.
@ProviderFor(newsArticleById)
const newsArticleByIdProvider = NewsArticleByIdFamily._();
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Used for article detail page.
final class NewsArticleByIdProvider
extends
$FunctionalProvider<
AsyncValue<NewsArticle?>,
NewsArticle?,
FutureOr<NewsArticle?>
>
with $FutureModifier<NewsArticle?>, $FutureProvider<NewsArticle?> {
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Used for article detail page.
const NewsArticleByIdProvider._({
required NewsArticleByIdFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'newsArticleByIdProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$newsArticleByIdHash();
@override
String toString() {
return r'newsArticleByIdProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<NewsArticle?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<NewsArticle?> create(Ref ref) {
final argument = this.argument as String;
return newsArticleById(ref, argument);
}
@override
bool operator ==(Object other) {
return other is NewsArticleByIdProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$newsArticleByIdHash() => r'4d28caa81d486fcd6cfefd16477355927bbcadc8';
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Used for article detail page.
final class NewsArticleByIdFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<NewsArticle?>, String> {
const NewsArticleByIdFamily._()
: super(
retry: null,
name: r'newsArticleByIdProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Used for article detail page.
NewsArticleByIdProvider call(String articleId) =>
NewsArticleByIdProvider._(argument: articleId, from: this);
@override
String toString() => r'newsArticleByIdProvider';
}

View File

@@ -0,0 +1,94 @@
/// Category Filter Chips Widget
///
/// Horizontal scrollable list of category filter chips.
/// Used in news list page for filtering articles by category.
library;
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';
/// Category Filter Chips
///
/// Displays a horizontal scrollable row of filter chips for news categories.
/// Features:
/// - "Tất cả" (All) option to show all categories
/// - 5 category options: Tin tức, Chuyên môn, Dự án, Sự kiện, Khuyến mãi
/// - Active state styling (primary blue background, white text)
/// - Inactive state styling (grey background, grey text)
class CategoryFilterChips extends StatelessWidget {
/// Currently selected category (null = All)
final NewsCategory? selectedCategory;
/// Callback when a category is tapped
final void Function(NewsCategory? category) onCategorySelected;
/// Constructor
const CategoryFilterChips({
super.key,
required this.selectedCategory,
required this.onCategorySelected,
});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Row(
children: [
// "Tất cả" chip
_buildCategoryChip(
label: 'Tất cả',
isSelected: selectedCategory == null,
onTap: () => onCategorySelected(null),
),
const SizedBox(width: AppSpacing.sm),
// Category chips
...NewsCategory.values.map((category) {
return Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: _buildCategoryChip(
label: category.displayName,
isSelected: selectedCategory == category,
onTap: () => onCategorySelected(category),
),
);
}),
],
),
);
}
/// Build individual category chip
Widget _buildCategoryChip({
required String label,
required bool isSelected,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: isSelected ? AppColors.primaryBlue : AppColors.grey100,
borderRadius: BorderRadius.circular(24),
),
child: Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : AppColors.grey500,
),
),
),
);
}
}

View File

@@ -0,0 +1,188 @@
/// Featured News Card Widget
///
/// Large featured article card with full-width image.
/// Used at the top of news list page for the main featured article.
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';
/// Featured News Card
///
/// Large card with:
/// - Full-width 200px height image
/// - Title (1.125rem, bold)
/// - Excerpt/description (truncated)
/// - Metadata: date, views, reading time
/// - Category badge (primary blue)
/// - Shadow and rounded corners
class FeaturedNewsCard extends StatelessWidget {
/// News article to display
final NewsArticle article;
/// Callback when card is tapped
final VoidCallback? onTap;
/// Constructor
const FeaturedNewsCard({super.key, required this.article, this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Featured image (200px height)
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppRadius.xl),
),
child: CachedNetworkImage(
imageUrl: article.imageUrl,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
height: 200,
color: AppColors.grey100,
child: const Center(child: CircularProgressIndicator()),
),
errorWidget: (context, url, error) => Container(
height: 200,
color: AppColors.grey100,
child: const Icon(
Icons.image_outlined,
size: 48,
color: AppColors.grey500,
),
),
),
),
// Content section
Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Text(
article.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
height: 1.4,
),
),
const SizedBox(height: 12),
// Excerpt
Text(
article.excerpt,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF64748B),
height: 1.5,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 16),
// Metadata row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left metadata (date, views, reading time)
Expanded(
child: Wrap(
spacing: 16,
runSpacing: 4,
children: [
// Date
_buildMetaItem(
icon: Icons.calendar_today,
text: article.formattedDate,
),
// Views
_buildMetaItem(
icon: Icons.visibility,
text: '${article.formattedViewCount} lượt xem',
),
// Reading time
_buildMetaItem(
icon: Icons.schedule,
text: article.readingTimeText,
),
],
),
),
// 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.w500,
color: Colors.white,
),
),
),
],
),
],
),
),
],
),
),
);
}
/// Build metadata item
Widget _buildMetaItem({required IconData icon, required 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)),
),
],
);
}
}

View File

@@ -0,0 +1,159 @@
/// News Card Widget
///
/// Compact news article card for list display.
/// Horizontal layout with thumbnail and 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';
/// News Card
///
/// Compact card with horizontal layout:
/// - 80x80 thumbnail (left)
/// - Title (max 2 lines, 0.875rem, bold)
/// - Excerpt (max 2 lines, 0.75rem, grey)
/// - Metadata: date and views
/// - Hover/tap effect (border color change)
class NewsCard extends StatelessWidget {
/// News article to display
final NewsArticle article;
/// Callback when card is tapped
final VoidCallback? onTap;
/// Constructor
const NewsCard({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.card),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Thumbnail (80x80)
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.md),
child: CachedNetworkImage(
imageUrl: article.imageUrl,
width: 80,
height: 80,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 80,
height: 80,
color: AppColors.grey100,
child: const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
errorWidget: (context, url, error) => Container(
width: 80,
height: 80,
color: AppColors.grey100,
child: const Icon(
Icons.image_outlined,
size: 24,
color: AppColors.grey500,
),
),
),
),
const SizedBox(width: AppSpacing.md),
// Content (flexible to fill remaining space)
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: 8),
// Excerpt (max 2 lines)
Text(
article.excerpt,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF64748B),
height: 1.4,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// Metadata row (date and views)
Row(
children: [
// Date
Icon(
Icons.calendar_today,
size: 12,
color: const Color(0xFF64748B),
),
const SizedBox(width: 4),
Text(
article.formattedDate,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF64748B),
),
),
const SizedBox(width: 16),
// Views
Icon(
Icons.visibility,
size: 12,
color: const Color(0xFF64748B),
),
const SizedBox(width: 4),
Text(
'${article.formattedViewCount} lượt xem',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF64748B),
),
),
],
),
],
),
),
],
),
),
);
}
}