submission

This commit is contained in:
Phuoc Nguyen
2025-11-27 17:58:13 +07:00
parent b6cb9e865a
commit 6e7e848ad6
15 changed files with 745 additions and 109 deletions

View File

@@ -139,3 +139,44 @@ curl --location 'https://land.dbiz.com//api/method/upload_file' \
--form 'docname="p9ti8veq2g"' \
--form 'optimize="true"'
#get detail of a project
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.project.get_detail' \
--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": "#DA00011"
}'
#response
{
"message": {
"success": true,
"data": {
"name": "#DA00011",
"designed_area": "f67gg7",
"address_of_project": "7fucuv",
"project_owner": "cycu",
"design_firm": null,
"contruction_contractor": null,
"design_area": 2585.0,
"products_included_in_the_design": "thy",
"project_progress": "k1mr565o91",
"expected_commencement_date": "2025-11-30",
"description": null,
"request_date": "2025-11-27 16:51:54",
"workflow_state": "Pending approval",
"reason_for_rejection": null,
"status": "Chờ phê duyệt",
"status_color": "Warning",
"is_allow_modify": true,
"is_allow_cancel": true,
"files_list": [
{
"name": "0068d2403c",
"file_url": "https://land.dbiz.com/private/files/image_picker_32BD79E6-7A71-448E-A5DF-6DA7D12A1303-66894-000015E4259DBB5B.png"
}
]
}
}
}

View File

@@ -308,6 +308,13 @@ class ApiConstants {
static const String saveProject =
'/building_material.building_material.api.project.save';
/// Get project detail (requires sid and csrf_token)
/// POST /api/method/building_material.building_material.api.project.get_detail
/// Body: { "name": "#DA00011" }
/// Returns: Full project detail with all fields
static const String getProjectDetail =
'/building_material.building_material.api.project.get_detail';
/// Create new project (legacy endpoint - may be deprecated)
/// POST /projects
static const String createProject = '/projects';

View File

@@ -189,7 +189,7 @@ final class LoggingInterceptorProvider
}
String _$loggingInterceptorHash() =>
r'4d3147e9084d261e14653386ecd74ee471993af4';
r'6afa480caa6fcd723dab769bb01601b8a37e20fd';
/// Provider for ErrorTransformerInterceptor

View File

@@ -42,6 +42,7 @@ import 'package:worker/features/price_policy/price_policy.dart';
import 'package:worker/features/products/presentation/pages/product_detail_page.dart';
import 'package:worker/features/products/presentation/pages/products_page.dart';
import 'package:worker/features/products/presentation/pages/write_review_page.dart';
import 'package:worker/features/projects/domain/entities/project_submission.dart';
import 'package:worker/features/projects/presentation/pages/submission_create_page.dart';
import 'package:worker/features/projects/presentation/pages/submissions_page.dart';
import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart';
@@ -391,12 +392,17 @@ final routerProvider = Provider<GoRouter>((ref) {
MaterialPage(key: state.pageKey, child: const SubmissionsPage()),
),
// Submission Create Route
// Submission Create/Edit Route
GoRoute(
path: RouteNames.submissionCreate,
name: RouteNames.submissionCreate,
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const SubmissionCreatePage()),
pageBuilder: (context, state) {
final submission = state.extra as ProjectSubmission?;
return MaterialPage(
key: state.pageKey,
child: SubmissionCreatePage(submission: submission),
);
},
),
// Quotes Route

View File

@@ -27,6 +27,10 @@ abstract class SubmissionsRemoteDataSource {
int limitPageLength = 0,
});
/// Fetch project detail by name
/// Returns the full project detail as a model
Future<ProjectSubmissionModel> getSubmissionDetail(String name);
/// Create or update a project submission
/// Returns the project name (ID) from the API response
Future<String> saveSubmission(ProjectSubmissionRequest request);
@@ -170,6 +174,42 @@ class SubmissionsRemoteDataSourceImpl implements SubmissionsRemoteDataSource {
}
}
/// Get project detail by name
///
/// Calls: POST /api/method/building_material.building_material.api.project.get_detail
/// Body: { "name": "#DA00011" }
/// Response: { "message": { "success": true, "data": {...} } }
/// Returns: Full project detail as model
@override
Future<ProjectSubmissionModel> getSubmissionDetail(String name) async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.getProjectDetail}',
data: {'name': name},
);
final data = response.data;
if (data == null) {
throw Exception('No data received from getProjectDetail API');
}
// API returns: { "message": { "success": true, "data": {...} } }
final message = data['message'] as Map<String, dynamic>?;
if (message == null) {
throw Exception('No message field in getProjectDetail response');
}
final detailData = message['data'] as Map<String, dynamic>?;
if (detailData == null) {
throw Exception('No data field in getProjectDetail response');
}
return ProjectSubmissionModel.fromJson(detailData);
} catch (e) {
throw Exception('Failed to get project detail: $e');
}
}
/// Save (create/update) a project submission
///
/// Calls: POST /api/method/building_material.building_material.api.project.save
@@ -227,7 +267,7 @@ class SubmissionsRemoteDataSourceImpl implements SubmissionsRemoteDataSource {
final fileName = filePath.split('/').last;
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath, filename: fileName),
'is_private': '1',
'is_private': '0',
'folder': 'Home/Attachments',
'doctype': 'Architectural Project',
'docname': projectName,

View File

@@ -1,47 +1,117 @@
/// Project Submission Model
///
/// Data model for project submission from API responses with Hive caching.
/// Based on API response from building_material.building_material.api.project.get_list
/// Data model for project submission from API responses.
/// Based on API response from building_material.building_material.api.project.get_detail
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/projects/domain/entities/project_submission.dart';
part 'project_submission_model.g.dart';
/// Project File Model
class ProjectFileModel {
/// Unique file identifier (API: name)
final String id;
/// Project Submission Model - Type ID: 14
@HiveType(typeId: HiveTypeIds.projectSubmissionModel)
class ProjectSubmissionModel extends HiveObject {
/// Full URL to the file (API: file_url)
final String fileUrl;
const ProjectFileModel({
required this.id,
required this.fileUrl,
});
/// Create from JSON (API response)
factory ProjectFileModel.fromJson(Map<String, dynamic> json) {
return ProjectFileModel(
id: json['name'] as String,
fileUrl: json['file_url'] as String,
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'name': id,
'file_url': fileUrl,
};
}
/// Convert to entity
ProjectFile toEntity() {
return ProjectFile(
id: id,
fileUrl: fileUrl,
);
}
/// Create from entity
factory ProjectFileModel.fromEntity(ProjectFile entity) {
return ProjectFileModel(
id: entity.id,
fileUrl: entity.fileUrl,
);
}
}
/// Project Submission Model
class ProjectSubmissionModel {
/// Unique submission identifier (API: name)
@HiveField(0)
final String submissionId;
/// Project name/title (API: designed_area)
@HiveField(1)
final String designedArea;
/// Design area value in square meters (API: design_area)
@HiveField(2)
final double designArea;
/// Submission/request date (API: request_date)
@HiveField(3)
final DateTime requestDate;
/// Status label - Vietnamese (API: status)
@HiveField(4)
final String status;
/// Rejection reason if rejected (API: reason_for_rejection)
@HiveField(5)
final String? reasonForRejection;
/// Status color indicator (API: status_color)
@HiveField(6)
final String statusColor;
ProjectSubmissionModel({
/// 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? constructionContractor;
/// Products included in the design (API: products_included_in_the_design)
final String? productsIncludedInTheDesign;
/// Project progress ID reference (API: project_progress)
final String? projectProgress;
/// Expected commencement date (API: expected_commencement_date)
final DateTime? expectedCommencementDate;
/// Project description (API: description)
final String? description;
/// Workflow state (API: workflow_state)
final String? workflowState;
/// Whether the submission can be modified (API: is_allow_modify)
final bool isAllowModify;
/// Whether the submission can be cancelled (API: is_allow_cancel)
final bool isAllowCancel;
/// List of attached files (API: files_list)
final List<ProjectFileModel> filesList;
const ProjectSubmissionModel({
required this.submissionId,
required this.designedArea,
required this.designArea,
@@ -49,10 +119,39 @@ class ProjectSubmissionModel extends HiveObject {
required this.status,
this.reasonForRejection,
required this.statusColor,
this.addressOfProject,
this.projectOwner,
this.designFirm,
this.constructionContractor,
this.productsIncludedInTheDesign,
this.projectProgress,
this.expectedCommencementDate,
this.description,
this.workflowState,
this.isAllowModify = false,
this.isAllowCancel = false,
this.filesList = const [],
});
/// Create from JSON (API response)
/// Handles both list response and detail response formats
factory ProjectSubmissionModel.fromJson(Map<String, dynamic> json) {
// Parse expected_commencement_date
DateTime? expectedDate;
final expectedDateStr = json['expected_commencement_date'] as String?;
if (expectedDateStr != null && expectedDateStr.isNotEmpty) {
try {
expectedDate = DateTime.parse(expectedDateStr);
} catch (_) {}
}
// Parse files_list
final filesListJson = json['files_list'] as List<dynamic>?;
final filesList = filesListJson
?.map((f) => ProjectFileModel.fromJson(f as Map<String, dynamic>))
.toList() ??
[];
return ProjectSubmissionModel(
submissionId: json['name'] as String,
designedArea: json['designed_area'] as String,
@@ -61,6 +160,19 @@ class ProjectSubmissionModel extends HiveObject {
status: json['status'] as String,
reasonForRejection: json['reason_for_rejection'] as String?,
statusColor: json['status_color'] as String,
addressOfProject: json['address_of_project'] as String?,
projectOwner: json['project_owner'] as String?,
designFirm: json['design_firm'] as String?,
constructionContractor: json['contruction_contractor'] as String?,
productsIncludedInTheDesign:
json['products_included_in_the_design'] as String?,
projectProgress: json['project_progress'] as String?,
expectedCommencementDate: expectedDate,
description: json['description'] as String?,
workflowState: json['workflow_state'] as String?,
isAllowModify: json['is_allow_modify'] as bool? ?? false,
isAllowCancel: json['is_allow_cancel'] as bool? ?? false,
filesList: filesList,
);
}
@@ -74,6 +186,19 @@ class ProjectSubmissionModel extends HiveObject {
'status': status,
'reason_for_rejection': reasonForRejection,
'status_color': statusColor,
'address_of_project': addressOfProject,
'project_owner': projectOwner,
'design_firm': designFirm,
'contruction_contractor': constructionContractor,
'products_included_in_the_design': productsIncludedInTheDesign,
'project_progress': projectProgress,
'expected_commencement_date':
expectedCommencementDate?.toIso8601String(),
'description': description,
'workflow_state': workflowState,
'is_allow_modify': isAllowModify,
'is_allow_cancel': isAllowCancel,
'files_list': filesList.map((f) => f.toJson()).toList(),
};
}
@@ -87,6 +212,18 @@ class ProjectSubmissionModel extends HiveObject {
status: status,
reasonForRejection: reasonForRejection,
statusColor: statusColor,
addressOfProject: addressOfProject,
projectOwner: projectOwner,
designFirm: designFirm,
constructionContractor: constructionContractor,
productsIncludedInTheDesign: productsIncludedInTheDesign,
projectProgress: projectProgress,
expectedCommencementDate: expectedCommencementDate,
description: description,
workflowState: workflowState,
isAllowModify: isAllowModify,
isAllowCancel: isAllowCancel,
filesList: filesList.map((f) => f.toEntity()).toList(),
);
}
@@ -100,6 +237,19 @@ class ProjectSubmissionModel extends HiveObject {
status: entity.status,
reasonForRejection: entity.reasonForRejection,
statusColor: entity.statusColor,
addressOfProject: entity.addressOfProject,
projectOwner: entity.projectOwner,
designFirm: entity.designFirm,
constructionContractor: entity.constructionContractor,
productsIncludedInTheDesign: entity.productsIncludedInTheDesign,
projectProgress: entity.projectProgress,
expectedCommencementDate: entity.expectedCommencementDate,
description: entity.description,
workflowState: entity.workflowState,
isAllowModify: entity.isAllowModify,
isAllowCancel: entity.isAllowCancel,
filesList:
entity.filesList.map((f) => ProjectFileModel.fromEntity(f)).toList(),
);
}
}

View File

@@ -1,60 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'project_submission_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ProjectSubmissionModelAdapter
extends TypeAdapter<ProjectSubmissionModel> {
@override
final typeId = 14;
@override
ProjectSubmissionModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ProjectSubmissionModel(
submissionId: fields[0] as String,
designedArea: fields[1] as String,
designArea: (fields[2] as num).toDouble(),
requestDate: fields[3] as DateTime,
status: fields[4] as String,
reasonForRejection: fields[5] as String?,
statusColor: fields[6] as String,
);
}
@override
void write(BinaryWriter writer, ProjectSubmissionModel obj) {
writer
..writeByte(7)
..writeByte(0)
..write(obj.submissionId)
..writeByte(1)
..write(obj.designedArea)
..writeByte(2)
..write(obj.designArea)
..writeByte(3)
..write(obj.requestDate)
..writeByte(4)
..write(obj.status)
..writeByte(5)
..write(obj.reasonForRejection)
..writeByte(6)
..write(obj.statusColor);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProjectSubmissionModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -138,6 +138,16 @@ class SubmissionsRepositoryImpl implements SubmissionsRepository {
}
}
@override
Future<ProjectSubmission> getSubmissionDetail(String name) async {
try {
final model = await _remoteDataSource.getSubmissionDetail(name);
return model.toEntity();
} catch (e) {
rethrow;
}
}
@override
Future<String> saveSubmission(ProjectSubmissionRequest request) async {
try {

View File

@@ -1,22 +1,53 @@
/// Domain Entity: Project Submission
///
/// Represents a completed project submitted for loyalty points.
/// Based on API response from building_material.building_material.api.project.get_list
/// Based on API response from building_material.building_material.api.project.get_detail
library;
import 'package:equatable/equatable.dart';
/// Project File Entity
///
/// Represents an uploaded file attached to a project submission.
class ProjectFile extends Equatable {
/// Unique file identifier (API: name)
final String id;
/// Full URL to the file (API: file_url)
final String fileUrl;
const ProjectFile({
required this.id,
required this.fileUrl,
});
@override
List<Object?> get props => [id, fileUrl];
}
/// Project Submission Entity
///
/// Contains information about a completed project submission.
/// Mapped from API response:
/// - name -> submissionId
/// - designed_area -> designedArea (project name/title)
/// - address_of_project -> addressOfProject
/// - project_owner -> projectOwner
/// - design_firm -> designFirm
/// - contruction_contractor -> constructionContractor
/// - design_area -> designArea (area value in m²)
/// - products_included_in_the_design -> productsIncludedInTheDesign
/// - project_progress -> projectProgress (ID reference)
/// - expected_commencement_date -> expectedCommencementDate
/// - description -> description
/// - request_date -> requestDate
/// - status -> status (Vietnamese label)
/// - workflow_state -> workflowState
/// - reason_for_rejection -> reasonForRejection
/// - status -> status (Vietnamese label)
/// - status_color -> statusColor
/// - is_allow_modify -> isAllowModify
/// - is_allow_cancel -> isAllowCancel
/// - files_list -> filesList
class ProjectSubmission extends Equatable {
/// Unique submission identifier (API: name)
final String submissionId;
@@ -24,31 +55,80 @@ class ProjectSubmission extends Equatable {
/// 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? constructionContractor;
/// Design area value in square meters (API: design_area)
final double designArea;
/// Products included in the design (API: products_included_in_the_design)
final String? productsIncludedInTheDesign;
/// Project progress ID reference (API: project_progress)
final String? projectProgress;
/// Expected commencement date (API: expected_commencement_date)
final DateTime? expectedCommencementDate;
/// Project description (API: description)
final String? description;
/// Submission/request date (API: request_date)
final DateTime requestDate;
/// Workflow state (API: workflow_state)
/// e.g., "Pending approval", "Approved", "Rejected", "Cancelled"
final String? workflowState;
/// Rejection reason if rejected (API: reason_for_rejection)
final String? reasonForRejection;
/// Status label - Vietnamese (API: status)
/// e.g., "Chờ phê duyệt", "Đã được phê duyệt", "Từ chối", "HỦY BỎ"
final String status;
/// Rejection reason if rejected (API: reason_for_rejection)
final String? reasonForRejection;
/// Status color indicator (API: status_color)
/// Values: "Warning", "Success", "Danger"
final String statusColor;
/// Whether the submission can be modified (API: is_allow_modify)
final bool isAllowModify;
/// Whether the submission can be cancelled (API: is_allow_cancel)
final bool isAllowCancel;
/// List of attached files (API: files_list)
final List<ProjectFile> filesList;
const ProjectSubmission({
required this.submissionId,
required this.designedArea,
this.addressOfProject,
this.projectOwner,
this.designFirm,
this.constructionContractor,
required this.designArea,
this.productsIncludedInTheDesign,
this.projectProgress,
this.expectedCommencementDate,
this.description,
required this.requestDate,
required this.status,
this.workflowState,
this.reasonForRejection,
required this.status,
required this.statusColor,
this.isAllowModify = false,
this.isAllowCancel = false,
this.filesList = const [],
});
/// Check if submission is pending approval
@@ -64,20 +144,44 @@ class ProjectSubmission extends Equatable {
ProjectSubmission copyWith({
String? submissionId,
String? designedArea,
String? addressOfProject,
String? projectOwner,
String? designFirm,
String? constructionContractor,
double? designArea,
String? productsIncludedInTheDesign,
String? projectProgress,
DateTime? expectedCommencementDate,
String? description,
DateTime? requestDate,
String? status,
String? workflowState,
String? reasonForRejection,
String? status,
String? statusColor,
bool? isAllowModify,
bool? isAllowCancel,
List<ProjectFile>? filesList,
}) {
return ProjectSubmission(
submissionId: submissionId ?? this.submissionId,
designedArea: designedArea ?? this.designedArea,
addressOfProject: addressOfProject ?? this.addressOfProject,
projectOwner: projectOwner ?? this.projectOwner,
designFirm: designFirm ?? this.designFirm,
constructionContractor: constructionContractor ?? this.constructionContractor,
designArea: designArea ?? this.designArea,
productsIncludedInTheDesign: productsIncludedInTheDesign ?? this.productsIncludedInTheDesign,
projectProgress: projectProgress ?? this.projectProgress,
expectedCommencementDate: expectedCommencementDate ?? this.expectedCommencementDate,
description: description ?? this.description,
requestDate: requestDate ?? this.requestDate,
status: status ?? this.status,
workflowState: workflowState ?? this.workflowState,
reasonForRejection: reasonForRejection ?? this.reasonForRejection,
status: status ?? this.status,
statusColor: statusColor ?? this.statusColor,
isAllowModify: isAllowModify ?? this.isAllowModify,
isAllowCancel: isAllowCancel ?? this.isAllowCancel,
filesList: filesList ?? this.filesList,
);
}
@@ -85,11 +189,23 @@ class ProjectSubmission extends Equatable {
List<Object?> get props => [
submissionId,
designedArea,
addressOfProject,
projectOwner,
designFirm,
constructionContractor,
designArea,
productsIncludedInTheDesign,
projectProgress,
expectedCommencementDate,
description,
requestDate,
status,
workflowState,
reasonForRejection,
status,
statusColor,
isAllowModify,
isAllowCancel,
filesList,
];
@override

View File

@@ -38,6 +38,10 @@ abstract class SubmissionsRepository {
int limitPageLength = 0,
});
/// Get project detail by name
/// Returns the full project detail as entity for form prefilling
Future<ProjectSubmission> getSubmissionDetail(String name);
/// Save (create/update) a project submission
/// Returns the project name (ID) from the API response
Future<String> saveSubmission(ProjectSubmissionRequest request);

View File

@@ -5,6 +5,7 @@ library;
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
@@ -13,11 +14,17 @@ 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/domain/entities/project_submission.dart';
import 'package:worker/features/projects/presentation/providers/submissions_provider.dart';
/// Project Submission Create Page
/// Project Submission Create/Edit Page
class SubmissionCreatePage extends ConsumerStatefulWidget {
const SubmissionCreatePage({super.key});
const SubmissionCreatePage({super.key, this.submission});
/// Optional submission for editing mode
/// If null, creates new submission
/// If provided, prefills form and updates existing submission
final ProjectSubmission? submission;
@override
ConsumerState<SubmissionCreatePage> createState() =>
@@ -40,8 +47,73 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
// Form state
ProjectProgress? _selectedProgress;
DateTime? _expectedStartDate;
final List<File> _uploadedFiles = [];
final List<File> _uploadedFiles = []; // New files to upload
List<ProjectFile> _existingFiles = []; // Existing files from API
bool _isSubmitting = false;
bool _isLoadingDetail = false;
/// Whether we're editing an existing submission
bool get isEditing => widget.submission != null;
@override
void initState() {
super.initState();
// Fetch full detail when editing
if (isEditing) {
_loadDetail();
}
}
/// Load full project detail from API for editing
Future<void> _loadDetail() async {
if (!isEditing) return;
setState(() => _isLoadingDetail = true);
try {
final detail = await ref.read(
submissionDetailProvider(widget.submission!.submissionId).future,
);
if (!mounted) return;
// Prefill form fields from entity
_projectNameController.text = detail.designedArea;
_addressController.text = detail.addressOfProject ?? '';
_ownerController.text = detail.projectOwner ?? '';
_designUnitController.text = detail.designFirm ?? '';
_constructionUnitController.text = detail.constructionContractor ?? '';
_areaController.text = detail.designArea.toString();
_productsController.text = detail.productsIncludedInTheDesign ?? '';
_descriptionController.text = detail.description ?? '';
// Set expected commencement date
_expectedStartDate = detail.expectedCommencementDate;
// Find matching progress from the list
final progressId = detail.projectProgress;
if (progressId != null) {
final progressList = await ref.read(projectProgressListProvider.future);
_selectedProgress = progressList.where((p) => p.id == progressId).firstOrNull;
}
// Set existing files from API
_existingFiles = detail.filesList;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi tải thông tin: $e'),
backgroundColor: AppColors.danger,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoadingDetail = false);
}
}
}
@override
void dispose() {
@@ -69,9 +141,9 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
),
onPressed: () => Navigator.of(context).pop(),
),
title: const Text(
'Đăng ký Công trình',
style: TextStyle(color: Colors.black),
title: Text(
isEditing ? 'Chỉnh sửa Dự án' : 'Đăng ký Công trình',
style: const TextStyle(color: Colors.black),
),
actions: [
IconButton(
@@ -88,7 +160,21 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
backgroundColor: AppColors.white,
centerTitle: false,
),
body: Form(
body: _isLoadingDetail
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text(
'Đang tải thông tin dự án...',
style: TextStyle(color: AppColors.grey500),
),
],
),
)
: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(4),
@@ -322,8 +408,41 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
),
),
// Existing files from API
if (_existingFiles.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Ảnh đã tải lên',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey500,
),
),
const SizedBox(height: 8),
..._existingFiles.asMap().entries.map((entry) {
final index = entry.key;
final file = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildExistingFileItem(file, index),
);
}),
],
// New files to upload
if (_uploadedFiles.isNotEmpty) ...[
const SizedBox(height: 16),
if (_existingFiles.isNotEmpty)
const Text(
'Ảnh mới',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey500,
),
),
if (_existingFiles.isNotEmpty) const SizedBox(height: 8),
..._uploadedFiles.asMap().entries.map((entry) {
final index = entry.key;
final file = entry.value;
@@ -737,6 +856,88 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
);
}
Widget _buildExistingFileItem(ProjectFile file, int index) {
final fileName = file.fileUrl.split('/').last;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF8F9FA),
border: Border.all(color: AppColors.success),
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
// Network image
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: CachedNetworkImage(
imageUrl: file.fileUrl,
width: 48,
height: 48,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 48,
height: 48,
color: AppColors.grey100,
child: const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
errorWidget: (context, url, error) => Container(
width: 48,
height: 48,
color: AppColors.grey100,
child: const Center(
child: FaIcon(
FontAwesomeIcons.image,
size: 24,
color: AppColors.grey500,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fileName,
style: const TextStyle(
fontWeight: FontWeight.w500,
color: AppColors.grey900,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
const Text(
'Đã tải lên',
style: TextStyle(
fontSize: 12,
color: AppColors.success,
),
),
],
),
),
// Checkmark icon
const FaIcon(
FontAwesomeIcons.circleCheck,
size: 16,
color: AppColors.success,
),
],
),
);
}
Widget _buildSubmitButton() {
return SizedBox(
width: double.infinity,
@@ -847,7 +1048,11 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
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?'),
content: Text(
isEditing
? 'Xác nhận cập nhật thông tin dự án?'
: 'Xác nhận gửi đăng ký công trình?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
@@ -870,7 +1075,9 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
final area = double.tryParse(_areaController.text.trim()) ?? 0.0;
// Create submission request
// Include name field when editing (for update)
final request = ProjectSubmissionRequest(
name: isEditing ? widget.submission!.submissionId : null,
designedArea: _projectNameController.text.trim(),
addressOfProject: _addressController.text.trim(),
projectOwner: _ownerController.text.trim(),
@@ -907,9 +1114,11 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
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.',
isEditing
? 'Cập nhật dự án thành công!'
: 'Đă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,
),

View File

@@ -171,7 +171,7 @@ class SubmissionsPage extends ConsumerWidget {
itemCount: submissions.length,
itemBuilder: (context, index) {
final submission = submissions[index];
return _buildSubmissionCard(context, submission);
return _buildSubmissionCard(context, ref, submission);
},
),
);
@@ -260,17 +260,22 @@ class SubmissionsPage extends ConsumerWidget {
);
}
Widget _buildSubmissionCard(BuildContext context, ProjectSubmission submission) {
Widget _buildSubmissionCard(BuildContext context, WidgetRef ref, ProjectSubmission submission) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
onTap: () {
// TODO: Navigate to submission detail
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Chi tiết dự án ${submission.submissionId}')),
onTap: () async {
// Navigate to edit submission page
final result = await context.push<bool>(
RouteNames.submissionCreate,
extra: submission,
);
if (result == true) {
// Refresh submissions list after successful update
ref.invalidate(allSubmissionsProvider);
}
},
borderRadius: BorderRadius.circular(12),
child: Container(

View File

@@ -193,6 +193,16 @@ AsyncValue<List<ProjectSubmission>> filteredSubmissions(Ref ref) {
});
}
/// Submission Detail Provider
///
/// Fetches full project detail by name for editing.
/// Uses family modifier to cache by submission name.
@riverpod
Future<ProjectSubmission> submissionDetail(Ref ref, String name) async {
final repository = await ref.watch(submissionsRepositoryProvider.future);
return repository.getSubmissionDetail(name);
}
/// Save Submission Provider
///
/// Handles creating new project submissions via API.

View File

@@ -569,6 +569,107 @@ final class FilteredSubmissionsProvider
String _$filteredSubmissionsHash() =>
r'5be22b3242426c6b0c2f9778eaee5c7cf23e4814';
/// Submission Detail Provider
///
/// Fetches full project detail by name for editing.
/// Uses family modifier to cache by submission name.
@ProviderFor(submissionDetail)
const submissionDetailProvider = SubmissionDetailFamily._();
/// Submission Detail Provider
///
/// Fetches full project detail by name for editing.
/// Uses family modifier to cache by submission name.
final class SubmissionDetailProvider
extends
$FunctionalProvider<
AsyncValue<ProjectSubmission>,
ProjectSubmission,
FutureOr<ProjectSubmission>
>
with
$FutureModifier<ProjectSubmission>,
$FutureProvider<ProjectSubmission> {
/// Submission Detail Provider
///
/// Fetches full project detail by name for editing.
/// Uses family modifier to cache by submission name.
const SubmissionDetailProvider._({
required SubmissionDetailFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'submissionDetailProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$submissionDetailHash();
@override
String toString() {
return r'submissionDetailProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<ProjectSubmission> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<ProjectSubmission> create(Ref ref) {
final argument = this.argument as String;
return submissionDetail(ref, argument);
}
@override
bool operator ==(Object other) {
return other is SubmissionDetailProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$submissionDetailHash() => r'd3c767aa55e74a36c6a2b9b9bf6dd8ad8bf8eff3';
/// Submission Detail Provider
///
/// Fetches full project detail by name for editing.
/// Uses family modifier to cache by submission name.
final class SubmissionDetailFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<ProjectSubmission>, String> {
const SubmissionDetailFamily._()
: super(
retry: null,
name: r'submissionDetailProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Submission Detail Provider
///
/// Fetches full project detail by name for editing.
/// Uses family modifier to cache by submission name.
SubmissionDetailProvider call(String name) =>
SubmissionDetailProvider._(argument: name, from: this);
@override
String toString() => r'submissionDetailProvider';
}
/// Save Submission Provider
///
/// Handles creating new project submissions via API.

View File

@@ -34,7 +34,6 @@ 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';
import 'package:worker/features/quotes/data/models/quote_model.dart';
import 'package:worker/features/showrooms/data/models/showroom_model.dart';
@@ -80,7 +79,6 @@ extension HiveRegistrar on HiveInterface {
registerAdapter(ProductModelAdapter());
registerAdapter(ProjectProgressModelAdapter());
registerAdapter(ProjectStatusModelAdapter());
registerAdapter(ProjectSubmissionModelAdapter());
registerAdapter(ProjectTypeAdapter());
registerAdapter(PromotionModelAdapter());
registerAdapter(QuoteItemModelAdapter());
@@ -141,7 +139,6 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
registerAdapter(ProductModelAdapter());
registerAdapter(ProjectProgressModelAdapter());
registerAdapter(ProjectStatusModelAdapter());
registerAdapter(ProjectSubmissionModelAdapter());
registerAdapter(ProjectTypeAdapter());
registerAdapter(PromotionModelAdapter());
registerAdapter(QuoteItemModelAdapter());