news page
This commit is contained in:
275
lib/features/news/presentation/pages/news_list_page.dart
Normal file
275
lib/features/news/presentation/pages/news_list_page.dart
Normal file
@@ -0,0 +1,275 @@
|
||||
/// News List Page
|
||||
///
|
||||
/// Displays all news articles with category filtering and featured section.
|
||||
/// Matches HTML design at html/news-list.html
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/news/domain/entities/news_article.dart';
|
||||
import 'package:worker/features/news/presentation/providers/news_provider.dart';
|
||||
import 'package:worker/features/news/presentation/widgets/category_filter_chips.dart';
|
||||
import 'package:worker/features/news/presentation/widgets/featured_news_card.dart';
|
||||
import 'package:worker/features/news/presentation/widgets/news_card.dart';
|
||||
|
||||
/// News List Page
|
||||
///
|
||||
/// Features:
|
||||
/// - Standard AppBar with title "Tin tức & chuyên môn"
|
||||
/// - Horizontal scrollable category chips (Tất cả, Tin tức, Chuyên môn, Dự án, Sự kiện, Khuyến mãi)
|
||||
/// - Featured article section (large card)
|
||||
/// - "Mới nhất" section with news cards list
|
||||
/// - RefreshIndicator for pull-to-refresh
|
||||
/// - Loading and error states
|
||||
class NewsListPage extends ConsumerWidget {
|
||||
const NewsListPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch providers
|
||||
final featuredArticleAsync = ref.watch(featuredArticleProvider);
|
||||
final filteredArticlesAsync = ref.watch(filteredNewsArticlesProvider);
|
||||
final selectedCategory = ref.watch(selectedNewsCategoryProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: _buildAppBar(context),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
// Invalidate providers to trigger refresh
|
||||
ref.invalidate(newsArticlesProvider);
|
||||
ref.invalidate(featuredArticleProvider);
|
||||
ref.invalidate(filteredNewsArticlesProvider);
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// Category Filter Chips
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: AppSpacing.md),
|
||||
child: CategoryFilterChips(
|
||||
selectedCategory: selectedCategory,
|
||||
onCategorySelected: (category) {
|
||||
ref
|
||||
.read(selectedNewsCategoryProvider.notifier)
|
||||
.setCategory(category);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Featured Article Section
|
||||
featuredArticleAsync.when(
|
||||
data: (article) {
|
||||
if (article == null) {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section title "Nổi bật"
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.star,
|
||||
size: 18,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Nổi bật',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1E293B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Featured card
|
||||
FeaturedNewsCard(
|
||||
article: article,
|
||||
onTap: () => _onArticleTap(context, article),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.md),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
error: (error, stack) =>
|
||||
const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||||
),
|
||||
|
||||
// Latest News Section
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.newspaper,
|
||||
size: 18,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Mới nhất',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1E293B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
|
||||
|
||||
// News List
|
||||
filteredArticlesAsync.when(
|
||||
data: (articles) {
|
||||
if (articles.isEmpty) {
|
||||
return SliverFillRemaining(child: _buildEmptyState());
|
||||
}
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final article = articles[index];
|
||||
return NewsCard(
|
||||
article: article,
|
||||
onTap: () => _onArticleTap(context, article),
|
||||
);
|
||||
}, childCount: articles.length),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, stack) => SliverFillRemaining(
|
||||
child: _buildErrorState(error.toString()),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom padding
|
||||
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build standard AppBar
|
||||
PreferredSizeWidget _buildAppBar(BuildContext context) {
|
||||
return AppBar(
|
||||
backgroundColor: AppColors.white,
|
||||
elevation: AppBarSpecs.elevation,
|
||||
title: const Text(
|
||||
'Tin tức & chuyên môn',
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: const [SizedBox(width: AppSpacing.sm)],
|
||||
);
|
||||
}
|
||||
|
||||
/// Build empty state
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.newspaper_outlined, size: 64, color: AppColors.grey500),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Chưa có tin tức',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1E293B),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Hãy quay lại sau để xem các bài viết mới',
|
||||
style: TextStyle(fontSize: 14, color: Color(0xFF64748B)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build error state
|
||||
Widget _buildErrorState(String error) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 64, color: AppColors.danger),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Không thể tải tin tức',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1E293B),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
error,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF64748B)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle article tap
|
||||
void _onArticleTap(BuildContext context, NewsArticle article) {
|
||||
// TODO: Navigate to article detail page when implemented
|
||||
// context.push('/news/${article.id}');
|
||||
|
||||
// For now, show a snackbar
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Xem bài viết: ${article.title}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
101
lib/features/news/presentation/providers/news_provider.dart
Normal file
101
lib/features/news/presentation/providers/news_provider.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
/// News Providers
|
||||
///
|
||||
/// State management for news articles using Riverpod.
|
||||
/// Provides access to news data and filtering capabilities.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/news/data/datasources/news_local_datasource.dart';
|
||||
import 'package:worker/features/news/data/repositories/news_repository_impl.dart';
|
||||
import 'package:worker/features/news/domain/entities/news_article.dart';
|
||||
import 'package:worker/features/news/domain/repositories/news_repository.dart';
|
||||
|
||||
part 'news_provider.g.dart';
|
||||
|
||||
/// News Local DataSource Provider
|
||||
///
|
||||
/// Provides instance of NewsLocalDataSource.
|
||||
@riverpod
|
||||
NewsLocalDataSource newsLocalDataSource(Ref ref) {
|
||||
return NewsLocalDataSource();
|
||||
}
|
||||
|
||||
/// News Repository Provider
|
||||
///
|
||||
/// Provides instance of NewsRepository implementation.
|
||||
@riverpod
|
||||
NewsRepository newsRepository(Ref ref) {
|
||||
final localDataSource = ref.watch(newsLocalDataSourceProvider);
|
||||
return NewsRepositoryImpl(localDataSource: localDataSource);
|
||||
}
|
||||
|
||||
/// News Articles Provider
|
||||
///
|
||||
/// Fetches all news articles sorted by published date.
|
||||
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
||||
@riverpod
|
||||
Future<List<NewsArticle>> newsArticles(Ref ref) async {
|
||||
final repository = ref.watch(newsRepositoryProvider);
|
||||
return repository.getAllArticles();
|
||||
}
|
||||
|
||||
/// Featured Article Provider
|
||||
///
|
||||
/// Fetches the featured article for the top section.
|
||||
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
|
||||
@riverpod
|
||||
Future<NewsArticle?> featuredArticle(Ref ref) async {
|
||||
final repository = ref.watch(newsRepositoryProvider);
|
||||
return repository.getFeaturedArticle();
|
||||
}
|
||||
|
||||
/// Selected News Category Provider
|
||||
///
|
||||
/// Manages the currently selected category filter.
|
||||
/// null means "All" is selected (show all categories).
|
||||
@riverpod
|
||||
class SelectedNewsCategory extends _$SelectedNewsCategory {
|
||||
@override
|
||||
NewsCategory? build() {
|
||||
// Default: show all categories
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Set selected category
|
||||
void setCategory(NewsCategory? category) {
|
||||
state = category;
|
||||
}
|
||||
|
||||
/// Clear selection (show all)
|
||||
void clearSelection() {
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtered News Articles Provider
|
||||
///
|
||||
/// Returns news articles filtered by selected category.
|
||||
/// If no category is selected, returns all articles.
|
||||
@riverpod
|
||||
Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
|
||||
final selectedCategory = ref.watch(selectedNewsCategoryProvider);
|
||||
final repository = ref.watch(newsRepositoryProvider);
|
||||
|
||||
// If no category selected, return all articles
|
||||
if (selectedCategory == null) {
|
||||
return repository.getAllArticles();
|
||||
}
|
||||
|
||||
// Filter by selected category
|
||||
return repository.getArticlesByCategory(selectedCategory);
|
||||
}
|
||||
|
||||
/// News Article by ID Provider
|
||||
///
|
||||
/// Fetches a specific article by ID.
|
||||
/// Used for article detail page.
|
||||
@riverpod
|
||||
Future<NewsArticle?> newsArticleById(Ref ref, String articleId) async {
|
||||
final repository = ref.watch(newsRepositoryProvider);
|
||||
return repository.getArticleById(articleId);
|
||||
}
|
||||
455
lib/features/news/presentation/providers/news_provider.g.dart
Normal file
455
lib/features/news/presentation/providers/news_provider.g.dart
Normal file
@@ -0,0 +1,455 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'news_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// News Local DataSource Provider
|
||||
///
|
||||
/// Provides instance of NewsLocalDataSource.
|
||||
|
||||
@ProviderFor(newsLocalDataSource)
|
||||
const newsLocalDataSourceProvider = NewsLocalDataSourceProvider._();
|
||||
|
||||
/// News Local DataSource Provider
|
||||
///
|
||||
/// Provides instance of NewsLocalDataSource.
|
||||
|
||||
final class NewsLocalDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
NewsLocalDataSource,
|
||||
NewsLocalDataSource,
|
||||
NewsLocalDataSource
|
||||
>
|
||||
with $Provider<NewsLocalDataSource> {
|
||||
/// News Local DataSource Provider
|
||||
///
|
||||
/// Provides instance of NewsLocalDataSource.
|
||||
const NewsLocalDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'newsLocalDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$newsLocalDataSourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<NewsLocalDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
NewsLocalDataSource create(Ref ref) {
|
||||
return newsLocalDataSource(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(NewsLocalDataSource value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<NewsLocalDataSource>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$newsLocalDataSourceHash() =>
|
||||
r'e7e7d71d20274fe8b498c7b15f8aeb9eb515af27';
|
||||
|
||||
/// News Repository Provider
|
||||
///
|
||||
/// Provides instance of NewsRepository implementation.
|
||||
|
||||
@ProviderFor(newsRepository)
|
||||
const newsRepositoryProvider = NewsRepositoryProvider._();
|
||||
|
||||
/// News Repository Provider
|
||||
///
|
||||
/// Provides instance of NewsRepository implementation.
|
||||
|
||||
final class NewsRepositoryProvider
|
||||
extends $FunctionalProvider<NewsRepository, NewsRepository, NewsRepository>
|
||||
with $Provider<NewsRepository> {
|
||||
/// News Repository Provider
|
||||
///
|
||||
/// Provides instance of NewsRepository implementation.
|
||||
const NewsRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'newsRepositoryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$newsRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<NewsRepository> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
NewsRepository create(Ref ref) {
|
||||
return newsRepository(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(NewsRepository value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<NewsRepository>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$newsRepositoryHash() => r'1536188fae6934f147f022a8f5d7bd62ff9453b5';
|
||||
|
||||
/// News Articles Provider
|
||||
///
|
||||
/// Fetches all news articles sorted by published date.
|
||||
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
||||
|
||||
@ProviderFor(newsArticles)
|
||||
const newsArticlesProvider = NewsArticlesProvider._();
|
||||
|
||||
/// News Articles Provider
|
||||
///
|
||||
/// Fetches all news articles sorted by published date.
|
||||
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
||||
|
||||
final class NewsArticlesProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<NewsArticle>>,
|
||||
List<NewsArticle>,
|
||||
FutureOr<List<NewsArticle>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<NewsArticle>>,
|
||||
$FutureProvider<List<NewsArticle>> {
|
||||
/// News Articles Provider
|
||||
///
|
||||
/// Fetches all news articles sorted by published date.
|
||||
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
||||
const NewsArticlesProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'newsArticlesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$newsArticlesHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<NewsArticle>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<NewsArticle>> create(Ref ref) {
|
||||
return newsArticles(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$newsArticlesHash() => r'24d70e49f7137c614c024dc93c97451c6e161ce6';
|
||||
|
||||
/// Featured Article Provider
|
||||
///
|
||||
/// Fetches the featured article for the top section.
|
||||
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
|
||||
|
||||
@ProviderFor(featuredArticle)
|
||||
const featuredArticleProvider = FeaturedArticleProvider._();
|
||||
|
||||
/// Featured Article Provider
|
||||
///
|
||||
/// Fetches the featured article for the top section.
|
||||
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
|
||||
|
||||
final class FeaturedArticleProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<NewsArticle?>,
|
||||
NewsArticle?,
|
||||
FutureOr<NewsArticle?>
|
||||
>
|
||||
with $FutureModifier<NewsArticle?>, $FutureProvider<NewsArticle?> {
|
||||
/// Featured Article Provider
|
||||
///
|
||||
/// Fetches the featured article for the top section.
|
||||
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
|
||||
const FeaturedArticleProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'featuredArticleProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$featuredArticleHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<NewsArticle?> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<NewsArticle?> create(Ref ref) {
|
||||
return featuredArticle(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$featuredArticleHash() => r'f7146600bc3bbaf5987ab6b09262135b1558f1c0';
|
||||
|
||||
/// Selected News Category Provider
|
||||
///
|
||||
/// Manages the currently selected category filter.
|
||||
/// null means "All" is selected (show all categories).
|
||||
|
||||
@ProviderFor(SelectedNewsCategory)
|
||||
const selectedNewsCategoryProvider = SelectedNewsCategoryProvider._();
|
||||
|
||||
/// Selected News Category Provider
|
||||
///
|
||||
/// Manages the currently selected category filter.
|
||||
/// null means "All" is selected (show all categories).
|
||||
final class SelectedNewsCategoryProvider
|
||||
extends $NotifierProvider<SelectedNewsCategory, NewsCategory?> {
|
||||
/// Selected News Category Provider
|
||||
///
|
||||
/// Manages the currently selected category filter.
|
||||
/// null means "All" is selected (show all categories).
|
||||
const SelectedNewsCategoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'selectedNewsCategoryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$selectedNewsCategoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SelectedNewsCategory create() => SelectedNewsCategory();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(NewsCategory? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<NewsCategory?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$selectedNewsCategoryHash() =>
|
||||
r'f1dca9a5d7de94cac90494d94ce05b727e6e4d5f';
|
||||
|
||||
/// Selected News Category Provider
|
||||
///
|
||||
/// Manages the currently selected category filter.
|
||||
/// null means "All" is selected (show all categories).
|
||||
|
||||
abstract class _$SelectedNewsCategory extends $Notifier<NewsCategory?> {
|
||||
NewsCategory? build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<NewsCategory?, NewsCategory?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<NewsCategory?, NewsCategory?>,
|
||||
NewsCategory?,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtered News Articles Provider
|
||||
///
|
||||
/// Returns news articles filtered by selected category.
|
||||
/// If no category is selected, returns all articles.
|
||||
|
||||
@ProviderFor(filteredNewsArticles)
|
||||
const filteredNewsArticlesProvider = FilteredNewsArticlesProvider._();
|
||||
|
||||
/// Filtered News Articles Provider
|
||||
///
|
||||
/// Returns news articles filtered by selected category.
|
||||
/// If no category is selected, returns all articles.
|
||||
|
||||
final class FilteredNewsArticlesProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<NewsArticle>>,
|
||||
List<NewsArticle>,
|
||||
FutureOr<List<NewsArticle>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<NewsArticle>>,
|
||||
$FutureProvider<List<NewsArticle>> {
|
||||
/// Filtered News Articles Provider
|
||||
///
|
||||
/// Returns news articles filtered by selected category.
|
||||
/// If no category is selected, returns all articles.
|
||||
const FilteredNewsArticlesProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'filteredNewsArticlesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$filteredNewsArticlesHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<NewsArticle>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<NewsArticle>> create(Ref ref) {
|
||||
return filteredNewsArticles(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$filteredNewsArticlesHash() =>
|
||||
r'f40a737b74b44f2d4fa86977175314ed0da471fa';
|
||||
|
||||
/// News Article by ID Provider
|
||||
///
|
||||
/// Fetches a specific article by ID.
|
||||
/// Used for article detail page.
|
||||
|
||||
@ProviderFor(newsArticleById)
|
||||
const newsArticleByIdProvider = NewsArticleByIdFamily._();
|
||||
|
||||
/// News Article by ID Provider
|
||||
///
|
||||
/// Fetches a specific article by ID.
|
||||
/// Used for article detail page.
|
||||
|
||||
final class NewsArticleByIdProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<NewsArticle?>,
|
||||
NewsArticle?,
|
||||
FutureOr<NewsArticle?>
|
||||
>
|
||||
with $FutureModifier<NewsArticle?>, $FutureProvider<NewsArticle?> {
|
||||
/// News Article by ID Provider
|
||||
///
|
||||
/// Fetches a specific article by ID.
|
||||
/// Used for article detail page.
|
||||
const NewsArticleByIdProvider._({
|
||||
required NewsArticleByIdFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'newsArticleByIdProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$newsArticleByIdHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'newsArticleByIdProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<NewsArticle?> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<NewsArticle?> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return newsArticleById(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is NewsArticleByIdProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$newsArticleByIdHash() => r'4d28caa81d486fcd6cfefd16477355927bbcadc8';
|
||||
|
||||
/// News Article by ID Provider
|
||||
///
|
||||
/// Fetches a specific article by ID.
|
||||
/// Used for article detail page.
|
||||
|
||||
final class NewsArticleByIdFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<NewsArticle?>, String> {
|
||||
const NewsArticleByIdFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'newsArticleByIdProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// News Article by ID Provider
|
||||
///
|
||||
/// Fetches a specific article by ID.
|
||||
/// Used for article detail page.
|
||||
|
||||
NewsArticleByIdProvider call(String articleId) =>
|
||||
NewsArticleByIdProvider._(argument: articleId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'newsArticleByIdProvider';
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
188
lib/features/news/presentation/widgets/featured_news_card.dart
Normal file
188
lib/features/news/presentation/widgets/featured_news_card.dart
Normal file
@@ -0,0 +1,188 @@
|
||||
/// Featured News Card Widget
|
||||
///
|
||||
/// Large featured article card with full-width image.
|
||||
/// Used at the top of news list page for the main featured article.
|
||||
library;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/news/domain/entities/news_article.dart';
|
||||
|
||||
/// Featured News Card
|
||||
///
|
||||
/// Large card with:
|
||||
/// - Full-width 200px height image
|
||||
/// - Title (1.125rem, bold)
|
||||
/// - Excerpt/description (truncated)
|
||||
/// - Metadata: date, views, reading time
|
||||
/// - Category badge (primary blue)
|
||||
/// - Shadow and rounded corners
|
||||
class FeaturedNewsCard extends StatelessWidget {
|
||||
/// News article to display
|
||||
final NewsArticle article;
|
||||
|
||||
/// Callback when card is tapped
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Constructor
|
||||
const FeaturedNewsCard({super.key, required this.article, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Featured image (200px height)
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(AppRadius.xl),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: article.imageUrl,
|
||||
width: double.infinity,
|
||||
height: 200,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
height: 200,
|
||||
color: AppColors.grey100,
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
height: 200,
|
||||
color: AppColors.grey100,
|
||||
child: const Icon(
|
||||
Icons.image_outlined,
|
||||
size: 48,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content section
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
article.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1E293B),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Excerpt
|
||||
Text(
|
||||
article.excerpt,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.5,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Metadata row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Left metadata (date, views, reading time)
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
// Date
|
||||
_buildMetaItem(
|
||||
icon: Icons.calendar_today,
|
||||
text: article.formattedDate,
|
||||
),
|
||||
|
||||
// Views
|
||||
_buildMetaItem(
|
||||
icon: Icons.visibility,
|
||||
text: '${article.formattedViewCount} lượt xem',
|
||||
),
|
||||
|
||||
// Reading time
|
||||
_buildMetaItem(
|
||||
icon: Icons.schedule,
|
||||
text: article.readingTimeText,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Category badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryBlue,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
article.category.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build metadata item
|
||||
Widget _buildMetaItem({required IconData icon, required String text}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: const Color(0xFF64748B)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
159
lib/features/news/presentation/widgets/news_card.dart
Normal file
159
lib/features/news/presentation/widgets/news_card.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
/// News Card Widget
|
||||
///
|
||||
/// Compact news article card for list display.
|
||||
/// Horizontal layout with thumbnail and content.
|
||||
library;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/news/domain/entities/news_article.dart';
|
||||
|
||||
/// News Card
|
||||
///
|
||||
/// Compact card with horizontal layout:
|
||||
/// - 80x80 thumbnail (left)
|
||||
/// - Title (max 2 lines, 0.875rem, bold)
|
||||
/// - Excerpt (max 2 lines, 0.75rem, grey)
|
||||
/// - Metadata: date and views
|
||||
/// - Hover/tap effect (border color change)
|
||||
class NewsCard extends StatelessWidget {
|
||||
/// News article to display
|
||||
final NewsArticle article;
|
||||
|
||||
/// Callback when card is tapped
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Constructor
|
||||
const NewsCard({super.key, required this.article, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Thumbnail (80x80)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: article.imageUrl,
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
color: AppColors.grey100,
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
color: AppColors.grey100,
|
||||
child: const Icon(
|
||||
Icons.image_outlined,
|
||||
size: 24,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
|
||||
// Content (flexible to fill remaining space)
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title (max 2 lines)
|
||||
Text(
|
||||
article.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1E293B),
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Excerpt (max 2 lines)
|
||||
Text(
|
||||
article.excerpt,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.4,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Metadata row (date and views)
|
||||
Row(
|
||||
children: [
|
||||
// Date
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 12,
|
||||
color: const Color(0xFF64748B),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
article.formattedDate,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Views
|
||||
Icon(
|
||||
Icons.visibility,
|
||||
size: 12,
|
||||
color: const Color(0xFF64748B),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${article.formattedViewCount} lượt xem',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user