update news
This commit is contained in:
23
docs/blog.sh
23
docs/blog.sh
@@ -10,3 +10,26 @@ curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \
|
||||
"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"
|
||||
}'
|
||||
@@ -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';
|
||||
|
||||
@@ -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<GoRouter>((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<GoRouter>((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<GoRouter>((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';
|
||||
|
||||
82
lib/features/auth/presentation/pages/splash_page.dart
Normal file
82
lib/features/auth/presentation/pages/splash_page.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
/// Splash Screen Page
|
||||
///
|
||||
/// Displays while checking authentication state on app startup.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
|
||||
/// Splash Page
|
||||
///
|
||||
/// Shows a loading screen while the app checks for stored authentication.
|
||||
/// This prevents the brief flash of login page before redirecting to home.
|
||||
class SplashPage extends StatelessWidget {
|
||||
const SplashPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.white,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Logo
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 20.0),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.primaryBlue, AppColors.lightBlue],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20.0),
|
||||
),
|
||||
child: const Column(
|
||||
children: [
|
||||
Text(
|
||||
'EUROTILE',
|
||||
style: TextStyle(
|
||||
color: AppColors.white,
|
||||
fontSize: 32.0,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.0),
|
||||
Text(
|
||||
'Worker App',
|
||||
style: TextStyle(
|
||||
color: AppColors.white,
|
||||
fontSize: 12.0,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 48.0),
|
||||
|
||||
// Loading Indicator
|
||||
const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryBlue),
|
||||
strokeWidth: 3.0,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16.0),
|
||||
|
||||
// Loading Text
|
||||
const Text(
|
||||
'Đang tải...',
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -272,7 +272,7 @@ final class AuthProvider extends $AsyncNotifierProvider<Auth, User?> {
|
||||
Auth create() => Auth();
|
||||
}
|
||||
|
||||
String _$authHash() => r'3f0562ffb573be47d8aae8beebccb1946240cbb6';
|
||||
String _$authHash() => r'f1a16022d628a21f230c0bb567e80ff6e293d840';
|
||||
|
||||
/// Authentication Provider
|
||||
///
|
||||
@@ -591,3 +591,69 @@ final class UserTotalPointsProvider extends $FunctionalProvider<int, int, int>
|
||||
}
|
||||
|
||||
String _$userTotalPointsHash() => r'9ccebb48a8641c3c0624b1649303b436e82602bd';
|
||||
|
||||
/// Initialize Frappe session
|
||||
///
|
||||
/// Call this to ensure a Frappe session exists before making API calls.
|
||||
/// This is separate from the Auth provider to avoid disposal issues.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // On login page or before API calls that need session
|
||||
/// await ref.read(initializeFrappeSessionProvider.future);
|
||||
/// ```
|
||||
|
||||
@ProviderFor(initializeFrappeSession)
|
||||
const initializeFrappeSessionProvider = InitializeFrappeSessionProvider._();
|
||||
|
||||
/// Initialize Frappe session
|
||||
///
|
||||
/// Call this to ensure a Frappe session exists before making API calls.
|
||||
/// This is separate from the Auth provider to avoid disposal issues.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // On login page or before API calls that need session
|
||||
/// await ref.read(initializeFrappeSessionProvider.future);
|
||||
/// ```
|
||||
|
||||
final class InitializeFrappeSessionProvider
|
||||
extends $FunctionalProvider<AsyncValue<void>, void, FutureOr<void>>
|
||||
with $FutureModifier<void>, $FutureProvider<void> {
|
||||
/// Initialize Frappe session
|
||||
///
|
||||
/// Call this to ensure a Frappe session exists before making API calls.
|
||||
/// This is separate from the Auth provider to avoid disposal issues.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // On login page or before API calls that need session
|
||||
/// await ref.read(initializeFrappeSessionProvider.future);
|
||||
/// ```
|
||||
const InitializeFrappeSessionProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'initializeFrappeSessionProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$initializeFrappeSessionHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<void> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<void> create(Ref ref) {
|
||||
return initializeFrappeSession(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$initializeFrappeSessionHash() =>
|
||||
r'1a9001246a39396e4712efc2cbeb0cac8b911f0c';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:worker/core/constants/api_constants.dart';
|
||||
import 'package:worker/core/network/dio_client.dart';
|
||||
import 'package:worker/core/services/frappe_auth_service.dart';
|
||||
import 'package:worker/features/news/data/models/blog_category_model.dart';
|
||||
import 'package:worker/features/news/data/models/blog_post_model.dart';
|
||||
|
||||
/// News Remote Data Source
|
||||
///
|
||||
@@ -90,4 +91,173 @@ class NewsRemoteDataSource {
|
||||
throw Exception('Unexpected error fetching blog categories: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get blog posts
|
||||
///
|
||||
/// Fetches all published blog posts from Frappe.
|
||||
/// Returns a list of [BlogPostModel].
|
||||
///
|
||||
/// API endpoint: POST https://land.dbiz.com/api/method/frappe.client.get_list
|
||||
/// Request body:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "doctype": "Blog Post",
|
||||
/// "fields": ["name","title","published_on","blogger","blog_intro","content","meta_image","meta_description","blog_category"],
|
||||
/// "filters": {"published":1},
|
||||
/// "order_by": "published_on desc",
|
||||
/// "limit_page_length": 0
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Response format:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "message": [
|
||||
/// {
|
||||
/// "name": "post-slug",
|
||||
/// "title": "Post Title",
|
||||
/// "published_on": "2024-01-01 10:00:00",
|
||||
/// "blogger": "Author Name",
|
||||
/// "blog_intro": "Short introduction...",
|
||||
/// "content": "<p>Full HTML content...</p>",
|
||||
/// "meta_image": "https://...",
|
||||
/// "meta_description": "SEO description",
|
||||
/// "blog_category": "tin-tức"
|
||||
/// },
|
||||
/// ...
|
||||
/// ]
|
||||
/// }
|
||||
/// ```
|
||||
Future<List<BlogPostModel>> getBlogPosts() async {
|
||||
try {
|
||||
// Get Frappe session headers
|
||||
final headers = await _frappeAuthService.getHeaders();
|
||||
|
||||
// Build full API URL
|
||||
const url =
|
||||
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}';
|
||||
|
||||
final response = await _dioClient.post<Map<String, dynamic>>(
|
||||
url,
|
||||
data: {
|
||||
'doctype': 'Blog Post',
|
||||
'fields': [
|
||||
'name',
|
||||
'title',
|
||||
'published_on',
|
||||
'blogger',
|
||||
'blog_intro',
|
||||
'content',
|
||||
'meta_image',
|
||||
'meta_description',
|
||||
'blog_category',
|
||||
],
|
||||
'filters': {'published': 1},
|
||||
'order_by': 'published_on desc',
|
||||
'limit_page_length': 0,
|
||||
},
|
||||
options: Options(headers: headers),
|
||||
);
|
||||
|
||||
if (response.data == null) {
|
||||
throw Exception('Empty response from server');
|
||||
}
|
||||
|
||||
// Parse the response using the wrapper model
|
||||
final postsResponse = BlogPostsResponse.fromJson(response.data!);
|
||||
|
||||
return postsResponse.message;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) {
|
||||
throw Exception('Blog posts endpoint not found');
|
||||
} else if (e.response?.statusCode == 500) {
|
||||
throw Exception('Server error while fetching blog posts');
|
||||
} else if (e.type == DioExceptionType.connectionTimeout) {
|
||||
throw Exception('Connection timeout while fetching blog posts');
|
||||
} else if (e.type == DioExceptionType.receiveTimeout) {
|
||||
throw Exception('Response timeout while fetching blog posts');
|
||||
} else {
|
||||
throw Exception('Failed to fetch blog posts: ${e.message}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Unexpected error fetching blog posts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get blog post detail by name
|
||||
///
|
||||
/// Fetches a single blog post by its unique name (slug) from Frappe.
|
||||
/// Returns a [BlogPostModel].
|
||||
///
|
||||
/// API endpoint: POST https://land.dbiz.com/api/method/frappe.client.get
|
||||
/// Request body:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "doctype": "Blog Post",
|
||||
/// "name": "post-slug"
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Response format:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "message": {
|
||||
/// "name": "post-slug",
|
||||
/// "title": "Post Title",
|
||||
/// "published_on": "2024-01-01 10:00:00",
|
||||
/// "blogger": "Author Name",
|
||||
/// "blog_intro": "Short introduction...",
|
||||
/// "content": "<p>Full HTML content...</p>",
|
||||
/// "meta_image": "https://...",
|
||||
/// "meta_description": "SEO description",
|
||||
/// "blog_category": "tin-tức"
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
Future<BlogPostModel> getBlogPostDetail(String postName) async {
|
||||
try {
|
||||
// Get Frappe session headers
|
||||
final headers = await _frappeAuthService.getHeaders();
|
||||
|
||||
// Build full API URL for frappe.client.get
|
||||
const url =
|
||||
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGet}';
|
||||
|
||||
final response = await _dioClient.post<Map<String, dynamic>>(
|
||||
url,
|
||||
data: {
|
||||
'doctype': 'Blog Post',
|
||||
'name': postName,
|
||||
},
|
||||
options: Options(headers: headers),
|
||||
);
|
||||
|
||||
if (response.data == null) {
|
||||
throw Exception('Empty response from server');
|
||||
}
|
||||
|
||||
// The response has the blog post data directly in "message" field
|
||||
final messageData = response.data!['message'];
|
||||
if (messageData == null) {
|
||||
throw Exception('Blog post not found: $postName');
|
||||
}
|
||||
|
||||
// Parse the blog post from the message field
|
||||
return BlogPostModel.fromJson(messageData as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) {
|
||||
throw Exception('Blog post not found: $postName');
|
||||
} else if (e.response?.statusCode == 500) {
|
||||
throw Exception('Server error while fetching blog post detail');
|
||||
} else if (e.type == DioExceptionType.connectionTimeout) {
|
||||
throw Exception('Connection timeout while fetching blog post detail');
|
||||
} else if (e.type == DioExceptionType.receiveTimeout) {
|
||||
throw Exception('Response timeout while fetching blog post detail');
|
||||
} else {
|
||||
throw Exception('Failed to fetch blog post detail: ${e.message}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Unexpected error fetching blog post detail: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
179
lib/features/news/data/models/blog_post_model.dart
Normal file
179
lib/features/news/data/models/blog_post_model.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
/// Blog Post Model for Frappe API
|
||||
///
|
||||
/// Maps to Frappe Blog Post doctype from blog.sh API.
|
||||
library;
|
||||
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:worker/features/news/domain/entities/news_article.dart';
|
||||
|
||||
part 'blog_post_model.g.dart';
|
||||
|
||||
/// Blog Post Model
|
||||
///
|
||||
/// Represents a blog post from Frappe ERPNext system.
|
||||
/// Fields match the API response from frappe.client.get_list for Blog Post doctype.
|
||||
@JsonSerializable()
|
||||
class BlogPostModel {
|
||||
/// Unique post identifier (docname)
|
||||
final String name;
|
||||
|
||||
/// Post title
|
||||
final String title;
|
||||
|
||||
/// Publication date (ISO 8601 string)
|
||||
@JsonKey(name: 'published_on')
|
||||
final String? publishedOn;
|
||||
|
||||
/// Author/blogger name
|
||||
final String? blogger;
|
||||
|
||||
/// Short introduction/excerpt
|
||||
@JsonKey(name: 'blog_intro')
|
||||
final String? blogIntro;
|
||||
|
||||
/// Full HTML content (legacy field)
|
||||
final String? content;
|
||||
|
||||
/// Full HTML content (new field from Frappe)
|
||||
@JsonKey(name: 'content_html')
|
||||
final String? contentHtml;
|
||||
|
||||
/// Featured/meta image URL
|
||||
@JsonKey(name: 'meta_image')
|
||||
final String? metaImage;
|
||||
|
||||
/// Meta description for SEO
|
||||
@JsonKey(name: 'meta_description')
|
||||
final String? metaDescription;
|
||||
|
||||
/// Blog category name
|
||||
@JsonKey(name: 'blog_category')
|
||||
final String? blogCategory;
|
||||
|
||||
/// Constructor
|
||||
const BlogPostModel({
|
||||
required this.name,
|
||||
required this.title,
|
||||
this.publishedOn,
|
||||
this.blogger,
|
||||
this.blogIntro,
|
||||
this.content,
|
||||
this.contentHtml,
|
||||
this.metaImage,
|
||||
this.metaDescription,
|
||||
this.blogCategory,
|
||||
});
|
||||
|
||||
/// Create model from JSON
|
||||
factory BlogPostModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$BlogPostModelFromJson(json);
|
||||
|
||||
/// Convert model to JSON
|
||||
Map<String, dynamic> toJson() => _$BlogPostModelToJson(this);
|
||||
|
||||
/// Convert to domain entity (NewsArticle)
|
||||
///
|
||||
/// Stores the original blog_category name in tags[0] for filtering purposes.
|
||||
NewsArticle toEntity() {
|
||||
// Parse published date
|
||||
DateTime publishedDate = DateTime.now();
|
||||
if (publishedOn != null) {
|
||||
try {
|
||||
publishedDate = DateTime.parse(publishedOn!);
|
||||
} catch (e) {
|
||||
// Use current date if parsing fails
|
||||
publishedDate = DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Extract excerpt from blogIntro or metaDescription
|
||||
final excerpt = blogIntro ?? metaDescription ?? '';
|
||||
|
||||
// Use content_html preferentially, fall back to content
|
||||
final htmlContent = contentHtml ?? content;
|
||||
|
||||
// Use meta image with full URL path
|
||||
String imageUrl;
|
||||
if (metaImage != null && metaImage!.isNotEmpty) {
|
||||
// If meta_image starts with /, prepend the base URL
|
||||
if (metaImage!.startsWith('/')) {
|
||||
imageUrl = 'https://land.dbiz.com$metaImage';
|
||||
} else if (metaImage!.startsWith('http')) {
|
||||
imageUrl = metaImage!;
|
||||
} else {
|
||||
imageUrl = 'https://land.dbiz.com/$metaImage';
|
||||
}
|
||||
} else {
|
||||
imageUrl = 'https://via.placeholder.com/400x300?text=${Uri.encodeComponent(title)}';
|
||||
}
|
||||
|
||||
// Parse category
|
||||
final category = _parseCategory(blogCategory ?? '');
|
||||
|
||||
// Calculate reading time (rough estimate: 200 words per minute)
|
||||
final wordCount = (htmlContent ?? excerpt).split(' ').length;
|
||||
final readingTime = (wordCount / 200).ceil().clamp(1, 60);
|
||||
|
||||
return NewsArticle(
|
||||
id: name,
|
||||
title: title,
|
||||
excerpt: excerpt.length > 200 ? '${excerpt.substring(0, 200)}...' : excerpt,
|
||||
content: htmlContent,
|
||||
imageUrl: imageUrl,
|
||||
category: category,
|
||||
publishedDate: publishedDate,
|
||||
viewCount: 0, // Not provided by API
|
||||
readingTimeMinutes: readingTime,
|
||||
isFeatured: false, // Not provided by API
|
||||
authorName: blogger,
|
||||
authorAvatar: null, // Not provided by API
|
||||
tags: blogCategory != null ? [blogCategory!] : [], // Store original category name for filtering
|
||||
likeCount: 0, // Not provided by API
|
||||
commentCount: 0, // Not provided by API
|
||||
shareCount: 0, // Not provided by API
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse category from blog category name
|
||||
static NewsCategory _parseCategory(String categoryName) {
|
||||
final lower = categoryName.toLowerCase();
|
||||
if (lower.contains('tin') || lower.contains('news')) {
|
||||
return NewsCategory.news;
|
||||
} else if (lower.contains('chuyên') ||
|
||||
lower.contains('kỹ thuật') ||
|
||||
lower.contains('professional') ||
|
||||
lower.contains('technique')) {
|
||||
return NewsCategory.professional;
|
||||
} else if (lower.contains('dự án') ||
|
||||
lower.contains('project') ||
|
||||
lower.contains('công trình')) {
|
||||
return NewsCategory.projects;
|
||||
} else if (lower.contains('sự kiện') || lower.contains('event')) {
|
||||
return NewsCategory.events;
|
||||
} else if (lower.contains('khuyến mãi') ||
|
||||
lower.contains('promotion') ||
|
||||
lower.contains('ưu đãi')) {
|
||||
return NewsCategory.promotions;
|
||||
}
|
||||
return NewsCategory.news;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BlogPostModel(name: $name, title: $title, category: $blogCategory, '
|
||||
'publishedOn: $publishedOn, hasContentHtml: ${contentHtml != null})';
|
||||
}
|
||||
}
|
||||
|
||||
/// Response wrapper for Blog Post list API
|
||||
@JsonSerializable()
|
||||
class BlogPostsResponse {
|
||||
final List<BlogPostModel> message;
|
||||
|
||||
const BlogPostsResponse({required this.message});
|
||||
|
||||
factory BlogPostsResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$BlogPostsResponseFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$BlogPostsResponseToJson(this);
|
||||
}
|
||||
71
lib/features/news/data/models/blog_post_model.g.dart
Normal file
71
lib/features/news/data/models/blog_post_model.g.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'blog_post_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
BlogPostModel _$BlogPostModelFromJson(Map<String, dynamic> json) =>
|
||||
$checkedCreate(
|
||||
'BlogPostModel',
|
||||
json,
|
||||
($checkedConvert) {
|
||||
final val = BlogPostModel(
|
||||
name: $checkedConvert('name', (v) => v as String),
|
||||
title: $checkedConvert('title', (v) => v as String),
|
||||
publishedOn: $checkedConvert('published_on', (v) => v as String?),
|
||||
blogger: $checkedConvert('blogger', (v) => v as String?),
|
||||
blogIntro: $checkedConvert('blog_intro', (v) => v as String?),
|
||||
content: $checkedConvert('content', (v) => v as String?),
|
||||
contentHtml: $checkedConvert('content_html', (v) => v as String?),
|
||||
metaImage: $checkedConvert('meta_image', (v) => v as String?),
|
||||
metaDescription: $checkedConvert(
|
||||
'meta_description',
|
||||
(v) => v as String?,
|
||||
),
|
||||
blogCategory: $checkedConvert('blog_category', (v) => v as String?),
|
||||
);
|
||||
return val;
|
||||
},
|
||||
fieldKeyMap: const {
|
||||
'publishedOn': 'published_on',
|
||||
'blogIntro': 'blog_intro',
|
||||
'contentHtml': 'content_html',
|
||||
'metaImage': 'meta_image',
|
||||
'metaDescription': 'meta_description',
|
||||
'blogCategory': 'blog_category',
|
||||
},
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$BlogPostModelToJson(BlogPostModel instance) =>
|
||||
<String, dynamic>{
|
||||
'name': instance.name,
|
||||
'title': instance.title,
|
||||
'published_on': ?instance.publishedOn,
|
||||
'blogger': ?instance.blogger,
|
||||
'blog_intro': ?instance.blogIntro,
|
||||
'content': ?instance.content,
|
||||
'content_html': ?instance.contentHtml,
|
||||
'meta_image': ?instance.metaImage,
|
||||
'meta_description': ?instance.metaDescription,
|
||||
'blog_category': ?instance.blogCategory,
|
||||
};
|
||||
|
||||
BlogPostsResponse _$BlogPostsResponseFromJson(Map<String, dynamic> json) =>
|
||||
$checkedCreate('BlogPostsResponse', json, ($checkedConvert) {
|
||||
final val = BlogPostsResponse(
|
||||
message: $checkedConvert(
|
||||
'message',
|
||||
(v) => (v as List<dynamic>)
|
||||
.map((e) => BlogPostModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
return val;
|
||||
});
|
||||
|
||||
Map<String, dynamic> _$BlogPostsResponseToJson(BlogPostsResponse instance) =>
|
||||
<String, dynamic>{
|
||||
'message': instance.message.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
@@ -12,17 +12,17 @@ import 'package:worker/features/news/domain/repositories/news_repository.dart';
|
||||
|
||||
/// News Repository Implementation
|
||||
class NewsRepositoryImpl implements NewsRepository {
|
||||
/// Local data source
|
||||
final NewsLocalDataSource localDataSource;
|
||||
|
||||
/// Remote data source
|
||||
final NewsRemoteDataSource remoteDataSource;
|
||||
|
||||
/// Constructor
|
||||
NewsRepositoryImpl({
|
||||
required this.localDataSource,
|
||||
required this.remoteDataSource,
|
||||
});
|
||||
/// Local data source
|
||||
final NewsLocalDataSource localDataSource;
|
||||
|
||||
/// Remote data source
|
||||
final NewsRemoteDataSource remoteDataSource;
|
||||
|
||||
@override
|
||||
Future<List<BlogCategory>> getBlogCategories() async {
|
||||
@@ -98,6 +98,20 @@ class NewsRepositoryImpl implements NewsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<NewsArticle?> getArticleByIdFromApi(String articleId) async {
|
||||
try {
|
||||
// Fetch blog post detail from Frappe API using frappe.client.get
|
||||
final blogPostModel = await remoteDataSource.getBlogPostDetail(articleId);
|
||||
|
||||
// Convert to domain entity
|
||||
return blogPostModel.toEntity();
|
||||
} catch (e) {
|
||||
print('[NewsRepository] Error getting article by id from API: $e');
|
||||
rethrow; // Re-throw to let providers handle the error
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<NewsArticle>> refreshArticles() async {
|
||||
try {
|
||||
|
||||
@@ -23,9 +23,13 @@ abstract class NewsRepository {
|
||||
/// Get articles by category
|
||||
Future<List<NewsArticle>> getArticlesByCategory(NewsCategory category);
|
||||
|
||||
/// Get a specific article by ID
|
||||
/// Get a specific article by ID (from local cache)
|
||||
Future<NewsArticle?> getArticleById(String articleId);
|
||||
|
||||
/// Get a specific article by ID from API
|
||||
/// Uses frappe.client.get endpoint to fetch the full blog post detail
|
||||
Future<NewsArticle?> getArticleByIdFromApi(String articleId);
|
||||
|
||||
/// Refresh articles from server
|
||||
Future<List<NewsArticle>> refreshArticles();
|
||||
}
|
||||
|
||||
@@ -735,16 +735,3 @@ class _NewsDetailPageState extends ConsumerState<NewsDetailPage> {
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for getting article by ID
|
||||
final newsArticleByIdProvider = FutureProvider.family<NewsArticle?, String>((
|
||||
ref,
|
||||
id,
|
||||
) async {
|
||||
final articles = await ref.watch(newsArticlesProvider.future);
|
||||
try {
|
||||
return articles.firstWhere((article) => article.id == id);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -86,45 +86,10 @@ class _NewsListPageState extends ConsumerState<NewsListPage> {
|
||||
}
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section title "Nổi bật"
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.star,
|
||||
size: 18,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Nổi bật',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1E293B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Featured card
|
||||
FeaturedNewsCard(
|
||||
child: FeaturedNewsCard(
|
||||
article: article,
|
||||
onTap: () => _onArticleTap(context, article),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SliverToBoxAdapter(
|
||||
@@ -137,10 +102,13 @@ class _NewsListPageState extends ConsumerState<NewsListPage> {
|
||||
const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: AppSpacing.xl),
|
||||
),
|
||||
// Latest News Section
|
||||
SliverToBoxAdapter(
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -148,8 +116,8 @@ class _NewsListPageState extends ConsumerState<NewsListPage> {
|
||||
size: 18,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Mới nhất',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
|
||||
@@ -47,27 +47,52 @@ Future<NewsRepository> newsRepository(Ref ref) async {
|
||||
);
|
||||
}
|
||||
|
||||
/// News Articles Provider
|
||||
/// All News Articles Provider (Internal)
|
||||
///
|
||||
/// Fetches all news articles sorted by published date.
|
||||
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
||||
/// Fetches ALL blog posts from Frappe API sorted by published date (latest first).
|
||||
/// This is the complete list used by both featured and latest articles providers.
|
||||
/// Do not use this provider directly in UI - use featuredArticle or newsArticles instead.
|
||||
@riverpod
|
||||
Future<List<NewsArticle>> newsArticles(Ref ref) async {
|
||||
final repository = await ref.watch(newsRepositoryProvider.future);
|
||||
return repository.getAllArticles();
|
||||
Future<List<NewsArticle>> _allNewsArticles(Ref ref) async {
|
||||
final remoteDataSource = await ref.watch(newsRemoteDataSourceProvider.future);
|
||||
|
||||
// Fetch blog posts from Frappe API
|
||||
final blogPosts = await remoteDataSource.getBlogPosts();
|
||||
|
||||
// Convert to NewsArticle entities
|
||||
final articles = blogPosts.map((post) => post.toEntity()).toList();
|
||||
|
||||
// Already sorted by published_on desc from API
|
||||
return articles;
|
||||
}
|
||||
|
||||
/// Featured Article Provider
|
||||
///
|
||||
/// Fetches the featured article for the top section.
|
||||
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
|
||||
/// Returns the first article from the complete list.
|
||||
/// This is the latest published article that will be displayed prominently at the top.
|
||||
@riverpod
|
||||
Future<NewsArticle?> featuredArticle(Ref ref) async {
|
||||
final repository = await ref.watch(newsRepositoryProvider.future);
|
||||
return repository.getFeaturedArticle();
|
||||
final allArticles = await ref.watch(_allNewsArticlesProvider.future);
|
||||
|
||||
// Return first article if available (latest post)
|
||||
return allArticles.isNotEmpty ? allArticles.first : null;
|
||||
}
|
||||
|
||||
/// Selected News Category Provider
|
||||
/// News Articles Provider
|
||||
///
|
||||
/// Returns latest news articles EXCLUDING the first item (which is shown as featured).
|
||||
/// This ensures each article only appears once on the page.
|
||||
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
||||
@riverpod
|
||||
Future<List<NewsArticle>> newsArticles(Ref ref) async {
|
||||
final allArticles = await ref.watch(_allNewsArticlesProvider.future);
|
||||
|
||||
// Return all articles except first (which is featured)
|
||||
// If only 0-1 articles, return empty list
|
||||
return allArticles.length > 1 ? allArticles.sublist(1) : [];
|
||||
}
|
||||
|
||||
/// Selected News Category Provider (Legacy - using enum)
|
||||
///
|
||||
/// Manages the currently selected category filter.
|
||||
/// null means "All" is selected (show all categories).
|
||||
@@ -90,32 +115,67 @@ class SelectedNewsCategory extends _$SelectedNewsCategory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtered News Articles Provider
|
||||
/// Selected Category Name Provider
|
||||
///
|
||||
/// Returns news articles filtered by selected category.
|
||||
/// If no category is selected, returns all articles.
|
||||
/// Manages the currently selected blog category name (from Frappe API).
|
||||
/// null means "All" is selected (show all categories).
|
||||
///
|
||||
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
|
||||
@riverpod
|
||||
Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
|
||||
final selectedCategory = ref.watch(selectedNewsCategoryProvider);
|
||||
final repository = await ref.watch(newsRepositoryProvider.future);
|
||||
|
||||
// If no category selected, return all articles
|
||||
if (selectedCategory == null) {
|
||||
return repository.getAllArticles();
|
||||
class SelectedCategoryName extends _$SelectedCategoryName {
|
||||
@override
|
||||
String? build() {
|
||||
// Default: show all categories
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter by selected category
|
||||
return repository.getArticlesByCategory(selectedCategory);
|
||||
/// Set selected category by name
|
||||
void setCategoryName(String? categoryName) {
|
||||
state = categoryName;
|
||||
}
|
||||
|
||||
/// Clear selection (show all)
|
||||
void clearSelection() {
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtered News Articles Provider
|
||||
///
|
||||
/// Returns news articles filtered by selected blog category name.
|
||||
/// Excludes the first article (which is shown as featured).
|
||||
/// If no category is selected, returns all articles except first.
|
||||
///
|
||||
/// The blog_category name from API is stored in article.tags[0] for filtering.
|
||||
@riverpod
|
||||
Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
|
||||
final selectedCategoryName = ref.watch(selectedCategoryNameProvider);
|
||||
final allArticles = await ref.watch(_allNewsArticlesProvider.future);
|
||||
|
||||
// Get articles excluding first (which is featured)
|
||||
final articlesWithoutFeatured = allArticles.length > 1 ? allArticles.sublist(1) : <NewsArticle>[];
|
||||
|
||||
// If no category selected, return all articles except first
|
||||
if (selectedCategoryName == null) {
|
||||
return articlesWithoutFeatured;
|
||||
}
|
||||
|
||||
// Filter articles by blog_category name (stored in tags[0])
|
||||
return articlesWithoutFeatured.where((article) {
|
||||
// Check if article has tags and first tag matches selected category
|
||||
return article.tags.isNotEmpty && article.tags[0] == selectedCategoryName;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// News Article by ID Provider
|
||||
///
|
||||
/// Fetches a specific article by ID.
|
||||
/// Fetches a specific article by ID from the Frappe API.
|
||||
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
|
||||
/// Used for article detail page.
|
||||
@riverpod
|
||||
Future<NewsArticle?> newsArticleById(Ref ref, String articleId) async {
|
||||
final repository = await ref.watch(newsRepositoryProvider.future);
|
||||
return repository.getArticleById(articleId);
|
||||
return repository.getArticleByIdFromApi(articleId);
|
||||
}
|
||||
|
||||
/// Blog Categories Provider
|
||||
|
||||
@@ -170,9 +170,121 @@ final class NewsRepositoryProvider
|
||||
|
||||
String _$newsRepositoryHash() => r'8e66d847014926ad542e402874e52d35b00cdbcc';
|
||||
|
||||
/// All News Articles Provider (Internal)
|
||||
///
|
||||
/// Fetches ALL blog posts from Frappe API sorted by published date (latest first).
|
||||
/// This is the complete list used by both featured and latest articles providers.
|
||||
/// Do not use this provider directly in UI - use featuredArticle or newsArticles instead.
|
||||
|
||||
@ProviderFor(_allNewsArticles)
|
||||
const _allNewsArticlesProvider = _AllNewsArticlesProvider._();
|
||||
|
||||
/// All News Articles Provider (Internal)
|
||||
///
|
||||
/// Fetches ALL blog posts from Frappe API sorted by published date (latest first).
|
||||
/// This is the complete list used by both featured and latest articles providers.
|
||||
/// Do not use this provider directly in UI - use featuredArticle or newsArticles instead.
|
||||
|
||||
final class _AllNewsArticlesProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<NewsArticle>>,
|
||||
List<NewsArticle>,
|
||||
FutureOr<List<NewsArticle>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<NewsArticle>>,
|
||||
$FutureProvider<List<NewsArticle>> {
|
||||
/// All News Articles Provider (Internal)
|
||||
///
|
||||
/// Fetches ALL blog posts from Frappe API sorted by published date (latest first).
|
||||
/// This is the complete list used by both featured and latest articles providers.
|
||||
/// Do not use this provider directly in UI - use featuredArticle or newsArticles instead.
|
||||
const _AllNewsArticlesProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'_allNewsArticlesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$_allNewsArticlesHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<NewsArticle>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<NewsArticle>> create(Ref ref) {
|
||||
return _allNewsArticles(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$_allNewsArticlesHash() => r'9ee5c1449f1a72710e801a6b4a9e5c72df842e61';
|
||||
|
||||
/// Featured Article Provider
|
||||
///
|
||||
/// Returns the first article from the complete list.
|
||||
/// This is the latest published article that will be displayed prominently at the top.
|
||||
|
||||
@ProviderFor(featuredArticle)
|
||||
const featuredArticleProvider = FeaturedArticleProvider._();
|
||||
|
||||
/// Featured Article Provider
|
||||
///
|
||||
/// Returns the first article from the complete list.
|
||||
/// This is the latest published article that will be displayed prominently at the top.
|
||||
|
||||
final class FeaturedArticleProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<NewsArticle?>,
|
||||
NewsArticle?,
|
||||
FutureOr<NewsArticle?>
|
||||
>
|
||||
with $FutureModifier<NewsArticle?>, $FutureProvider<NewsArticle?> {
|
||||
/// Featured Article Provider
|
||||
///
|
||||
/// Returns the first article from the complete list.
|
||||
/// This is the latest published article that will be displayed prominently at the top.
|
||||
const FeaturedArticleProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'featuredArticleProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$featuredArticleHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<NewsArticle?> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<NewsArticle?> create(Ref ref) {
|
||||
return featuredArticle(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$featuredArticleHash() => r'046567d4385aca2abe10767a98744c2c1cfafd78';
|
||||
|
||||
/// News Articles Provider
|
||||
///
|
||||
/// Fetches all news articles sorted by published date.
|
||||
/// Returns latest news articles EXCLUDING the first item (which is shown as featured).
|
||||
/// This ensures each article only appears once on the page.
|
||||
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
||||
|
||||
@ProviderFor(newsArticles)
|
||||
@@ -180,7 +292,8 @@ const newsArticlesProvider = NewsArticlesProvider._();
|
||||
|
||||
/// News Articles Provider
|
||||
///
|
||||
/// Fetches all news articles sorted by published date.
|
||||
/// Returns latest news articles EXCLUDING the first item (which is shown as featured).
|
||||
/// This ensures each article only appears once on the page.
|
||||
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
||||
|
||||
final class NewsArticlesProvider
|
||||
@@ -195,7 +308,8 @@ final class NewsArticlesProvider
|
||||
$FutureProvider<List<NewsArticle>> {
|
||||
/// News Articles Provider
|
||||
///
|
||||
/// Fetches all news articles sorted by published date.
|
||||
/// Returns latest news articles EXCLUDING the first item (which is shown as featured).
|
||||
/// This ensures each article only appears once on the page.
|
||||
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
||||
const NewsArticlesProvider._()
|
||||
: super(
|
||||
@@ -223,62 +337,9 @@ final class NewsArticlesProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$newsArticlesHash() => r'789d916f1ce7d76f26429cfce97c65a71915edf3';
|
||||
String _$newsArticlesHash() => r'954f28885540368a095a3423f4f64c0f1ff0f47d';
|
||||
|
||||
/// Featured Article Provider
|
||||
///
|
||||
/// Fetches the featured article for the top section.
|
||||
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
|
||||
|
||||
@ProviderFor(featuredArticle)
|
||||
const featuredArticleProvider = FeaturedArticleProvider._();
|
||||
|
||||
/// Featured Article Provider
|
||||
///
|
||||
/// Fetches the featured article for the top section.
|
||||
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
|
||||
|
||||
final class FeaturedArticleProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<NewsArticle?>,
|
||||
NewsArticle?,
|
||||
FutureOr<NewsArticle?>
|
||||
>
|
||||
with $FutureModifier<NewsArticle?>, $FutureProvider<NewsArticle?> {
|
||||
/// Featured Article Provider
|
||||
///
|
||||
/// Fetches the featured article for the top section.
|
||||
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
|
||||
const FeaturedArticleProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'featuredArticleProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$featuredArticleHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<NewsArticle?> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<NewsArticle?> create(Ref ref) {
|
||||
return featuredArticle(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$featuredArticleHash() => r'5fd7057d3f828d6f717b08d59561aa9637eb0097';
|
||||
|
||||
/// Selected News Category Provider
|
||||
/// Selected News Category Provider (Legacy - using enum)
|
||||
///
|
||||
/// Manages the currently selected category filter.
|
||||
/// null means "All" is selected (show all categories).
|
||||
@@ -286,13 +347,13 @@ String _$featuredArticleHash() => r'5fd7057d3f828d6f717b08d59561aa9637eb0097';
|
||||
@ProviderFor(SelectedNewsCategory)
|
||||
const selectedNewsCategoryProvider = SelectedNewsCategoryProvider._();
|
||||
|
||||
/// Selected News Category Provider
|
||||
/// Selected News Category Provider (Legacy - using enum)
|
||||
///
|
||||
/// Manages the currently selected category filter.
|
||||
/// null means "All" is selected (show all categories).
|
||||
final class SelectedNewsCategoryProvider
|
||||
extends $NotifierProvider<SelectedNewsCategory, NewsCategory?> {
|
||||
/// Selected News Category Provider
|
||||
/// Selected News Category Provider (Legacy - using enum)
|
||||
///
|
||||
/// Manages the currently selected category filter.
|
||||
/// null means "All" is selected (show all categories).
|
||||
@@ -326,7 +387,7 @@ final class SelectedNewsCategoryProvider
|
||||
String _$selectedNewsCategoryHash() =>
|
||||
r'f1dca9a5d7de94cac90494d94ce05b727e6e4d5f';
|
||||
|
||||
/// Selected News Category Provider
|
||||
/// Selected News Category Provider (Legacy - using enum)
|
||||
///
|
||||
/// Manages the currently selected category filter.
|
||||
/// null means "All" is selected (show all categories).
|
||||
@@ -350,18 +411,104 @@ abstract class _$SelectedNewsCategory extends $Notifier<NewsCategory?> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected Category Name Provider
|
||||
///
|
||||
/// Manages the currently selected blog category name (from Frappe API).
|
||||
/// null means "All" is selected (show all categories).
|
||||
///
|
||||
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
|
||||
|
||||
@ProviderFor(SelectedCategoryName)
|
||||
const selectedCategoryNameProvider = SelectedCategoryNameProvider._();
|
||||
|
||||
/// Selected Category Name Provider
|
||||
///
|
||||
/// Manages the currently selected blog category name (from Frappe API).
|
||||
/// null means "All" is selected (show all categories).
|
||||
///
|
||||
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
|
||||
final class SelectedCategoryNameProvider
|
||||
extends $NotifierProvider<SelectedCategoryName, String?> {
|
||||
/// Selected Category Name Provider
|
||||
///
|
||||
/// Manages the currently selected blog category name (from Frappe API).
|
||||
/// null means "All" is selected (show all categories).
|
||||
///
|
||||
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
|
||||
const SelectedCategoryNameProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'selectedCategoryNameProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$selectedCategoryNameHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SelectedCategoryName create() => SelectedCategoryName();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(String? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<String?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$selectedCategoryNameHash() =>
|
||||
r'8dfbf490b986275e6ed9d7b423ae16f074c7fa36';
|
||||
|
||||
/// Selected Category Name Provider
|
||||
///
|
||||
/// Manages the currently selected blog category name (from Frappe API).
|
||||
/// null means "All" is selected (show all categories).
|
||||
///
|
||||
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
|
||||
|
||||
abstract class _$SelectedCategoryName extends $Notifier<String?> {
|
||||
String? build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<String?, String?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<String?, String?>,
|
||||
String?,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtered News Articles Provider
|
||||
///
|
||||
/// Returns news articles filtered by selected category.
|
||||
/// If no category is selected, returns all articles.
|
||||
/// Returns news articles filtered by selected blog category name.
|
||||
/// Excludes the first article (which is shown as featured).
|
||||
/// If no category is selected, returns all articles except first.
|
||||
///
|
||||
/// The blog_category name from API is stored in article.tags[0] for filtering.
|
||||
|
||||
@ProviderFor(filteredNewsArticles)
|
||||
const filteredNewsArticlesProvider = FilteredNewsArticlesProvider._();
|
||||
|
||||
/// Filtered News Articles Provider
|
||||
///
|
||||
/// Returns news articles filtered by selected category.
|
||||
/// If no category is selected, returns all articles.
|
||||
/// Returns news articles filtered by selected blog category name.
|
||||
/// Excludes the first article (which is shown as featured).
|
||||
/// If no category is selected, returns all articles except first.
|
||||
///
|
||||
/// The blog_category name from API is stored in article.tags[0] for filtering.
|
||||
|
||||
final class FilteredNewsArticlesProvider
|
||||
extends
|
||||
@@ -375,8 +522,11 @@ final class FilteredNewsArticlesProvider
|
||||
$FutureProvider<List<NewsArticle>> {
|
||||
/// Filtered News Articles Provider
|
||||
///
|
||||
/// Returns news articles filtered by selected category.
|
||||
/// If no category is selected, returns all articles.
|
||||
/// Returns news articles filtered by selected blog category name.
|
||||
/// Excludes the first article (which is shown as featured).
|
||||
/// If no category is selected, returns all articles except first.
|
||||
///
|
||||
/// The blog_category name from API is stored in article.tags[0] for filtering.
|
||||
const FilteredNewsArticlesProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
@@ -404,11 +554,12 @@ final class FilteredNewsArticlesProvider
|
||||
}
|
||||
|
||||
String _$filteredNewsArticlesHash() =>
|
||||
r'f5d6faa2d510eae188f12fa41d052eeb43e08cc9';
|
||||
r'52b823eabce0acfbef33cc85b5f31f3e9588df4f';
|
||||
|
||||
/// News Article by ID Provider
|
||||
///
|
||||
/// Fetches a specific article by ID.
|
||||
/// Fetches a specific article by ID from the Frappe API.
|
||||
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
|
||||
/// Used for article detail page.
|
||||
|
||||
@ProviderFor(newsArticleById)
|
||||
@@ -416,7 +567,8 @@ const newsArticleByIdProvider = NewsArticleByIdFamily._();
|
||||
|
||||
/// News Article by ID Provider
|
||||
///
|
||||
/// Fetches a specific article by ID.
|
||||
/// Fetches a specific article by ID from the Frappe API.
|
||||
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
|
||||
/// Used for article detail page.
|
||||
|
||||
final class NewsArticleByIdProvider
|
||||
@@ -429,7 +581,8 @@ final class NewsArticleByIdProvider
|
||||
with $FutureModifier<NewsArticle?>, $FutureProvider<NewsArticle?> {
|
||||
/// News Article by ID Provider
|
||||
///
|
||||
/// Fetches a specific article by ID.
|
||||
/// Fetches a specific article by ID from the Frappe API.
|
||||
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
|
||||
/// Used for article detail page.
|
||||
const NewsArticleByIdProvider._({
|
||||
required NewsArticleByIdFamily super.from,
|
||||
@@ -475,11 +628,12 @@ final class NewsArticleByIdProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$newsArticleByIdHash() => r'f2b5ee4a3f7b67d0ee9e9c91169d740a9f250b50';
|
||||
String _$newsArticleByIdHash() => r'83e4790f0ebb80da5f0385f489ed2221fe769e3c';
|
||||
|
||||
/// News Article by ID Provider
|
||||
///
|
||||
/// Fetches a specific article by ID.
|
||||
/// Fetches a specific article by ID from the Frappe API.
|
||||
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
|
||||
/// Used for article detail page.
|
||||
|
||||
final class NewsArticleByIdFamily extends $Family
|
||||
@@ -495,7 +649,8 @@ final class NewsArticleByIdFamily extends $Family
|
||||
|
||||
/// News Article by ID Provider
|
||||
///
|
||||
/// Fetches a specific article by ID.
|
||||
/// Fetches a specific article by ID from the Frappe API.
|
||||
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
|
||||
/// Used for article detail page.
|
||||
|
||||
NewsArticleByIdProvider call(String articleId) =>
|
||||
|
||||
@@ -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,
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user