update news

This commit is contained in:
Phuoc Nguyen
2025-11-10 15:37:55 +07:00
parent 36bdf6613b
commit 67fd5ed142
17 changed files with 1016 additions and 211 deletions

View File

@@ -735,16 +735,3 @@ class _NewsDetailPageState extends ConsumerState<NewsDetailPage> {
// );
}
}
/// Provider for getting article by ID
final newsArticleByIdProvider = FutureProvider.family<NewsArticle?, String>((
ref,
id,
) async {
final articles = await ref.watch(newsArticlesProvider.future);
try {
return articles.firstWhere((article) => article.id == id);
} catch (e) {
return null;
}
});

View File

@@ -86,44 +86,9 @@ class _NewsListPageState extends ConsumerState<NewsListPage> {
}
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),
],
child: FeaturedNewsCard(
article: article,
onTap: () => _onArticleTap(context, article),
),
);
},
@@ -137,10 +102,13 @@ class _NewsListPageState extends ConsumerState<NewsListPage> {
const SliverToBoxAdapter(child: SizedBox.shrink()),
),
const SliverToBoxAdapter(
child: SizedBox(height: AppSpacing.xl),
),
// Latest News Section
SliverToBoxAdapter(
const SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Row(
children: [
Icon(
@@ -148,8 +116,8 @@ class _NewsListPageState extends ConsumerState<NewsListPage> {
size: 18,
color: AppColors.primaryBlue,
),
const SizedBox(width: 8),
const Text(
SizedBox(width: 8),
Text(
'Mới nhất',
style: TextStyle(
fontSize: 18,

View File

@@ -47,27 +47,52 @@ Future<NewsRepository> newsRepository(Ref ref) async {
);
}
/// News Articles Provider
/// All News Articles Provider (Internal)
///
/// Fetches all news articles sorted by published date.
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
/// Fetches ALL blog posts from Frappe API sorted by published date (latest first).
/// This is the complete list used by both featured and latest articles providers.
/// Do not use this provider directly in UI - use featuredArticle or newsArticles instead.
@riverpod
Future<List<NewsArticle>> newsArticles(Ref ref) async {
final repository = await ref.watch(newsRepositoryProvider.future);
return repository.getAllArticles();
Future<List<NewsArticle>> _allNewsArticles(Ref ref) async {
final remoteDataSource = await ref.watch(newsRemoteDataSourceProvider.future);
// Fetch blog posts from Frappe API
final blogPosts = await remoteDataSource.getBlogPosts();
// Convert to NewsArticle entities
final articles = blogPosts.map((post) => post.toEntity()).toList();
// Already sorted by published_on desc from API
return articles;
}
/// Featured Article Provider
///
/// Fetches the featured article for the top section.
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
/// Returns the first article from the complete list.
/// This is the latest published article that will be displayed prominently at the top.
@riverpod
Future<NewsArticle?> featuredArticle(Ref ref) async {
final repository = await ref.watch(newsRepositoryProvider.future);
return repository.getFeaturedArticle();
final allArticles = await ref.watch(_allNewsArticlesProvider.future);
// Return first article if available (latest post)
return allArticles.isNotEmpty ? allArticles.first : null;
}
/// Selected News Category Provider
/// News Articles Provider
///
/// Returns latest news articles EXCLUDING the first item (which is shown as featured).
/// This ensures each article only appears once on the page.
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
@riverpod
Future<List<NewsArticle>> newsArticles(Ref ref) async {
final allArticles = await ref.watch(_allNewsArticlesProvider.future);
// Return all articles except first (which is featured)
// If only 0-1 articles, return empty list
return allArticles.length > 1 ? allArticles.sublist(1) : [];
}
/// Selected News Category Provider (Legacy - using enum)
///
/// Manages the currently selected category filter.
/// null means "All" is selected (show all categories).
@@ -90,32 +115,67 @@ class SelectedNewsCategory extends _$SelectedNewsCategory {
}
}
/// Filtered News Articles Provider
/// Selected Category Name Provider
///
/// Returns news articles filtered by selected category.
/// If no category is selected, returns all articles.
/// Manages the currently selected blog category name (from Frappe API).
/// null means "All" is selected (show all categories).
///
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
@riverpod
Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
final selectedCategory = ref.watch(selectedNewsCategoryProvider);
final repository = await ref.watch(newsRepositoryProvider.future);
// If no category selected, return all articles
if (selectedCategory == null) {
return repository.getAllArticles();
class SelectedCategoryName extends _$SelectedCategoryName {
@override
String? build() {
// Default: show all categories
return null;
}
// Filter by selected category
return repository.getArticlesByCategory(selectedCategory);
/// Set selected category by name
void setCategoryName(String? categoryName) {
state = categoryName;
}
/// Clear selection (show all)
void clearSelection() {
state = null;
}
}
/// Filtered News Articles Provider
///
/// Returns news articles filtered by selected blog category name.
/// Excludes the first article (which is shown as featured).
/// If no category is selected, returns all articles except first.
///
/// The blog_category name from API is stored in article.tags[0] for filtering.
@riverpod
Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
final selectedCategoryName = ref.watch(selectedCategoryNameProvider);
final allArticles = await ref.watch(_allNewsArticlesProvider.future);
// Get articles excluding first (which is featured)
final articlesWithoutFeatured = allArticles.length > 1 ? allArticles.sublist(1) : <NewsArticle>[];
// If no category selected, return all articles except first
if (selectedCategoryName == null) {
return articlesWithoutFeatured;
}
// Filter articles by blog_category name (stored in tags[0])
return articlesWithoutFeatured.where((article) {
// Check if article has tags and first tag matches selected category
return article.tags.isNotEmpty && article.tags[0] == selectedCategoryName;
}).toList();
}
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Fetches a specific article by ID from the Frappe API.
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
/// Used for article detail page.
@riverpod
Future<NewsArticle?> newsArticleById(Ref ref, String articleId) async {
final repository = await ref.watch(newsRepositoryProvider.future);
return repository.getArticleById(articleId);
return repository.getArticleByIdFromApi(articleId);
}
/// Blog Categories Provider

View File

@@ -170,9 +170,121 @@ final class NewsRepositoryProvider
String _$newsRepositoryHash() => r'8e66d847014926ad542e402874e52d35b00cdbcc';
/// All News Articles Provider (Internal)
///
/// Fetches ALL blog posts from Frappe API sorted by published date (latest first).
/// This is the complete list used by both featured and latest articles providers.
/// Do not use this provider directly in UI - use featuredArticle or newsArticles instead.
@ProviderFor(_allNewsArticles)
const _allNewsArticlesProvider = _AllNewsArticlesProvider._();
/// All News Articles Provider (Internal)
///
/// Fetches ALL blog posts from Frappe API sorted by published date (latest first).
/// This is the complete list used by both featured and latest articles providers.
/// Do not use this provider directly in UI - use featuredArticle or newsArticles instead.
final class _AllNewsArticlesProvider
extends
$FunctionalProvider<
AsyncValue<List<NewsArticle>>,
List<NewsArticle>,
FutureOr<List<NewsArticle>>
>
with
$FutureModifier<List<NewsArticle>>,
$FutureProvider<List<NewsArticle>> {
/// All News Articles Provider (Internal)
///
/// Fetches ALL blog posts from Frappe API sorted by published date (latest first).
/// This is the complete list used by both featured and latest articles providers.
/// Do not use this provider directly in UI - use featuredArticle or newsArticles instead.
const _AllNewsArticlesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'_allNewsArticlesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$_allNewsArticlesHash();
@$internal
@override
$FutureProviderElement<List<NewsArticle>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<NewsArticle>> create(Ref ref) {
return _allNewsArticles(ref);
}
}
String _$_allNewsArticlesHash() => r'9ee5c1449f1a72710e801a6b4a9e5c72df842e61';
/// Featured Article Provider
///
/// Returns the first article from the complete list.
/// This is the latest published article that will be displayed prominently at the top.
@ProviderFor(featuredArticle)
const featuredArticleProvider = FeaturedArticleProvider._();
/// Featured Article Provider
///
/// Returns the first article from the complete list.
/// This is the latest published article that will be displayed prominently at the top.
final class FeaturedArticleProvider
extends
$FunctionalProvider<
AsyncValue<NewsArticle?>,
NewsArticle?,
FutureOr<NewsArticle?>
>
with $FutureModifier<NewsArticle?>, $FutureProvider<NewsArticle?> {
/// Featured Article Provider
///
/// Returns the first article from the complete list.
/// This is the latest published article that will be displayed prominently at the top.
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'046567d4385aca2abe10767a98744c2c1cfafd78';
/// News Articles Provider
///
/// Fetches all news articles sorted by published date.
/// Returns latest news articles EXCLUDING the first item (which is shown as featured).
/// This ensures each article only appears once on the page.
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
@ProviderFor(newsArticles)
@@ -180,7 +292,8 @@ const newsArticlesProvider = NewsArticlesProvider._();
/// News Articles Provider
///
/// Fetches all news articles sorted by published date.
/// Returns latest news articles EXCLUDING the first item (which is shown as featured).
/// This ensures each article only appears once on the page.
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
final class NewsArticlesProvider
@@ -195,7 +308,8 @@ final class NewsArticlesProvider
$FutureProvider<List<NewsArticle>> {
/// News Articles Provider
///
/// Fetches all news articles sorted by published date.
/// Returns latest news articles EXCLUDING the first item (which is shown as featured).
/// This ensures each article only appears once on the page.
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
const NewsArticlesProvider._()
: super(
@@ -223,62 +337,9 @@ final class NewsArticlesProvider
}
}
String _$newsArticlesHash() => r'789d916f1ce7d76f26429cfce97c65a71915edf3';
String _$newsArticlesHash() => r'954f28885540368a095a3423f4f64c0f1ff0f47d';
/// 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'5fd7057d3f828d6f717b08d59561aa9637eb0097';
/// Selected News Category Provider
/// Selected News Category Provider (Legacy - using enum)
///
/// Manages the currently selected category filter.
/// null means "All" is selected (show all categories).
@@ -286,13 +347,13 @@ String _$featuredArticleHash() => r'5fd7057d3f828d6f717b08d59561aa9637eb0097';
@ProviderFor(SelectedNewsCategory)
const selectedNewsCategoryProvider = SelectedNewsCategoryProvider._();
/// Selected News Category Provider
/// Selected News Category Provider (Legacy - using enum)
///
/// 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
/// Selected News Category Provider (Legacy - using enum)
///
/// Manages the currently selected category filter.
/// null means "All" is selected (show all categories).
@@ -326,7 +387,7 @@ final class SelectedNewsCategoryProvider
String _$selectedNewsCategoryHash() =>
r'f1dca9a5d7de94cac90494d94ce05b727e6e4d5f';
/// Selected News Category Provider
/// Selected News Category Provider (Legacy - using enum)
///
/// Manages the currently selected category filter.
/// null means "All" is selected (show all categories).
@@ -350,18 +411,104 @@ abstract class _$SelectedNewsCategory extends $Notifier<NewsCategory?> {
}
}
/// Selected Category Name Provider
///
/// Manages the currently selected blog category name (from Frappe API).
/// null means "All" is selected (show all categories).
///
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
@ProviderFor(SelectedCategoryName)
const selectedCategoryNameProvider = SelectedCategoryNameProvider._();
/// Selected Category Name Provider
///
/// Manages the currently selected blog category name (from Frappe API).
/// null means "All" is selected (show all categories).
///
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
final class SelectedCategoryNameProvider
extends $NotifierProvider<SelectedCategoryName, String?> {
/// Selected Category Name Provider
///
/// Manages the currently selected blog category name (from Frappe API).
/// null means "All" is selected (show all categories).
///
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
const SelectedCategoryNameProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedCategoryNameProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedCategoryNameHash();
@$internal
@override
SelectedCategoryName create() => SelectedCategoryName();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$selectedCategoryNameHash() =>
r'8dfbf490b986275e6ed9d7b423ae16f074c7fa36';
/// Selected Category Name Provider
///
/// Manages the currently selected blog category name (from Frappe API).
/// null means "All" is selected (show all categories).
///
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
abstract class _$SelectedCategoryName extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
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.
/// Returns news articles filtered by selected blog category name.
/// Excludes the first article (which is shown as featured).
/// If no category is selected, returns all articles except first.
///
/// The blog_category name from API is stored in article.tags[0] for filtering.
@ProviderFor(filteredNewsArticles)
const filteredNewsArticlesProvider = FilteredNewsArticlesProvider._();
/// Filtered News Articles Provider
///
/// Returns news articles filtered by selected category.
/// If no category is selected, returns all articles.
/// Returns news articles filtered by selected blog category name.
/// Excludes the first article (which is shown as featured).
/// If no category is selected, returns all articles except first.
///
/// The blog_category name from API is stored in article.tags[0] for filtering.
final class FilteredNewsArticlesProvider
extends
@@ -375,8 +522,11 @@ final class FilteredNewsArticlesProvider
$FutureProvider<List<NewsArticle>> {
/// Filtered News Articles Provider
///
/// Returns news articles filtered by selected category.
/// If no category is selected, returns all articles.
/// Returns news articles filtered by selected blog category name.
/// Excludes the first article (which is shown as featured).
/// If no category is selected, returns all articles except first.
///
/// The blog_category name from API is stored in article.tags[0] for filtering.
const FilteredNewsArticlesProvider._()
: super(
from: null,
@@ -404,11 +554,12 @@ final class FilteredNewsArticlesProvider
}
String _$filteredNewsArticlesHash() =>
r'f5d6faa2d510eae188f12fa41d052eeb43e08cc9';
r'52b823eabce0acfbef33cc85b5f31f3e9588df4f';
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Fetches a specific article by ID from the Frappe API.
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
/// Used for article detail page.
@ProviderFor(newsArticleById)
@@ -416,7 +567,8 @@ const newsArticleByIdProvider = NewsArticleByIdFamily._();
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Fetches a specific article by ID from the Frappe API.
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
/// Used for article detail page.
final class NewsArticleByIdProvider
@@ -429,7 +581,8 @@ final class NewsArticleByIdProvider
with $FutureModifier<NewsArticle?>, $FutureProvider<NewsArticle?> {
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Fetches a specific article by ID from the Frappe API.
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
/// Used for article detail page.
const NewsArticleByIdProvider._({
required NewsArticleByIdFamily super.from,
@@ -475,11 +628,12 @@ final class NewsArticleByIdProvider
}
}
String _$newsArticleByIdHash() => r'f2b5ee4a3f7b67d0ee9e9c91169d740a9f250b50';
String _$newsArticleByIdHash() => r'83e4790f0ebb80da5f0385f489ed2221fe769e3c';
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Fetches a specific article by ID from the Frappe API.
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
/// Used for article detail page.
final class NewsArticleByIdFamily extends $Family
@@ -495,7 +649,8 @@ final class NewsArticleByIdFamily extends $Family
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Fetches a specific article by ID from the Frappe API.
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
/// Used for article detail page.
NewsArticleByIdProvider call(String articleId) =>

View File

@@ -20,15 +20,15 @@ import 'package:worker/features/news/domain/entities/news_article.dart';
/// - Category badge (primary blue)
/// - Shadow and rounded corners
class FeaturedNewsCard extends StatelessWidget {
/// Constructor
const FeaturedNewsCard({super.key, required this.article, this.onTap});
/// 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(
@@ -126,17 +126,17 @@ class FeaturedNewsCard extends StatelessWidget {
text: article.formattedDate,
),
// Views
_buildMetaItem(
icon: Icons.visibility,
text: '${article.formattedViewCount} lượt xem',
),
// Reading time
_buildMetaItem(
icon: Icons.schedule,
text: article.readingTimeText,
),
// // Views
// _buildMetaItem(
// icon: Icons.visibility,
// text: '${article.formattedViewCount} lượt xem',
// ),
//
// // Reading time
// _buildMetaItem(
// icon: Icons.schedule,
// text: article.readingTimeText,
// ),
],
),
),

View File

@@ -133,19 +133,19 @@ class NewsCard extends StatelessWidget {
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),
),
),
// 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),
// ),
// ),
],
),
],