This commit is contained in:
Phuoc Nguyen
2025-11-10 14:21:27 +07:00
parent 2a71c65577
commit 36bdf6613b
33 changed files with 2206 additions and 252 deletions

View File

@@ -0,0 +1,93 @@
/// News Remote DataSource
///
/// Handles fetching news/blog data from the Frappe API.
library;
import 'package:dio/dio.dart';
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';
/// News Remote Data Source
///
/// Provides methods to fetch news and blog content from the Frappe API.
/// Uses FrappeAuthService for session management.
class NewsRemoteDataSource {
NewsRemoteDataSource(this._dioClient, this._frappeAuthService);
final DioClient _dioClient;
final FrappeAuthService _frappeAuthService;
/// Get blog categories
///
/// Fetches all published blog categories from Frappe.
/// Returns a list of [BlogCategoryModel].
///
/// API endpoint: POST https://land.dbiz.com/api/method/frappe.client.get_list
/// Request body:
/// ```json
/// {
/// "doctype": "Blog Category",
/// "fields": ["title","name"],
/// "filters": {"published":1},
/// "order_by": "creation desc",
/// "limit_page_length": 0
/// }
/// ```
///
/// Response format:
/// ```json
/// {
/// "message": [
/// {"title": "Tin tức", "name": "tin-tức"},
/// {"title": "Chuyên môn", "name": "chuyên-môn"},
/// ...
/// ]
/// }
/// ```
Future<List<BlogCategoryModel>> getBlogCategories() async {
try {
// Get Frappe session headers
final headers = await _frappeAuthService.getHeaders();
// Build full API URL
final url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}';
final response = await _dioClient.post<Map<String, dynamic>>(
url,
data: {
'doctype': 'Blog Category',
'fields': ['title', 'name'],
'filters': {'published': 1},
'order_by': 'creation 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 categoriesResponse = BlogCategoriesResponse.fromJson(response.data!);
return categoriesResponse.message;
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception('Blog categories endpoint not found');
} else if (e.response?.statusCode == 500) {
throw Exception('Server error while fetching blog categories');
} else if (e.type == DioExceptionType.connectionTimeout) {
throw Exception('Connection timeout while fetching blog categories');
} else if (e.type == DioExceptionType.receiveTimeout) {
throw Exception('Response timeout while fetching blog categories');
} else {
throw Exception('Failed to fetch blog categories: ${e.message}');
}
} catch (e) {
throw Exception('Unexpected error fetching blog categories: $e');
}
}
}

View File

@@ -0,0 +1,128 @@
/// Data Model: Blog Category
///
/// Data Transfer Object for blog/news category information from Frappe API.
/// This model handles JSON serialization/deserialization for API responses.
library;
import 'package:json_annotation/json_annotation.dart';
import 'package:worker/features/news/domain/entities/blog_category.dart';
part 'blog_category_model.g.dart';
/// Blog Category Model
///
/// Used for:
/// - API JSON serialization/deserialization
/// - Converting to/from domain entity
///
/// Example API response:
/// ```json
/// {
/// "title": "Tin tức",
/// "name": "tin-tức"
/// }
/// ```
@JsonSerializable()
class BlogCategoryModel {
/// Display title of the category (e.g., "Tin tức", "Chuyên môn")
final String title;
/// URL-safe name/slug of the category (e.g., "tin-tức", "chuyên-môn")
final String name;
const BlogCategoryModel({
required this.title,
required this.name,
});
/// From JSON constructor
factory BlogCategoryModel.fromJson(Map<String, dynamic> json) =>
_$BlogCategoryModelFromJson(json);
/// To JSON method
Map<String, dynamic> toJson() => _$BlogCategoryModelToJson(this);
/// Convert to domain entity
BlogCategory toEntity() {
return BlogCategory(
title: title,
name: name,
);
}
/// Create from domain entity
factory BlogCategoryModel.fromEntity(BlogCategory entity) {
return BlogCategoryModel(
title: entity.title,
name: entity.name,
);
}
/// Copy with method for creating modified copies
BlogCategoryModel copyWith({
String? title,
String? name,
}) {
return BlogCategoryModel(
title: title ?? this.title,
name: name ?? this.name,
);
}
@override
String toString() {
return 'BlogCategoryModel(title: $title, name: $name)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is BlogCategoryModel &&
other.title == title &&
other.name == name;
}
@override
int get hashCode {
return Object.hash(title, name);
}
}
/// API Response wrapper for blog categories list
///
/// Frappe API wraps the response in a "message" field.
/// Example:
/// ```json
/// {
/// "message": [
/// {"title": "Tin tức", "name": "tin-tức"},
/// {"title": "Chuyên môn", "name": "chuyên-môn"}
/// ]
/// }
/// ```
class BlogCategoriesResponse {
/// List of blog categories
final List<BlogCategoryModel> message;
BlogCategoriesResponse({
required this.message,
});
/// From JSON constructor
factory BlogCategoriesResponse.fromJson(Map<String, dynamic> json) {
final messageList = json['message'] as List<dynamic>;
final categories = messageList
.map((item) => BlogCategoryModel.fromJson(item as Map<String, dynamic>))
.toList();
return BlogCategoriesResponse(message: categories);
}
/// To JSON method
Map<String, dynamic> toJson() {
return {
'message': message.map((category) => category.toJson()).toList(),
};
}
}

View File

@@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'blog_category_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BlogCategoryModel _$BlogCategoryModelFromJson(Map<String, dynamic> json) =>
$checkedCreate('BlogCategoryModel', json, ($checkedConvert) {
final val = BlogCategoryModel(
title: $checkedConvert('title', (v) => v as String),
name: $checkedConvert('name', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$BlogCategoryModelToJson(BlogCategoryModel instance) =>
<String, dynamic>{'title': instance.title, 'name': instance.name};

View File

@@ -5,6 +5,8 @@
library;
import 'package:worker/features/news/data/datasources/news_local_datasource.dart';
import 'package:worker/features/news/data/datasources/news_remote_datasource.dart';
import 'package:worker/features/news/domain/entities/blog_category.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
import 'package:worker/features/news/domain/repositories/news_repository.dart';
@@ -13,8 +15,30 @@ class NewsRepositoryImpl implements NewsRepository {
/// Local data source
final NewsLocalDataSource localDataSource;
/// Remote data source
final NewsRemoteDataSource remoteDataSource;
/// Constructor
NewsRepositoryImpl({required this.localDataSource});
NewsRepositoryImpl({
required this.localDataSource,
required this.remoteDataSource,
});
@override
Future<List<BlogCategory>> getBlogCategories() async {
try {
// Fetch categories from remote API
final models = await remoteDataSource.getBlogCategories();
// Convert to domain entities
final entities = models.map((model) => model.toEntity()).toList();
return entities;
} catch (e) {
print('[NewsRepository] Error getting blog categories: $e');
rethrow; // Re-throw to let providers handle the error
}
}
@override
Future<List<NewsArticle>> getAllArticles() async {

View File

@@ -0,0 +1,98 @@
/// Domain Entity: Blog Category
///
/// Represents a blog/news category from the Frappe CMS.
/// This entity contains category information for filtering news articles.
///
/// This is a pure domain entity with no external dependencies.
library;
/// Blog Category Entity
///
/// Contains information needed to display and filter blog categories:
/// - Display title (Vietnamese)
/// - URL-safe name/slug
///
/// Categories from the API:
/// - Tin tức (News)
/// - Chuyên môn (Professional/Technical)
/// - Dự án (Projects)
/// - Khuyến mãi (Promotions)
class BlogCategory {
/// Display title of the category (e.g., "Tin tức", "Chuyên môn")
final String title;
/// URL-safe name/slug of the category (e.g., "tin-tức", "chuyên-môn")
/// Used for API filtering and routing
final String name;
/// Constructor
const BlogCategory({
required this.title,
required this.name,
});
/// Get category icon name based on the category
String get iconName {
switch (name) {
case 'tin-tức':
return 'newspaper';
case 'chuyên-môn':
return 'school';
case 'dự-án':
return 'construction';
case 'khuyến-mãi':
return 'local_offer';
default:
return 'category';
}
}
/// Get category color based on the category
String get colorHex {
switch (name) {
case 'tin-tức':
return '#005B9A'; // Primary blue
case 'chuyên-môn':
return '#2E7D32'; // Green
case 'dự-án':
return '#F57C00'; // Orange
case 'khuyến-mãi':
return '#C62828'; // Red
default:
return '#757575'; // Grey
}
}
/// Copy with method for immutability
BlogCategory copyWith({
String? title,
String? name,
}) {
return BlogCategory(
title: title ?? this.title,
name: name ?? this.name,
);
}
/// Equality operator
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is BlogCategory &&
other.title == title &&
other.name == name;
}
/// Hash code
@override
int get hashCode {
return Object.hash(title, name);
}
/// String representation
@override
String toString() {
return 'BlogCategory(title: $title, name: $name)';
}
}

View File

@@ -4,12 +4,16 @@
/// This is an abstract interface following the Repository Pattern.
library;
import 'package:worker/features/news/domain/entities/blog_category.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
/// News Repository Interface
///
/// Provides methods to fetch and manage news articles.
/// Provides methods to fetch and manage news articles and categories.
abstract class NewsRepository {
/// Get all blog categories from Frappe API
Future<List<BlogCategory>> getBlogCategories();
/// Get all news articles
Future<List<NewsArticle>> getAllArticles();

View File

@@ -19,20 +19,36 @@ import 'package:worker/features/news/presentation/widgets/news_card.dart';
///
/// 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)
/// - Horizontal scrollable category chips (dynamic from Frappe API)
/// - 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 {
class NewsListPage extends ConsumerStatefulWidget {
const NewsListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<NewsListPage> createState() => _NewsListPageState();
}
class _NewsListPageState extends ConsumerState<NewsListPage> {
/// Currently selected category name (null = All)
String? selectedCategoryName;
@override
Widget build(BuildContext context) {
// Watch providers
final featuredArticleAsync = ref.watch(featuredArticleProvider);
final filteredArticlesAsync = ref.watch(filteredNewsArticlesProvider);
final selectedCategory = ref.watch(selectedNewsCategoryProvider);
final newsArticlesAsync = ref.watch(newsArticlesProvider);
// Filter articles by selected category
final filteredArticles = newsArticlesAsync.whenData((articles) {
if (selectedCategoryName == null) {
return articles;
}
// TODO: Filter by category when articles have category field
return articles;
});
return Scaffold(
backgroundColor: Colors.white,
@@ -40,9 +56,10 @@ class NewsListPage extends ConsumerWidget {
body: RefreshIndicator(
onRefresh: () async {
// Invalidate providers to trigger refresh
ref.invalidate(newsArticlesProvider);
ref.invalidate(featuredArticleProvider);
ref.invalidate(filteredNewsArticlesProvider);
ref
..invalidate(newsArticlesProvider)
..invalidate(featuredArticleProvider)
..invalidate(blogCategoriesProvider);
},
child: CustomScrollView(
slivers: [
@@ -51,11 +68,11 @@ class NewsListPage extends ConsumerWidget {
child: Padding(
padding: const EdgeInsets.only(top: 4, bottom: AppSpacing.md),
child: CategoryFilterChips(
selectedCategory: selectedCategory,
onCategorySelected: (category) {
ref
.read(selectedNewsCategoryProvider.notifier)
.setCategory(category);
selectedCategoryName: selectedCategoryName,
onCategorySelected: (categoryName) {
setState(() {
selectedCategoryName = categoryName;
});
},
),
),
@@ -148,7 +165,7 @@ class NewsListPage extends ConsumerWidget {
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
// News List
filteredArticlesAsync.when(
filteredArticles.when(
data: (articles) {
if (articles.isEmpty) {
return SliverFillRemaining(child: _buildEmptyState());

View File

@@ -5,10 +5,14 @@
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/network/dio_client.dart';
import 'package:worker/core/services/frappe_auth_provider.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/data/datasources/news_remote_datasource.dart';
import 'package:worker/features/news/domain/entities/blog_category.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
import 'package:worker/features/news/domain/repositories/news_repository.dart';
import 'package:worker/features/news/data/repositories/news_repository_impl.dart';
part 'news_provider.g.dart';
@@ -20,13 +24,27 @@ NewsLocalDataSource newsLocalDataSource(Ref ref) {
return NewsLocalDataSource();
}
/// News Remote DataSource Provider
///
/// Provides instance of NewsRemoteDataSource with Frappe auth service.
@riverpod
Future<NewsRemoteDataSource> newsRemoteDataSource(Ref ref) async {
final dioClient = await ref.watch(dioClientProvider.future);
final frappeAuthService = ref.watch(frappeAuthServiceProvider);
return NewsRemoteDataSource(dioClient, frappeAuthService);
}
/// News Repository Provider
///
/// Provides instance of NewsRepository implementation.
@riverpod
NewsRepository newsRepository(Ref ref) {
Future<NewsRepository> newsRepository(Ref ref) async {
final localDataSource = ref.watch(newsLocalDataSourceProvider);
return NewsRepositoryImpl(localDataSource: localDataSource);
final remoteDataSource = await ref.watch(newsRemoteDataSourceProvider.future);
return NewsRepositoryImpl(
localDataSource: localDataSource,
remoteDataSource: remoteDataSource,
);
}
/// News Articles Provider
@@ -35,7 +53,7 @@ NewsRepository newsRepository(Ref ref) {
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
@riverpod
Future<List<NewsArticle>> newsArticles(Ref ref) async {
final repository = ref.watch(newsRepositoryProvider);
final repository = await ref.watch(newsRepositoryProvider.future);
return repository.getAllArticles();
}
@@ -45,7 +63,7 @@ Future<List<NewsArticle>> newsArticles(Ref ref) async {
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
@riverpod
Future<NewsArticle?> featuredArticle(Ref ref) async {
final repository = ref.watch(newsRepositoryProvider);
final repository = await ref.watch(newsRepositoryProvider.future);
return repository.getFeaturedArticle();
}
@@ -79,7 +97,7 @@ class SelectedNewsCategory extends _$SelectedNewsCategory {
@riverpod
Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
final selectedCategory = ref.watch(selectedNewsCategoryProvider);
final repository = ref.watch(newsRepositoryProvider);
final repository = await ref.watch(newsRepositoryProvider.future);
// If no category selected, return all articles
if (selectedCategory == null) {
@@ -96,6 +114,22 @@ Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
/// Used for article detail page.
@riverpod
Future<NewsArticle?> newsArticleById(Ref ref, String articleId) async {
final repository = ref.watch(newsRepositoryProvider);
final repository = await ref.watch(newsRepositoryProvider.future);
return repository.getArticleById(articleId);
}
/// Blog Categories Provider
///
/// Fetches all published blog categories from Frappe API.
/// Returns AsyncValue<List<BlogCategory>> (domain entities) for proper loading/error handling.
///
/// Example categories:
/// - Tin tức (News)
/// - Chuyên môn (Professional)
/// - Dự án (Projects)
/// - Khuyến mãi (Promotions)
@riverpod
Future<List<BlogCategory>> blogCategories(Ref ref) async {
final repository = await ref.watch(newsRepositoryProvider.future);
return repository.getBlogCategories();
}

View File

@@ -67,6 +67,59 @@ final class NewsLocalDataSourceProvider
String _$newsLocalDataSourceHash() =>
r'e7e7d71d20274fe8b498c7b15f8aeb9eb515af27';
/// News Remote DataSource Provider
///
/// Provides instance of NewsRemoteDataSource with Frappe auth service.
@ProviderFor(newsRemoteDataSource)
const newsRemoteDataSourceProvider = NewsRemoteDataSourceProvider._();
/// News Remote DataSource Provider
///
/// Provides instance of NewsRemoteDataSource with Frappe auth service.
final class NewsRemoteDataSourceProvider
extends
$FunctionalProvider<
AsyncValue<NewsRemoteDataSource>,
NewsRemoteDataSource,
FutureOr<NewsRemoteDataSource>
>
with
$FutureModifier<NewsRemoteDataSource>,
$FutureProvider<NewsRemoteDataSource> {
/// News Remote DataSource Provider
///
/// Provides instance of NewsRemoteDataSource with Frappe auth service.
const NewsRemoteDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'newsRemoteDataSourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$newsRemoteDataSourceHash();
@$internal
@override
$FutureProviderElement<NewsRemoteDataSource> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<NewsRemoteDataSource> create(Ref ref) {
return newsRemoteDataSource(ref);
}
}
String _$newsRemoteDataSourceHash() =>
r'27db8dc4fadf806349fe4f0ad5fed1999620c1a3';
/// News Repository Provider
///
/// Provides instance of NewsRepository implementation.
@@ -79,8 +132,13 @@ const newsRepositoryProvider = NewsRepositoryProvider._();
/// Provides instance of NewsRepository implementation.
final class NewsRepositoryProvider
extends $FunctionalProvider<NewsRepository, NewsRepository, NewsRepository>
with $Provider<NewsRepository> {
extends
$FunctionalProvider<
AsyncValue<NewsRepository>,
NewsRepository,
FutureOr<NewsRepository>
>
with $FutureModifier<NewsRepository>, $FutureProvider<NewsRepository> {
/// News Repository Provider
///
/// Provides instance of NewsRepository implementation.
@@ -100,24 +158,17 @@ final class NewsRepositoryProvider
@$internal
@override
$ProviderElement<NewsRepository> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
$FutureProviderElement<NewsRepository> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
NewsRepository create(Ref ref) {
FutureOr<NewsRepository> create(Ref ref) {
return newsRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(NewsRepository value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<NewsRepository>(value),
);
}
}
String _$newsRepositoryHash() => r'1536188fae6934f147f022a8f5d7bd62ff9453b5';
String _$newsRepositoryHash() => r'8e66d847014926ad542e402874e52d35b00cdbcc';
/// News Articles Provider
///
@@ -172,7 +223,7 @@ final class NewsArticlesProvider
}
}
String _$newsArticlesHash() => r'24d70e49f7137c614c024dc93c97451c6e161ce6';
String _$newsArticlesHash() => r'789d916f1ce7d76f26429cfce97c65a71915edf3';
/// Featured Article Provider
///
@@ -225,7 +276,7 @@ final class FeaturedArticleProvider
}
}
String _$featuredArticleHash() => r'f7146600bc3bbaf5987ab6b09262135b1558f1c0';
String _$featuredArticleHash() => r'5fd7057d3f828d6f717b08d59561aa9637eb0097';
/// Selected News Category Provider
///
@@ -353,7 +404,7 @@ final class FilteredNewsArticlesProvider
}
String _$filteredNewsArticlesHash() =>
r'f40a737b74b44f2d4fa86977175314ed0da471fa';
r'f5d6faa2d510eae188f12fa41d052eeb43e08cc9';
/// News Article by ID Provider
///
@@ -424,7 +475,7 @@ final class NewsArticleByIdProvider
}
}
String _$newsArticleByIdHash() => r'4d28caa81d486fcd6cfefd16477355927bbcadc8';
String _$newsArticleByIdHash() => r'f2b5ee4a3f7b67d0ee9e9c91169d740a9f250b50';
/// News Article by ID Provider
///
@@ -453,3 +504,76 @@ final class NewsArticleByIdFamily extends $Family
@override
String toString() => r'newsArticleByIdProvider';
}
/// Blog Categories Provider
///
/// Fetches all published blog categories from Frappe API.
/// Returns AsyncValue<List<BlogCategory>> (domain entities) for proper loading/error handling.
///
/// Example categories:
/// - Tin tức (News)
/// - Chuyên môn (Professional)
/// - Dự án (Projects)
/// - Khuyến mãi (Promotions)
@ProviderFor(blogCategories)
const blogCategoriesProvider = BlogCategoriesProvider._();
/// Blog Categories Provider
///
/// Fetches all published blog categories from Frappe API.
/// Returns AsyncValue<List<BlogCategory>> (domain entities) for proper loading/error handling.
///
/// Example categories:
/// - Tin tức (News)
/// - Chuyên môn (Professional)
/// - Dự án (Projects)
/// - Khuyến mãi (Promotions)
final class BlogCategoriesProvider
extends
$FunctionalProvider<
AsyncValue<List<BlogCategory>>,
List<BlogCategory>,
FutureOr<List<BlogCategory>>
>
with
$FutureModifier<List<BlogCategory>>,
$FutureProvider<List<BlogCategory>> {
/// Blog Categories Provider
///
/// Fetches all published blog categories from Frappe API.
/// Returns AsyncValue<List<BlogCategory>> (domain entities) for proper loading/error handling.
///
/// Example categories:
/// - Tin tức (News)
/// - Chuyên môn (Professional)
/// - Dự án (Projects)
/// - Khuyến mãi (Promotions)
const BlogCategoriesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'blogCategoriesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$blogCategoriesHash();
@$internal
@override
$FutureProviderElement<List<BlogCategory>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<BlogCategory>> create(Ref ref) {
return blogCategories(ref);
}
}
String _$blogCategoriesHash() => r'd87493142946be20ab309ea94d6173a8005b516e';

View File

@@ -2,37 +2,53 @@
///
/// Horizontal scrollable list of category filter chips.
/// Used in news list page for filtering articles by category.
/// Fetches categories dynamically from the Frappe API.
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/domain/entities/blog_category.dart';
import 'package:worker/features/news/presentation/providers/news_provider.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
/// - Dynamic categories from Frappe API (Tin tức, Chuyên môn, Dự á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;
/// - Loading state with shimmer effect
/// - Error state with retry button
class CategoryFilterChips extends ConsumerWidget {
/// Currently selected category name (null = All)
final String? selectedCategoryName;
/// Callback when a category is tapped
final void Function(NewsCategory? category) onCategorySelected;
/// Callback when a category is tapped (passes category name)
final void Function(String? categoryName) onCategorySelected;
/// Constructor
const CategoryFilterChips({
super.key,
required this.selectedCategory,
required this.selectedCategoryName,
required this.onCategorySelected,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final categoriesAsync = ref.watch(blogCategoriesProvider);
return categoriesAsync.when(
data: (categories) => _buildCategoryChips(categories),
loading: () => _buildLoadingState(),
error: (error, stack) => _buildErrorState(error, ref),
);
}
/// Build category chips with data
Widget _buildCategoryChips(List<BlogCategory> categories) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
@@ -41,20 +57,20 @@ class CategoryFilterChips extends StatelessWidget {
// "Tất cả" chip
_buildCategoryChip(
label: 'Tất cả',
isSelected: selectedCategory == null,
isSelected: selectedCategoryName == null,
onTap: () => onCategorySelected(null),
),
const SizedBox(width: AppSpacing.sm),
// Category chips
...NewsCategory.values.map((category) {
// Dynamic category chips from API
...categories.map((category) {
return Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: _buildCategoryChip(
label: category.displayName,
isSelected: selectedCategory == category,
onTap: () => onCategorySelected(category),
label: category.title,
isSelected: selectedCategoryName == category.name,
onTap: () => onCategorySelected(category.name),
),
);
}),
@@ -63,6 +79,70 @@ class CategoryFilterChips extends StatelessWidget {
);
}
/// Build loading state with shimmer placeholders
Widget _buildLoadingState() {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Row(
children: List.generate(5, (index) {
return Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: Container(
width: 80,
height: 32,
decoration: BoxDecoration(
color: AppColors.grey100,
borderRadius: BorderRadius.circular(24),
),
),
);
}),
),
);
}
/// Build error state with retry
Widget _buildErrorState(Object error, WidgetRef ref) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: AppColors.grey100,
borderRadius: BorderRadius.circular(24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 16, color: AppColors.grey500),
const SizedBox(width: AppSpacing.xs),
Text(
'Lỗi tải danh mục',
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
),
const SizedBox(width: AppSpacing.xs),
GestureDetector(
onTap: () => ref.refresh(blogCategoriesProvider),
child: Icon(Icons.refresh, size: 16, color: AppColors.primaryBlue),
),
],
),
),
],
),
);
}
/// Build individual category chip
Widget _buildCategoryChip({
required String label,