update news
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user