update news
This commit is contained in:
23
docs/blog.sh
23
docs/blog.sh
@@ -9,4 +9,27 @@ curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \
|
|||||||
"filters": {"published":1},
|
"filters": {"published":1},
|
||||||
"order_by" : "creation desc",
|
"order_by" : "creation desc",
|
||||||
"limit_page_length": 0
|
"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
|
/// POST /api/method/frappe.client.get_list
|
||||||
static const String frappeGetList = '/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)
|
/// Register user (requires session sid and csrf_token)
|
||||||
/// POST /api/method/building_material.building_material.api.user.register
|
/// POST /api/method/building_material.building_material.api.user.register
|
||||||
static const String frappeRegister = '/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/login_page.dart';
|
||||||
import 'package:worker/features/auth/presentation/pages/otp_verification_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/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/cart_page.dart';
|
||||||
import 'package:worker/features/cart/presentation/pages/checkout_page.dart';
|
import 'package:worker/features/cart/presentation/pages/checkout_page.dart';
|
||||||
import 'package:worker/features/chat/presentation/pages/chat_list_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);
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
// Initial route
|
// Initial route - start with splash screen
|
||||||
initialLocation: RouteNames.login,
|
initialLocation: RouteNames.splash,
|
||||||
|
|
||||||
// Redirect based on auth state
|
// Redirect based on auth state
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
|
final isLoading = authState.isLoading;
|
||||||
final isLoggedIn = authState.value != null;
|
final isLoggedIn = authState.value != null;
|
||||||
|
final isOnSplashPage = state.matchedLocation == RouteNames.splash;
|
||||||
final isOnLoginPage = state.matchedLocation == RouteNames.login;
|
final isOnLoginPage = state.matchedLocation == RouteNames.login;
|
||||||
final isOnRegisterPage = state.matchedLocation == RouteNames.register;
|
final isOnRegisterPage = state.matchedLocation == RouteNames.register;
|
||||||
final isOnBusinessUnitPage =
|
final isOnBusinessUnitPage =
|
||||||
@@ -62,8 +65,18 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
final isOnAuthPage =
|
final isOnAuthPage =
|
||||||
isOnLoginPage || isOnRegisterPage || isOnBusinessUnitPage || isOnOtpPage;
|
isOnLoginPage || isOnRegisterPage || isOnBusinessUnitPage || isOnOtpPage;
|
||||||
|
|
||||||
// If not logged in and not on auth pages, redirect to login
|
// While loading auth state, show splash screen
|
||||||
if (!isLoggedIn && !isOnAuthPage) {
|
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;
|
return RouteNames.login;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +91,14 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
|
|
||||||
// Route definitions
|
// Route definitions
|
||||||
routes: [
|
routes: [
|
||||||
|
// Splash Screen Route
|
||||||
|
GoRoute(
|
||||||
|
path: RouteNames.splash,
|
||||||
|
name: RouteNames.splash,
|
||||||
|
pageBuilder: (context, state) =>
|
||||||
|
MaterialPage(key: state.pageKey, child: const SplashPage()),
|
||||||
|
),
|
||||||
|
|
||||||
// Authentication Routes
|
// Authentication Routes
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.login,
|
path: RouteNames.login,
|
||||||
@@ -486,7 +507,8 @@ class RouteNames {
|
|||||||
'/model-houses/design-request/create';
|
'/model-houses/design-request/create';
|
||||||
static const String designRequestDetail = '/model-houses/design-request/:id';
|
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 login = '/login';
|
||||||
static const String otpVerification = '/otp-verification';
|
static const String otpVerification = '/otp-verification';
|
||||||
static const String register = '/register';
|
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();
|
Auth create() => Auth();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$authHash() => r'3f0562ffb573be47d8aae8beebccb1946240cbb6';
|
String _$authHash() => r'f1a16022d628a21f230c0bb567e80ff6e293d840';
|
||||||
|
|
||||||
/// Authentication Provider
|
/// Authentication Provider
|
||||||
///
|
///
|
||||||
@@ -591,3 +591,69 @@ final class UserTotalPointsProvider extends $FunctionalProvider<int, int, int>
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$userTotalPointsHash() => r'9ccebb48a8641c3c0624b1649303b436e82602bd';
|
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
|
// Promotions Section
|
||||||
// SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
// child: promotionsAsync.when(
|
child: promotionsAsync.when(
|
||||||
// data: (promotions) => promotions.isNotEmpty
|
data: (promotions) => promotions.isNotEmpty
|
||||||
// ? PromotionSlider(
|
? PromotionSlider(
|
||||||
// promotions: promotions,
|
promotions: promotions,
|
||||||
// onPromotionTap: (promotion) {
|
onPromotionTap: (promotion) {
|
||||||
// // Navigate to promotion details
|
// Navigate to promotion details
|
||||||
// context.push('/promotions/${promotion.id}');
|
context.push('/promotions/${promotion.id}');
|
||||||
// },
|
},
|
||||||
// )
|
)
|
||||||
// : const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
// loading: () => const Padding(
|
loading: () => const Padding(
|
||||||
// padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
// child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
// ),
|
),
|
||||||
// error: (error, stack) => const SizedBox.shrink(),
|
error: (error, stack) => const SizedBox.shrink(),
|
||||||
// ),
|
),
|
||||||
// ),
|
),
|
||||||
|
|
||||||
// Quick Action Sections
|
// Quick Action Sections
|
||||||
SliverToBoxAdapter(
|
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/network/dio_client.dart';
|
||||||
import 'package:worker/core/services/frappe_auth_service.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_category_model.dart';
|
||||||
|
import 'package:worker/features/news/data/models/blog_post_model.dart';
|
||||||
|
|
||||||
/// News Remote Data Source
|
/// News Remote Data Source
|
||||||
///
|
///
|
||||||
@@ -90,4 +91,173 @@ class NewsRemoteDataSource {
|
|||||||
throw Exception('Unexpected error fetching blog categories: $e');
|
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
|
/// News Repository Implementation
|
||||||
class NewsRepositoryImpl implements NewsRepository {
|
class NewsRepositoryImpl implements NewsRepository {
|
||||||
/// Local data source
|
|
||||||
final NewsLocalDataSource localDataSource;
|
|
||||||
|
|
||||||
/// Remote data source
|
|
||||||
final NewsRemoteDataSource remoteDataSource;
|
|
||||||
|
|
||||||
/// Constructor
|
/// Constructor
|
||||||
NewsRepositoryImpl({
|
NewsRepositoryImpl({
|
||||||
required this.localDataSource,
|
required this.localDataSource,
|
||||||
required this.remoteDataSource,
|
required this.remoteDataSource,
|
||||||
});
|
});
|
||||||
|
/// Local data source
|
||||||
|
final NewsLocalDataSource localDataSource;
|
||||||
|
|
||||||
|
/// Remote data source
|
||||||
|
final NewsRemoteDataSource remoteDataSource;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<BlogCategory>> getBlogCategories() async {
|
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
|
@override
|
||||||
Future<List<NewsArticle>> refreshArticles() async {
|
Future<List<NewsArticle>> refreshArticles() async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -23,9 +23,13 @@ abstract class NewsRepository {
|
|||||||
/// Get articles by category
|
/// Get articles by category
|
||||||
Future<List<NewsArticle>> getArticlesByCategory(NewsCategory 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);
|
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
|
/// Refresh articles from server
|
||||||
Future<List<NewsArticle>> refreshArticles();
|
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,44 +86,9 @@ class _NewsListPageState extends ConsumerState<NewsListPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Column(
|
child: FeaturedNewsCard(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
article: article,
|
||||||
children: [
|
onTap: () => _onArticleTap(context, article),
|
||||||
// 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),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -137,10 +102,13 @@ class _NewsListPageState extends ConsumerState<NewsListPage> {
|
|||||||
const SliverToBoxAdapter(child: SizedBox.shrink()),
|
const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SizedBox(height: AppSpacing.xl),
|
||||||
|
),
|
||||||
// Latest News Section
|
// Latest News Section
|
||||||
SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
padding: EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -148,8 +116,8 @@ class _NewsListPageState extends ConsumerState<NewsListPage> {
|
|||||||
size: 18,
|
size: 18,
|
||||||
color: AppColors.primaryBlue,
|
color: AppColors.primaryBlue,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
const Text(
|
Text(
|
||||||
'Mới nhất',
|
'Mới nhất',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
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.
|
/// Fetches ALL blog posts from Frappe API sorted by published date (latest first).
|
||||||
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
/// 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
|
@riverpod
|
||||||
Future<List<NewsArticle>> newsArticles(Ref ref) async {
|
Future<List<NewsArticle>> _allNewsArticles(Ref ref) async {
|
||||||
final repository = await ref.watch(newsRepositoryProvider.future);
|
final remoteDataSource = await ref.watch(newsRemoteDataSourceProvider.future);
|
||||||
return repository.getAllArticles();
|
|
||||||
|
// 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
|
/// Featured Article Provider
|
||||||
///
|
///
|
||||||
/// Fetches the featured article for the top section.
|
/// Returns the first article from the complete list.
|
||||||
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
|
/// This is the latest published article that will be displayed prominently at the top.
|
||||||
@riverpod
|
@riverpod
|
||||||
Future<NewsArticle?> featuredArticle(Ref ref) async {
|
Future<NewsArticle?> featuredArticle(Ref ref) async {
|
||||||
final repository = await ref.watch(newsRepositoryProvider.future);
|
final allArticles = await ref.watch(_allNewsArticlesProvider.future);
|
||||||
return repository.getFeaturedArticle();
|
|
||||||
|
// 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.
|
/// Manages the currently selected category filter.
|
||||||
/// null means "All" is selected (show all categories).
|
/// 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.
|
/// Manages the currently selected blog category name (from Frappe API).
|
||||||
/// If no category is selected, returns all articles.
|
/// null means "All" is selected (show all categories).
|
||||||
|
///
|
||||||
|
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
|
||||||
@riverpod
|
@riverpod
|
||||||
Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
|
class SelectedCategoryName extends _$SelectedCategoryName {
|
||||||
final selectedCategory = ref.watch(selectedNewsCategoryProvider);
|
@override
|
||||||
final repository = await ref.watch(newsRepositoryProvider.future);
|
String? build() {
|
||||||
|
// Default: show all categories
|
||||||
// If no category selected, return all articles
|
return null;
|
||||||
if (selectedCategory == null) {
|
|
||||||
return repository.getAllArticles();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by selected category
|
/// Set selected category by name
|
||||||
return repository.getArticlesByCategory(selectedCategory);
|
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
|
/// 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.
|
/// Used for article detail page.
|
||||||
@riverpod
|
@riverpod
|
||||||
Future<NewsArticle?> newsArticleById(Ref ref, String articleId) async {
|
Future<NewsArticle?> newsArticleById(Ref ref, String articleId) async {
|
||||||
final repository = await ref.watch(newsRepositoryProvider.future);
|
final repository = await ref.watch(newsRepositoryProvider.future);
|
||||||
return repository.getArticleById(articleId);
|
return repository.getArticleByIdFromApi(articleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blog Categories Provider
|
/// Blog Categories Provider
|
||||||
|
|||||||
@@ -170,9 +170,121 @@ final class NewsRepositoryProvider
|
|||||||
|
|
||||||
String _$newsRepositoryHash() => r'8e66d847014926ad542e402874e52d35b00cdbcc';
|
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
|
/// 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.
|
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
||||||
|
|
||||||
@ProviderFor(newsArticles)
|
@ProviderFor(newsArticles)
|
||||||
@@ -180,7 +292,8 @@ const newsArticlesProvider = NewsArticlesProvider._();
|
|||||||
|
|
||||||
/// News Articles Provider
|
/// 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.
|
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
||||||
|
|
||||||
final class NewsArticlesProvider
|
final class NewsArticlesProvider
|
||||||
@@ -195,7 +308,8 @@ final class NewsArticlesProvider
|
|||||||
$FutureProvider<List<NewsArticle>> {
|
$FutureProvider<List<NewsArticle>> {
|
||||||
/// News Articles Provider
|
/// 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.
|
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
||||||
const NewsArticlesProvider._()
|
const NewsArticlesProvider._()
|
||||||
: super(
|
: super(
|
||||||
@@ -223,62 +337,9 @@ final class NewsArticlesProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$newsArticlesHash() => r'789d916f1ce7d76f26429cfce97c65a71915edf3';
|
String _$newsArticlesHash() => r'954f28885540368a095a3423f4f64c0f1ff0f47d';
|
||||||
|
|
||||||
/// Featured Article Provider
|
/// Selected News Category Provider (Legacy - using enum)
|
||||||
///
|
|
||||||
/// 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
|
|
||||||
///
|
///
|
||||||
/// Manages the currently selected category filter.
|
/// Manages the currently selected category filter.
|
||||||
/// null means "All" is selected (show all categories).
|
/// null means "All" is selected (show all categories).
|
||||||
@@ -286,13 +347,13 @@ String _$featuredArticleHash() => r'5fd7057d3f828d6f717b08d59561aa9637eb0097';
|
|||||||
@ProviderFor(SelectedNewsCategory)
|
@ProviderFor(SelectedNewsCategory)
|
||||||
const selectedNewsCategoryProvider = SelectedNewsCategoryProvider._();
|
const selectedNewsCategoryProvider = SelectedNewsCategoryProvider._();
|
||||||
|
|
||||||
/// Selected News Category Provider
|
/// Selected News Category Provider (Legacy - using enum)
|
||||||
///
|
///
|
||||||
/// Manages the currently selected category filter.
|
/// Manages the currently selected category filter.
|
||||||
/// null means "All" is selected (show all categories).
|
/// null means "All" is selected (show all categories).
|
||||||
final class SelectedNewsCategoryProvider
|
final class SelectedNewsCategoryProvider
|
||||||
extends $NotifierProvider<SelectedNewsCategory, NewsCategory?> {
|
extends $NotifierProvider<SelectedNewsCategory, NewsCategory?> {
|
||||||
/// Selected News Category Provider
|
/// Selected News Category Provider (Legacy - using enum)
|
||||||
///
|
///
|
||||||
/// Manages the currently selected category filter.
|
/// Manages the currently selected category filter.
|
||||||
/// null means "All" is selected (show all categories).
|
/// null means "All" is selected (show all categories).
|
||||||
@@ -326,7 +387,7 @@ final class SelectedNewsCategoryProvider
|
|||||||
String _$selectedNewsCategoryHash() =>
|
String _$selectedNewsCategoryHash() =>
|
||||||
r'f1dca9a5d7de94cac90494d94ce05b727e6e4d5f';
|
r'f1dca9a5d7de94cac90494d94ce05b727e6e4d5f';
|
||||||
|
|
||||||
/// Selected News Category Provider
|
/// Selected News Category Provider (Legacy - using enum)
|
||||||
///
|
///
|
||||||
/// Manages the currently selected category filter.
|
/// Manages the currently selected category filter.
|
||||||
/// null means "All" is selected (show all categories).
|
/// 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
|
/// Filtered News Articles Provider
|
||||||
///
|
///
|
||||||
/// Returns news articles filtered by selected category.
|
/// Returns news articles filtered by selected blog category name.
|
||||||
/// If no category is selected, returns all articles.
|
/// 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)
|
@ProviderFor(filteredNewsArticles)
|
||||||
const filteredNewsArticlesProvider = FilteredNewsArticlesProvider._();
|
const filteredNewsArticlesProvider = FilteredNewsArticlesProvider._();
|
||||||
|
|
||||||
/// Filtered News Articles Provider
|
/// Filtered News Articles Provider
|
||||||
///
|
///
|
||||||
/// Returns news articles filtered by selected category.
|
/// Returns news articles filtered by selected blog category name.
|
||||||
/// If no category is selected, returns all articles.
|
/// 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
|
final class FilteredNewsArticlesProvider
|
||||||
extends
|
extends
|
||||||
@@ -375,8 +522,11 @@ final class FilteredNewsArticlesProvider
|
|||||||
$FutureProvider<List<NewsArticle>> {
|
$FutureProvider<List<NewsArticle>> {
|
||||||
/// Filtered News Articles Provider
|
/// Filtered News Articles Provider
|
||||||
///
|
///
|
||||||
/// Returns news articles filtered by selected category.
|
/// Returns news articles filtered by selected blog category name.
|
||||||
/// If no category is selected, returns all articles.
|
/// 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._()
|
const FilteredNewsArticlesProvider._()
|
||||||
: super(
|
: super(
|
||||||
from: null,
|
from: null,
|
||||||
@@ -404,11 +554,12 @@ final class FilteredNewsArticlesProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$filteredNewsArticlesHash() =>
|
String _$filteredNewsArticlesHash() =>
|
||||||
r'f5d6faa2d510eae188f12fa41d052eeb43e08cc9';
|
r'52b823eabce0acfbef33cc85b5f31f3e9588df4f';
|
||||||
|
|
||||||
/// News Article by ID Provider
|
/// 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.
|
/// Used for article detail page.
|
||||||
|
|
||||||
@ProviderFor(newsArticleById)
|
@ProviderFor(newsArticleById)
|
||||||
@@ -416,7 +567,8 @@ const newsArticleByIdProvider = NewsArticleByIdFamily._();
|
|||||||
|
|
||||||
/// News Article by ID Provider
|
/// 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.
|
/// Used for article detail page.
|
||||||
|
|
||||||
final class NewsArticleByIdProvider
|
final class NewsArticleByIdProvider
|
||||||
@@ -429,7 +581,8 @@ final class NewsArticleByIdProvider
|
|||||||
with $FutureModifier<NewsArticle?>, $FutureProvider<NewsArticle?> {
|
with $FutureModifier<NewsArticle?>, $FutureProvider<NewsArticle?> {
|
||||||
/// News Article by ID Provider
|
/// 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.
|
/// Used for article detail page.
|
||||||
const NewsArticleByIdProvider._({
|
const NewsArticleByIdProvider._({
|
||||||
required NewsArticleByIdFamily super.from,
|
required NewsArticleByIdFamily super.from,
|
||||||
@@ -475,11 +628,12 @@ final class NewsArticleByIdProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$newsArticleByIdHash() => r'f2b5ee4a3f7b67d0ee9e9c91169d740a9f250b50';
|
String _$newsArticleByIdHash() => r'83e4790f0ebb80da5f0385f489ed2221fe769e3c';
|
||||||
|
|
||||||
/// News Article by ID Provider
|
/// 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.
|
/// Used for article detail page.
|
||||||
|
|
||||||
final class NewsArticleByIdFamily extends $Family
|
final class NewsArticleByIdFamily extends $Family
|
||||||
@@ -495,7 +649,8 @@ final class NewsArticleByIdFamily extends $Family
|
|||||||
|
|
||||||
/// News Article by ID Provider
|
/// 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.
|
/// Used for article detail page.
|
||||||
|
|
||||||
NewsArticleByIdProvider call(String articleId) =>
|
NewsArticleByIdProvider call(String articleId) =>
|
||||||
|
|||||||
@@ -20,15 +20,15 @@ import 'package:worker/features/news/domain/entities/news_article.dart';
|
|||||||
/// - Category badge (primary blue)
|
/// - Category badge (primary blue)
|
||||||
/// - Shadow and rounded corners
|
/// - Shadow and rounded corners
|
||||||
class FeaturedNewsCard extends StatelessWidget {
|
class FeaturedNewsCard extends StatelessWidget {
|
||||||
|
|
||||||
|
/// Constructor
|
||||||
|
const FeaturedNewsCard({super.key, required this.article, this.onTap});
|
||||||
/// News article to display
|
/// News article to display
|
||||||
final NewsArticle article;
|
final NewsArticle article;
|
||||||
|
|
||||||
/// Callback when card is tapped
|
/// Callback when card is tapped
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
/// Constructor
|
|
||||||
const FeaturedNewsCard({super.key, required this.article, this.onTap});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@@ -126,17 +126,17 @@ class FeaturedNewsCard extends StatelessWidget {
|
|||||||
text: article.formattedDate,
|
text: article.formattedDate,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Views
|
// // Views
|
||||||
_buildMetaItem(
|
// _buildMetaItem(
|
||||||
icon: Icons.visibility,
|
// icon: Icons.visibility,
|
||||||
text: '${article.formattedViewCount} lượt xem',
|
// text: '${article.formattedViewCount} lượt xem',
|
||||||
),
|
// ),
|
||||||
|
//
|
||||||
// Reading time
|
// // Reading time
|
||||||
_buildMetaItem(
|
// _buildMetaItem(
|
||||||
icon: Icons.schedule,
|
// icon: Icons.schedule,
|
||||||
text: article.readingTimeText,
|
// text: article.readingTimeText,
|
||||||
),
|
// ),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -133,19 +133,19 @@ class NewsCard extends StatelessWidget {
|
|||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
// Views
|
// Views
|
||||||
Icon(
|
// Icon(
|
||||||
Icons.visibility,
|
// Icons.visibility,
|
||||||
size: 12,
|
// size: 12,
|
||||||
color: const Color(0xFF64748B),
|
// color: const Color(0xFF64748B),
|
||||||
),
|
// ),
|
||||||
const SizedBox(width: 4),
|
// const SizedBox(width: 4),
|
||||||
Text(
|
// Text(
|
||||||
'${article.formattedViewCount} lượt xem',
|
// '${article.formattedViewCount} lượt xem',
|
||||||
style: const TextStyle(
|
// style: const TextStyle(
|
||||||
fontSize: 12,
|
// fontSize: 12,
|
||||||
color: Color(0xFF64748B),
|
// color: Color(0xFF64748B),
|
||||||
),
|
// ),
|
||||||
),
|
// ),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user