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

@@ -166,10 +166,9 @@ class HomePage extends ConsumerWidget {
onTap: () => context.push(RouteNames.orders),
),
QuickAction(
icon: Icons.receipt_long,
label: 'Thanh toán',
onTap: () =>
context.push(RouteNames.payments)
icon: Icons.receipt_long,
label: 'Thanh toán',
onTap: () => context.push(RouteNames.payments),
),
],
),
@@ -197,8 +196,6 @@ class HomePage extends ConsumerWidget {
],
),
// Sample Houses & News Section
QuickActionSection(
title: 'Nhà mẫu, dự án & tin tức',
@@ -214,11 +211,11 @@ class HomePage extends ConsumerWidget {
onTap: () =>
_showComingSoon(context, 'Đăng ký dự án', l10n),
),
// QuickAction(
// icon: Icons.article,
// label: 'Tin tức',
// onTap: () => _showComingSoon(context, 'Tin tức', l10n),
// ),
QuickAction(
icon: Icons.article,
label: 'Tin tức',
onTap: () => context.push(RouteNames.news),
),
],
),

View File

@@ -10,6 +10,7 @@ import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/home/presentation/pages/home_page.dart';
import 'package:worker/features/loyalty/presentation/pages/loyalty_page.dart';
import 'package:worker/features/main/presentation/providers/current_page_provider.dart';
import 'package:worker/features/news/presentation/pages/news_list_page.dart';
import 'package:worker/features/promotions/presentation/pages/promotions_page.dart';
/// Main Scaffold Page
@@ -31,7 +32,7 @@ class MainScaffold extends ConsumerWidget {
final pages = [
const HomePage(),
const LoyaltyPage(), // Loyalty
const PromotionsPage(),
const NewsListPage(),
_buildComingSoonPage('Thông báo'), // Notifications
_buildComingSoonPage('Cài đặt'), // Account
];
@@ -94,7 +95,7 @@ class MainScaffold extends ConsumerWidget {
),
const BottomNavigationBarItem(
icon: Icon(Icons.local_offer),
label: 'Khuyến mãi',
label: 'Tin tức',
),
BottomNavigationBarItem(
icon: Stack(

View File

@@ -0,0 +1,216 @@
/// News Local DataSource
///
/// Handles all local data operations for news articles.
/// Currently provides mock data for development and testing.
/// Will be extended to use Hive cache when backend API is available.
library;
import 'package:worker/features/news/data/models/news_article_model.dart';
/// News Local Data Source
///
/// Provides mock data for news articles.
/// In production, this will cache data from the remote API.
class NewsLocalDataSource {
/// Get all news articles
///
/// Returns a list of all articles from mock data.
/// In production, this will fetch from Hive cache.
Future<List<NewsArticleModel>> getAllArticles() async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 300));
return _mockArticles;
}
/// Get featured article
///
/// Returns the main featured article for the top section.
Future<NewsArticleModel?> getFeaturedArticle() async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 200));
try {
return _mockArticles.firstWhere((article) => article.isFeatured);
} catch (e) {
return null;
}
}
/// Get articles by category
///
/// Returns filtered list of articles matching the [category].
Future<List<NewsArticleModel>> getArticlesByCategory(String category) async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 200));
if (category == 'all') {
return _mockArticles;
}
return _mockArticles
.where(
(article) => article.category.toLowerCase() == category.toLowerCase(),
)
.toList();
}
/// Get a specific article by ID
///
/// Returns the article if found, null otherwise.
Future<NewsArticleModel?> getArticleById(String articleId) async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 100));
try {
return _mockArticles.firstWhere((article) => article.id == articleId);
} catch (e) {
return null;
}
}
/// Check if cache is valid
///
/// Returns true if cached data is still valid.
/// Currently always returns false since we're using mock data.
Future<bool> isCacheValid() async {
// TODO: Implement cache validation when using Hive
return false;
}
/// Cache articles locally
///
/// Saves articles to Hive for offline access.
/// Currently not implemented (using mock data).
Future<void> cacheArticles(List<NewsArticleModel> articles) async {
// TODO: Implement Hive caching when backend API is ready
}
/// Clear cached articles
///
/// Removes all cached articles from Hive.
/// Currently not implemented (using mock data).
Future<void> clearCache() async {
// TODO: Implement cache clearing when using Hive
}
/// Mock articles matching HTML design
///
/// This data will be replaced with real API data in production.
static final List<NewsArticleModel> _mockArticles = [
// Featured article
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.',
imageUrl:
'https://images.unsplash.com/photo-1503387762-592deb58ef4e?w=400&h=200&fit=crop',
category: 'news',
publishedDate: '2024-11-15T00:00:00.000Z',
viewCount: 2300,
readingTimeMinutes: 5,
isFeatured: true,
),
// Latest articles
const NewsArticleModel(
id: 'news-1',
title: 'Hướng dẫn thi công gạch granite 60x60 chuyên nghiệp',
excerpt:
'Quy trình thi công chi tiết từ A-Z cho thầy thợ xây dựng. Các bước chuẩn bị, kỹ thuật thi công và kinh nghiệm thực tế.',
imageUrl:
'https://images.unsplash.com/photo-1586023492125-27b2c045efd7?w=80&h=80&fit=crop',
category: 'professional',
publishedDate: '2024-11-12T00:00:00.000Z',
viewCount: 1800,
readingTimeMinutes: 8,
isFeatured: false,
),
const NewsArticleModel(
id: 'news-2',
title: 'Bảng giá gạch men cao cấp mới nhất tháng 11/2024',
excerpt:
'Cập nhật bảng giá chi tiết các dòng sản phẩm gạch men nhập khẩu. So sánh giá các thương hiệu hàng đầu.',
imageUrl:
'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=80&h=80&fit=crop',
category: 'news',
publishedDate: '2024-11-10T00:00:00.000Z',
viewCount: 3100,
readingTimeMinutes: 4,
isFeatured: false,
),
const NewsArticleModel(
id: 'news-3',
title: 'Mẹo chọn gạch ốp tường phòng bếp đẹp và bền',
excerpt:
'Những lưu ý quan trọng khi chọn gạch ốp tường cho khu vực bếp. Tư vấn về chất liệu, màu sắc và kích thước phù hợp.',
imageUrl:
'https://images.unsplash.com/photo-1545558014-8692077e9b5c?w=80&h=80&fit=crop',
category: 'professional',
publishedDate: '2024-11-08T00:00:00.000Z',
viewCount: 1500,
readingTimeMinutes: 6,
isFeatured: false,
),
const NewsArticleModel(
id: 'news-4',
title: 'Dự án biệt thự Quận 2: Ứng dụng gạch men cao cấp',
excerpt:
'Case study về việc sử dụng gạch men trong dự án biệt thự 300m². Chia sẻ kinh nghiệm và bài học từ thầu thợ.',
imageUrl:
'https://images.unsplash.com/photo-1484101403633-562f891dc89a?w=80&h=80&fit=crop',
category: 'projects',
publishedDate: '2024-11-05T00:00:00.000Z',
viewCount: 2700,
readingTimeMinutes: 10,
isFeatured: false,
),
const NewsArticleModel(
id: 'news-5',
title: 'Công cụ hỗ trợ tính toán diện tích gạch chính xác',
excerpt:
'Hướng dẫn sử dụng các công cụ và ứng dụng giúp tính toán diện tích gạch cần thiết cho công trình một cách chính xác nhất.',
imageUrl:
'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=80&h=80&fit=crop',
category: 'professional',
publishedDate: '2024-11-03T00:00:00.000Z',
viewCount: 1200,
readingTimeMinutes: 7,
isFeatured: false,
),
// Additional articles for different categories
const NewsArticleModel(
id: 'event-1',
title: 'Hội nghị thầu thợ miền Nam 2024',
excerpt:
'Tham gia sự kiện kết nối, chia sẻ kinh nghiệm và cập nhật xu hướng mới nhất trong ngành xây dựng.',
imageUrl:
'https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=80&h=80&fit=crop',
category: 'events',
publishedDate: '2024-11-01T00:00:00.000Z',
viewCount: 950,
readingTimeMinutes: 3,
isFeatured: false,
),
const NewsArticleModel(
id: 'promo-1',
title: 'Khuyến mãi mua 10 tặng 1 - Gạch Granite 60x60',
excerpt:
'Chương trình ưu đãi đặc biệt dành cho thầu thợ và đại lý. Áp dụng cho đơn hàng từ 500m² trở lên.',
imageUrl:
'https://images.unsplash.com/photo-1607400201889-565b1ee75f8e?w=80&h=80&fit=crop',
category: 'promotions',
publishedDate: '2024-10-28T00:00:00.000Z',
viewCount: 4200,
readingTimeMinutes: 2,
isFeatured: false,
),
];
}

View File

@@ -0,0 +1,180 @@
/// Data Model: News Article Model
///
/// Data layer model for news articles.
/// Handles JSON serialization and conversion to/from domain entity.
library;
import 'package:worker/features/news/domain/entities/news_article.dart';
/// News Article Model
///
/// Used in the data layer for:
/// - JSON serialization/deserialization from API
/// - Conversion to domain entity
/// - Local storage (if needed)
class NewsArticleModel {
/// Unique article ID
final String id;
/// Article title
final String title;
/// Article excerpt/summary
final String excerpt;
/// Full article content (optional)
final String? content;
/// Featured image URL
final String imageUrl;
/// Article category
final String category;
/// Publication date (ISO 8601 string)
final String publishedDate;
/// View count
final int viewCount;
/// Estimated reading time in minutes
final int readingTimeMinutes;
/// Whether this is a featured article
final bool isFeatured;
/// Author name (optional)
final String? authorName;
/// Author avatar URL (optional)
final String? authorAvatar;
/// Constructor
const NewsArticleModel({
required this.id,
required this.title,
required this.excerpt,
this.content,
required this.imageUrl,
required this.category,
required this.publishedDate,
required this.viewCount,
required this.readingTimeMinutes,
this.isFeatured = false,
this.authorName,
this.authorAvatar,
});
/// Create model from JSON
factory NewsArticleModel.fromJson(Map<String, dynamic> json) {
return NewsArticleModel(
id: json['id'] as String,
title: json['title'] as String,
excerpt: json['excerpt'] as String,
content: json['content'] as String?,
imageUrl: json['image_url'] as String,
category: json['category'] as String,
publishedDate: json['published_date'] as String,
viewCount: json['view_count'] as int,
readingTimeMinutes: json['reading_time_minutes'] as int,
isFeatured: json['is_featured'] as bool? ?? false,
authorName: json['author_name'] as String?,
authorAvatar: json['author_avatar'] as String?,
);
}
/// Convert model to JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'excerpt': excerpt,
'content': content,
'image_url': imageUrl,
'category': category,
'published_date': publishedDate,
'view_count': viewCount,
'reading_time_minutes': readingTimeMinutes,
'is_featured': isFeatured,
'author_name': authorName,
'author_avatar': authorAvatar,
};
}
/// Convert model to domain entity
NewsArticle toEntity() {
return NewsArticle(
id: id,
title: title,
excerpt: excerpt,
content: content,
imageUrl: imageUrl,
category: _parseCategory(category),
publishedDate: DateTime.parse(publishedDate),
viewCount: viewCount,
readingTimeMinutes: readingTimeMinutes,
isFeatured: isFeatured,
authorName: authorName,
authorAvatar: authorAvatar,
);
}
/// Create model from domain entity
factory NewsArticleModel.fromEntity(NewsArticle entity) {
return NewsArticleModel(
id: entity.id,
title: entity.title,
excerpt: entity.excerpt,
content: entity.content,
imageUrl: entity.imageUrl,
category: _categoryToString(entity.category),
publishedDate: entity.publishedDate.toIso8601String(),
viewCount: entity.viewCount,
readingTimeMinutes: entity.readingTimeMinutes,
isFeatured: entity.isFeatured,
authorName: entity.authorName,
authorAvatar: entity.authorAvatar,
);
}
/// Parse category from string
static NewsCategory _parseCategory(String category) {
switch (category.toLowerCase()) {
case 'news':
return NewsCategory.news;
case 'professional':
case 'technique':
return NewsCategory.professional;
case 'projects':
return NewsCategory.projects;
case 'events':
return NewsCategory.events;
case 'promotions':
return NewsCategory.promotions;
default:
return NewsCategory.news;
}
}
/// Convert category to string
static String _categoryToString(NewsCategory category) {
switch (category) {
case NewsCategory.news:
return 'news';
case NewsCategory.professional:
return 'professional';
case NewsCategory.projects:
return 'projects';
case NewsCategory.events:
return 'events';
case NewsCategory.promotions:
return 'promotions';
}
}
@override
String toString() {
return 'NewsArticleModel(id: $id, title: $title, category: $category, '
'publishedDate: $publishedDate)';
}
}

View File

@@ -0,0 +1,87 @@
/// Repository Implementation: News Repository
///
/// Concrete implementation of the NewsRepository interface.
/// Coordinates between local and remote data sources.
library;
import 'package:worker/features/news/data/datasources/news_local_datasource.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
import 'package:worker/features/news/domain/repositories/news_repository.dart';
/// News Repository Implementation
class NewsRepositoryImpl implements NewsRepository {
/// Local data source
final NewsLocalDataSource localDataSource;
/// Constructor
NewsRepositoryImpl({required this.localDataSource});
@override
Future<List<NewsArticle>> getAllArticles() async {
try {
final models = await localDataSource.getAllArticles();
final entities = models.map((model) => model.toEntity()).toList();
// Sort by published date (newest first)
entities.sort((a, b) => b.publishedDate.compareTo(a.publishedDate));
return entities;
} catch (e) {
print('[NewsRepository] Error getting articles: $e');
return [];
}
}
@override
Future<NewsArticle?> getFeaturedArticle() async {
try {
final model = await localDataSource.getFeaturedArticle();
return model?.toEntity();
} catch (e) {
print('[NewsRepository] Error getting featured article: $e');
return null;
}
}
@override
Future<List<NewsArticle>> getArticlesByCategory(NewsCategory category) async {
try {
final categoryString = category.filterName;
final models = await localDataSource.getArticlesByCategory(
categoryString,
);
final entities = models.map((model) => model.toEntity()).toList();
// Sort by published date (newest first)
entities.sort((a, b) => b.publishedDate.compareTo(a.publishedDate));
return entities;
} catch (e) {
print('[NewsRepository] Error getting articles by category: $e');
return [];
}
}
@override
Future<NewsArticle?> getArticleById(String articleId) async {
try {
final model = await localDataSource.getArticleById(articleId);
return model?.toEntity();
} catch (e) {
print('[NewsRepository] Error getting article by id: $e');
return null;
}
}
@override
Future<List<NewsArticle>> refreshArticles() async {
try {
await localDataSource.clearCache();
return getAllArticles();
} catch (e) {
print('[NewsRepository] Error refreshing articles: $e');
return [];
}
}
}

View File

@@ -0,0 +1,209 @@
/// Domain Entity: News Article
///
/// Pure business entity representing a news article or blog post.
/// This entity is framework-independent and contains only business logic.
library;
/// News Article Entity
///
/// Represents a news article/blog post in the app.
/// Used for displaying news, tips, project showcases, and professional content.
class NewsArticle {
/// Unique article ID
final String id;
/// Article title
final String title;
/// Article excerpt/summary
final String excerpt;
/// Full article content (optional, may load separately)
final String? content;
/// Featured image URL
final String imageUrl;
/// Article category
final NewsCategory category;
/// Publication date
final DateTime publishedDate;
/// View count
final int viewCount;
/// Estimated reading time in minutes
final int readingTimeMinutes;
/// Whether this is a featured article
final bool isFeatured;
/// Author name (optional)
final String? authorName;
/// Author avatar URL (optional)
final String? authorAvatar;
/// Constructor
const NewsArticle({
required this.id,
required this.title,
required this.excerpt,
this.content,
required this.imageUrl,
required this.category,
required this.publishedDate,
required this.viewCount,
required this.readingTimeMinutes,
this.isFeatured = false,
this.authorName,
this.authorAvatar,
});
/// Get formatted publication date (dd/MM/yyyy)
String get formattedDate {
return '${publishedDate.day.toString().padLeft(2, '0')}/'
'${publishedDate.month.toString().padLeft(2, '0')}/'
'${publishedDate.year}';
}
/// Get formatted view count (e.g., "2.3K")
String get formattedViewCount {
if (viewCount >= 1000) {
return '${(viewCount / 1000).toStringAsFixed(1)}K';
}
return viewCount.toString();
}
/// Get reading time display text
String get readingTimeText => '$readingTimeMinutes phút đọc';
/// Copy with method for immutability
NewsArticle copyWith({
String? id,
String? title,
String? excerpt,
String? content,
String? imageUrl,
NewsCategory? category,
DateTime? publishedDate,
int? viewCount,
int? readingTimeMinutes,
bool? isFeatured,
String? authorName,
String? authorAvatar,
}) {
return NewsArticle(
id: id ?? this.id,
title: title ?? this.title,
excerpt: excerpt ?? this.excerpt,
content: content ?? this.content,
imageUrl: imageUrl ?? this.imageUrl,
category: category ?? this.category,
publishedDate: publishedDate ?? this.publishedDate,
viewCount: viewCount ?? this.viewCount,
readingTimeMinutes: readingTimeMinutes ?? this.readingTimeMinutes,
isFeatured: isFeatured ?? this.isFeatured,
authorName: authorName ?? this.authorName,
authorAvatar: authorAvatar ?? this.authorAvatar,
);
}
/// Equality operator
@override
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;
}
/// Hash code
@override
int get hashCode {
return Object.hash(
id,
title,
excerpt,
content,
imageUrl,
category,
publishedDate,
viewCount,
readingTimeMinutes,
isFeatured,
authorName,
authorAvatar,
);
}
/// String representation
@override
String toString() {
return 'NewsArticle(id: $id, title: $title, category: $category, '
'publishedDate: $publishedDate, isFeatured: $isFeatured)';
}
}
/// News Category enum
enum NewsCategory {
/// General news
news,
/// Professional/technical content
professional,
/// Project showcases
projects,
/// Events
events,
/// Promotions
promotions,
}
/// Extension for News Category display
extension NewsCategoryX on NewsCategory {
String get displayName {
switch (this) {
case NewsCategory.news:
return 'Tin tức';
case NewsCategory.professional:
return 'Chuyên môn';
case NewsCategory.projects:
return 'Dự án';
case NewsCategory.events:
return 'Sự kiện';
case NewsCategory.promotions:
return 'Khuyến mãi';
}
}
String get filterName {
switch (this) {
case NewsCategory.news:
return 'news';
case NewsCategory.professional:
return 'professional';
case NewsCategory.projects:
return 'projects';
case NewsCategory.events:
return 'events';
case NewsCategory.promotions:
return 'promotions';
}
}
}

View File

@@ -0,0 +1,27 @@
/// Domain Repository Interface: News Repository
///
/// Defines the contract for news article data operations.
/// This is an abstract interface following the Repository Pattern.
library;
import 'package:worker/features/news/domain/entities/news_article.dart';
/// News Repository Interface
///
/// Provides methods to fetch and manage news articles.
abstract class NewsRepository {
/// Get all news articles
Future<List<NewsArticle>> getAllArticles();
/// Get featured article
Future<NewsArticle?> getFeaturedArticle();
/// Get articles by category
Future<List<NewsArticle>> getArticlesByCategory(NewsCategory category);
/// Get a specific article by ID
Future<NewsArticle?> getArticleById(String articleId);
/// Refresh articles from server
Future<List<NewsArticle>> refreshArticles();
}

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),
),
),
],
),
],
),
),
],
),
),
);
}
}