From b6cb9e865a7f0a33170ce1602a646c81ad54aee1 Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Thu, 27 Nov 2025 16:56:01 +0700 Subject: [PATCH] create submission --- docs/projects.sh | 79 +++- lib/core/constants/api_constants.dart | 19 + lib/core/constants/storage_constants.dart | 5 + lib/core/database/hive_service.dart | 6 + lib/core/network/api_interceptor.dart | 2 +- .../project_progress_local_datasource.dart | 45 ++ .../submissions_remote_datasource.dart | 157 +++++++ .../data/models/project_progress_model.dart | 60 +++ .../data/models/project_progress_model.g.dart | 44 ++ .../models/project_submission_request.dart | 119 +++++ .../submissions_repository_impl.dart | 77 ++++ .../domain/entities/project_progress.dart | 35 ++ .../repositories/submissions_repository.dart | 25 + .../pages/submission_create_page.dart | 427 +++++++++++++----- .../presentation/pages/submissions_page.dart | 8 +- .../providers/submissions_provider.dart | 195 +++++++- .../providers/submissions_provider.g.dart | 277 +++++++++++- lib/hive_registrar.g.dart | 3 + 18 files changed, 1445 insertions(+), 138 deletions(-) create mode 100644 lib/features/projects/data/datasources/project_progress_local_datasource.dart create mode 100644 lib/features/projects/data/models/project_progress_model.dart create mode 100644 lib/features/projects/data/models/project_progress_model.g.dart create mode 100644 lib/features/projects/data/models/project_submission_request.dart create mode 100644 lib/features/projects/domain/entities/project_progress.dart diff --git a/docs/projects.sh b/docs/projects.sh index 84de842..c91ab03 100644 --- a/docs/projects.sh +++ b/docs/projects.sh @@ -61,4 +61,81 @@ curl --location 'https://land.dbiz.com//api/method/building_material.building_ma "status_color": "Success" } ] -} \ No newline at end of file +} + +#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"' + diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index 42d1264..c1ee70c 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -289,6 +289,25 @@ class ApiConstants { static const String getProjectList = '/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) /// POST /projects static const String createProject = '/projects'; diff --git a/lib/core/constants/storage_constants.dart b/lib/core/constants/storage_constants.dart index 672adbd..1a2c570 100644 --- a/lib/core/constants/storage_constants.dart +++ b/lib/core/constants/storage_constants.dart @@ -67,6 +67,9 @@ class HiveBoxNames { /// Project status list cache 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 static List get allBoxes => [ userBox, @@ -81,6 +84,7 @@ class HiveBoxNames { wardBox, orderStatusBox, projectStatusBox, + projectProgressBox, settingsBox, cacheBox, syncStateBox, @@ -144,6 +148,7 @@ class HiveTypeIds { static const int wardModel = 32; static const int orderStatusModel = 62; static const int projectStatusModel = 63; + static const int projectProgressModel = 64; // Enums (33-61) static const int userRole = 33; diff --git a/lib/core/database/hive_service.dart b/lib/core/database/hive_service.dart index 22d73d4..b45dd22 100644 --- a/lib/core/database/hive_service.dart +++ b/lib/core/database/hive_service.dart @@ -111,6 +111,9 @@ class HiveService { debugPrint( 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectStatusModel) ? "✓" : "✗"} ProjectStatusModel adapter', ); + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectProgressModel) ? "✓" : "✗"} ProjectProgressModel adapter', + ); debugPrint( 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.entryType) ? "✓" : "✗"} EntryType adapter', ); @@ -180,6 +183,9 @@ class HiveService { // Project status box (non-sensitive) - caches project status list from API Hive.openBox(HiveBoxNames.projectStatusBox), + + // Project progress box (non-sensitive) - caches construction progress stages from API + Hive.openBox(HiveBoxNames.projectProgressBox), ]); // Open potentially encrypted boxes (sensitive data) diff --git a/lib/core/network/api_interceptor.dart b/lib/core/network/api_interceptor.dart index 57388ec..d3f7a9d 100644 --- a/lib/core/network/api_interceptor.dart +++ b/lib/core/network/api_interceptor.dart @@ -569,7 +569,7 @@ Future authInterceptor(Ref ref, Dio dio) async { @riverpod LoggingInterceptor loggingInterceptor(Ref ref) { // 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( enableRequestLogging: false, diff --git a/lib/features/projects/data/datasources/project_progress_local_datasource.dart b/lib/features/projects/data/datasources/project_progress_local_datasource.dart new file mode 100644 index 0000000..5525eeb --- /dev/null +++ b/lib/features/projects/data/datasources/project_progress_local_datasource.dart @@ -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 get _box => Hive.box(HiveBoxNames.projectProgressBox); + + /// Save project progress list to cache + Future cacheProgressList(List 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 getCachedProgressList() { + try { + final values = _box.values.whereType().toList(); + return values; + } catch (e) { + return []; + } + } + + /// Check if cache exists and is not empty + bool hasCachedData() { + return _box.isNotEmpty; + } + + /// Clear all cached progress + Future clearCache() async { + await _box.clear(); + } +} diff --git a/lib/features/projects/data/datasources/submissions_remote_datasource.dart b/lib/features/projects/data/datasources/submissions_remote_datasource.dart index ff809ee..c65c0e4 100644 --- a/lib/features/projects/data/datasources/submissions_remote_datasource.dart +++ b/lib/features/projects/data/datasources/submissions_remote_datasource.dart @@ -3,10 +3,13 @@ /// Handles remote API calls for project submissions. library; +import 'package:dio/dio.dart'; import 'package:worker/core/constants/api_constants.dart'; import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/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_submission_model.dart'; +import 'package:worker/features/projects/data/models/project_submission_request.dart'; /// Submissions Remote Data Source /// @@ -15,11 +18,27 @@ abstract class SubmissionsRemoteDataSource { /// Fetch project status list from API Future> getProjectStatusList(); + /// Fetch project progress list from API (construction stages) + Future> getProjectProgressList(); + /// Fetch all submissions from remote API Future> getSubmissions({ int limitStart = 0, int limitPageLength = 0, }); + + /// Create or update a project submission + /// Returns the project name (ID) from the API response + Future 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 uploadProjectFile({ + required String projectName, + required String filePath, + }); } /// 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> getProjectProgressList() async { + try { + final response = await _dioClient.post>( + '${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 progressList = message as List; + return progressList + .map((json) => + ProjectProgressModel.fromJson(json as Map)) + .toList(); + } catch (e) { + throw Exception('Failed to get project progress list: $e'); + } + } + /// Get list of project submissions /// /// 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'); } } + + /// 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 saveSubmission(ProjectSubmissionRequest request) async { + try { + final response = await _dioClient.post>( + '${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?; + if (message == null) { + throw Exception('No message in saveProject response'); + } + + final messageData = message['data'] as Map?; + 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 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>( + '${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'); + } + } } diff --git a/lib/features/projects/data/models/project_progress_model.dart b/lib/features/projects/data/models/project_progress_model.dart new file mode 100644 index 0000000..6dd357c --- /dev/null +++ b/lib/features/projects/data/models/project_progress_model.dart @@ -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 json) { + return ProjectProgressModel( + id: json['name'] as String, + status: json['status'] as String, + ); + } + + /// Convert to JSON + Map 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, + ); + } +} diff --git a/lib/features/projects/data/models/project_progress_model.g.dart b/lib/features/projects/data/models/project_progress_model.g.dart new file mode 100644 index 0000000..e0802e6 --- /dev/null +++ b/lib/features/projects/data/models/project_progress_model.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'project_progress_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class ProjectProgressModelAdapter extends TypeAdapter { + @override + final typeId = 64; + + @override + ProjectProgressModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + 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; +} diff --git a/lib/features/projects/data/models/project_submission_request.dart b/lib/features/projects/data/models/project_submission_request.dart new file mode 100644 index 0000000..cc9afd2 --- /dev/null +++ b/lib/features/projects/data/models/project_submission_request.dart @@ -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 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, + ); + } +} diff --git a/lib/features/projects/data/repositories/submissions_repository_impl.dart b/lib/features/projects/data/repositories/submissions_repository_impl.dart index 524ad01..185473c 100644 --- a/lib/features/projects/data/repositories/submissions_repository_impl.dart +++ b/lib/features/projects/data/repositories/submissions_repository_impl.dart @@ -3,8 +3,11 @@ /// Implements the submissions repository interface with caching support. 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/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_submission.dart'; import 'package:worker/features/projects/domain/repositories/submissions_repository.dart'; @@ -16,10 +19,12 @@ class SubmissionsRepositoryImpl implements SubmissionsRepository { const SubmissionsRepositoryImpl( this._remoteDataSource, this._statusLocalDataSource, + this._progressLocalDataSource, ); final SubmissionsRemoteDataSource _remoteDataSource; final ProjectStatusLocalDataSource _statusLocalDataSource; + final ProjectProgressLocalDataSource _progressLocalDataSource; /// 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> 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 _refreshProgressCache() async { + try { + final progressModels = await _remoteDataSource.getProjectProgressList(); + await _progressLocalDataSource.cacheProgressList(progressModels); + } catch (e) { + // Silently fail - we already returned cached data + } + } + @override Future> getSubmissions({ int limitStart = 0, @@ -84,4 +137,28 @@ class SubmissionsRepositoryImpl implements SubmissionsRepository { rethrow; } } + + @override + Future saveSubmission(ProjectSubmissionRequest request) async { + try { + return await _remoteDataSource.saveSubmission(request); + } catch (e) { + rethrow; + } + } + + @override + Future uploadProjectFile({ + required String projectName, + required String filePath, + }) async { + try { + return await _remoteDataSource.uploadProjectFile( + projectName: projectName, + filePath: filePath, + ); + } catch (e) { + rethrow; + } + } } diff --git a/lib/features/projects/domain/entities/project_progress.dart b/lib/features/projects/domain/entities/project_progress.dart new file mode 100644 index 0000000..2457666 --- /dev/null +++ b/lib/features/projects/domain/entities/project_progress.dart @@ -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 get props => [id, status]; + + @override + String toString() => 'ProjectProgress(id: $id, status: $status)'; +} diff --git a/lib/features/projects/domain/repositories/submissions_repository.dart b/lib/features/projects/domain/repositories/submissions_repository.dart index 1b528b2..1ab846a 100644 --- a/lib/features/projects/domain/repositories/submissions_repository.dart +++ b/lib/features/projects/domain/repositories/submissions_repository.dart @@ -3,6 +3,8 @@ /// Repository interface for project submissions operations. 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_submission.dart'; @@ -20,9 +22,32 @@ abstract class SubmissionsRepository { 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> getProjectProgressList({ + bool forceRefresh = false, + }); + /// Get all project submissions for the current user Future> getSubmissions({ int limitStart = 0, int limitPageLength = 0, }); + + /// Save (create/update) a project submission + /// Returns the project name (ID) from the API response + Future 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 uploadProjectFile({ + required String projectName, + required String filePath, + }); } diff --git a/lib/features/projects/presentation/pages/submission_create_page.dart b/lib/features/projects/presentation/pages/submission_create_page.dart index 54b3829..1baa361 100644 --- a/lib/features/projects/presentation/pages/submission_create_page.dart +++ b/lib/features/projects/presentation/pages/submission_create_page.dart @@ -11,6 +11,9 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:image_picker/image_picker.dart'; import 'package:worker/core/constants/ui_constants.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 class SubmissionCreatePage extends ConsumerStatefulWidget { @@ -35,10 +38,10 @@ class _SubmissionCreatePageState extends ConsumerState { final _descriptionController = TextEditingController(); // Form state - String? _selectedProgress; + ProjectProgress? _selectedProgress; DateTime? _expectedStartDate; final List _uploadedFiles = []; - bool _showStartDateField = false; + bool _isSubmitting = false; @override void dispose() { @@ -217,10 +220,8 @@ class _SubmissionCreatePageState extends ConsumerState { _buildProgressDropdown(), - if (_showStartDateField) ...[ - const SizedBox(height: 16), - _buildDateField(), - ], + const SizedBox(height: 16), + _buildExpectedDateField(), ], ), ), @@ -434,6 +435,8 @@ class _SubmissionCreatePageState extends ConsumerState { } Widget _buildProgressDropdown() { + final progressListAsync = ref.watch(projectProgressListProvider); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -457,68 +460,93 @@ class _SubmissionCreatePageState extends ConsumerState { ], ), const SizedBox(height: 8), - DropdownButtonFormField( - value: _selectedProgress, - decoration: InputDecoration( - filled: true, - fillColor: AppColors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.grey100), + progressListAsync.when( + data: (progressList) => DropdownButtonFormField( + initialValue: _selectedProgress, + decoration: InputDecoration( + filled: true, + fillColor: AppColors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.grey100), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.grey100), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), ), - enabledBorder: OutlineInputBorder( + hint: const Text('Chọn tiến độ'), + items: progressList + .map((progress) => DropdownMenuItem( + value: progress, + child: Text(progress.status), + )) + .toList(), + onChanged: (value) { + setState(() { + _selectedProgress = value; + }); + }, + validator: (value) { + if (value == null) { + return 'Vui lòng chọn tiến độ công trình'; + } + 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), - borderSide: const BorderSide(color: AppColors.grey100), ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + 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)), + ], ), ), - hint: const Text('Chọn tiến độ'), - items: const [ - DropdownMenuItem( - value: 'not-started', - child: Text('Chưa khởi công'), + 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), ), - DropdownMenuItem( - value: 'foundation', - child: Text('Khởi công móng'), + 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'), + ), + ], ), - 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) { - setState(() { - _selectedProgress = value; - _showStartDateField = value == 'not-started'; - if (!_showStartDateField) { - _expectedStartDate = null; - } - }); - }, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Vui lòng chọn tiến độ công trình'; - } - return null; - }, + ), ), ], ); } - Widget _buildDateField() { + Widget _buildExpectedDateField() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -532,7 +560,7 @@ class _SubmissionCreatePageState extends ConsumerState { ), const SizedBox(height: 8), InkWell( - onTap: _pickDate, + onTap: _pickExpectedDate, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( @@ -545,7 +573,7 @@ class _SubmissionCreatePageState extends ConsumerState { children: [ Text( _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', style: TextStyle( color: _expectedStartDate != null @@ -571,35 +599,89 @@ class _SubmissionCreatePageState extends ConsumerState { final fileSizeInBytes = file.lengthSync(); 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( padding: const EdgeInsets.all(12), decoration: BoxDecoration( 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), ), child: Row( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.file( - file, - width: 48, - height: 48, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( + // Image with upload overlay + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + file, width: 48, height: 48, - color: AppColors.grey100, - child: const FaIcon( - FontAwesomeIcons.image, - size: 24, - color: AppColors.grey500, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 48, + height: 48, + color: AppColors.grey100, + child: const FaIcon( + FontAwesomeIcons.image, + size: 24, + color: AppColors.grey500, + ), + ); + }, + ), + ), + // 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(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), Expanded( @@ -617,27 +699,39 @@ class _SubmissionCreatePageState extends ConsumerState { ), const SizedBox(height: 2), Text( - '${fileSizeInMB}MB', - style: const TextStyle( + isUploading + ? 'Đang tải lên...' + : isUploaded + ? 'Đã tải lên' + : hasError + ? 'Lỗi tải lên' + : '${fileSizeInMB}MB', + style: TextStyle( fontSize: 12, - color: AppColors.grey500, + color: hasError + ? AppColors.danger + : isUploaded + ? AppColors.success + : AppColors.grey500, ), ), ], ), ), - IconButton( - icon: const FaIcon( - FontAwesomeIcons.xmark, - size: 16, - color: AppColors.danger, + // Only show remove button when not uploading + if (!_isSubmitting) + IconButton( + icon: const FaIcon( + FontAwesomeIcons.xmark, + size: 16, + color: AppColors.danger, + ), + onPressed: () { + setState(() { + _uploadedFiles.removeAt(index); + }); + }, ), - onPressed: () { - setState(() { - _uploadedFiles.removeAt(index); - }); - }, - ), ], ), ); @@ -648,39 +742,50 @@ class _SubmissionCreatePageState extends ConsumerState { width: double.infinity, height: 48, child: ElevatedButton( - onPressed: _handleSubmit, + onPressed: _isSubmitting ? null : _handleSubmit, style: ElevatedButton.styleFrom( backgroundColor: AppColors.primaryBlue, foregroundColor: AppColors.white, + disabledBackgroundColor: AppColors.primaryBlue.withValues(alpha: 0.6), + disabledForegroundColor: AppColors.white, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FaIcon(FontAwesomeIcons.paperPlane, size: 16), - SizedBox(width: 8), - Text( - 'Gửi đăng ký', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, + child: _isSubmitting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.white), + ), + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FaIcon(FontAwesomeIcons.paperPlane, size: 16), + SizedBox(width: 8), + Text( + 'Gửi đăng ký', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], ), - ), - ], - ), ), ); } - Future _pickDate() async { + Future _pickExpectedDate() async { final date = await showDatePicker( context: context, - initialDate: DateTime.now(), + initialDate: _expectedStartDate ?? 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) { @@ -725,34 +830,106 @@ class _SubmissionCreatePageState extends ConsumerState { } Future _handleSubmit() async { - if (_formKey.currentState!.validate()) { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Xác nhận'), - content: const Text('Xác nhận gửi đăng ký công trình?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Hủy'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Xác nhận'), - ), - ], + 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; + } - if (confirmed == true && mounted) { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Xác nhận'), + content: const Text('Xác nhận gửi đăng ký công trình?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Hủy'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Xác nhận'), + ), + ], + ), + ); + + 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( const SnackBar( 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.', ), + 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(); } } } diff --git a/lib/features/projects/presentation/pages/submissions_page.dart b/lib/features/projects/presentation/pages/submissions_page.dart index 75f3d38..4b4a142 100644 --- a/lib/features/projects/presentation/pages/submissions_page.dart +++ b/lib/features/projects/presentation/pages/submissions_page.dart @@ -39,7 +39,13 @@ class SubmissionsPage extends ConsumerWidget { actions: [ IconButton( icon: const FaIcon(FontAwesomeIcons.plus, color: Colors.black, size: 20), - onPressed: () => context.push(RouteNames.submissionCreate), + onPressed: () async { + final result = await context.push(RouteNames.submissionCreate); + if (result == true) { + // Refresh submissions list after successful creation + ref.invalidate(allSubmissionsProvider); + } + }, ), const SizedBox(width: AppSpacing.sm), ], diff --git a/lib/features/projects/presentation/providers/submissions_provider.dart b/lib/features/projects/presentation/providers/submissions_provider.dart index b4bb680..38a72c6 100644 --- a/lib/features/projects/presentation/providers/submissions_provider.dart +++ b/lib/features/projects/presentation/providers/submissions_provider.dart @@ -5,9 +5,12 @@ library; import 'package:riverpod_annotation/riverpod_annotation.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/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/domain/entities/project_progress.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'; @@ -20,6 +23,12 @@ ProjectStatusLocalDataSource projectStatusLocalDataSource(Ref ref) { return ProjectStatusLocalDataSource(); } +/// Project Progress Local Data Source Provider +@riverpod +ProjectProgressLocalDataSource projectProgressLocalDataSource(Ref ref) { + return ProjectProgressLocalDataSource(); +} + /// Submissions Remote Data Source Provider @riverpod Future submissionsRemoteDataSource(Ref ref) async { @@ -32,7 +41,12 @@ Future submissionsRemoteDataSource(Ref ref) async { Future submissionsRepository(Ref ref) async { final remoteDataSource = await ref.watch(submissionsRemoteDataSourceProvider.future); final statusLocalDataSource = ref.watch(projectStatusLocalDataSourceProvider); - return SubmissionsRepositoryImpl(remoteDataSource, statusLocalDataSource); + final progressLocalDataSource = ref.watch(projectProgressLocalDataSourceProvider); + return SubmissionsRepositoryImpl( + remoteDataSource, + statusLocalDataSource, + progressLocalDataSource, + ); } /// 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> build() async { + final repository = await ref.watch(submissionsRepositoryProvider.future); + return repository.getProjectProgressList(); + } + + /// Refresh progress list from remote (force refresh) + Future 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 /// /// 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 class AllSubmissions extends _$AllSubmissions { @override Future> 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(projectProgressListProvider.future); // Then fetch submissions final repository = await ref.watch(submissionsRepositoryProvider.future); @@ -77,8 +114,9 @@ class AllSubmissions extends _$AllSubmissions { Future refresh() async { state = const AsyncValue.loading(); 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(projectProgressListProvider.notifier).refresh(); final repository = await ref.read(submissionsRepositoryProvider.future); return repository.getSubmissions(); @@ -154,3 +192,152 @@ AsyncValue> filteredSubmissions(Ref ref) { return filtered; }); } + +/// Save Submission Provider +/// +/// Handles creating new project submissions via API. +@riverpod +class SaveSubmission extends _$SaveSubmission { + @override + AsyncValue build() { + return const AsyncValue.data(null); + } + + /// Save a new project submission + /// + /// Returns the project name (ID) if successful, throws exception on failure. + Future 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 build() { + return []; + } + + /// Initialize with file paths + void initFiles(List filePaths) { + state = filePaths + .map((path) => FileUploadState(filePath: path)) + .toList(); + } + + /// Upload all files for a project + /// Returns list of uploaded file URLs + Future> uploadAll(String projectName) async { + final uploadedUrls = []; + + 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 = []; + } +} diff --git a/lib/features/projects/presentation/providers/submissions_provider.g.dart b/lib/features/projects/presentation/providers/submissions_provider.g.dart index 265baa4..51b1a4e 100644 --- a/lib/features/projects/presentation/providers/submissions_provider.g.dart +++ b/lib/features/projects/presentation/providers/submissions_provider.g.dart @@ -62,6 +62,62 @@ final class ProjectStatusLocalDataSourceProvider String _$projectStatusLocalDataSourceHash() => 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 { + /// 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 $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( + value, + ), + ); + } +} + +String _$projectProgressLocalDataSourceHash() => + r'653d03b47f5642f3391e7a312649a2603489b224'; + /// Submissions Remote Data Source Provider @ProviderFor(submissionsRemoteDataSource) @@ -155,7 +211,7 @@ final class SubmissionsRepositoryProvider } String _$submissionsRepositoryHash() => - r'd8261cc538c1fdaa47064e4945302b80f49098bb'; + r'652208a4ef93cde9b40ae66164d44bba786dfed0'; /// Project Status List Provider /// @@ -221,10 +277,80 @@ abstract class _$ProjectStatusList extends $AsyncNotifier> { } } +/// 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> { + /// 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> { + FutureOr> build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = + this.ref + as $Ref>, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue>, + List + >, + AsyncValue>, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + /// All Submissions Provider /// /// 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) const allSubmissionsProvider = AllSubmissionsProvider._(); @@ -232,13 +358,13 @@ const allSubmissionsProvider = AllSubmissionsProvider._(); /// All Submissions Provider /// /// 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 extends $AsyncNotifierProvider> { /// All Submissions Provider /// /// 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._() : super( from: null, @@ -258,12 +384,12 @@ final class AllSubmissionsProvider AllSubmissions create() => AllSubmissions(); } -String _$allSubmissionsHash() => r'a4a7fb0d2953efb21e2e6343429f7550c763ea85'; +String _$allSubmissionsHash() => r'ab0f1ffdc5e6bdb62dbd56ff3e586ecc1ff05bea'; /// All Submissions Provider /// /// 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 extends $AsyncNotifier> { @@ -442,3 +568,142 @@ final class FilteredSubmissionsProvider String _$filteredSubmissionsHash() => 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> { + /// 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 value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$saveSubmissionHash() => r'64afa1a9662c36431c143c46a8ca34a786cb0860'; + +/// Save Submission Provider +/// +/// Handles creating new project submissions via API. + +abstract class _$SaveSubmission extends $Notifier> { + AsyncValue build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref, AsyncValue>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, AsyncValue>, + AsyncValue, + 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> { + /// 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 value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(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 build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, List>, + List, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/hive_registrar.g.dart b/lib/hive_registrar.g.dart index 6d67584..bd37a07 100644 --- a/lib/hive_registrar.g.dart +++ b/lib/hive_registrar.g.dart @@ -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/stock_level_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_submission_model.dart'; import 'package:worker/features/quotes/data/models/quote_item_model.dart'; @@ -77,6 +78,7 @@ extension HiveRegistrar on HiveInterface { registerAdapter(PointsRecordModelAdapter()); registerAdapter(PointsStatusAdapter()); registerAdapter(ProductModelAdapter()); + registerAdapter(ProjectProgressModelAdapter()); registerAdapter(ProjectStatusModelAdapter()); registerAdapter(ProjectSubmissionModelAdapter()); registerAdapter(ProjectTypeAdapter()); @@ -137,6 +139,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(PointsRecordModelAdapter()); registerAdapter(PointsStatusAdapter()); registerAdapter(ProductModelAdapter()); + registerAdapter(ProjectProgressModelAdapter()); registerAdapter(ProjectStatusModelAdapter()); registerAdapter(ProjectSubmissionModelAdapter()); registerAdapter(ProjectTypeAdapter());