create submission

This commit is contained in:
Phuoc Nguyen
2025-11-27 16:56:01 +07:00
parent ba04576750
commit b6cb9e865a
18 changed files with 1445 additions and 138 deletions

View File

@@ -62,3 +62,80 @@ curl --location 'https://land.dbiz.com//api/method/building_material.building_ma
} }
] ]
} }
#get project progress
curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; full_name=Ha%20Duy%20Lam; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; system_user=yes; user_id=lamhd%40gmail.com; user_image=/files/avatar_0986788766_1763627962.jpg' \
--header 'Content-Type: application/json' \
--data '{
"doctype": "Progress of construction",
"fields": ["name","status"],
"order_by": "number_of_display asc",
"limit_page_length": 0
}'
#response
{
"message": [
{
"name": "h6n0hat3o2",
"status": "Chưa khởi công"
},
{
"name": "k1mr565o91",
"status": "Khởi công móng"
},
{
"name": "2obpqokr8q",
"status": "Đang phần thô"
},
{
"name": "i5qkovb09j",
"status": "Đang hoàn thiện"
},
{
"name": "kdj1jjlr28",
"status": "Cất nóc"
},
{
"name": "254e3ealdf",
"status": "Hoàn thiện"
}
]
}
#create new project
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.project.save' \
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; full_name=Ha%20Duy%20Lam; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; system_user=yes; user_id=lamhd%40gmail.com; user_image=/files/avatar_0986788766_1763627962.jpg' \
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
--header 'Content-Type: application/json' \
--data '{
"name": "p9ti8veq2g",
"designed_area": "Sunrise Villa Phase 355",
"address_of_project": "123 Đường Võ Văn Kiệt, Quận 2, TP.HCM",
"project_owner": "Nguyễn Văn A",
"design_firm": "Studio Green",
"contruction_contractor": "CTCP Xây Dựng Minh Phú",
"design_area": 350.5,
"products_included_in_the_design": "Gạch ốp lát, sơn ngoại thất, \nkhóa thông minh",
"project_progress": "h6n0hat3o2",
"expected_commencement_date": "2026-01-15",
"description": "Yêu cầu phối màu mới cho khu vực hồ bơi",
"request_date": "2025-11-26 09:30:00"
}'
#upload image file for project
#docname is the project name returned from create new project
#file is the local path of the file to be uploaded
#other parameters can be kept as is
curl --location 'https://land.dbiz.com//api/method/upload_file' \
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; full_name=Ha%20Duy%20Lam; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; system_user=yes; user_id=lamhd%40gmail.com; user_image=/files/avatar_0986788766_1763627962.jpg' \
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
--form 'file=@"/C:/Users/tiennld/Downloads/76369094c7604b3e1271.jpg"' \
--form 'is_private="1"' \
--form 'folder="Home/Attachments"' \
--form 'doctype="Architectural Project"' \
--form 'docname="p9ti8veq2g"' \
--form 'optimize="true"'

View File

@@ -289,6 +289,25 @@ class ApiConstants {
static const String getProjectList = static const String getProjectList =
'/building_material.building_material.api.project.get_list'; '/building_material.building_material.api.project.get_list';
/// Save (create/update) project submission
/// POST /api/method/building_material.building_material.api.project.save
/// Body: {
/// "name": "...", // optional for new, required for update
/// "designed_area": "Project Name",
/// "address_of_project": "...",
/// "project_owner": "...",
/// "design_firm": "...",
/// "contruction_contractor": "...",
/// "design_area": 350.5,
/// "products_included_in_the_design": "...",
/// "project_progress": "progress_id", // from ProjectProgress.id
/// "expected_commencement_date": "2026-01-15",
/// "description": "...",
/// "request_date": "2025-11-26 09:30:00"
/// }
static const String saveProject =
'/building_material.building_material.api.project.save';
/// Create new project (legacy endpoint - may be deprecated) /// Create new project (legacy endpoint - may be deprecated)
/// POST /projects /// POST /projects
static const String createProject = '/projects'; static const String createProject = '/projects';

View File

@@ -67,6 +67,9 @@ class HiveBoxNames {
/// Project status list cache /// Project status list cache
static const String projectStatusBox = 'project_status_box'; static const String projectStatusBox = 'project_status_box';
/// Project progress list cache (construction stages)
static const String projectProgressBox = 'project_progress_box';
/// Get all box names for initialization /// Get all box names for initialization
static List<String> get allBoxes => [ static List<String> get allBoxes => [
userBox, userBox,
@@ -81,6 +84,7 @@ class HiveBoxNames {
wardBox, wardBox,
orderStatusBox, orderStatusBox,
projectStatusBox, projectStatusBox,
projectProgressBox,
settingsBox, settingsBox,
cacheBox, cacheBox,
syncStateBox, syncStateBox,
@@ -144,6 +148,7 @@ class HiveTypeIds {
static const int wardModel = 32; static const int wardModel = 32;
static const int orderStatusModel = 62; static const int orderStatusModel = 62;
static const int projectStatusModel = 63; static const int projectStatusModel = 63;
static const int projectProgressModel = 64;
// Enums (33-61) // Enums (33-61)
static const int userRole = 33; static const int userRole = 33;

View File

@@ -111,6 +111,9 @@ class HiveService {
debugPrint( debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectStatusModel) ? "" : ""} ProjectStatusModel adapter', 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectStatusModel) ? "" : ""} ProjectStatusModel adapter',
); );
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectProgressModel) ? "" : ""} ProjectProgressModel adapter',
);
debugPrint( debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.entryType) ? "" : ""} EntryType adapter', 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.entryType) ? "" : ""} EntryType adapter',
); );
@@ -180,6 +183,9 @@ class HiveService {
// Project status box (non-sensitive) - caches project status list from API // Project status box (non-sensitive) - caches project status list from API
Hive.openBox<dynamic>(HiveBoxNames.projectStatusBox), Hive.openBox<dynamic>(HiveBoxNames.projectStatusBox),
// Project progress box (non-sensitive) - caches construction progress stages from API
Hive.openBox<dynamic>(HiveBoxNames.projectProgressBox),
]); ]);
// Open potentially encrypted boxes (sensitive data) // Open potentially encrypted boxes (sensitive data)

View File

@@ -569,7 +569,7 @@ Future<AuthInterceptor> authInterceptor(Ref ref, Dio dio) async {
@riverpod @riverpod
LoggingInterceptor loggingInterceptor(Ref ref) { LoggingInterceptor loggingInterceptor(Ref ref) {
// Only enable logging in debug mode // Only enable logging in debug mode
const bool isDebug = false; // TODO: Replace with kDebugMode from Flutter const bool isDebug = true; // TODO: Replace with kDebugMode from Flutter
return LoggingInterceptor( return LoggingInterceptor(
enableRequestLogging: false, enableRequestLogging: false,

View File

@@ -0,0 +1,45 @@
/// Project Progress Local Data Source
///
/// Handles local caching of project progress 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_progress_model.dart';
/// Project Progress Local Data Source
class ProjectProgressLocalDataSource {
/// Get Hive box for project progress
Box<dynamic> get _box => Hive.box(HiveBoxNames.projectProgressBox);
/// Save project progress list to cache
Future<void> cacheProgressList(List<ProjectProgressModel> progressList) async {
// Clear existing cache
await _box.clear();
// Save each progress with its id as key
for (final progress in progressList) {
await _box.put(progress.id, progress);
}
}
/// Get cached project progress list
List<ProjectProgressModel> getCachedProgressList() {
try {
final values = _box.values.whereType<ProjectProgressModel>().toList();
return values;
} catch (e) {
return [];
}
}
/// Check if cache exists and is not empty
bool hasCachedData() {
return _box.isNotEmpty;
}
/// Clear all cached progress
Future<void> clearCache() async {
await _box.clear();
}
}

View File

@@ -3,10 +3,13 @@
/// Handles remote API calls for project submissions. /// Handles remote API calls for project submissions.
library; library;
import 'package:dio/dio.dart';
import 'package:worker/core/constants/api_constants.dart'; 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/features/projects/data/models/project_progress_model.dart';
import 'package:worker/features/projects/data/models/project_status_model.dart'; import 'package:worker/features/projects/data/models/project_status_model.dart';
import 'package:worker/features/projects/data/models/project_submission_model.dart'; import 'package:worker/features/projects/data/models/project_submission_model.dart';
import 'package:worker/features/projects/data/models/project_submission_request.dart';
/// Submissions Remote Data Source /// Submissions Remote Data Source
/// ///
@@ -15,11 +18,27 @@ abstract class SubmissionsRemoteDataSource {
/// Fetch project status list from API /// Fetch project status list from API
Future<List<ProjectStatusModel>> getProjectStatusList(); Future<List<ProjectStatusModel>> getProjectStatusList();
/// Fetch project progress list from API (construction stages)
Future<List<ProjectProgressModel>> getProjectProgressList();
/// Fetch all submissions from remote API /// Fetch all submissions from remote API
Future<List<ProjectSubmissionModel>> getSubmissions({ Future<List<ProjectSubmissionModel>> getSubmissions({
int limitStart = 0, int limitStart = 0,
int limitPageLength = 0, int limitPageLength = 0,
}); });
/// Create or update a project submission
/// Returns the project name (ID) from the API response
Future<String> saveSubmission(ProjectSubmissionRequest request);
/// Upload a file for a project submission
/// [projectName] is the project ID returned from saveSubmission
/// [filePath] is the local path to the file
/// Returns the uploaded file URL
Future<String> uploadProjectFile({
required String projectName,
required String filePath,
});
} }
/// Submissions Remote Data Source Implementation /// Submissions Remote Data Source Implementation
@@ -67,6 +86,50 @@ class SubmissionsRemoteDataSourceImpl implements SubmissionsRemoteDataSource {
} }
} }
/// Get project progress list (construction stages)
///
/// Calls: POST /api/method/frappe.client.get_list
/// Body: {
/// "doctype": "Progress of construction",
/// "fields": ["name", "status"],
/// "order_by": "number_of_display asc",
/// "limit_page_length": 0
/// }
/// Returns: List of construction progress stages
@override
Future<List<ProjectProgressModel>> getProjectProgressList() async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}',
data: {
'doctype': 'Progress of construction',
'fields': ['name', 'status'],
'order_by': 'number_of_display asc',
'limit_page_length': 0,
},
);
final data = response.data;
if (data == null) {
throw Exception('No data received from getProjectProgressList API');
}
// API returns: { "message": [...] }
final message = data['message'];
if (message == null) {
throw Exception('No message field in getProjectProgressList response');
}
final List<dynamic> progressList = message as List<dynamic>;
return progressList
.map((json) =>
ProjectProgressModel.fromJson(json as Map<String, dynamic>))
.toList();
} catch (e) {
throw Exception('Failed to get project progress list: $e');
}
}
/// Get list of project submissions /// Get list of project submissions
/// ///
/// Calls: POST /api/method/building_material.building_material.api.project.get_list /// Calls: POST /api/method/building_material.building_material.api.project.get_list
@@ -106,4 +169,98 @@ class SubmissionsRemoteDataSourceImpl implements SubmissionsRemoteDataSource {
throw Exception('Failed to get project submissions: $e'); throw Exception('Failed to get project submissions: $e');
} }
} }
/// Save (create/update) a project submission
///
/// Calls: POST /api/method/building_material.building_material.api.project.save
/// Body: ProjectSubmissionRequest.toJson()
/// Returns: Project name (ID) from response
@override
Future<String> saveSubmission(ProjectSubmissionRequest request) async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.saveProject}',
data: request.toJson(),
);
final data = response.data;
if (data == null) {
throw Exception('No data received from saveProject API');
}
// Check for error in response
if (data['exc_type'] != null || data['exception'] != null) {
final errorMessage =
data['_server_messages'] ?? data['exception'] ?? 'Unknown error';
throw Exception('API error: $errorMessage');
}
// Extract project name from response
// Response format: { "message": { "success": true, "data": { "name": "#DA00007" } } }
final message = data['message'] as Map<String, dynamic>?;
if (message == null) {
throw Exception('No message in saveProject response');
}
final messageData = message['data'] as Map<String, dynamic>?;
if (messageData == null || messageData['name'] == null) {
throw Exception('No project name in saveProject response');
}
return messageData['name'] as String;
} catch (e) {
throw Exception('Failed to save project submission: $e');
}
}
/// Upload a file for a project submission
///
/// Calls: POST /api/method/upload_file
/// Form-data: file, is_private, folder, doctype, docname, optimize
/// Returns: Uploaded file URL
@override
Future<String> uploadProjectFile({
required String projectName,
required String filePath,
}) async {
try {
final fileName = filePath.split('/').last;
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath, filename: fileName),
'is_private': '1',
'folder': 'Home/Attachments',
'doctype': 'Architectural Project',
'docname': projectName,
'optimize': 'true',
});
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.uploadFile}',
data: formData,
);
final data = response.data;
if (data == null) {
throw Exception('No data received from uploadFile API');
}
// Check for error in response
if (data['exc_type'] != null || data['exception'] != null) {
final errorMessage =
data['_server_messages'] ?? data['exception'] ?? 'Unknown error';
throw Exception('API error: $errorMessage');
}
// Extract file URL from response
// Response format: { "message": { "file_url": "/files/...", ... } }
final message = data['message'];
if (message == null || message['file_url'] == null) {
throw Exception('No file URL in uploadFile response');
}
return message['file_url'] as String;
} catch (e) {
throw Exception('Failed to upload project file: $e');
}
}
} }

View File

@@ -0,0 +1,60 @@
/// Project Progress Model
///
/// Data model for project progress from API responses with Hive caching.
/// Based on API response from frappe.client.get_list with doctype "Progress of construction"
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/projects/domain/entities/project_progress.dart';
part 'project_progress_model.g.dart';
/// Project Progress Model - Type ID: 64
@HiveType(typeId: HiveTypeIds.projectProgressModel)
class ProjectProgressModel extends HiveObject {
/// Unique identifier (API: name)
@HiveField(0)
final String id;
/// Progress status label in Vietnamese (API: status)
@HiveField(1)
final String status;
ProjectProgressModel({
required this.id,
required this.status,
});
/// Create from JSON (API response)
factory ProjectProgressModel.fromJson(Map<String, dynamic> json) {
return ProjectProgressModel(
id: json['name'] as String,
status: json['status'] as String,
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'name': id,
'status': status,
};
}
/// Convert to entity
ProjectProgress toEntity() {
return ProjectProgress(
id: id,
status: status,
);
}
/// Create from entity
factory ProjectProgressModel.fromEntity(ProjectProgress entity) {
return ProjectProgressModel(
id: entity.id,
status: entity.status,
);
}
}

View File

@@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'project_progress_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ProjectProgressModelAdapter extends TypeAdapter<ProjectProgressModel> {
@override
final typeId = 64;
@override
ProjectProgressModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ProjectProgressModel(
id: fields[0] as String,
status: fields[1] as String,
);
}
@override
void write(BinaryWriter writer, ProjectProgressModel obj) {
writer
..writeByte(2)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.status);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProjectProgressModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,119 @@
/// Project Submission Request Model
///
/// Request model for creating/updating project submissions via API.
/// Based on API: building_material.building_material.api.project.save
library;
import 'package:intl/intl.dart';
/// Project Submission Request
///
/// Used to create or update project submissions.
class ProjectSubmissionRequest {
/// Project ID (optional for new, required for update)
final String? name;
/// Project name/title (API: designed_area)
final String designedArea;
/// Project address (API: address_of_project)
final String addressOfProject;
/// Project owner name (API: project_owner)
final String projectOwner;
/// Design firm name (API: design_firm)
final String? designFirm;
/// Construction contractor name (API: contruction_contractor)
final String? contractionContractor;
/// Design area in m² (API: design_area)
final double designArea;
/// Products included in the design (API: products_included_in_the_design)
final String productsIncludedInTheDesign;
/// Project progress ID from ProjectProgress.id (API: project_progress)
final String projectProgress;
/// Expected commencement date (API: expected_commencement_date)
final DateTime? expectedCommencementDate;
/// Project description (API: description)
final String? description;
/// Request date (API: request_date)
final DateTime? requestDate;
const ProjectSubmissionRequest({
this.name,
required this.designedArea,
required this.addressOfProject,
required this.projectOwner,
this.designFirm,
this.contractionContractor,
required this.designArea,
required this.productsIncludedInTheDesign,
required this.projectProgress,
this.expectedCommencementDate,
this.description,
this.requestDate,
});
/// Convert to JSON for API request
Map<String, dynamic> toJson() {
final dateFormat = DateFormat('yyyy-MM-dd');
final dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm:ss');
return {
if (name != null) 'name': name,
'designed_area': designedArea,
'address_of_project': addressOfProject,
'project_owner': projectOwner,
if (designFirm != null) 'design_firm': designFirm,
if (contractionContractor != null)
'contruction_contractor': contractionContractor,
'design_area': designArea,
'products_included_in_the_design': productsIncludedInTheDesign,
'project_progress': projectProgress,
if (expectedCommencementDate != null)
'expected_commencement_date': dateFormat.format(expectedCommencementDate!),
if (description != null) 'description': description,
'request_date': dateTimeFormat.format(requestDate ?? DateTime.now()),
};
}
/// Create a copy with updated fields
ProjectSubmissionRequest copyWith({
String? name,
String? designedArea,
String? addressOfProject,
String? projectOwner,
String? designFirm,
String? contractionContractor,
double? designArea,
String? productsIncludedInTheDesign,
String? projectProgress,
DateTime? expectedCommencementDate,
String? description,
DateTime? requestDate,
}) {
return ProjectSubmissionRequest(
name: name ?? this.name,
designedArea: designedArea ?? this.designedArea,
addressOfProject: addressOfProject ?? this.addressOfProject,
projectOwner: projectOwner ?? this.projectOwner,
designFirm: designFirm ?? this.designFirm,
contractionContractor: contractionContractor ?? this.contractionContractor,
designArea: designArea ?? this.designArea,
productsIncludedInTheDesign:
productsIncludedInTheDesign ?? this.productsIncludedInTheDesign,
projectProgress: projectProgress ?? this.projectProgress,
expectedCommencementDate:
expectedCommencementDate ?? this.expectedCommencementDate,
description: description ?? this.description,
requestDate: requestDate ?? this.requestDate,
);
}
}

View File

@@ -3,8 +3,11 @@
/// Implements the submissions repository interface with caching support. /// Implements the submissions repository interface with caching support.
library; library;
import 'package:worker/features/projects/data/datasources/project_progress_local_datasource.dart';
import 'package:worker/features/projects/data/datasources/project_status_local_datasource.dart'; 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/data/datasources/submissions_remote_datasource.dart';
import 'package:worker/features/projects/data/models/project_submission_request.dart';
import 'package:worker/features/projects/domain/entities/project_progress.dart';
import 'package:worker/features/projects/domain/entities/project_status.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/entities/project_submission.dart';
import 'package:worker/features/projects/domain/repositories/submissions_repository.dart'; import 'package:worker/features/projects/domain/repositories/submissions_repository.dart';
@@ -16,10 +19,12 @@ class SubmissionsRepositoryImpl implements SubmissionsRepository {
const SubmissionsRepositoryImpl( const SubmissionsRepositoryImpl(
this._remoteDataSource, this._remoteDataSource,
this._statusLocalDataSource, this._statusLocalDataSource,
this._progressLocalDataSource,
); );
final SubmissionsRemoteDataSource _remoteDataSource; final SubmissionsRemoteDataSource _remoteDataSource;
final ProjectStatusLocalDataSource _statusLocalDataSource; final ProjectStatusLocalDataSource _statusLocalDataSource;
final ProjectProgressLocalDataSource _progressLocalDataSource;
/// Get project status list with cache-first pattern /// Get project status list with cache-first pattern
/// ///
@@ -69,6 +74,54 @@ class SubmissionsRepositoryImpl implements SubmissionsRepository {
} }
} }
/// Get project progress 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<ProjectProgress>> getProjectProgressList({
bool forceRefresh = false,
}) async {
// Check cache first (unless force refresh)
if (!forceRefresh && _progressLocalDataSource.hasCachedData()) {
final cachedProgress = _progressLocalDataSource.getCachedProgressList();
if (cachedProgress.isNotEmpty) {
// Return cached data immediately
// Also refresh cache in background (fire and forget)
_refreshProgressCache();
return cachedProgress.map((model) => model.toEntity()).toList();
}
}
// No cache or force refresh - fetch from API
try {
final progressModels = await _remoteDataSource.getProjectProgressList();
// Cache the result
await _progressLocalDataSource.cacheProgressList(progressModels);
return progressModels.map((model) => model.toEntity()).toList();
} catch (e) {
// If API fails, try to return cached data as fallback
final cachedProgress = _progressLocalDataSource.getCachedProgressList();
if (cachedProgress.isNotEmpty) {
return cachedProgress.map((model) => model.toEntity()).toList();
}
rethrow;
}
}
/// Refresh progress cache in background
Future<void> _refreshProgressCache() async {
try {
final progressModels = await _remoteDataSource.getProjectProgressList();
await _progressLocalDataSource.cacheProgressList(progressModels);
} catch (e) {
// Silently fail - we already returned cached data
}
}
@override @override
Future<List<ProjectSubmission>> getSubmissions({ Future<List<ProjectSubmission>> getSubmissions({
int limitStart = 0, int limitStart = 0,
@@ -84,4 +137,28 @@ class SubmissionsRepositoryImpl implements SubmissionsRepository {
rethrow; rethrow;
} }
} }
@override
Future<String> saveSubmission(ProjectSubmissionRequest request) async {
try {
return await _remoteDataSource.saveSubmission(request);
} catch (e) {
rethrow;
}
}
@override
Future<String> uploadProjectFile({
required String projectName,
required String filePath,
}) async {
try {
return await _remoteDataSource.uploadProjectFile(
projectName: projectName,
filePath: filePath,
);
} catch (e) {
rethrow;
}
}
} }

View File

@@ -0,0 +1,35 @@
/// Project Progress Entity
///
/// Represents a construction progress stage from the API.
/// Used for dropdown selection when creating/updating project submissions.
library;
import 'package:equatable/equatable.dart';
/// Project Progress Entity
///
/// Contains construction progress stages:
/// - Chưa khởi công (Not started)
/// - Khởi công móng (Foundation started)
/// - Đang phần thô (Rough construction)
/// - Đang hoàn thiện (Finishing)
/// - Cất nóc (Roofing complete)
/// - Hoàn thiện (Completed)
class ProjectProgress extends Equatable {
/// Unique identifier (API: name)
final String id;
/// Progress status label in Vietnamese (API: status)
final String status;
const ProjectProgress({
required this.id,
required this.status,
});
@override
List<Object?> get props => [id, status];
@override
String toString() => 'ProjectProgress(id: $id, status: $status)';
}

View File

@@ -3,6 +3,8 @@
/// Repository interface for project submissions operations. /// Repository interface for project submissions operations.
library; library;
import 'package:worker/features/projects/data/models/project_submission_request.dart';
import 'package:worker/features/projects/domain/entities/project_progress.dart';
import 'package:worker/features/projects/domain/entities/project_status.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/entities/project_submission.dart';
@@ -20,9 +22,32 @@ abstract class SubmissionsRepository {
bool forceRefresh = false, bool forceRefresh = false,
}); });
/// Get list of construction progress stages
///
/// Uses cache-first pattern:
/// - Returns cached data if available
/// - Fetches from API and updates cache
/// - [forceRefresh] bypasses cache and fetches fresh data
Future<List<ProjectProgress>> getProjectProgressList({
bool forceRefresh = false,
});
/// Get all project submissions for the current user /// Get all project submissions for the current user
Future<List<ProjectSubmission>> getSubmissions({ Future<List<ProjectSubmission>> getSubmissions({
int limitStart = 0, int limitStart = 0,
int limitPageLength = 0, int limitPageLength = 0,
}); });
/// Save (create/update) a project submission
/// Returns the project name (ID) from the API response
Future<String> saveSubmission(ProjectSubmissionRequest request);
/// Upload a file for a project submission
/// [projectName] is the project ID returned from saveSubmission
/// [filePath] is the local path to the file
/// Returns the uploaded file URL
Future<String> uploadProjectFile({
required String projectName,
required String filePath,
});
} }

View File

@@ -11,6 +11,9 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/projects/data/models/project_submission_request.dart';
import 'package:worker/features/projects/domain/entities/project_progress.dart';
import 'package:worker/features/projects/presentation/providers/submissions_provider.dart';
/// Project Submission Create Page /// Project Submission Create Page
class SubmissionCreatePage extends ConsumerStatefulWidget { class SubmissionCreatePage extends ConsumerStatefulWidget {
@@ -35,10 +38,10 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
final _descriptionController = TextEditingController(); final _descriptionController = TextEditingController();
// Form state // Form state
String? _selectedProgress; ProjectProgress? _selectedProgress;
DateTime? _expectedStartDate; DateTime? _expectedStartDate;
final List<File> _uploadedFiles = []; final List<File> _uploadedFiles = [];
bool _showStartDateField = false; bool _isSubmitting = false;
@override @override
void dispose() { void dispose() {
@@ -217,10 +220,8 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
_buildProgressDropdown(), _buildProgressDropdown(),
if (_showStartDateField) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
_buildDateField(), _buildExpectedDateField(),
],
], ],
), ),
), ),
@@ -434,6 +435,8 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
} }
Widget _buildProgressDropdown() { Widget _buildProgressDropdown() {
final progressListAsync = ref.watch(projectProgressListProvider);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -457,8 +460,9 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
DropdownButtonFormField<String>( progressListAsync.when(
value: _selectedProgress, data: (progressList) => DropdownButtonFormField<ProjectProgress>(
initialValue: _selectedProgress,
decoration: InputDecoration( decoration: InputDecoration(
filled: true, filled: true,
fillColor: AppColors.white, fillColor: AppColors.white,
@@ -476,49 +480,73 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
), ),
), ),
hint: const Text('Chọn tiến độ'), hint: const Text('Chọn tiến độ'),
items: const [ items: progressList
DropdownMenuItem( .map((progress) => DropdownMenuItem<ProjectProgress>(
value: 'not-started', value: progress,
child: Text('Chưa khởi công'), child: Text(progress.status),
), ))
DropdownMenuItem( .toList(),
value: 'foundation',
child: Text('Khởi công móng'),
),
DropdownMenuItem(
value: 'rough-construction',
child: Text('Đang phần thô'),
),
DropdownMenuItem(
value: 'finishing',
child: Text('Đang hoàn thiện'),
),
DropdownMenuItem(
value: 'topped-out',
child: Text('Cất nóc'),
),
],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_selectedProgress = value; _selectedProgress = value;
_showStartDateField = value == 'not-started';
if (!_showStartDateField) {
_expectedStartDate = null;
}
}); });
}, },
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null) {
return 'Vui lòng chọn tiến độ công trình'; return 'Vui lòng chọn tiến độ công trình';
} }
return null; return null;
}, },
), ),
loading: () => Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.white,
border: Border.all(color: AppColors.grey100),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('Đang tải...', style: TextStyle(color: AppColors.grey500)),
],
),
),
error: (error, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.white,
border: Border.all(color: AppColors.danger),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const FaIcon(FontAwesomeIcons.circleExclamation,
size: 16, color: AppColors.danger),
const SizedBox(width: 12),
const Expanded(
child: Text('Không thể tải danh sách tiến độ',
style: TextStyle(color: AppColors.danger)),
),
TextButton(
onPressed: () =>
ref.invalidate(projectProgressListProvider),
child: const Text('Thử lại'),
),
],
),
),
),
], ],
); );
} }
Widget _buildDateField() { Widget _buildExpectedDateField() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -532,7 +560,7 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
InkWell( InkWell(
onTap: _pickDate, onTap: _pickExpectedDate,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -545,7 +573,7 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
children: [ children: [
Text( Text(
_expectedStartDate != null _expectedStartDate != null
? '${_expectedStartDate!.day}/${_expectedStartDate!.month}/${_expectedStartDate!.year}' ? '${_expectedStartDate!.day.toString().padLeft(2, '0')}/${_expectedStartDate!.month.toString().padLeft(2, '0')}/${_expectedStartDate!.year}'
: 'Chọn ngày', : 'Chọn ngày',
style: TextStyle( style: TextStyle(
color: _expectedStartDate != null color: _expectedStartDate != null
@@ -571,14 +599,29 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
final fileSizeInBytes = file.lengthSync(); final fileSizeInBytes = file.lengthSync();
final fileSizeInMB = (fileSizeInBytes / (1024 * 1024)).toStringAsFixed(2); final fileSizeInMB = (fileSizeInBytes / (1024 * 1024)).toStringAsFixed(2);
// Get upload state for this file
final uploadStates = ref.watch(uploadProjectFilesProvider);
final isUploading = index < uploadStates.length && uploadStates[index].isUploading;
final isUploaded = index < uploadStates.length && uploadStates[index].isUploaded;
final hasError = index < uploadStates.length && uploadStates[index].error != null;
return Container( return Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF8F9FA), color: const Color(0xFFF8F9FA),
border: Border.all(color: AppColors.grey100), border: Border.all(
color: hasError
? AppColors.danger
: isUploaded
? AppColors.success
: AppColors.grey100,
),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
child: Row( child: Row(
children: [
// Image with upload overlay
Stack(
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
@@ -601,6 +644,45 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
}, },
), ),
), ),
// Uploading overlay
if (isUploading)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(4),
),
child: const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
),
),
// Uploaded checkmark overlay
if (isUploaded)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: AppColors.success.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(4),
),
child: const Center(
child: FaIcon(
FontAwesomeIcons.check,
size: 20,
color: Colors.white,
),
),
),
),
],
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
@@ -617,15 +699,27 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'${fileSizeInMB}MB', isUploading
style: const TextStyle( ? 'Đang tải lên...'
: isUploaded
? 'Đã tải lên'
: hasError
? 'Lỗi tải lên'
: '${fileSizeInMB}MB',
style: TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.grey500, color: hasError
? AppColors.danger
: isUploaded
? AppColors.success
: AppColors.grey500,
), ),
), ),
], ],
), ),
), ),
// Only show remove button when not uploading
if (!_isSubmitting)
IconButton( IconButton(
icon: const FaIcon( icon: const FaIcon(
FontAwesomeIcons.xmark, FontAwesomeIcons.xmark,
@@ -648,16 +742,27 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
width: double.infinity, width: double.infinity,
height: 48, height: 48,
child: ElevatedButton( child: ElevatedButton(
onPressed: _handleSubmit, onPressed: _isSubmitting ? null : _handleSubmit,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue, backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white, foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.primaryBlue.withValues(alpha: 0.6),
disabledForegroundColor: AppColors.white,
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
), ),
child: const Row( child: _isSubmitting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(AppColors.white),
),
)
: const Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
FaIcon(FontAwesomeIcons.paperPlane, size: 16), FaIcon(FontAwesomeIcons.paperPlane, size: 16),
@@ -675,12 +780,12 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
); );
} }
Future<void> _pickDate() async { Future<void> _pickExpectedDate() async {
final date = await showDatePicker( final date = await showDatePicker(
context: context, context: context,
initialDate: DateTime.now(), initialDate: _expectedStartDate ?? DateTime.now(),
firstDate: DateTime.now(), firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)), lastDate: DateTime.now().add(const Duration(days: 365 * 3)),
); );
if (date != null) { if (date != null) {
@@ -725,7 +830,19 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
} }
Future<void> _handleSubmit() async { Future<void> _handleSubmit() async {
if (_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) return;
// Validate progress selection
if (_selectedProgress == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vui lòng chọn tiến độ công trình'),
backgroundColor: AppColors.danger,
),
);
return;
}
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -744,15 +861,75 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
), ),
); );
if (confirmed == true && mounted) { if (confirmed != true || !mounted) return;
setState(() => _isSubmitting = true);
try {
// Parse area as double
final area = double.tryParse(_areaController.text.trim()) ?? 0.0;
// Create submission request
final request = ProjectSubmissionRequest(
designedArea: _projectNameController.text.trim(),
addressOfProject: _addressController.text.trim(),
projectOwner: _ownerController.text.trim(),
designFirm: _designUnitController.text.trim().isNotEmpty
? _designUnitController.text.trim()
: null,
contractionContractor: _constructionUnitController.text.trim().isNotEmpty
? _constructionUnitController.text.trim()
: null,
designArea: area,
productsIncludedInTheDesign: _productsController.text.trim(),
projectProgress: _selectedProgress!.id, // Use ProjectProgress.id (name from API)
expectedCommencementDate: _expectedStartDate,
description: _descriptionController.text.trim().isNotEmpty
? _descriptionController.text.trim()
: null,
requestDate: DateTime.now(),
);
// Step 1: Save project and get project name
final projectName = await ref.read(saveSubmissionProvider.notifier).save(request);
if (!mounted) return;
// Step 2: Upload files if any
if (_uploadedFiles.isNotEmpty) {
// Initialize upload provider with file paths
final filePaths = _uploadedFiles.map((f) => f.path).toList();
ref.read(uploadProjectFilesProvider.notifier).initFiles(filePaths);
// Upload all files
await ref.read(uploadProjectFilesProvider.notifier).uploadAll(projectName);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text( content: Text(
'Đăng ký công trình đã được gửi thành công!\nChúng tôi sẽ xem xét và liên hệ với bạn sớm nhất.', 'Đăng ký công trình đã được gửi thành công!\nChúng tôi sẽ xem xét và liên hệ với bạn sớm nhất.',
), ),
backgroundColor: AppColors.success,
), ),
); );
Navigator.pop(context); Navigator.pop(context, true); // Return true to indicate success
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi: ${e.toString().replaceAll('Exception: ', '')}'),
backgroundColor: AppColors.danger,
),
);
}
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
// Clear upload state
ref.read(uploadProjectFilesProvider.notifier).clear();
} }
} }
} }

View File

@@ -39,7 +39,13 @@ class SubmissionsPage extends ConsumerWidget {
actions: [ actions: [
IconButton( IconButton(
icon: const FaIcon(FontAwesomeIcons.plus, color: Colors.black, size: 20), icon: const FaIcon(FontAwesomeIcons.plus, color: Colors.black, size: 20),
onPressed: () => context.push(RouteNames.submissionCreate), onPressed: () async {
final result = await context.push<bool>(RouteNames.submissionCreate);
if (result == true) {
// Refresh submissions list after successful creation
ref.invalidate(allSubmissionsProvider);
}
},
), ),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
], ],

View File

@@ -5,9 +5,12 @@ library;
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/network/dio_client.dart'; import 'package:worker/core/network/dio_client.dart';
import 'package:worker/features/projects/data/datasources/project_progress_local_datasource.dart';
import 'package:worker/features/projects/data/datasources/project_status_local_datasource.dart'; 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/data/datasources/submissions_remote_datasource.dart';
import 'package:worker/features/projects/data/models/project_submission_request.dart';
import 'package:worker/features/projects/data/repositories/submissions_repository_impl.dart'; import 'package:worker/features/projects/data/repositories/submissions_repository_impl.dart';
import 'package:worker/features/projects/domain/entities/project_progress.dart';
import 'package:worker/features/projects/domain/entities/project_status.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/entities/project_submission.dart';
import 'package:worker/features/projects/domain/repositories/submissions_repository.dart'; import 'package:worker/features/projects/domain/repositories/submissions_repository.dart';
@@ -20,6 +23,12 @@ ProjectStatusLocalDataSource projectStatusLocalDataSource(Ref ref) {
return ProjectStatusLocalDataSource(); return ProjectStatusLocalDataSource();
} }
/// Project Progress Local Data Source Provider
@riverpod
ProjectProgressLocalDataSource projectProgressLocalDataSource(Ref ref) {
return ProjectProgressLocalDataSource();
}
/// Submissions Remote Data Source Provider /// Submissions Remote Data Source Provider
@riverpod @riverpod
Future<SubmissionsRemoteDataSource> submissionsRemoteDataSource(Ref ref) async { Future<SubmissionsRemoteDataSource> submissionsRemoteDataSource(Ref ref) async {
@@ -32,7 +41,12 @@ Future<SubmissionsRemoteDataSource> submissionsRemoteDataSource(Ref ref) async {
Future<SubmissionsRepository> submissionsRepository(Ref ref) async { Future<SubmissionsRepository> submissionsRepository(Ref ref) async {
final remoteDataSource = await ref.watch(submissionsRemoteDataSourceProvider.future); final remoteDataSource = await ref.watch(submissionsRemoteDataSourceProvider.future);
final statusLocalDataSource = ref.watch(projectStatusLocalDataSourceProvider); final statusLocalDataSource = ref.watch(projectStatusLocalDataSourceProvider);
return SubmissionsRepositoryImpl(remoteDataSource, statusLocalDataSource); final progressLocalDataSource = ref.watch(projectProgressLocalDataSourceProvider);
return SubmissionsRepositoryImpl(
remoteDataSource,
statusLocalDataSource,
progressLocalDataSource,
);
} }
/// Project Status List Provider /// Project Status List Provider
@@ -57,16 +71,39 @@ class ProjectStatusList extends _$ProjectStatusList {
} }
} }
/// Project Progress List Provider
///
/// Fetches construction progress stages from API with cache-first pattern.
/// Used for dropdown selection when creating/updating project submissions.
@riverpod
class ProjectProgressList extends _$ProjectProgressList {
@override
Future<List<ProjectProgress>> build() async {
final repository = await ref.watch(submissionsRepositoryProvider.future);
return repository.getProjectProgressList();
}
/// Refresh progress list from remote (force refresh)
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final repository = await ref.read(submissionsRepositoryProvider.future);
return repository.getProjectProgressList(forceRefresh: true);
});
}
}
/// All Submissions Provider /// All Submissions Provider
/// ///
/// Fetches and manages submissions data from remote. /// Fetches and manages submissions data from remote.
/// Waits for project status list to be loaded first. /// Waits for project status list and progress list to be loaded first.
@riverpod @riverpod
class AllSubmissions extends _$AllSubmissions { class AllSubmissions extends _$AllSubmissions {
@override @override
Future<List<ProjectSubmission>> build() async { Future<List<ProjectSubmission>> build() async {
// Ensure status list is loaded first (for filter options) // Ensure status list and progress list are loaded first (for filter options)
await ref.watch(projectStatusListProvider.future); await ref.watch(projectStatusListProvider.future);
await ref.watch(projectProgressListProvider.future);
// Then fetch submissions // Then fetch submissions
final repository = await ref.watch(submissionsRepositoryProvider.future); final repository = await ref.watch(submissionsRepositoryProvider.future);
@@ -77,8 +114,9 @@ class AllSubmissions extends _$AllSubmissions {
Future<void> refresh() async { Future<void> refresh() async {
state = const AsyncValue.loading(); state = const AsyncValue.loading();
state = await AsyncValue.guard(() async { state = await AsyncValue.guard(() async {
// Also refresh status list // Also refresh status list and progress list
await ref.read(projectStatusListProvider.notifier).refresh(); await ref.read(projectStatusListProvider.notifier).refresh();
await ref.read(projectProgressListProvider.notifier).refresh();
final repository = await ref.read(submissionsRepositoryProvider.future); final repository = await ref.read(submissionsRepositoryProvider.future);
return repository.getSubmissions(); return repository.getSubmissions();
@@ -154,3 +192,152 @@ AsyncValue<List<ProjectSubmission>> filteredSubmissions(Ref ref) {
return filtered; return filtered;
}); });
} }
/// Save Submission Provider
///
/// Handles creating new project submissions via API.
@riverpod
class SaveSubmission extends _$SaveSubmission {
@override
AsyncValue<void> build() {
return const AsyncValue.data(null);
}
/// Save a new project submission
///
/// Returns the project name (ID) if successful, throws exception on failure.
Future<String> save(ProjectSubmissionRequest request) async {
state = const AsyncValue.loading();
try {
final repository = await ref.read(submissionsRepositoryProvider.future);
if (!ref.mounted) throw Exception('Provider disposed');
final projectName = await repository.saveSubmission(request);
if (!ref.mounted) return projectName;
state = const AsyncValue.data(null);
// Refresh submissions list after successful save
ref.invalidate(allSubmissionsProvider);
return projectName;
} catch (e, st) {
if (ref.mounted) {
state = AsyncValue.error(e, st);
}
rethrow;
}
}
}
/// Upload state for tracking individual file uploads
class FileUploadState {
final String filePath;
final bool isUploading;
final bool isUploaded;
final String? fileUrl;
final String? error;
const FileUploadState({
required this.filePath,
this.isUploading = false,
this.isUploaded = false,
this.fileUrl,
this.error,
});
FileUploadState copyWith({
bool? isUploading,
bool? isUploaded,
String? fileUrl,
String? error,
}) {
return FileUploadState(
filePath: filePath,
isUploading: isUploading ?? this.isUploading,
isUploaded: isUploaded ?? this.isUploaded,
fileUrl: fileUrl ?? this.fileUrl,
error: error,
);
}
}
/// Upload Project Files Provider
///
/// Handles uploading multiple files for a project submission.
/// Tracks upload state for each file individually.
@riverpod
class UploadProjectFiles extends _$UploadProjectFiles {
@override
List<FileUploadState> build() {
return [];
}
/// Initialize with file paths
void initFiles(List<String> filePaths) {
state = filePaths
.map((path) => FileUploadState(filePath: path))
.toList();
}
/// Upload all files for a project
/// Returns list of uploaded file URLs
Future<List<String>> uploadAll(String projectName) async {
final uploadedUrls = <String>[];
for (var i = 0; i < state.length; i++) {
if (!ref.mounted) break;
// Mark as uploading
state = [
...state.sublist(0, i),
state[i].copyWith(isUploading: true),
...state.sublist(i + 1),
];
try {
final repository = await ref.read(submissionsRepositoryProvider.future);
if (!ref.mounted) break;
final fileUrl = await repository.uploadProjectFile(
projectName: projectName,
filePath: state[i].filePath,
);
if (!ref.mounted) break;
// Mark as uploaded
state = [
...state.sublist(0, i),
state[i].copyWith(
isUploading: false,
isUploaded: true,
fileUrl: fileUrl,
),
...state.sublist(i + 1),
];
uploadedUrls.add(fileUrl);
} catch (e) {
if (!ref.mounted) break;
// Mark as failed
state = [
...state.sublist(0, i),
state[i].copyWith(
isUploading: false,
error: e.toString(),
),
...state.sublist(i + 1),
];
}
}
return uploadedUrls;
}
/// Clear all files
void clear() {
state = [];
}
}

View File

@@ -62,6 +62,62 @@ final class ProjectStatusLocalDataSourceProvider
String _$projectStatusLocalDataSourceHash() => String _$projectStatusLocalDataSourceHash() =>
r'c57291e51bd390f9524369860c241d7a0a90fdbf'; r'c57291e51bd390f9524369860c241d7a0a90fdbf';
/// Project Progress Local Data Source Provider
@ProviderFor(projectProgressLocalDataSource)
const projectProgressLocalDataSourceProvider =
ProjectProgressLocalDataSourceProvider._();
/// Project Progress Local Data Source Provider
final class ProjectProgressLocalDataSourceProvider
extends
$FunctionalProvider<
ProjectProgressLocalDataSource,
ProjectProgressLocalDataSource,
ProjectProgressLocalDataSource
>
with $Provider<ProjectProgressLocalDataSource> {
/// Project Progress Local Data Source Provider
const ProjectProgressLocalDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'projectProgressLocalDataSourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$projectProgressLocalDataSourceHash();
@$internal
@override
$ProviderElement<ProjectProgressLocalDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
ProjectProgressLocalDataSource create(Ref ref) {
return projectProgressLocalDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ProjectProgressLocalDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ProjectProgressLocalDataSource>(
value,
),
);
}
}
String _$projectProgressLocalDataSourceHash() =>
r'653d03b47f5642f3391e7a312649a2603489b224';
/// Submissions Remote Data Source Provider /// Submissions Remote Data Source Provider
@ProviderFor(submissionsRemoteDataSource) @ProviderFor(submissionsRemoteDataSource)
@@ -155,7 +211,7 @@ final class SubmissionsRepositoryProvider
} }
String _$submissionsRepositoryHash() => String _$submissionsRepositoryHash() =>
r'd8261cc538c1fdaa47064e4945302b80f49098bb'; r'652208a4ef93cde9b40ae66164d44bba786dfed0';
/// Project Status List Provider /// Project Status List Provider
/// ///
@@ -221,10 +277,80 @@ abstract class _$ProjectStatusList extends $AsyncNotifier<List<ProjectStatus>> {
} }
} }
/// Project Progress List Provider
///
/// Fetches construction progress stages from API with cache-first pattern.
/// Used for dropdown selection when creating/updating project submissions.
@ProviderFor(ProjectProgressList)
const projectProgressListProvider = ProjectProgressListProvider._();
/// Project Progress List Provider
///
/// Fetches construction progress stages from API with cache-first pattern.
/// Used for dropdown selection when creating/updating project submissions.
final class ProjectProgressListProvider
extends $AsyncNotifierProvider<ProjectProgressList, List<ProjectProgress>> {
/// Project Progress List Provider
///
/// Fetches construction progress stages from API with cache-first pattern.
/// Used for dropdown selection when creating/updating project submissions.
const ProjectProgressListProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'projectProgressListProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$projectProgressListHash();
@$internal
@override
ProjectProgressList create() => ProjectProgressList();
}
String _$projectProgressListHash() =>
r'5ee1c23f90bfa61237f38a6b72c353f0ecb7a2a9';
/// Project Progress List Provider
///
/// Fetches construction progress stages from API with cache-first pattern.
/// Used for dropdown selection when creating/updating project submissions.
abstract class _$ProjectProgressList
extends $AsyncNotifier<List<ProjectProgress>> {
FutureOr<List<ProjectProgress>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref =
this.ref
as $Ref<AsyncValue<List<ProjectProgress>>, List<ProjectProgress>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<List<ProjectProgress>>,
List<ProjectProgress>
>,
AsyncValue<List<ProjectProgress>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// All Submissions Provider /// All Submissions Provider
/// ///
/// Fetches and manages submissions data from remote. /// Fetches and manages submissions data from remote.
/// Waits for project status list to be loaded first. /// Waits for project status list and progress list to be loaded first.
@ProviderFor(AllSubmissions) @ProviderFor(AllSubmissions)
const allSubmissionsProvider = AllSubmissionsProvider._(); const allSubmissionsProvider = AllSubmissionsProvider._();
@@ -232,13 +358,13 @@ const allSubmissionsProvider = AllSubmissionsProvider._();
/// All Submissions Provider /// All Submissions Provider
/// ///
/// Fetches and manages submissions data from remote. /// Fetches and manages submissions data from remote.
/// Waits for project status list to be loaded first. /// Waits for project status list and progress list to be loaded first.
final class AllSubmissionsProvider final class AllSubmissionsProvider
extends $AsyncNotifierProvider<AllSubmissions, List<ProjectSubmission>> { extends $AsyncNotifierProvider<AllSubmissions, List<ProjectSubmission>> {
/// All Submissions Provider /// All Submissions Provider
/// ///
/// Fetches and manages submissions data from remote. /// Fetches and manages submissions data from remote.
/// Waits for project status list to be loaded first. /// Waits for project status list and progress list to be loaded first.
const AllSubmissionsProvider._() const AllSubmissionsProvider._()
: super( : super(
from: null, from: null,
@@ -258,12 +384,12 @@ final class AllSubmissionsProvider
AllSubmissions create() => AllSubmissions(); AllSubmissions create() => AllSubmissions();
} }
String _$allSubmissionsHash() => r'a4a7fb0d2953efb21e2e6343429f7550c763ea85'; String _$allSubmissionsHash() => r'ab0f1ffdc5e6bdb62dbd56ff3e586ecc1ff05bea';
/// All Submissions Provider /// All Submissions Provider
/// ///
/// Fetches and manages submissions data from remote. /// Fetches and manages submissions data from remote.
/// Waits for project status list to be loaded first. /// Waits for project status list and progress list to be loaded first.
abstract class _$AllSubmissions abstract class _$AllSubmissions
extends $AsyncNotifier<List<ProjectSubmission>> { extends $AsyncNotifier<List<ProjectSubmission>> {
@@ -442,3 +568,142 @@ final class FilteredSubmissionsProvider
String _$filteredSubmissionsHash() => String _$filteredSubmissionsHash() =>
r'5be22b3242426c6b0c2f9778eaee5c7cf23e4814'; r'5be22b3242426c6b0c2f9778eaee5c7cf23e4814';
/// Save Submission Provider
///
/// Handles creating new project submissions via API.
@ProviderFor(SaveSubmission)
const saveSubmissionProvider = SaveSubmissionProvider._();
/// Save Submission Provider
///
/// Handles creating new project submissions via API.
final class SaveSubmissionProvider
extends $NotifierProvider<SaveSubmission, AsyncValue<void>> {
/// Save Submission Provider
///
/// Handles creating new project submissions via API.
const SaveSubmissionProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'saveSubmissionProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$saveSubmissionHash();
@$internal
@override
SaveSubmission create() => SaveSubmission();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<void> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<void>>(value),
);
}
}
String _$saveSubmissionHash() => r'64afa1a9662c36431c143c46a8ca34a786cb0860';
/// Save Submission Provider
///
/// Handles creating new project submissions via API.
abstract class _$SaveSubmission extends $Notifier<AsyncValue<void>> {
AsyncValue<void> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<void>, AsyncValue<void>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, AsyncValue<void>>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Upload Project Files Provider
///
/// Handles uploading multiple files for a project submission.
/// Tracks upload state for each file individually.
@ProviderFor(UploadProjectFiles)
const uploadProjectFilesProvider = UploadProjectFilesProvider._();
/// Upload Project Files Provider
///
/// Handles uploading multiple files for a project submission.
/// Tracks upload state for each file individually.
final class UploadProjectFilesProvider
extends $NotifierProvider<UploadProjectFiles, List<FileUploadState>> {
/// Upload Project Files Provider
///
/// Handles uploading multiple files for a project submission.
/// Tracks upload state for each file individually.
const UploadProjectFilesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'uploadProjectFilesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$uploadProjectFilesHash();
@$internal
@override
UploadProjectFiles create() => UploadProjectFiles();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<FileUploadState> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<FileUploadState>>(value),
);
}
}
String _$uploadProjectFilesHash() =>
r'd6219bc1f0b0d6ac70b9e3cea731267c82a68e1f';
/// Upload Project Files Provider
///
/// Handles uploading multiple files for a project submission.
/// Tracks upload state for each file individually.
abstract class _$UploadProjectFiles extends $Notifier<List<FileUploadState>> {
List<FileUploadState> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<List<FileUploadState>, List<FileUploadState>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<List<FileUploadState>, List<FileUploadState>>,
List<FileUploadState>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -32,6 +32,7 @@ import 'package:worker/features/products/data/models/category_model.dart';
import 'package:worker/features/products/data/models/product_model.dart'; import 'package:worker/features/products/data/models/product_model.dart';
import 'package:worker/features/products/data/models/stock_level_model.dart'; import 'package:worker/features/products/data/models/stock_level_model.dart';
import 'package:worker/features/projects/data/models/design_request_model.dart'; import 'package:worker/features/projects/data/models/design_request_model.dart';
import 'package:worker/features/projects/data/models/project_progress_model.dart';
import 'package:worker/features/projects/data/models/project_status_model.dart'; import 'package:worker/features/projects/data/models/project_status_model.dart';
import 'package:worker/features/projects/data/models/project_submission_model.dart'; import 'package:worker/features/projects/data/models/project_submission_model.dart';
import 'package:worker/features/quotes/data/models/quote_item_model.dart'; import 'package:worker/features/quotes/data/models/quote_item_model.dart';
@@ -77,6 +78,7 @@ extension HiveRegistrar on HiveInterface {
registerAdapter(PointsRecordModelAdapter()); registerAdapter(PointsRecordModelAdapter());
registerAdapter(PointsStatusAdapter()); registerAdapter(PointsStatusAdapter());
registerAdapter(ProductModelAdapter()); registerAdapter(ProductModelAdapter());
registerAdapter(ProjectProgressModelAdapter());
registerAdapter(ProjectStatusModelAdapter()); registerAdapter(ProjectStatusModelAdapter());
registerAdapter(ProjectSubmissionModelAdapter()); registerAdapter(ProjectSubmissionModelAdapter());
registerAdapter(ProjectTypeAdapter()); registerAdapter(ProjectTypeAdapter());
@@ -137,6 +139,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
registerAdapter(PointsRecordModelAdapter()); registerAdapter(PointsRecordModelAdapter());
registerAdapter(PointsStatusAdapter()); registerAdapter(PointsStatusAdapter());
registerAdapter(ProductModelAdapter()); registerAdapter(ProductModelAdapter());
registerAdapter(ProjectProgressModelAdapter());
registerAdapter(ProjectStatusModelAdapter()); registerAdapter(ProjectStatusModelAdapter());
registerAdapter(ProjectSubmissionModelAdapter()); registerAdapter(ProjectSubmissionModelAdapter());
registerAdapter(ProjectTypeAdapter()); registerAdapter(ProjectTypeAdapter());