From ea485d8c3aecd6705f00ddd3aef748cd5487a34b Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Mon, 3 Nov 2025 11:48:41 +0700 Subject: [PATCH] news page --- lib/core/router/app_router.dart | 15 + .../home/presentation/pages/home_page.dart | 19 +- .../presentation/pages/main_scaffold.dart | 5 +- .../datasources/news_local_datasource.dart | 216 +++++++++ .../news/data/models/news_article_model.dart | 180 +++++++ .../repositories/news_repository_impl.dart | 87 ++++ .../news/domain/entities/news_article.dart | 209 ++++++++ .../domain/repositories/news_repository.dart | 27 ++ .../presentation/pages/news_list_page.dart | 275 +++++++++++ .../presentation/providers/news_provider.dart | 101 ++++ .../providers/news_provider.g.dart | 455 ++++++++++++++++++ .../widgets/category_filter_chips.dart | 94 ++++ .../widgets/featured_news_card.dart | 188 ++++++++ .../news/presentation/widgets/news_card.dart | 159 ++++++ 14 files changed, 2017 insertions(+), 13 deletions(-) create mode 100644 lib/features/news/data/datasources/news_local_datasource.dart create mode 100644 lib/features/news/data/models/news_article_model.dart create mode 100644 lib/features/news/data/repositories/news_repository_impl.dart create mode 100644 lib/features/news/domain/entities/news_article.dart create mode 100644 lib/features/news/domain/repositories/news_repository.dart create mode 100644 lib/features/news/presentation/pages/news_list_page.dart create mode 100644 lib/features/news/presentation/providers/news_provider.dart create mode 100644 lib/features/news/presentation/providers/news_provider.g.dart create mode 100644 lib/features/news/presentation/widgets/category_filter_chips.dart create mode 100644 lib/features/news/presentation/widgets/featured_news_card.dart create mode 100644 lib/features/news/presentation/widgets/news_card.dart diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index b994a69..6ee0f1c 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -21,6 +21,7 @@ import 'package:worker/features/products/presentation/pages/products_page.dart'; import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart'; import 'package:worker/features/quotes/presentation/pages/quotes_page.dart'; import 'package:worker/features/price_policy/price_policy.dart'; +import 'package:worker/features/news/presentation/pages/news_list_page.dart'; /// App Router /// @@ -200,6 +201,16 @@ class AppRouter { ), ), + // News Route + GoRoute( + path: RouteNames.news, + name: RouteNames.news, + pageBuilder: (context, state) => MaterialPage( + key: state.pageKey, + child: const NewsListPage(), + ), + ), + // TODO: Add more routes as features are implemented ], @@ -322,6 +333,10 @@ class RouteNames { // Price Policy Route static const String pricePolicy = '/price-policy'; + // News Route + static const String news = '/news'; + static const String newsDetail = '/news/:id'; + // Chat Route static const String chat = '/chat'; diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 7e9ffc7..19aad78 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -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), + ), ], ), diff --git a/lib/features/main/presentation/pages/main_scaffold.dart b/lib/features/main/presentation/pages/main_scaffold.dart index c0cf474..a77831f 100644 --- a/lib/features/main/presentation/pages/main_scaffold.dart +++ b/lib/features/main/presentation/pages/main_scaffold.dart @@ -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( diff --git a/lib/features/news/data/datasources/news_local_datasource.dart b/lib/features/news/data/datasources/news_local_datasource.dart new file mode 100644 index 0000000..e166b95 --- /dev/null +++ b/lib/features/news/data/datasources/news_local_datasource.dart @@ -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> getAllArticles() async { + // Simulate network delay + await Future.delayed(const Duration(milliseconds: 300)); + + return _mockArticles; + } + + /// Get featured article + /// + /// Returns the main featured article for the top section. + Future getFeaturedArticle() async { + // Simulate network delay + await Future.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> getArticlesByCategory(String category) async { + // Simulate network delay + await Future.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 getArticleById(String articleId) async { + // Simulate network delay + await Future.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 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 cacheArticles(List 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 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 _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, + ), + ]; +} diff --git a/lib/features/news/data/models/news_article_model.dart b/lib/features/news/data/models/news_article_model.dart new file mode 100644 index 0000000..cc8c38c --- /dev/null +++ b/lib/features/news/data/models/news_article_model.dart @@ -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 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 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)'; + } +} diff --git a/lib/features/news/data/repositories/news_repository_impl.dart b/lib/features/news/data/repositories/news_repository_impl.dart new file mode 100644 index 0000000..b185b8e --- /dev/null +++ b/lib/features/news/data/repositories/news_repository_impl.dart @@ -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> 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 getFeaturedArticle() async { + try { + final model = await localDataSource.getFeaturedArticle(); + return model?.toEntity(); + } catch (e) { + print('[NewsRepository] Error getting featured article: $e'); + return null; + } + } + + @override + Future> 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 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> refreshArticles() async { + try { + await localDataSource.clearCache(); + return getAllArticles(); + } catch (e) { + print('[NewsRepository] Error refreshing articles: $e'); + return []; + } + } +} diff --git a/lib/features/news/domain/entities/news_article.dart b/lib/features/news/domain/entities/news_article.dart new file mode 100644 index 0000000..d98bc00 --- /dev/null +++ b/lib/features/news/domain/entities/news_article.dart @@ -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'; + } + } +} diff --git a/lib/features/news/domain/repositories/news_repository.dart b/lib/features/news/domain/repositories/news_repository.dart new file mode 100644 index 0000000..605ede0 --- /dev/null +++ b/lib/features/news/domain/repositories/news_repository.dart @@ -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> getAllArticles(); + + /// Get featured article + Future getFeaturedArticle(); + + /// Get articles by category + Future> getArticlesByCategory(NewsCategory category); + + /// Get a specific article by ID + Future getArticleById(String articleId); + + /// Refresh articles from server + Future> refreshArticles(); +} diff --git a/lib/features/news/presentation/pages/news_list_page.dart b/lib/features/news/presentation/pages/news_list_page.dart new file mode 100644 index 0000000..cf1e60a --- /dev/null +++ b/lib/features/news/presentation/pages/news_list_page.dart @@ -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), + ), + ); + } +} diff --git a/lib/features/news/presentation/providers/news_provider.dart b/lib/features/news/presentation/providers/news_provider.dart new file mode 100644 index 0000000..cf2247c --- /dev/null +++ b/lib/features/news/presentation/providers/news_provider.dart @@ -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> for proper loading/error handling. +@riverpod +Future> 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 (null if no featured article). +@riverpod +Future 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> 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 newsArticleById(Ref ref, String articleId) async { + final repository = ref.watch(newsRepositoryProvider); + return repository.getArticleById(articleId); +} diff --git a/lib/features/news/presentation/providers/news_provider.g.dart b/lib/features/news/presentation/providers/news_provider.g.dart new file mode 100644 index 0000000..95b1eba --- /dev/null +++ b/lib/features/news/presentation/providers/news_provider.g.dart @@ -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 { + /// 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 $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(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 + with $Provider { + /// 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 $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(value), + ); + } +} + +String _$newsRepositoryHash() => r'1536188fae6934f147f022a8f5d7bd62ff9453b5'; + +/// News Articles Provider +/// +/// Fetches all news articles sorted by published date. +/// Returns AsyncValue> for proper loading/error handling. + +@ProviderFor(newsArticles) +const newsArticlesProvider = NewsArticlesProvider._(); + +/// News Articles Provider +/// +/// Fetches all news articles sorted by published date. +/// Returns AsyncValue> for proper loading/error handling. + +final class NewsArticlesProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// News Articles Provider + /// + /// Fetches all news articles sorted by published date. + /// Returns AsyncValue> 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> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return newsArticles(ref); + } +} + +String _$newsArticlesHash() => r'24d70e49f7137c614c024dc93c97451c6e161ce6'; + +/// Featured Article Provider +/// +/// Fetches the featured article for the top section. +/// Returns AsyncValue (null if no featured article). + +@ProviderFor(featuredArticle) +const featuredArticleProvider = FeaturedArticleProvider._(); + +/// Featured Article Provider +/// +/// Fetches the featured article for the top section. +/// Returns AsyncValue (null if no featured article). + +final class FeaturedArticleProvider + extends + $FunctionalProvider< + AsyncValue, + NewsArticle?, + FutureOr + > + with $FutureModifier, $FutureProvider { + /// Featured Article Provider + /// + /// Fetches the featured article for the top section. + /// Returns AsyncValue (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 $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr 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 { + /// 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(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? build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + 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, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// 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> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> 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?, + FutureOr + > + with $FutureModifier, $FutureProvider { + /// 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 $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr 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, 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'; +} diff --git a/lib/features/news/presentation/widgets/category_filter_chips.dart b/lib/features/news/presentation/widgets/category_filter_chips.dart new file mode 100644 index 0000000..bd6c00b --- /dev/null +++ b/lib/features/news/presentation/widgets/category_filter_chips.dart @@ -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, + ), + ), + ), + ); + } +} diff --git a/lib/features/news/presentation/widgets/featured_news_card.dart b/lib/features/news/presentation/widgets/featured_news_card.dart new file mode 100644 index 0000000..7a48535 --- /dev/null +++ b/lib/features/news/presentation/widgets/featured_news_card.dart @@ -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)), + ), + ], + ); + } +} diff --git a/lib/features/news/presentation/widgets/news_card.dart b/lib/features/news/presentation/widgets/news_card.dart new file mode 100644 index 0000000..859398d --- /dev/null +++ b/lib/features/news/presentation/widgets/news_card.dart @@ -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), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +}