update review api.

This commit is contained in:
Phuoc Nguyen
2025-11-17 17:54:32 +07:00
parent 0798b28db5
commit 0841e3bf3d
23 changed files with 4856 additions and 209 deletions

View File

@@ -0,0 +1,341 @@
/// Remote Data Source: Reviews
///
/// Handles API calls for review operations.
library;
import 'package:dio/dio.dart';
import 'package:worker/core/errors/exceptions.dart';
import 'package:worker/core/network/dio_client.dart';
import 'package:worker/features/reviews/data/models/review_model.dart';
import 'package:worker/features/reviews/data/models/review_response_model.dart';
import 'package:worker/features/reviews/data/models/review_statistics_model.dart';
/// Reviews remote data source interface
abstract class ReviewsRemoteDataSource {
/// Get reviews for a product (legacy - returns only reviews)
Future<List<ReviewModel>> getProductReviews({
required String itemId,
int limitPageLength = 10,
int limitStart = 0,
});
/// Get complete review response with statistics
Future<ReviewResponseModel> getProductReviewsWithStats({
required String itemId,
int limitPageLength = 10,
int limitStart = 0,
});
/// Submit a review (create or update)
Future<void> submitReview({
required String itemId,
required double rating,
required String comment,
String? name,
});
/// Delete a review
Future<void> deleteReview({required String name});
}
/// Reviews remote data source implementation
class ReviewsRemoteDataSourceImpl implements ReviewsRemoteDataSource {
const ReviewsRemoteDataSourceImpl(this._dioClient);
final DioClient _dioClient;
// API endpoints
static const String _getListEndpoint =
'/api/method/building_material.building_material.api.item_feedback.get_list';
static const String _updateEndpoint =
'/api/method/building_material.building_material.api.item_feedback.update';
static const String _deleteEndpoint =
'/api/method/building_material.building_material.api.item_feedback.delete';
@override
Future<List<ReviewModel>> getProductReviews({
required String itemId,
int limitPageLength = 10,
int limitStart = 0,
}) async {
try {
final response = await _dioClient.post(
_getListEndpoint,
data: {
'limit_page_length': limitPageLength,
'limit_start': limitStart,
'item_id': itemId,
},
);
// Handle response
if (response.statusCode == 200 || response.statusCode == 201) {
final data = response.data;
// Try different response formats
List<dynamic> reviewsJson;
if (data is Map<String, dynamic>) {
// Frappe typically returns: { "message": {...} }
if (data.containsKey('message')) {
final message = data['message'];
if (message is List) {
reviewsJson = message;
} else if (message is Map<String, dynamic>) {
// New API format: { "message": { "feedbacks": [...], "statistics": {...} } }
if (message.containsKey('feedbacks')) {
reviewsJson = message['feedbacks'] as List;
} else if (message.containsKey('data')) {
// Alternative: { "message": { "data": [...] } }
reviewsJson = message['data'] as List;
} else {
throw const ParseException(
'Unexpected response format: message map has no feedbacks or data key',
);
}
} else {
throw const ParseException(
'Unexpected response format: message is not a list or map',
);
}
} else if (data.containsKey('data')) {
// Alternative: { "data": [...] }
reviewsJson = data['data'] as List;
} else if (data.containsKey('feedbacks')) {
// Direct feedbacks key
reviewsJson = data['feedbacks'] as List;
} else {
throw const ParseException(
'Unexpected response format: no message, data, or feedbacks key',
);
}
} else if (data is List) {
// Direct list response
reviewsJson = data;
} else {
throw ParseException(
'Unexpected response format: ${data.runtimeType}',
);
}
// Parse reviews
return reviewsJson
.map((json) => ReviewModel.fromJson(json as Map<String, dynamic>))
.toList();
} else {
throw NetworkException(
'Failed to fetch reviews',
statusCode: response.statusCode,
data: response.data,
);
}
} on DioException catch (e) {
throw _handleDioException(e);
} on ParseException {
rethrow;
} catch (e, stackTrace) {
throw UnknownException(
'Failed to fetch reviews: ${e.toString()}',
e,
stackTrace,
);
}
}
@override
Future<ReviewResponseModel> getProductReviewsWithStats({
required String itemId,
int limitPageLength = 10,
int limitStart = 0,
}) async {
try {
final response = await _dioClient.post(
_getListEndpoint,
data: {
'limit_page_length': limitPageLength,
'limit_start': limitStart,
'item_id': itemId,
},
);
// Handle response
if (response.statusCode == 200 || response.statusCode == 201) {
final data = response.data;
// Parse the message object
if (data is Map<String, dynamic> && data.containsKey('message')) {
final message = data['message'];
if (message is Map<String, dynamic>) {
// New API format with complete response
return ReviewResponseModel.fromJson(message);
} else {
throw const ParseException(
'Unexpected response format: message is not a map',
);
}
} else {
throw const ParseException(
'Unexpected response format: no message key',
);
}
} else {
throw NetworkException(
'Failed to fetch reviews',
statusCode: response.statusCode,
data: response.data,
);
}
} on DioException catch (e) {
throw _handleDioException(e);
} on ParseException {
rethrow;
} catch (e, stackTrace) {
throw UnknownException(
'Failed to fetch reviews: ${e.toString()}',
e,
stackTrace,
);
}
}
@override
Future<void> submitReview({
required String itemId,
required double rating,
required String comment,
String? name,
}) async {
try {
final data = {
'item_id': itemId,
'rating': rating,
'comment': comment,
if (name != null) 'name': name,
};
final response = await _dioClient.post(
_updateEndpoint,
data: data,
);
// Handle response
if (response.statusCode != 200 && response.statusCode != 201) {
throw NetworkException(
'Failed to submit review',
statusCode: response.statusCode,
data: response.data,
);
}
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e, stackTrace) {
throw UnknownException(
'Failed to submit review: ${e.toString()}',
e,
stackTrace,
);
}
}
@override
Future<void> deleteReview({required String name}) async {
try {
final response = await _dioClient.post(
_deleteEndpoint,
data: {'name': name},
);
// Handle response
if (response.statusCode != 200 && response.statusCode != 204) {
throw NetworkException(
'Failed to delete review',
statusCode: response.statusCode,
data: response.data,
);
}
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e, stackTrace) {
throw UnknownException(
'Failed to delete review: ${e.toString()}',
e,
stackTrace,
);
}
}
/// Handle Dio exceptions and convert to app exceptions
Exception _handleDioException(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return const TimeoutException();
case DioExceptionType.connectionError:
return const NoInternetException();
case DioExceptionType.badResponse:
final statusCode = e.response?.statusCode;
final data = e.response?.data;
// Extract error message from response if available
String? errorMessage;
if (data is Map<String, dynamic>) {
errorMessage = data['message'] as String? ??
data['error'] as String? ??
data['exc'] as String?;
}
switch (statusCode) {
case 400:
return BadRequestException(
errorMessage ?? 'Dữ liệu không hợp lệ',
);
case 401:
return UnauthorizedException(
errorMessage ?? 'Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.',
);
case 403:
return const ForbiddenException();
case 404:
return NotFoundException(
errorMessage ?? 'Không tìm thấy đánh giá',
);
case 409:
return ConflictException(
errorMessage ?? 'Đánh giá đã tồn tại',
);
case 429:
return const RateLimitException();
case 500:
case 502:
case 503:
case 504:
return ServerException(
errorMessage ?? 'Lỗi máy chủ. Vui lòng thử lại sau.',
statusCode,
);
default:
return NetworkException(
errorMessage ?? 'Lỗi mạng không xác định',
statusCode: statusCode,
data: data,
);
}
case DioExceptionType.cancel:
return const NetworkException('Yêu cầu đã bị hủy');
case DioExceptionType.badCertificate:
return const NetworkException('Lỗi chứng chỉ SSL');
case DioExceptionType.unknown:
default:
return NetworkException(
'Lỗi kết nối: ${e.message ?? "Unknown error"}',
);
}
}
}

View File

@@ -0,0 +1,221 @@
/// Data Model: Review
///
/// JSON serializable model for review data from API.
library;
import 'package:worker/features/reviews/domain/entities/review.dart';
/// Review data model
///
/// Handles JSON serialization/deserialization for review data from the API.
///
/// API Response format (assumed based on common Frappe patterns):
/// ```json
/// {
/// "name": "ITEM-{item_id}-{user_email}",
/// "item_id": "GIB20 G04",
/// "rating": 0.5,
/// "comment": "Good product",
/// "owner": "user@example.com",
/// "creation": "2024-11-17 10:30:00",
/// "modified": "2024-11-17 10:30:00"
/// }
/// ```
class ReviewModel {
const ReviewModel({
required this.name,
required this.itemId,
required this.rating,
required this.comment,
this.owner,
this.ownerFullName,
this.creation,
this.modified,
});
/// Unique review identifier (format: ITEM-{item_id}-{user_email})
final String name;
/// Product item code
final String itemId;
/// Rating (0-1 scale from API)
final double rating;
/// Review comment text
final String comment;
/// Email of the review owner
final String? owner;
/// Full name of the review owner (if available)
final String? ownerFullName;
/// ISO 8601 timestamp when review was created
final String? creation;
/// ISO 8601 timestamp when review was last modified
final String? modified;
/// Create model from JSON
factory ReviewModel.fromJson(Map<String, dynamic> json) {
// Handle nested user object if present
String? ownerEmail;
String? fullName;
if (json.containsKey('user') && json['user'] is Map<String, dynamic>) {
final user = json['user'] as Map<String, dynamic>;
ownerEmail = user['name'] as String?;
fullName = user['full_name'] as String?;
} else {
ownerEmail = json['owner'] as String?;
fullName = json['owner_full_name'] as String? ?? json['full_name'] as String?;
}
return ReviewModel(
name: json['name'] as String,
itemId: json['item_id'] as String? ?? '',
rating: (json['rating'] as num).toDouble(),
comment: json['comment'] as String? ?? '',
owner: ownerEmail,
ownerFullName: fullName,
creation: json['creation'] as String?,
modified: json['modified'] as String?,
);
}
/// Convert model to JSON
Map<String, dynamic> toJson() {
return {
'name': name,
'item_id': itemId,
'rating': rating,
'comment': comment,
if (owner != null) 'owner': owner,
if (ownerFullName != null) 'owner_full_name': ownerFullName,
if (creation != null) 'creation': creation,
if (modified != null) 'modified': modified,
};
}
/// Convert to domain entity
Review toEntity() {
return Review(
id: name,
itemId: itemId,
rating: rating,
comment: comment,
reviewerName: ownerFullName ?? _extractNameFromEmail(owner),
reviewerEmail: owner,
reviewDate: creation != null ? _parseDateTime(creation!) : null,
);
}
/// Create model from domain entity
factory ReviewModel.fromEntity(Review entity) {
return ReviewModel(
name: entity.id,
itemId: entity.itemId,
rating: entity.rating,
comment: entity.comment,
owner: entity.reviewerEmail,
ownerFullName: entity.reviewerName,
creation: entity.reviewDate?.toIso8601String(),
);
}
/// Extract name from email (fallback if full name not available)
///
/// Example: "john.doe@example.com" -> "John Doe"
String? _extractNameFromEmail(String? email) {
if (email == null) return null;
final username = email.split('@').first;
final parts = username.split('.');
return parts
.map((part) => part.isEmpty
? ''
: part[0].toUpperCase() + part.substring(1).toLowerCase())
.join(' ');
}
/// Parse datetime string from API
///
/// Handles common formats:
/// - ISO 8601: "2024-11-17T10:30:00"
/// - Frappe format: "2024-11-17 10:30:00"
DateTime? _parseDateTime(String dateString) {
try {
// Try ISO 8601 first
return DateTime.tryParse(dateString);
} catch (e) {
try {
// Try replacing space with T for ISO 8601 compatibility
final normalized = dateString.replaceFirst(' ', 'T');
return DateTime.tryParse(normalized);
} catch (e) {
return null;
}
}
}
/// Copy with method
ReviewModel copyWith({
String? name,
String? itemId,
double? rating,
String? comment,
String? owner,
String? ownerFullName,
String? creation,
String? modified,
}) {
return ReviewModel(
name: name ?? this.name,
itemId: itemId ?? this.itemId,
rating: rating ?? this.rating,
comment: comment ?? this.comment,
owner: owner ?? this.owner,
ownerFullName: ownerFullName ?? this.ownerFullName,
creation: creation ?? this.creation,
modified: modified ?? this.modified,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ReviewModel &&
other.name == name &&
other.itemId == itemId &&
other.rating == rating &&
other.comment == comment &&
other.owner == owner &&
other.ownerFullName == ownerFullName &&
other.creation == creation &&
other.modified == modified;
}
@override
int get hashCode {
return Object.hash(
name,
itemId,
rating,
comment,
owner,
ownerFullName,
creation,
modified,
);
}
@override
String toString() {
return 'ReviewModel(name: $name, itemId: $itemId, rating: $rating, '
'comment: ${comment.substring(0, comment.length > 30 ? 30 : comment.length)}..., '
'owner: $owner, creation: $creation)';
}
}

View File

@@ -0,0 +1,79 @@
/// Data Model: Review Response
///
/// Complete API response including reviews, statistics, and user feedback status.
library;
import 'package:worker/features/reviews/data/models/review_model.dart';
import 'package:worker/features/reviews/data/models/review_statistics_model.dart';
/// Review response data model
///
/// Wraps the complete API response structure:
/// ```json
/// {
/// "feedbacks": [...],
/// "is_already_feedback": false,
/// "my_feedback": {...} or null,
/// "statistics": {...}
/// }
/// ```
class ReviewResponseModel {
const ReviewResponseModel({
required this.feedbacks,
required this.isAlreadyFeedback,
required this.statistics,
this.myFeedback,
});
/// List of all reviews/feedbacks
final List<ReviewModel> feedbacks;
/// Whether current user has already submitted feedback
final bool isAlreadyFeedback;
/// Current user's feedback (if exists)
final ReviewModel? myFeedback;
/// Aggregate statistics
final ReviewStatisticsModel statistics;
/// Create model from JSON
factory ReviewResponseModel.fromJson(Map<String, dynamic> json) {
final feedbacksList = json['feedbacks'] as List<dynamic>? ?? [];
final feedbacks = feedbacksList
.map((item) => ReviewModel.fromJson(item as Map<String, dynamic>))
.toList();
ReviewModel? myFeedback;
if (json['my_feedback'] != null && json['my_feedback'] is Map<String, dynamic>) {
myFeedback = ReviewModel.fromJson(json['my_feedback'] as Map<String, dynamic>);
}
final statistics = json['statistics'] != null && json['statistics'] is Map<String, dynamic>
? ReviewStatisticsModel.fromJson(json['statistics'] as Map<String, dynamic>)
: const ReviewStatisticsModel(totalFeedback: 0, averageRating: 0.0);
return ReviewResponseModel(
feedbacks: feedbacks,
isAlreadyFeedback: json['is_already_feedback'] as bool? ?? false,
myFeedback: myFeedback,
statistics: statistics,
);
}
/// Convert model to JSON
Map<String, dynamic> toJson() {
return {
'feedbacks': feedbacks.map((r) => r.toJson()).toList(),
'is_already_feedback': isAlreadyFeedback,
'my_feedback': myFeedback?.toJson(),
'statistics': statistics.toJson(),
};
}
@override
String toString() {
return 'ReviewResponseModel(feedbacks: ${feedbacks.length}, '
'isAlreadyFeedback: $isAlreadyFeedback, statistics: $statistics)';
}
}

View File

@@ -0,0 +1,79 @@
/// Data Model: Review Statistics
///
/// JSON serializable model for review statistics from API.
library;
import 'package:worker/features/reviews/domain/entities/review_statistics.dart';
/// Review statistics data model
///
/// Handles JSON serialization/deserialization for review statistics.
///
/// API Response format:
/// ```json
/// {
/// "total_feedback": 2,
/// "average_rating": 2.25
/// }
/// ```
class ReviewStatisticsModel {
const ReviewStatisticsModel({
required this.totalFeedback,
required this.averageRating,
});
/// Total number of reviews/feedbacks
final int totalFeedback;
/// Average rating (0-5 scale from API)
final double averageRating;
/// Create model from JSON
factory ReviewStatisticsModel.fromJson(Map<String, dynamic> json) {
return ReviewStatisticsModel(
totalFeedback: json['total_feedback'] as int? ?? 0,
averageRating: (json['average_rating'] as num?)?.toDouble() ?? 0.0,
);
}
/// Convert model to JSON
Map<String, dynamic> toJson() {
return {
'total_feedback': totalFeedback,
'average_rating': averageRating,
};
}
/// Convert to domain entity
ReviewStatistics toEntity() {
return ReviewStatistics(
totalFeedback: totalFeedback,
averageRating: averageRating,
);
}
/// Create model from domain entity
factory ReviewStatisticsModel.fromEntity(ReviewStatistics entity) {
return ReviewStatisticsModel(
totalFeedback: entity.totalFeedback,
averageRating: entity.averageRating,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ReviewStatisticsModel &&
other.totalFeedback == totalFeedback &&
other.averageRating == averageRating;
}
@override
int get hashCode => Object.hash(totalFeedback, averageRating);
@override
String toString() {
return 'ReviewStatisticsModel(totalFeedback: $totalFeedback, averageRating: $averageRating)';
}
}

View File

@@ -0,0 +1,75 @@
/// Repository Implementation: Reviews
///
/// Implements the reviews repository interface.
library;
import 'package:worker/features/reviews/data/datasources/reviews_remote_datasource.dart';
import 'package:worker/features/reviews/domain/entities/review.dart';
import 'package:worker/features/reviews/domain/entities/review_statistics.dart';
import 'package:worker/features/reviews/domain/repositories/reviews_repository.dart';
/// Reviews repository implementation
class ReviewsRepositoryImpl implements ReviewsRepository {
const ReviewsRepositoryImpl(this._remoteDataSource);
final ReviewsRemoteDataSource _remoteDataSource;
@override
Future<List<Review>> getProductReviews({
required String itemId,
int limitPageLength = 10,
int limitStart = 0,
}) async {
final models = await _remoteDataSource.getProductReviews(
itemId: itemId,
limitPageLength: limitPageLength,
limitStart: limitStart,
);
// Convert models to entities and sort by date (newest first)
final reviews = models.map((model) => model.toEntity()).toList();
reviews.sort((a, b) {
if (a.reviewDate == null && b.reviewDate == null) return 0;
if (a.reviewDate == null) return 1;
if (b.reviewDate == null) return -1;
return b.reviewDate!.compareTo(a.reviewDate!);
});
return reviews;
}
@override
Future<ReviewStatistics> getProductReviewStatistics({
required String itemId,
}) async {
final response = await _remoteDataSource.getProductReviewsWithStats(
itemId: itemId,
limitPageLength: 50, // Get enough reviews to calculate stats
limitStart: 0,
);
// Return statistics from API (already calculated server-side)
return response.statistics.toEntity();
}
@override
Future<void> submitReview({
required String itemId,
required double rating,
required String comment,
String? name,
}) async {
await _remoteDataSource.submitReview(
itemId: itemId,
rating: rating,
comment: comment,
name: name,
);
}
@override
Future<void> deleteReview({required String name}) async {
await _remoteDataSource.deleteReview(name: name);
}
}