diff --git a/docs/blog.sh b/docs/blog.sh index 6e82528..d10b04c 100644 --- a/docs/blog.sh +++ b/docs/blog.sh @@ -9,4 +9,27 @@ curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \ "filters": {"published":1}, "order_by" : "creation desc", "limit_page_length": 0 +}' + +GET LIST BLOG +curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \ +--header 'Cookie: sid=3976ecf8b1f2708cb6ec569d56addec1dcfadb9321201e84648eb6a3; full_name=PublicAPI; sid=3976ecf8b1f2708cb6ec569d56addec1dcfadb9321201e84648eb6a3; system_user=no; user_id=public_api%40dbiz.com; user_image=' \ +--header 'X-Frappe-Csrf-Token: 79e51e95363a0c697f50c50b2ac8d6bb90d81ca6c4170da4296da292' \ +--header 'Content-Type: application/json' \ +--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 +}' + +blog detail +curl --location 'https://land.dbiz.com//api/method/frappe.client.get' \ +--header 'Cookie: sid=18b0b29f511c1a2f4ea33a110fd9839a0da833a051a6ca30d2b387f9; full_name=PublicAPI; sid=723d7a4c28209a1c5451d2dce1f7232c04addb2e040a273f3a56ea77; system_user=no; user_id=public_api%40dbiz.com; user_image=' \ +--header 'X-Frappe-Csrf-Token: 2b039c0e717027480d1faff125aeece598f65a2a822858e12e5c107a' \ +--header 'Content-Type: application/json' \ +--data '{ + "doctype": "Blog Post", + "name" : "thông-báo-chương-trình-mua-gạch-eurotile-tặng-keo-chà-ron-và-keo-dán-gạch" }' \ No newline at end of file diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index 2d8ffdb..e21df03 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -357,6 +357,10 @@ class ApiConstants { /// POST /api/method/frappe.client.get_list static const String frappeGetList = '/frappe.client.get_list'; + /// Frappe client get (requires sid and csrf_token) + /// POST /api/method/frappe.client.get + static const String frappeGet = '/frappe.client.get'; + /// Register user (requires session sid and csrf_token) /// POST /api/method/building_material.building_material.api.user.register static const String frappeRegister = '/building_material.building_material.api.user.register'; diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 985231c..eb804a7 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -17,6 +17,7 @@ import 'package:worker/features/auth/presentation/pages/business_unit_selection_ import 'package:worker/features/auth/presentation/pages/login_page.dart'; import 'package:worker/features/auth/presentation/pages/otp_verification_page.dart'; import 'package:worker/features/auth/presentation/pages/register_page.dart'; +import 'package:worker/features/auth/presentation/pages/splash_page.dart'; import 'package:worker/features/cart/presentation/pages/cart_page.dart'; import 'package:worker/features/cart/presentation/pages/checkout_page.dart'; import 'package:worker/features/chat/presentation/pages/chat_list_page.dart'; @@ -48,12 +49,14 @@ final routerProvider = Provider((ref) { final authState = ref.watch(authProvider); return GoRouter( - // Initial route - initialLocation: RouteNames.login, + // Initial route - start with splash screen + initialLocation: RouteNames.splash, // Redirect based on auth state redirect: (context, state) { + final isLoading = authState.isLoading; final isLoggedIn = authState.value != null; + final isOnSplashPage = state.matchedLocation == RouteNames.splash; final isOnLoginPage = state.matchedLocation == RouteNames.login; final isOnRegisterPage = state.matchedLocation == RouteNames.register; final isOnBusinessUnitPage = @@ -62,8 +65,18 @@ final routerProvider = Provider((ref) { final isOnAuthPage = isOnLoginPage || isOnRegisterPage || isOnBusinessUnitPage || isOnOtpPage; - // If not logged in and not on auth pages, redirect to login - if (!isLoggedIn && !isOnAuthPage) { + // While loading auth state, show splash screen + if (isLoading) { + return RouteNames.splash; + } + + // After loading, redirect from splash to appropriate page + if (isOnSplashPage && !isLoading) { + return isLoggedIn ? RouteNames.home : RouteNames.login; + } + + // If not logged in and not on auth/splash pages, redirect to login + if (!isLoggedIn && !isOnAuthPage && !isOnSplashPage) { return RouteNames.login; } @@ -78,6 +91,14 @@ final routerProvider = Provider((ref) { // Route definitions routes: [ + // Splash Screen Route + GoRoute( + path: RouteNames.splash, + name: RouteNames.splash, + pageBuilder: (context, state) => + MaterialPage(key: state.pageKey, child: const SplashPage()), + ), + // Authentication Routes GoRoute( path: RouteNames.login, @@ -486,7 +507,8 @@ class RouteNames { '/model-houses/design-request/create'; static const String designRequestDetail = '/model-houses/design-request/:id'; - // Authentication Routes (TODO: implement when auth feature is ready) + // Authentication Routes + static const String splash = '/splash'; static const String login = '/login'; static const String otpVerification = '/otp-verification'; static const String register = '/register'; diff --git a/lib/features/auth/presentation/pages/splash_page.dart b/lib/features/auth/presentation/pages/splash_page.dart new file mode 100644 index 0000000..a37875c --- /dev/null +++ b/lib/features/auth/presentation/pages/splash_page.dart @@ -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(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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/providers/auth_provider.g.dart b/lib/features/auth/presentation/providers/auth_provider.g.dart index d3a8c6b..2c5181b 100644 --- a/lib/features/auth/presentation/providers/auth_provider.g.dart +++ b/lib/features/auth/presentation/providers/auth_provider.g.dart @@ -272,7 +272,7 @@ final class AuthProvider extends $AsyncNotifierProvider { Auth create() => Auth(); } -String _$authHash() => r'3f0562ffb573be47d8aae8beebccb1946240cbb6'; +String _$authHash() => r'f1a16022d628a21f230c0bb567e80ff6e293d840'; /// Authentication Provider /// @@ -591,3 +591,69 @@ final class UserTotalPointsProvider extends $FunctionalProvider } 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, void, FutureOr> + with $FutureModifier, $FutureProvider { + /// 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 $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return initializeFrappeSession(ref); + } +} + +String _$initializeFrappeSessionHash() => + r'1a9001246a39396e4712efc2cbeb0cac8b911f0c'; diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index a36272b..f30aee5 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -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( diff --git a/lib/features/news/data/datasources/news_remote_datasource.dart b/lib/features/news/data/datasources/news_remote_datasource.dart index bcfb1ef..1c7480c 100644 --- a/lib/features/news/data/datasources/news_remote_datasource.dart +++ b/lib/features/news/data/datasources/news_remote_datasource.dart @@ -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": "

Full HTML content...

", + /// "meta_image": "https://...", + /// "meta_description": "SEO description", + /// "blog_category": "tin-tức" + /// }, + /// ... + /// ] + /// } + /// ``` + Future> 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>( + 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": "

Full HTML content...

", + /// "meta_image": "https://...", + /// "meta_description": "SEO description", + /// "blog_category": "tin-tức" + /// } + /// } + /// ``` + Future 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>( + 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); + } 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'); + } + } } diff --git a/lib/features/news/data/models/blog_post_model.dart b/lib/features/news/data/models/blog_post_model.dart new file mode 100644 index 0000000..0868f62 --- /dev/null +++ b/lib/features/news/data/models/blog_post_model.dart @@ -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 json) => + _$BlogPostModelFromJson(json); + + /// Convert model to JSON + Map 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 message; + + const BlogPostsResponse({required this.message}); + + factory BlogPostsResponse.fromJson(Map json) => + _$BlogPostsResponseFromJson(json); + + Map toJson() => _$BlogPostsResponseToJson(this); +} diff --git a/lib/features/news/data/models/blog_post_model.g.dart b/lib/features/news/data/models/blog_post_model.g.dart new file mode 100644 index 0000000..f0f0923 --- /dev/null +++ b/lib/features/news/data/models/blog_post_model.g.dart @@ -0,0 +1,71 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'blog_post_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +BlogPostModel _$BlogPostModelFromJson(Map 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 _$BlogPostModelToJson(BlogPostModel instance) => + { + '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 json) => + $checkedCreate('BlogPostsResponse', json, ($checkedConvert) { + final val = BlogPostsResponse( + message: $checkedConvert( + 'message', + (v) => (v as List) + .map((e) => BlogPostModel.fromJson(e as Map)) + .toList(), + ), + ); + return val; + }); + +Map _$BlogPostsResponseToJson(BlogPostsResponse instance) => + { + 'message': instance.message.map((e) => e.toJson()).toList(), + }; diff --git a/lib/features/news/data/repositories/news_repository_impl.dart b/lib/features/news/data/repositories/news_repository_impl.dart index 3dd519d..500c58a 100644 --- a/lib/features/news/data/repositories/news_repository_impl.dart +++ b/lib/features/news/data/repositories/news_repository_impl.dart @@ -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> getBlogCategories() async { @@ -98,6 +98,20 @@ class NewsRepositoryImpl implements NewsRepository { } } + @override + Future 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> refreshArticles() async { try { diff --git a/lib/features/news/domain/repositories/news_repository.dart b/lib/features/news/domain/repositories/news_repository.dart index c307dcb..b0d7a39 100644 --- a/lib/features/news/domain/repositories/news_repository.dart +++ b/lib/features/news/domain/repositories/news_repository.dart @@ -23,9 +23,13 @@ abstract class NewsRepository { /// Get articles by category Future> getArticlesByCategory(NewsCategory category); - /// Get a specific article by ID + /// Get a specific article by ID (from local cache) Future getArticleById(String articleId); + /// Get a specific article by ID from API + /// Uses frappe.client.get endpoint to fetch the full blog post detail + Future getArticleByIdFromApi(String articleId); + /// Refresh articles from server Future> refreshArticles(); } diff --git a/lib/features/news/presentation/pages/news_detail_page.dart b/lib/features/news/presentation/pages/news_detail_page.dart index 0ff616e..b0c9174 100644 --- a/lib/features/news/presentation/pages/news_detail_page.dart +++ b/lib/features/news/presentation/pages/news_detail_page.dart @@ -735,16 +735,3 @@ class _NewsDetailPageState extends ConsumerState { // ); } } - -/// Provider for getting article by ID -final newsArticleByIdProvider = FutureProvider.family(( - ref, - id, -) async { - final articles = await ref.watch(newsArticlesProvider.future); - try { - return articles.firstWhere((article) => article.id == id); - } catch (e) { - return null; - } -}); diff --git a/lib/features/news/presentation/pages/news_list_page.dart b/lib/features/news/presentation/pages/news_list_page.dart index 06d08ae..3a66bf4 100644 --- a/lib/features/news/presentation/pages/news_list_page.dart +++ b/lib/features/news/presentation/pages/news_list_page.dart @@ -86,44 +86,9 @@ class _NewsListPageState extends ConsumerState { } 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 { 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 { size: 18, color: AppColors.primaryBlue, ), - const SizedBox(width: 8), - const Text( + SizedBox(width: 8), + Text( 'Mới nhất', style: TextStyle( fontSize: 18, diff --git a/lib/features/news/presentation/providers/news_provider.dart b/lib/features/news/presentation/providers/news_provider.dart index 33ff557..d79c700 100644 --- a/lib/features/news/presentation/providers/news_provider.dart +++ b/lib/features/news/presentation/providers/news_provider.dart @@ -47,27 +47,52 @@ Future newsRepository(Ref ref) async { ); } -/// News Articles Provider +/// All News Articles Provider (Internal) /// -/// Fetches all news articles sorted by published date. -/// Returns AsyncValue> 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> newsArticles(Ref ref) async { - final repository = await ref.watch(newsRepositoryProvider.future); - return repository.getAllArticles(); +Future> _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 (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 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> for proper loading/error handling. +@riverpod +Future> 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> 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> 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) : []; + + // 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 newsArticleById(Ref ref, String articleId) async { final repository = await ref.watch(newsRepositoryProvider.future); - return repository.getArticleById(articleId); + return repository.getArticleByIdFromApi(articleId); } /// Blog Categories Provider diff --git a/lib/features/news/presentation/providers/news_provider.g.dart b/lib/features/news/presentation/providers/news_provider.g.dart index 7becf87..196011f 100644 --- a/lib/features/news/presentation/providers/news_provider.g.dart +++ b/lib/features/news/presentation/providers/news_provider.g.dart @@ -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, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// 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> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> 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?, + FutureOr + > + with $FutureModifier, $FutureProvider { + /// 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 $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr 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> 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> for proper loading/error handling. final class NewsArticlesProvider @@ -195,7 +308,8 @@ final class NewsArticlesProvider $FutureProvider> { /// 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> 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 (null if no featured article). - -@ProviderFor(featuredArticle) -const featuredArticleProvider = FeaturedArticleProvider._(); - -/// Featured Article Provider -/// -/// Fetches the featured article for the top section. -/// Returns AsyncValue (null if no featured article). - -final class FeaturedArticleProvider - extends - $FunctionalProvider< - AsyncValue, - NewsArticle?, - FutureOr - > - with $FutureModifier, $FutureProvider { - /// Featured Article Provider - /// - /// Fetches the featured article for the top section. - /// Returns AsyncValue (null if no featured article). - const FeaturedArticleProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'featuredArticleProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$featuredArticleHash(); - - @$internal - @override - $FutureProviderElement $createElement( - $ProviderPointer pointer, - ) => $FutureProviderElement(pointer); - - @override - FutureOr create(Ref ref) { - return featuredArticle(ref); - } -} - -String _$featuredArticleHash() => r'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 { - /// 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 { } } +/// 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 { + /// 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(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? build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + 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> { /// 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, $FutureProvider { /// 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) => diff --git a/lib/features/news/presentation/widgets/featured_news_card.dart b/lib/features/news/presentation/widgets/featured_news_card.dart index 7a48535..b80c1eb 100644 --- a/lib/features/news/presentation/widgets/featured_news_card.dart +++ b/lib/features/news/presentation/widgets/featured_news_card.dart @@ -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, + // ), ], ), ), diff --git a/lib/features/news/presentation/widgets/news_card.dart b/lib/features/news/presentation/widgets/news_card.dart index 859398d..cd8b458 100644 --- a/lib/features/news/presentation/widgets/news_card.dart +++ b/lib/features/news/presentation/widgets/news_card.dart @@ -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), + // ), + // ), ], ), ],