This commit is contained in:
Phuoc Nguyen
2025-11-27 14:59:48 +07:00
parent dc8e60f589
commit ba04576750
25 changed files with 931 additions and 721 deletions

View File

@@ -0,0 +1,47 @@
/// Project Status Local Data Source
///
/// Handles local caching of project status list using Hive.
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/projects/data/models/project_status_model.dart';
/// Project Status Local Data Source
class ProjectStatusLocalDataSource {
/// Get Hive box for project statuses
Box<dynamic> get _box => Hive.box(HiveBoxNames.projectStatusBox);
/// Save project status list to cache
Future<void> cacheStatusList(List<ProjectStatusModel> statuses) async {
// Clear existing cache
await _box.clear();
// Save each status with its index as key
for (final status in statuses) {
await _box.put(status.index, status);
}
}
/// Get cached project status list
List<ProjectStatusModel> getCachedStatusList() {
try {
final values = _box.values.whereType<ProjectStatusModel>().toList()
// Sort by index
..sort((a, b) => a.index.compareTo(b.index));
return values;
} catch (e) {
return [];
}
}
/// Check if cache exists and is not empty
bool hasCachedData() {
return _box.isNotEmpty;
}
/// Clear all cached statuses
Future<void> clearCache() async {
await _box.clear();
}
}

View File

@@ -3,166 +3,107 @@
/// Handles remote API calls for project submissions.
library;
import 'package:worker/features/projects/domain/entities/project_submission.dart';
import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/network/dio_client.dart';
import 'package:worker/features/projects/data/models/project_status_model.dart';
import 'package:worker/features/projects/data/models/project_submission_model.dart';
/// Submissions Remote Data Source
///
/// Abstract interface for remote submissions operations.
/// Interface for remote project submission operations.
abstract class SubmissionsRemoteDataSource {
/// Fetch project status list from API
Future<List<ProjectStatusModel>> getProjectStatusList();
/// Fetch all submissions from remote API
Future<List<ProjectSubmission>> getSubmissions();
/// Fetch a single submission by ID
Future<ProjectSubmission> getSubmissionById(String submissionId);
/// Create a new submission
Future<ProjectSubmission> createSubmission(ProjectSubmission submission);
/// Update an existing submission
Future<ProjectSubmission> updateSubmission(ProjectSubmission submission);
/// Delete a submission
Future<void> deleteSubmission(String submissionId);
Future<List<ProjectSubmissionModel>> getSubmissions({
int limitStart = 0,
int limitPageLength = 0,
});
}
/// Mock Implementation of Submissions Remote Data Source
/// Submissions Remote Data Source Implementation
///
/// Provides mock data for development and testing.
/// Uses Frappe API endpoints for project submissions.
class SubmissionsRemoteDataSourceImpl implements SubmissionsRemoteDataSource {
@override
Future<List<ProjectSubmission>> getSubmissions() async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 500));
const SubmissionsRemoteDataSourceImpl(this._dioClient);
return [
ProjectSubmission(
submissionId: 'DA001',
userId: 'user123',
projectName: 'Chung cư Vinhomes Grand Park - Block A1',
projectAddress: 'TP.HCM',
projectValue: 850000000,
projectType: ProjectType.residential,
status: SubmissionStatus.approved,
beforePhotos: [],
afterPhotos: [],
invoices: [],
submittedAt: DateTime(2023, 11, 15),
reviewedAt: DateTime(2023, 11, 20),
pointsEarned: 8500,
),
ProjectSubmission(
submissionId: 'DA002',
userId: 'user123',
projectName: 'Trung tâm thương mại Bitexco',
projectAddress: 'TP.HCM',
projectValue: 2200000000,
projectType: ProjectType.commercial,
status: SubmissionStatus.pending,
beforePhotos: [],
afterPhotos: [],
invoices: [],
submittedAt: DateTime(2023, 11, 25),
),
ProjectSubmission(
submissionId: 'DA003',
userId: 'user123',
projectName: 'Biệt thự sinh thái Ecopark',
projectAddress: 'Hà Nội',
projectValue: 420000000,
projectType: ProjectType.residential,
status: SubmissionStatus.approved,
beforePhotos: [],
afterPhotos: [],
invoices: [],
submittedAt: DateTime(2023, 10, 10),
reviewedAt: DateTime(2023, 10, 15),
pointsEarned: 4200,
),
ProjectSubmission(
submissionId: 'DA004',
userId: 'user123',
projectName: 'Nhà xưởng sản xuất ABC',
projectAddress: 'Bình Dương',
projectValue: 1500000000,
projectType: ProjectType.industrial,
status: SubmissionStatus.rejected,
beforePhotos: [],
afterPhotos: [],
invoices: [],
submittedAt: DateTime(2023, 11, 20),
reviewedAt: DateTime(2023, 11, 28),
rejectionReason: 'Thiếu giấy phép xây dựng và báo cáo tác động môi trường',
),
ProjectSubmission(
submissionId: 'DA005',
userId: 'user123',
projectName: 'Khách sạn 5 sao Diamond Plaza',
projectAddress: 'Đà Nẵng',
projectValue: 5800000000,
projectType: ProjectType.commercial,
status: SubmissionStatus.pending,
beforePhotos: [],
afterPhotos: [],
invoices: [],
submittedAt: DateTime(2023, 12, 1),
),
ProjectSubmission(
submissionId: 'DA006',
userId: 'user123',
projectName: 'Khu đô thị thông minh Smart City',
projectAddress: 'Hà Nội',
projectValue: 8500000000,
projectType: ProjectType.residential,
status: SubmissionStatus.approved,
beforePhotos: [],
afterPhotos: [],
invoices: [],
submittedAt: DateTime(2023, 11, 10),
reviewedAt: DateTime(2023, 11, 18),
pointsEarned: 85000,
),
];
final DioClient _dioClient;
/// Get project status list
///
/// Calls: POST /api/method/building_material.building_material.api.project.get_project_status_list
/// Body: { "limit_start": 0, "limit_page_length": 0 }
/// Returns: List of project statuses with labels and colors
@override
Future<List<ProjectStatusModel>> getProjectStatusList() async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.getProjectStatusList}',
data: <String, dynamic>{
'limit_start': 0,
'limit_page_length': 0,
},
);
final data = response.data;
if (data == null) {
throw Exception('No data received from getProjectStatusList API');
}
// API returns: { "message": [...] }
final message = data['message'];
if (message == null) {
throw Exception('No message field in getProjectStatusList response');
}
final List<dynamic> statusList = message as List<dynamic>;
return statusList
.map((json) =>
ProjectStatusModel.fromJson(json as Map<String, dynamic>))
.toList();
} catch (e) {
throw Exception('Failed to get project status list: $e');
}
}
/// Get list of project submissions
///
/// Calls: POST /api/method/building_material.building_material.api.project.get_list
/// Body: { "limit_start": 0, "limit_page_length": 0 }
/// Returns: List of project submissions
@override
Future<ProjectSubmission> getSubmissionById(String submissionId) async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 300));
Future<List<ProjectSubmissionModel>> getSubmissions({
int limitStart = 0,
int limitPageLength = 0,
}) async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.getProjectList}',
data: {
'limit_start': limitStart,
'limit_page_length': limitPageLength,
},
);
final submissions = await getSubmissions();
return submissions.firstWhere(
(s) => s.submissionId == submissionId,
orElse: () => throw Exception('Submission not found'),
);
}
final data = response.data;
if (data == null) {
throw Exception('No data received from getProjectList API');
}
@override
Future<ProjectSubmission> createSubmission(
ProjectSubmission submission,
) async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 800));
// API returns: { "message": [...] }
final message = data['message'];
if (message == null) {
throw Exception('No message field in getProjectList response');
}
// In real implementation, this would call the API
return submission;
}
@override
Future<ProjectSubmission> updateSubmission(
ProjectSubmission submission,
) async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 600));
// In real implementation, this would call the API
return submission;
}
@override
Future<void> deleteSubmission(String submissionId) async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 400));
// In real implementation, this would call the API
final List<dynamic> submissionsList = message as List<dynamic>;
return submissionsList
.map((json) =>
ProjectSubmissionModel.fromJson(json as Map<String, dynamic>))
.toList();
} catch (e) {
throw Exception('Failed to get project submissions: $e');
}
}
}

View File

@@ -0,0 +1,73 @@
/// Project Status Model
///
/// Data model for project status from API responses with Hive caching.
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/projects/domain/entities/project_status.dart';
part 'project_status_model.g.dart';
/// Project Status Model - Type ID: 63
@HiveType(typeId: HiveTypeIds.projectStatusModel)
class ProjectStatusModel extends HiveObject {
@HiveField(0)
final String status;
@HiveField(1)
final String label;
@HiveField(2)
final String color;
@HiveField(3)
final int index;
ProjectStatusModel({
required this.status,
required this.label,
required this.color,
required this.index,
});
/// Create from JSON
factory ProjectStatusModel.fromJson(Map<String, dynamic> json) {
return ProjectStatusModel(
status: json['status'] as String,
label: json['label'] as String,
color: json['color'] as String,
index: json['index'] as int,
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'status': status,
'label': label,
'color': color,
'index': index,
};
}
/// Convert to entity
ProjectStatus toEntity() {
return ProjectStatus(
status: status,
label: label,
color: color,
index: index,
);
}
/// Create from entity
factory ProjectStatusModel.fromEntity(ProjectStatus entity) {
return ProjectStatusModel(
status: entity.status,
label: entity.label,
color: entity.color,
index: entity.index,
);
}
}

View File

@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'project_status_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ProjectStatusModelAdapter extends TypeAdapter<ProjectStatusModel> {
@override
final typeId = 63;
@override
ProjectStatusModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ProjectStatusModel(
status: fields[0] as String,
label: fields[1] as String,
color: fields[2] as String,
index: (fields[3] as num).toInt(),
);
}
@override
void write(BinaryWriter writer, ProjectStatusModel obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.status)
..writeByte(1)
..write(obj.label)
..writeByte(2)
..write(obj.color)
..writeByte(3)
..write(obj.index);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProjectStatusModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -1,129 +1,105 @@
import 'dart:convert';
/// Project Submission Model
///
/// Data model for project submission from API responses with Hive caching.
/// Based on API response from building_material.building_material.api.project.get_list
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/core/database/models/enums.dart';
import 'package:worker/features/projects/domain/entities/project_submission.dart';
part 'project_submission_model.g.dart';
/// Project Submission Model - Type ID: 14
@HiveType(typeId: HiveTypeIds.projectSubmissionModel)
class ProjectSubmissionModel extends HiveObject {
ProjectSubmissionModel({
required this.submissionId,
required this.userId,
required this.projectName,
required this.projectAddress,
required this.projectValue,
required this.projectType,
this.beforePhotos,
this.afterPhotos,
this.invoices,
required this.status,
this.reviewNotes,
this.rejectionReason,
this.pointsEarned,
required this.submittedAt,
this.reviewedAt,
this.reviewedBy,
});
/// Unique submission identifier (API: name)
@HiveField(0)
final String submissionId;
/// Project name/title (API: designed_area)
@HiveField(1)
final String userId;
final String designedArea;
/// Design area value in square meters (API: design_area)
@HiveField(2)
final String projectName;
final double designArea;
/// Submission/request date (API: request_date)
@HiveField(3)
final String projectAddress;
final DateTime requestDate;
/// Status label - Vietnamese (API: status)
@HiveField(4)
final double projectValue;
final String status;
/// Rejection reason if rejected (API: reason_for_rejection)
@HiveField(5)
final ProjectType projectType;
final String? reasonForRejection;
/// Status color indicator (API: status_color)
@HiveField(6)
final String? beforePhotos;
@HiveField(7)
final String? afterPhotos;
@HiveField(8)
final String? invoices;
@HiveField(9)
final SubmissionStatus status;
@HiveField(10)
final String? reviewNotes;
@HiveField(11)
final String? rejectionReason;
@HiveField(12)
final int? pointsEarned;
@HiveField(13)
final DateTime submittedAt;
@HiveField(14)
final DateTime? reviewedAt;
@HiveField(15)
final String? reviewedBy;
final String statusColor;
factory ProjectSubmissionModel.fromJson(
Map<String, dynamic> json,
) => ProjectSubmissionModel(
submissionId: json['submission_id'] as String,
userId: json['user_id'] as String,
projectName: json['project_name'] as String,
projectAddress: json['project_address'] as String,
projectValue: (json['project_value'] as num).toDouble(),
projectType: ProjectType.values.firstWhere(
(e) => e.name == json['project_type'],
),
beforePhotos: json['before_photos'] != null
? jsonEncode(json['before_photos'])
: null,
afterPhotos: json['after_photos'] != null
? jsonEncode(json['after_photos'])
: null,
invoices: json['invoices'] != null ? jsonEncode(json['invoices']) : null,
status: SubmissionStatus.values.firstWhere((e) => e.name == json['status']),
reviewNotes: json['review_notes'] as String?,
rejectionReason: json['rejection_reason'] as String?,
pointsEarned: json['points_earned'] as int?,
submittedAt: DateTime.parse(json['submitted_at']?.toString() ?? ''),
reviewedAt: json['reviewed_at'] != null
? DateTime.parse(json['reviewed_at']?.toString() ?? '')
: null,
reviewedBy: json['reviewed_by'] as String?,
);
ProjectSubmissionModel({
required this.submissionId,
required this.designedArea,
required this.designArea,
required this.requestDate,
required this.status,
this.reasonForRejection,
required this.statusColor,
});
Map<String, dynamic> toJson() => {
'submission_id': submissionId,
'user_id': userId,
'project_name': projectName,
'project_address': projectAddress,
'project_value': projectValue,
'project_type': projectType.name,
'before_photos': beforePhotos != null ? jsonDecode(beforePhotos!) : null,
'after_photos': afterPhotos != null ? jsonDecode(afterPhotos!) : null,
'invoices': invoices != null ? jsonDecode(invoices!) : null,
'status': status.name,
'review_notes': reviewNotes,
'rejection_reason': rejectionReason,
'points_earned': pointsEarned,
'submitted_at': submittedAt.toIso8601String(),
'reviewed_at': reviewedAt?.toIso8601String(),
'reviewed_by': reviewedBy,
};
List<String>? get beforePhotosList {
if (beforePhotos == null) return null;
try {
final decoded = jsonDecode(beforePhotos!) as List;
return decoded.map((e) => e.toString()).toList();
} catch (e) {
return null;
}
/// Create from JSON (API response)
factory ProjectSubmissionModel.fromJson(Map<String, dynamic> json) {
return ProjectSubmissionModel(
submissionId: json['name'] as String,
designedArea: json['designed_area'] as String,
designArea: (json['design_area'] as num).toDouble(),
requestDate: DateTime.parse(json['request_date'] as String),
status: json['status'] as String,
reasonForRejection: json['reason_for_rejection'] as String?,
statusColor: json['status_color'] as String,
);
}
List<String>? get afterPhotosList {
if (afterPhotos == null) return null;
try {
final decoded = jsonDecode(afterPhotos!) as List;
return decoded.map((e) => e.toString()).toList();
} catch (e) {
return null;
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'name': submissionId,
'designed_area': designedArea,
'design_area': designArea,
'request_date': requestDate.toIso8601String(),
'status': status,
'reason_for_rejection': reasonForRejection,
'status_color': statusColor,
};
}
/// Convert to entity
ProjectSubmission toEntity() {
return ProjectSubmission(
submissionId: submissionId,
designedArea: designedArea,
designArea: designArea,
requestDate: requestDate,
status: status,
reasonForRejection: reasonForRejection,
statusColor: statusColor,
);
}
/// Create from entity
factory ProjectSubmissionModel.fromEntity(ProjectSubmission entity) {
return ProjectSubmissionModel(
submissionId: entity.submissionId,
designedArea: entity.designedArea,
designArea: entity.designArea,
requestDate: entity.requestDate,
status: entity.status,
reasonForRejection: entity.reasonForRejection,
statusColor: entity.statusColor,
);
}
}

View File

@@ -19,60 +19,33 @@ class ProjectSubmissionModelAdapter
};
return ProjectSubmissionModel(
submissionId: fields[0] as String,
userId: fields[1] as String,
projectName: fields[2] as String,
projectAddress: fields[3] as String,
projectValue: (fields[4] as num).toDouble(),
projectType: fields[5] as ProjectType,
beforePhotos: fields[6] as String?,
afterPhotos: fields[7] as String?,
invoices: fields[8] as String?,
status: fields[9] as SubmissionStatus,
reviewNotes: fields[10] as String?,
rejectionReason: fields[11] as String?,
pointsEarned: (fields[12] as num?)?.toInt(),
submittedAt: fields[13] as DateTime,
reviewedAt: fields[14] as DateTime?,
reviewedBy: fields[15] as String?,
designedArea: fields[1] as String,
designArea: (fields[2] as num).toDouble(),
requestDate: fields[3] as DateTime,
status: fields[4] as String,
reasonForRejection: fields[5] as String?,
statusColor: fields[6] as String,
);
}
@override
void write(BinaryWriter writer, ProjectSubmissionModel obj) {
writer
..writeByte(16)
..writeByte(7)
..writeByte(0)
..write(obj.submissionId)
..writeByte(1)
..write(obj.userId)
..write(obj.designedArea)
..writeByte(2)
..write(obj.projectName)
..write(obj.designArea)
..writeByte(3)
..write(obj.projectAddress)
..write(obj.requestDate)
..writeByte(4)
..write(obj.projectValue)
..writeByte(5)
..write(obj.projectType)
..writeByte(6)
..write(obj.beforePhotos)
..writeByte(7)
..write(obj.afterPhotos)
..writeByte(8)
..write(obj.invoices)
..writeByte(9)
..write(obj.status)
..writeByte(10)
..write(obj.reviewNotes)
..writeByte(11)
..write(obj.rejectionReason)
..writeByte(12)
..write(obj.pointsEarned)
..writeByte(13)
..write(obj.submittedAt)
..writeByte(14)
..write(obj.reviewedAt)
..writeByte(15)
..write(obj.reviewedBy);
..writeByte(5)
..write(obj.reasonForRejection)
..writeByte(6)
..write(obj.statusColor);
}
@override

View File

@@ -1,66 +1,85 @@
/// Submissions Repository Implementation
///
/// Implements the submissions repository interface.
/// Implements the submissions repository interface with caching support.
library;
import 'package:worker/features/projects/data/datasources/project_status_local_datasource.dart';
import 'package:worker/features/projects/data/datasources/submissions_remote_datasource.dart';
import 'package:worker/features/projects/domain/entities/project_status.dart';
import 'package:worker/features/projects/domain/entities/project_submission.dart';
import 'package:worker/features/projects/domain/repositories/submissions_repository.dart';
/// Submissions Repository Implementation
///
/// Handles data operations for project submissions.
/// Handles data operations for project submissions with cache-first pattern.
class SubmissionsRepositoryImpl implements SubmissionsRepository {
const SubmissionsRepositoryImpl(
this._remoteDataSource,
this._statusLocalDataSource,
);
const SubmissionsRepositoryImpl(this._remoteDataSource);
final SubmissionsRemoteDataSource _remoteDataSource;
final ProjectStatusLocalDataSource _statusLocalDataSource;
/// Get project status list with cache-first pattern
///
/// 1. Return cached data if available
/// 2. Fetch from API in background and update cache
/// 3. If no cache, wait for API response
@override
Future<List<ProjectSubmission>> getSubmissions() async {
Future<List<ProjectStatus>> getProjectStatusList({
bool forceRefresh = false,
}) async {
// Check cache first (unless force refresh)
if (!forceRefresh && _statusLocalDataSource.hasCachedData()) {
final cachedStatuses = _statusLocalDataSource.getCachedStatusList();
if (cachedStatuses.isNotEmpty) {
// Return cached data immediately
// Also refresh cache in background (fire and forget)
_refreshStatusCache();
return cachedStatuses.map((model) => model.toEntity()).toList();
}
}
// No cache or force refresh - fetch from API
try {
return await _remoteDataSource.getSubmissions();
final statusModels = await _remoteDataSource.getProjectStatusList();
// Cache the result
await _statusLocalDataSource.cacheStatusList(statusModels);
return statusModels.map((model) => model.toEntity()).toList();
} catch (e) {
// In real implementation, handle errors properly
// For now, rethrow
// If API fails, try to return cached data as fallback
final cachedStatuses = _statusLocalDataSource.getCachedStatusList();
if (cachedStatuses.isNotEmpty) {
return cachedStatuses.map((model) => model.toEntity()).toList();
}
rethrow;
}
}
@override
Future<ProjectSubmission> getSubmissionById(String submissionId) async {
/// Refresh status cache in background
Future<void> _refreshStatusCache() async {
try {
return await _remoteDataSource.getSubmissionById(submissionId);
final statusModels = await _remoteDataSource.getProjectStatusList();
await _statusLocalDataSource.cacheStatusList(statusModels);
} catch (e) {
rethrow;
// Silently fail - we already returned cached data
}
}
@override
Future<ProjectSubmission> createSubmission(
ProjectSubmission submission,
) async {
Future<List<ProjectSubmission>> getSubmissions({
int limitStart = 0,
int limitPageLength = 0,
}) async {
try {
return await _remoteDataSource.createSubmission(submission);
} catch (e) {
rethrow;
}
}
@override
Future<ProjectSubmission> updateSubmission(
ProjectSubmission submission,
) async {
try {
return await _remoteDataSource.updateSubmission(submission);
} catch (e) {
rethrow;
}
}
@override
Future<void> deleteSubmission(String submissionId) async {
try {
await _remoteDataSource.deleteSubmission(submissionId);
final submissionModels = await _remoteDataSource.getSubmissions(
limitStart: limitStart,
limitPageLength: limitPageLength,
);
return submissionModels.map((model) => model.toEntity()).toList();
} catch (e) {
rethrow;
}