This commit is contained in:
Phuoc Nguyen
2025-11-10 14:21:27 +07:00
parent 2a71c65577
commit 36bdf6613b
33 changed files with 2206 additions and 252 deletions

View File

@@ -0,0 +1,93 @@
/// News Remote DataSource
///
/// Handles fetching news/blog data from the Frappe API.
library;
import 'package:dio/dio.dart';
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';
/// News Remote Data Source
///
/// Provides methods to fetch news and blog content from the Frappe API.
/// Uses FrappeAuthService for session management.
class NewsRemoteDataSource {
NewsRemoteDataSource(this._dioClient, this._frappeAuthService);
final DioClient _dioClient;
final FrappeAuthService _frappeAuthService;
/// Get blog categories
///
/// Fetches all published blog categories from Frappe.
/// Returns a list of [BlogCategoryModel].
///
/// API endpoint: POST https://land.dbiz.com/api/method/frappe.client.get_list
/// Request body:
/// ```json
/// {
/// "doctype": "Blog Category",
/// "fields": ["title","name"],
/// "filters": {"published":1},
/// "order_by": "creation desc",
/// "limit_page_length": 0
/// }
/// ```
///
/// Response format:
/// ```json
/// {
/// "message": [
/// {"title": "Tin tức", "name": "tin-tức"},
/// {"title": "Chuyên môn", "name": "chuyên-môn"},
/// ...
/// ]
/// }
/// ```
Future<List<BlogCategoryModel>> getBlogCategories() async {
try {
// Get Frappe session headers
final headers = await _frappeAuthService.getHeaders();
// Build full API URL
final url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}';
final response = await _dioClient.post<Map<String, dynamic>>(
url,
data: {
'doctype': 'Blog Category',
'fields': ['title', 'name'],
'filters': {'published': 1},
'order_by': 'creation 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 categoriesResponse = BlogCategoriesResponse.fromJson(response.data!);
return categoriesResponse.message;
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception('Blog categories endpoint not found');
} else if (e.response?.statusCode == 500) {
throw Exception('Server error while fetching blog categories');
} else if (e.type == DioExceptionType.connectionTimeout) {
throw Exception('Connection timeout while fetching blog categories');
} else if (e.type == DioExceptionType.receiveTimeout) {
throw Exception('Response timeout while fetching blog categories');
} else {
throw Exception('Failed to fetch blog categories: ${e.message}');
}
} catch (e) {
throw Exception('Unexpected error fetching blog categories: $e');
}
}
}

View File

@@ -0,0 +1,128 @@
/// Data Model: Blog Category
///
/// Data Transfer Object for blog/news category information from Frappe API.
/// This model handles JSON serialization/deserialization for API responses.
library;
import 'package:json_annotation/json_annotation.dart';
import 'package:worker/features/news/domain/entities/blog_category.dart';
part 'blog_category_model.g.dart';
/// Blog Category Model
///
/// Used for:
/// - API JSON serialization/deserialization
/// - Converting to/from domain entity
///
/// Example API response:
/// ```json
/// {
/// "title": "Tin tức",
/// "name": "tin-tức"
/// }
/// ```
@JsonSerializable()
class BlogCategoryModel {
/// Display title of the category (e.g., "Tin tức", "Chuyên môn")
final String title;
/// URL-safe name/slug of the category (e.g., "tin-tức", "chuyên-môn")
final String name;
const BlogCategoryModel({
required this.title,
required this.name,
});
/// From JSON constructor
factory BlogCategoryModel.fromJson(Map<String, dynamic> json) =>
_$BlogCategoryModelFromJson(json);
/// To JSON method
Map<String, dynamic> toJson() => _$BlogCategoryModelToJson(this);
/// Convert to domain entity
BlogCategory toEntity() {
return BlogCategory(
title: title,
name: name,
);
}
/// Create from domain entity
factory BlogCategoryModel.fromEntity(BlogCategory entity) {
return BlogCategoryModel(
title: entity.title,
name: entity.name,
);
}
/// Copy with method for creating modified copies
BlogCategoryModel copyWith({
String? title,
String? name,
}) {
return BlogCategoryModel(
title: title ?? this.title,
name: name ?? this.name,
);
}
@override
String toString() {
return 'BlogCategoryModel(title: $title, name: $name)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is BlogCategoryModel &&
other.title == title &&
other.name == name;
}
@override
int get hashCode {
return Object.hash(title, name);
}
}
/// API Response wrapper for blog categories list
///
/// Frappe API wraps the response in a "message" field.
/// Example:
/// ```json
/// {
/// "message": [
/// {"title": "Tin tức", "name": "tin-tức"},
/// {"title": "Chuyên môn", "name": "chuyên-môn"}
/// ]
/// }
/// ```
class BlogCategoriesResponse {
/// List of blog categories
final List<BlogCategoryModel> message;
BlogCategoriesResponse({
required this.message,
});
/// From JSON constructor
factory BlogCategoriesResponse.fromJson(Map<String, dynamic> json) {
final messageList = json['message'] as List<dynamic>;
final categories = messageList
.map((item) => BlogCategoryModel.fromJson(item as Map<String, dynamic>))
.toList();
return BlogCategoriesResponse(message: categories);
}
/// To JSON method
Map<String, dynamic> toJson() {
return {
'message': message.map((category) => category.toJson()).toList(),
};
}
}

View File

@@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'blog_category_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BlogCategoryModel _$BlogCategoryModelFromJson(Map<String, dynamic> json) =>
$checkedCreate('BlogCategoryModel', json, ($checkedConvert) {
final val = BlogCategoryModel(
title: $checkedConvert('title', (v) => v as String),
name: $checkedConvert('name', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$BlogCategoryModelToJson(BlogCategoryModel instance) =>
<String, dynamic>{'title': instance.title, 'name': instance.name};

View File

@@ -5,6 +5,8 @@
library;
import 'package:worker/features/news/data/datasources/news_local_datasource.dart';
import 'package:worker/features/news/data/datasources/news_remote_datasource.dart';
import 'package:worker/features/news/domain/entities/blog_category.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
import 'package:worker/features/news/domain/repositories/news_repository.dart';
@@ -13,8 +15,30 @@ class NewsRepositoryImpl implements NewsRepository {
/// Local data source
final NewsLocalDataSource localDataSource;
/// Remote data source
final NewsRemoteDataSource remoteDataSource;
/// Constructor
NewsRepositoryImpl({required this.localDataSource});
NewsRepositoryImpl({
required this.localDataSource,
required this.remoteDataSource,
});
@override
Future<List<BlogCategory>> getBlogCategories() async {
try {
// Fetch categories from remote API
final models = await remoteDataSource.getBlogCategories();
// Convert to domain entities
final entities = models.map((model) => model.toEntity()).toList();
return entities;
} catch (e) {
print('[NewsRepository] Error getting blog categories: $e');
rethrow; // Re-throw to let providers handle the error
}
}
@override
Future<List<NewsArticle>> getAllArticles() async {