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

@@ -0,0 +1,82 @@
/// Splash Screen Page
///
/// Displays while checking authentication state on app startup.
library;
import 'package:flutter/material.dart';
import 'package:worker/core/theme/colors.dart';
/// Splash Page
///
/// Shows a loading screen while the app checks for stored authentication.
/// This prevents the brief flash of login page before redirecting to home.
class SplashPage extends StatelessWidget {
const SplashPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.white,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo
Container(
padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 20.0),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.primaryBlue, AppColors.lightBlue],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20.0),
),
child: const Column(
children: [
Text(
'EUROTILE',
style: TextStyle(
color: AppColors.white,
fontSize: 32.0,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
),
),
SizedBox(height: 4.0),
Text(
'Worker App',
style: TextStyle(
color: AppColors.white,
fontSize: 12.0,
letterSpacing: 0.5,
),
),
],
),
),
const SizedBox(height: 48.0),
// Loading Indicator
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryBlue),
strokeWidth: 3.0,
),
const SizedBox(height: 16.0),
// Loading Text
const Text(
'Đang tải...',
style: TextStyle(
fontSize: 14.0,
color: AppColors.grey500,
),
),
],
),
),
);
}
}

View File

@@ -272,7 +272,7 @@ final class AuthProvider extends $AsyncNotifierProvider<Auth, User?> {
Auth create() => Auth();
}
String _$authHash() => r'3f0562ffb573be47d8aae8beebccb1946240cbb6';
String _$authHash() => r'f1a16022d628a21f230c0bb567e80ff6e293d840';
/// Authentication Provider
///
@@ -591,3 +591,69 @@ final class UserTotalPointsProvider extends $FunctionalProvider<int, int, int>
}
String _$userTotalPointsHash() => r'9ccebb48a8641c3c0624b1649303b436e82602bd';
/// Initialize Frappe session
///
/// Call this to ensure a Frappe session exists before making API calls.
/// This is separate from the Auth provider to avoid disposal issues.
///
/// Usage:
/// ```dart
/// // On login page or before API calls that need session
/// await ref.read(initializeFrappeSessionProvider.future);
/// ```
@ProviderFor(initializeFrappeSession)
const initializeFrappeSessionProvider = InitializeFrappeSessionProvider._();
/// Initialize Frappe session
///
/// Call this to ensure a Frappe session exists before making API calls.
/// This is separate from the Auth provider to avoid disposal issues.
///
/// Usage:
/// ```dart
/// // On login page or before API calls that need session
/// await ref.read(initializeFrappeSessionProvider.future);
/// ```
final class InitializeFrappeSessionProvider
extends $FunctionalProvider<AsyncValue<void>, void, FutureOr<void>>
with $FutureModifier<void>, $FutureProvider<void> {
/// Initialize Frappe session
///
/// Call this to ensure a Frappe session exists before making API calls.
/// This is separate from the Auth provider to avoid disposal issues.
///
/// Usage:
/// ```dart
/// // On login page or before API calls that need session
/// await ref.read(initializeFrappeSessionProvider.future);
/// ```
const InitializeFrappeSessionProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'initializeFrappeSessionProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$initializeFrappeSessionHash();
@$internal
@override
$FutureProviderElement<void> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<void> create(Ref ref) {
return initializeFrappeSession(ref);
}
}
String _$initializeFrappeSessionHash() =>
r'1a9001246a39396e4712efc2cbeb0cac8b911f0c';

View File

@@ -103,24 +103,24 @@ class HomePage extends ConsumerWidget {
),
// Promotions Section
// SliverToBoxAdapter(
// child: promotionsAsync.when(
// data: (promotions) => promotions.isNotEmpty
// ? PromotionSlider(
// promotions: promotions,
// onPromotionTap: (promotion) {
// // Navigate to promotion details
// context.push('/promotions/${promotion.id}');
// },
// )
// : const SizedBox.shrink(),
// loading: () => const Padding(
// padding: EdgeInsets.all(16),
// child: Center(child: CircularProgressIndicator()),
// ),
// error: (error, stack) => const SizedBox.shrink(),
// ),
// ),
SliverToBoxAdapter(
child: promotionsAsync.when(
data: (promotions) => promotions.isNotEmpty
? PromotionSlider(
promotions: promotions,
onPromotionTap: (promotion) {
// Navigate to promotion details
context.push('/promotions/${promotion.id}');
},
)
: const SizedBox.shrink(),
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
error: (error, stack) => const SizedBox.shrink(),
),
),
// Quick Action Sections
SliverToBoxAdapter(

View File

@@ -8,6 +8,7 @@ import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/network/dio_client.dart';
import 'package:worker/core/services/frappe_auth_service.dart';
import 'package:worker/features/news/data/models/blog_category_model.dart';
import 'package:worker/features/news/data/models/blog_post_model.dart';
/// News Remote Data Source
///
@@ -90,4 +91,173 @@ class NewsRemoteDataSource {
throw Exception('Unexpected error fetching blog categories: $e');
}
}
/// Get blog posts
///
/// Fetches all published blog posts from Frappe.
/// Returns a list of [BlogPostModel].
///
/// API endpoint: POST https://land.dbiz.com/api/method/frappe.client.get_list
/// Request body:
/// ```json
/// {
/// "doctype": "Blog Post",
/// "fields": ["name","title","published_on","blogger","blog_intro","content","meta_image","meta_description","blog_category"],
/// "filters": {"published":1},
/// "order_by": "published_on desc",
/// "limit_page_length": 0
/// }
/// ```
///
/// Response format:
/// ```json
/// {
/// "message": [
/// {
/// "name": "post-slug",
/// "title": "Post Title",
/// "published_on": "2024-01-01 10:00:00",
/// "blogger": "Author Name",
/// "blog_intro": "Short introduction...",
/// "content": "<p>Full HTML content...</p>",
/// "meta_image": "https://...",
/// "meta_description": "SEO description",
/// "blog_category": "tin-tức"
/// },
/// ...
/// ]
/// }
/// ```
Future<List<BlogPostModel>> getBlogPosts() async {
try {
// Get Frappe session headers
final headers = await _frappeAuthService.getHeaders();
// Build full API URL
const url =
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}';
final response = await _dioClient.post<Map<String, dynamic>>(
url,
data: {
'doctype': 'Blog Post',
'fields': [
'name',
'title',
'published_on',
'blogger',
'blog_intro',
'content',
'meta_image',
'meta_description',
'blog_category',
],
'filters': {'published': 1},
'order_by': 'published_on desc',
'limit_page_length': 0,
},
options: Options(headers: headers),
);
if (response.data == null) {
throw Exception('Empty response from server');
}
// Parse the response using the wrapper model
final postsResponse = BlogPostsResponse.fromJson(response.data!);
return postsResponse.message;
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception('Blog posts endpoint not found');
} else if (e.response?.statusCode == 500) {
throw Exception('Server error while fetching blog posts');
} else if (e.type == DioExceptionType.connectionTimeout) {
throw Exception('Connection timeout while fetching blog posts');
} else if (e.type == DioExceptionType.receiveTimeout) {
throw Exception('Response timeout while fetching blog posts');
} else {
throw Exception('Failed to fetch blog posts: ${e.message}');
}
} catch (e) {
throw Exception('Unexpected error fetching blog posts: $e');
}
}
/// Get blog post detail by name
///
/// Fetches a single blog post by its unique name (slug) from Frappe.
/// Returns a [BlogPostModel].
///
/// API endpoint: POST https://land.dbiz.com/api/method/frappe.client.get
/// Request body:
/// ```json
/// {
/// "doctype": "Blog Post",
/// "name": "post-slug"
/// }
/// ```
///
/// Response format:
/// ```json
/// {
/// "message": {
/// "name": "post-slug",
/// "title": "Post Title",
/// "published_on": "2024-01-01 10:00:00",
/// "blogger": "Author Name",
/// "blog_intro": "Short introduction...",
/// "content": "<p>Full HTML content...</p>",
/// "meta_image": "https://...",
/// "meta_description": "SEO description",
/// "blog_category": "tin-tức"
/// }
/// }
/// ```
Future<BlogPostModel> getBlogPostDetail(String postName) async {
try {
// Get Frappe session headers
final headers = await _frappeAuthService.getHeaders();
// Build full API URL for frappe.client.get
const url =
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGet}';
final response = await _dioClient.post<Map<String, dynamic>>(
url,
data: {
'doctype': 'Blog Post',
'name': postName,
},
options: Options(headers: headers),
);
if (response.data == null) {
throw Exception('Empty response from server');
}
// The response has the blog post data directly in "message" field
final messageData = response.data!['message'];
if (messageData == null) {
throw Exception('Blog post not found: $postName');
}
// Parse the blog post from the message field
return BlogPostModel.fromJson(messageData as Map<String, dynamic>);
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception('Blog post not found: $postName');
} else if (e.response?.statusCode == 500) {
throw Exception('Server error while fetching blog post detail');
} else if (e.type == DioExceptionType.connectionTimeout) {
throw Exception('Connection timeout while fetching blog post detail');
} else if (e.type == DioExceptionType.receiveTimeout) {
throw Exception('Response timeout while fetching blog post detail');
} else {
throw Exception('Failed to fetch blog post detail: ${e.message}');
}
} catch (e) {
throw Exception('Unexpected error fetching blog post detail: $e');
}
}
}

View File

@@ -0,0 +1,179 @@
/// Blog Post Model for Frappe API
///
/// Maps to Frappe Blog Post doctype from blog.sh API.
library;
import 'package:json_annotation/json_annotation.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
part 'blog_post_model.g.dart';
/// Blog Post Model
///
/// Represents a blog post from Frappe ERPNext system.
/// Fields match the API response from frappe.client.get_list for Blog Post doctype.
@JsonSerializable()
class BlogPostModel {
/// Unique post identifier (docname)
final String name;
/// Post title
final String title;
/// Publication date (ISO 8601 string)
@JsonKey(name: 'published_on')
final String? publishedOn;
/// Author/blogger name
final String? blogger;
/// Short introduction/excerpt
@JsonKey(name: 'blog_intro')
final String? blogIntro;
/// Full HTML content (legacy field)
final String? content;
/// Full HTML content (new field from Frappe)
@JsonKey(name: 'content_html')
final String? contentHtml;
/// Featured/meta image URL
@JsonKey(name: 'meta_image')
final String? metaImage;
/// Meta description for SEO
@JsonKey(name: 'meta_description')
final String? metaDescription;
/// Blog category name
@JsonKey(name: 'blog_category')
final String? blogCategory;
/// Constructor
const BlogPostModel({
required this.name,
required this.title,
this.publishedOn,
this.blogger,
this.blogIntro,
this.content,
this.contentHtml,
this.metaImage,
this.metaDescription,
this.blogCategory,
});
/// Create model from JSON
factory BlogPostModel.fromJson(Map<String, dynamic> json) =>
_$BlogPostModelFromJson(json);
/// Convert model to JSON
Map<String, dynamic> toJson() => _$BlogPostModelToJson(this);
/// Convert to domain entity (NewsArticle)
///
/// Stores the original blog_category name in tags[0] for filtering purposes.
NewsArticle toEntity() {
// Parse published date
DateTime publishedDate = DateTime.now();
if (publishedOn != null) {
try {
publishedDate = DateTime.parse(publishedOn!);
} catch (e) {
// Use current date if parsing fails
publishedDate = DateTime.now();
}
}
// Extract excerpt from blogIntro or metaDescription
final excerpt = blogIntro ?? metaDescription ?? '';
// Use content_html preferentially, fall back to content
final htmlContent = contentHtml ?? content;
// Use meta image with full URL path
String imageUrl;
if (metaImage != null && metaImage!.isNotEmpty) {
// If meta_image starts with /, prepend the base URL
if (metaImage!.startsWith('/')) {
imageUrl = 'https://land.dbiz.com$metaImage';
} else if (metaImage!.startsWith('http')) {
imageUrl = metaImage!;
} else {
imageUrl = 'https://land.dbiz.com/$metaImage';
}
} else {
imageUrl = 'https://via.placeholder.com/400x300?text=${Uri.encodeComponent(title)}';
}
// Parse category
final category = _parseCategory(blogCategory ?? '');
// Calculate reading time (rough estimate: 200 words per minute)
final wordCount = (htmlContent ?? excerpt).split(' ').length;
final readingTime = (wordCount / 200).ceil().clamp(1, 60);
return NewsArticle(
id: name,
title: title,
excerpt: excerpt.length > 200 ? '${excerpt.substring(0, 200)}...' : excerpt,
content: htmlContent,
imageUrl: imageUrl,
category: category,
publishedDate: publishedDate,
viewCount: 0, // Not provided by API
readingTimeMinutes: readingTime,
isFeatured: false, // Not provided by API
authorName: blogger,
authorAvatar: null, // Not provided by API
tags: blogCategory != null ? [blogCategory!] : [], // Store original category name for filtering
likeCount: 0, // Not provided by API
commentCount: 0, // Not provided by API
shareCount: 0, // Not provided by API
);
}
/// Parse category from blog category name
static NewsCategory _parseCategory(String categoryName) {
final lower = categoryName.toLowerCase();
if (lower.contains('tin') || lower.contains('news')) {
return NewsCategory.news;
} else if (lower.contains('chuyên') ||
lower.contains('kỹ thuật') ||
lower.contains('professional') ||
lower.contains('technique')) {
return NewsCategory.professional;
} else if (lower.contains('dự án') ||
lower.contains('project') ||
lower.contains('công trình')) {
return NewsCategory.projects;
} else if (lower.contains('sự kiện') || lower.contains('event')) {
return NewsCategory.events;
} else if (lower.contains('khuyến mãi') ||
lower.contains('promotion') ||
lower.contains('ưu đãi')) {
return NewsCategory.promotions;
}
return NewsCategory.news;
}
@override
String toString() {
return 'BlogPostModel(name: $name, title: $title, category: $blogCategory, '
'publishedOn: $publishedOn, hasContentHtml: ${contentHtml != null})';
}
}
/// Response wrapper for Blog Post list API
@JsonSerializable()
class BlogPostsResponse {
final List<BlogPostModel> message;
const BlogPostsResponse({required this.message});
factory BlogPostsResponse.fromJson(Map<String, dynamic> json) =>
_$BlogPostsResponseFromJson(json);
Map<String, dynamic> toJson() => _$BlogPostsResponseToJson(this);
}

View File

@@ -0,0 +1,71 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'blog_post_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BlogPostModel _$BlogPostModelFromJson(Map<String, dynamic> json) =>
$checkedCreate(
'BlogPostModel',
json,
($checkedConvert) {
final val = BlogPostModel(
name: $checkedConvert('name', (v) => v as String),
title: $checkedConvert('title', (v) => v as String),
publishedOn: $checkedConvert('published_on', (v) => v as String?),
blogger: $checkedConvert('blogger', (v) => v as String?),
blogIntro: $checkedConvert('blog_intro', (v) => v as String?),
content: $checkedConvert('content', (v) => v as String?),
contentHtml: $checkedConvert('content_html', (v) => v as String?),
metaImage: $checkedConvert('meta_image', (v) => v as String?),
metaDescription: $checkedConvert(
'meta_description',
(v) => v as String?,
),
blogCategory: $checkedConvert('blog_category', (v) => v as String?),
);
return val;
},
fieldKeyMap: const {
'publishedOn': 'published_on',
'blogIntro': 'blog_intro',
'contentHtml': 'content_html',
'metaImage': 'meta_image',
'metaDescription': 'meta_description',
'blogCategory': 'blog_category',
},
);
Map<String, dynamic> _$BlogPostModelToJson(BlogPostModel instance) =>
<String, dynamic>{
'name': instance.name,
'title': instance.title,
'published_on': ?instance.publishedOn,
'blogger': ?instance.blogger,
'blog_intro': ?instance.blogIntro,
'content': ?instance.content,
'content_html': ?instance.contentHtml,
'meta_image': ?instance.metaImage,
'meta_description': ?instance.metaDescription,
'blog_category': ?instance.blogCategory,
};
BlogPostsResponse _$BlogPostsResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('BlogPostsResponse', json, ($checkedConvert) {
final val = BlogPostsResponse(
message: $checkedConvert(
'message',
(v) => (v as List<dynamic>)
.map((e) => BlogPostModel.fromJson(e as Map<String, dynamic>))
.toList(),
),
);
return val;
});
Map<String, dynamic> _$BlogPostsResponseToJson(BlogPostsResponse instance) =>
<String, dynamic>{
'message': instance.message.map((e) => e.toJson()).toList(),
};

View File

@@ -12,17 +12,17 @@ import 'package:worker/features/news/domain/repositories/news_repository.dart';
/// News Repository Implementation
class NewsRepositoryImpl implements NewsRepository {
/// Local data source
final NewsLocalDataSource localDataSource;
/// Remote data source
final NewsRemoteDataSource remoteDataSource;
/// Constructor
NewsRepositoryImpl({
required this.localDataSource,
required this.remoteDataSource,
});
/// Local data source
final NewsLocalDataSource localDataSource;
/// Remote data source
final NewsRemoteDataSource remoteDataSource;
@override
Future<List<BlogCategory>> getBlogCategories() async {
@@ -98,6 +98,20 @@ class NewsRepositoryImpl implements NewsRepository {
}
}
@override
Future<NewsArticle?> getArticleByIdFromApi(String articleId) async {
try {
// Fetch blog post detail from Frappe API using frappe.client.get
final blogPostModel = await remoteDataSource.getBlogPostDetail(articleId);
// Convert to domain entity
return blogPostModel.toEntity();
} catch (e) {
print('[NewsRepository] Error getting article by id from API: $e');
rethrow; // Re-throw to let providers handle the error
}
}
@override
Future<List<NewsArticle>> refreshArticles() async {
try {

View File

@@ -23,9 +23,13 @@ abstract class NewsRepository {
/// Get articles by category
Future<List<NewsArticle>> getArticlesByCategory(NewsCategory category);
/// Get a specific article by ID
/// Get a specific article by ID (from local cache)
Future<NewsArticle?> getArticleById(String articleId);
/// Get a specific article by ID from API
/// Uses frappe.client.get endpoint to fetch the full blog post detail
Future<NewsArticle?> getArticleByIdFromApi(String articleId);
/// Refresh articles from server
Future<List<NewsArticle>> refreshArticles();
}

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),
// ),
// ),
],
),
],