Compare commits

..

3 Commits

Author SHA1 Message Date
Phuoc Nguyen
6e7e848ad6 submission 2025-11-27 17:58:13 +07:00
Phuoc Nguyen
b6cb9e865a create submission 2025-11-27 16:56:01 +07:00
Phuoc Nguyen
ba04576750 add 2025-11-27 14:59:48 +07:00
32 changed files with 3031 additions and 878 deletions

182
docs/projects.sh Normal file
View File

@@ -0,0 +1,182 @@
#get status list
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.project.get_project_status_list' \
--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 '{
"limit_start": 0,
"limit_page_length": 0
}'
#response
{
"message": [
{
"status": "Pending approval",
"label": "Chờ phê duyệt",
"color": "Warning",
"index": 1
},
{
"status": "Approved",
"label": "Đã được phê duyệt",
"color": "Success",
"index": 2
},
{
"status": "Rejected",
"label": "Từ chối",
"color": "Danger",
"index": 3
},
{
"status": "Cancelled",
"label": "HỦY BỎ",
"color": "Danger",
"index": 4
}
]
}
#get project list
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.project.get_list' \
--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 '{
"limit_start": 0,
"limit_page_length": 0
}'
#response
{
"message": [
{
"name": "p9ti8veq2g",
"designed_area": "Sunrise Villa Phase 355",
"design_area": 350.5,
"request_date": "2025-11-26 09:30:00",
"status": "Đã được phê duyệt",
"reason_for_rejection": null,
"status_color": "Success"
}
]
}
#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"'
#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

@@ -272,31 +272,71 @@ class ApiConstants {
static const String getPaymentDetails = '/payments';
// ============================================================================
// Project Endpoints
// Project Endpoints (Frappe ERPNext)
// ============================================================================
/// Create new project
/// Get project status list (requires sid and csrf_token)
/// POST /api/method/building_material.building_material.api.project.get_project_status_list
/// Body: { "limit_start": 0, "limit_page_length": 0 }
/// Returns: { "message": [{ "status": "...", "label": "...", "color": "...", "index": 0 }] }
static const String getProjectStatusList =
'/building_material.building_material.api.project.get_project_status_list';
/// Get list of project submissions (requires sid and csrf_token)
/// POST /api/method/building_material.building_material.api.project.get_list
/// Body: { "limit_start": 0, "limit_page_length": 0 }
/// Returns: { "message": [{ "name": "...", "designed_area": "...", "design_area": 0, ... }] }
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';
/// 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';
/// Get user's projects
/// Get user's projects (legacy endpoint - may be deprecated)
/// GET /projects?status={status}&page={page}&limit={limit}
static const String getProjects = '/projects';
/// Get project details by ID
/// Get project details by ID (legacy endpoint - may be deprecated)
/// GET /projects/{projectId}
static const String getProjectDetails = '/projects';
/// Update project
/// Update project (legacy endpoint - may be deprecated)
/// PUT /projects/{projectId}
static const String updateProject = '/projects';
/// Update project progress
/// Update project progress (legacy endpoint - may be deprecated)
/// PATCH /projects/{projectId}/progress
/// Body: { "progress": 75 }
static const String updateProjectProgress = '/projects';
/// Delete project
/// Delete project (legacy endpoint - may be deprecated)
/// DELETE /projects/{projectId}
static const String deleteProject = '/projects';

View File

@@ -64,6 +64,12 @@ class HiveBoxNames {
/// Order status list cache
static const String orderStatusBox = 'order_status_box';
/// 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<String> get allBoxes => [
userBox,
@@ -77,6 +83,8 @@ class HiveBoxNames {
cityBox,
wardBox,
orderStatusBox,
projectStatusBox,
projectProgressBox,
settingsBox,
cacheBox,
syncStateBox,
@@ -139,6 +147,8 @@ class HiveTypeIds {
static const int cityModel = 31;
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;
@@ -239,6 +249,37 @@ class OrderStatusIndex {
static const int cancelled = 6;
}
/// Project Status Indices
///
/// Index values for project statuses stored in Hive.
/// These correspond to the index field in ProjectStatusModel.
///
/// API Response Structure:
/// - status: "Pending approval" (English status name)
/// - label: "Chờ phê duyệt" (Vietnamese display label)
/// - color: "Warning" (Status color indicator)
/// - index: 1 (Unique identifier)
class ProjectStatusIndex {
// Private constructor to prevent instantiation
ProjectStatusIndex._();
/// Pending approval - "Chờ phê duyệt"
/// Color: Warning
static const int pendingApproval = 1;
/// Approved - "Đã được phê duyệt"
/// Color: Success
static const int approved = 2;
/// Rejected - "Từ chối"
/// Color: Danger
static const int rejected = 3;
/// Cancelled - "HỦY BỎ"
/// Color: Danger
static const int cancelled = 4;
}
/// Hive Keys (continued)
extension HiveKeysContinued on HiveKeys {
// Cache Box Keys

View File

@@ -102,9 +102,18 @@ class HiveService {
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.orderStatus) ? "" : ""} OrderStatus adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.orderStatusModel) ? "" : ""} OrderStatusModel adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectType) ? "" : ""} ProjectType adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectStatusModel) ? "" : ""} ProjectStatusModel adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectProgressModel) ? "" : ""} ProjectProgressModel adapter',
);
debugPrint(
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.entryType) ? "" : ""} EntryType adapter',
);
@@ -171,6 +180,12 @@ class HiveService {
// Order status box (non-sensitive) - caches order status list from API
Hive.openBox<dynamic>(HiveBoxNames.orderStatusBox),
// Project status box (non-sensitive) - caches project status list from API
Hive.openBox<dynamic>(HiveBoxNames.projectStatusBox),
// Project progress box (non-sensitive) - caches construction progress stages from API
Hive.openBox<dynamic>(HiveBoxNames.projectProgressBox),
]);
// Open potentially encrypted boxes (sensitive data)

View File

@@ -569,7 +569,7 @@ Future<AuthInterceptor> 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,

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

@@ -272,7 +272,7 @@ final class AuthProvider extends $AsyncNotifierProvider<Auth, User?> {
Auth create() => Auth();
}
String _$authHash() => r'f1a16022d628a21f230c0bb567e80ff6e293d840';
String _$authHash() => r'f0438cf6eb9eb17c0afc6b23055acd09926b21ae';
/// Authentication Provider
///

View File

@@ -239,18 +239,4 @@ class _HomePageState extends ConsumerState<HomePage> {
),
);
}
/// Show coming soon message
void _showComingSoon(
BuildContext context,
String feature,
AppLocalizations l10n,
) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$feature - ${l10n.comingSoon}'),
duration: const Duration(seconds: 1),
),
);
}
}

View File

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

View File

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

View File

@@ -3,166 +3,304 @@
/// Handles remote API calls for project submissions.
library;
import 'package:worker/features/projects/domain/entities/project_submission.dart';
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
///
/// Abstract interface for remote submissions operations.
/// Interface for remote project submission operations.
abstract class SubmissionsRemoteDataSource {
/// Fetch project status list from API
Future<List<ProjectStatusModel>> getProjectStatusList();
/// Fetch project progress list from API (construction stages)
Future<List<ProjectProgressModel>> getProjectProgressList();
/// Fetch all submissions from remote API
Future<List<ProjectSubmission>> getSubmissions();
Future<List<ProjectSubmissionModel>> getSubmissions({
int limitStart = 0,
int limitPageLength = 0,
});
/// Fetch a single submission by ID
Future<ProjectSubmission> getSubmissionById(String submissionId);
/// Fetch project detail by name
/// Returns the full project detail as a model
Future<ProjectSubmissionModel> getSubmissionDetail(String name);
/// Create a new submission
Future<ProjectSubmission> createSubmission(ProjectSubmission submission);
/// Create or update a project submission
/// Returns the project name (ID) from the API response
Future<String> saveSubmission(ProjectSubmissionRequest request);
/// Update an existing submission
Future<ProjectSubmission> updateSubmission(ProjectSubmission submission);
/// Delete a submission
Future<void> deleteSubmission(String submissionId);
/// Upload a file for a project submission
/// [projectName] is the project ID returned from saveSubmission
/// [filePath] is the local path to the file
/// Returns the uploaded file URL
Future<String> uploadProjectFile({
required String projectName,
required String filePath,
});
}
/// Mock Implementation of Submissions Remote Data Source
/// Submissions Remote Data Source Implementation
///
/// Provides mock data for development and testing.
/// Uses Frappe API endpoints for project submissions.
class SubmissionsRemoteDataSourceImpl implements SubmissionsRemoteDataSource {
@override
Future<List<ProjectSubmission>> getSubmissions() async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 500));
const SubmissionsRemoteDataSourceImpl(this._dioClient);
return [
ProjectSubmission(
submissionId: 'DA001',
userId: 'user123',
projectName: 'Chung cư Vinhomes Grand Park - Block A1',
projectAddress: 'TP.HCM',
projectValue: 850000000,
projectType: ProjectType.residential,
status: SubmissionStatus.approved,
beforePhotos: [],
afterPhotos: [],
invoices: [],
submittedAt: DateTime(2023, 11, 15),
reviewedAt: DateTime(2023, 11, 20),
pointsEarned: 8500,
),
ProjectSubmission(
submissionId: 'DA002',
userId: 'user123',
projectName: 'Trung tâm thương mại Bitexco',
projectAddress: 'TP.HCM',
projectValue: 2200000000,
projectType: ProjectType.commercial,
status: SubmissionStatus.pending,
beforePhotos: [],
afterPhotos: [],
invoices: [],
submittedAt: DateTime(2023, 11, 25),
),
ProjectSubmission(
submissionId: 'DA003',
userId: 'user123',
projectName: 'Biệt thự sinh thái Ecopark',
projectAddress: 'Hà Nội',
projectValue: 420000000,
projectType: ProjectType.residential,
status: SubmissionStatus.approved,
beforePhotos: [],
afterPhotos: [],
invoices: [],
submittedAt: DateTime(2023, 10, 10),
reviewedAt: DateTime(2023, 10, 15),
pointsEarned: 4200,
),
ProjectSubmission(
submissionId: 'DA004',
userId: 'user123',
projectName: 'Nhà xưởng sản xuất ABC',
projectAddress: 'Bình Dương',
projectValue: 1500000000,
projectType: ProjectType.industrial,
status: SubmissionStatus.rejected,
beforePhotos: [],
afterPhotos: [],
invoices: [],
submittedAt: DateTime(2023, 11, 20),
reviewedAt: DateTime(2023, 11, 28),
rejectionReason: 'Thiếu giấy phép xây dựng và báo cáo tác động môi trường',
),
ProjectSubmission(
submissionId: 'DA005',
userId: 'user123',
projectName: 'Khách sạn 5 sao Diamond Plaza',
projectAddress: 'Đà Nẵng',
projectValue: 5800000000,
projectType: ProjectType.commercial,
status: SubmissionStatus.pending,
beforePhotos: [],
afterPhotos: [],
invoices: [],
submittedAt: DateTime(2023, 12, 1),
),
ProjectSubmission(
submissionId: 'DA006',
userId: 'user123',
projectName: 'Khu đô thị thông minh Smart City',
projectAddress: 'Hà Nội',
projectValue: 8500000000,
projectType: ProjectType.residential,
status: SubmissionStatus.approved,
beforePhotos: [],
afterPhotos: [],
invoices: [],
submittedAt: DateTime(2023, 11, 10),
reviewedAt: DateTime(2023, 11, 18),
pointsEarned: 85000,
),
];
final DioClient _dioClient;
/// Get project status list
///
/// Calls: POST /api/method/building_material.building_material.api.project.get_project_status_list
/// Body: { "limit_start": 0, "limit_page_length": 0 }
/// Returns: List of project statuses with labels and colors
@override
Future<List<ProjectStatusModel>> getProjectStatusList() async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.getProjectStatusList}',
data: <String, dynamic>{
'limit_start': 0,
'limit_page_length': 0,
},
);
final data = response.data;
if (data == null) {
throw Exception('No data received from getProjectStatusList API');
}
// API returns: { "message": [...] }
final message = data['message'];
if (message == null) {
throw Exception('No message field in getProjectStatusList response');
}
final List<dynamic> statusList = message as List<dynamic>;
return statusList
.map((json) =>
ProjectStatusModel.fromJson(json as Map<String, dynamic>))
.toList();
} catch (e) {
throw Exception('Failed to get project status list: $e');
}
}
/// Get 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<ProjectSubmission> getSubmissionById(String submissionId) async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 300));
Future<List<ProjectProgressModel>> getProjectProgressList() async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}',
data: {
'doctype': 'Progress of construction',
'fields': ['name', 'status'],
'order_by': 'number_of_display asc',
'limit_page_length': 0,
},
);
final submissions = await getSubmissions();
return submissions.firstWhere(
(s) => s.submissionId == submissionId,
orElse: () => throw Exception('Submission not found'),
);
final data = response.data;
if (data == null) {
throw Exception('No data received from getProjectProgressList API');
}
// API returns: { "message": [...] }
final message = data['message'];
if (message == null) {
throw Exception('No message field in getProjectProgressList response');
}
final List<dynamic> progressList = message as List<dynamic>;
return progressList
.map((json) =>
ProjectProgressModel.fromJson(json as Map<String, dynamic>))
.toList();
} catch (e) {
throw Exception('Failed to get project progress list: $e');
}
}
/// Get list of project submissions
///
/// Calls: POST /api/method/building_material.building_material.api.project.get_list
/// Body: { "limit_start": 0, "limit_page_length": 0 }
/// Returns: List of project submissions
@override
Future<ProjectSubmission> createSubmission(
ProjectSubmission submission,
) async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 800));
Future<List<ProjectSubmissionModel>> getSubmissions({
int limitStart = 0,
int limitPageLength = 0,
}) async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.getProjectList}',
data: {
'limit_start': limitStart,
'limit_page_length': limitPageLength,
},
);
// In real implementation, this would call the API
return submission;
final data = response.data;
if (data == null) {
throw Exception('No data received from getProjectList API');
}
// API returns: { "message": [...] }
final message = data['message'];
if (message == null) {
throw Exception('No message field in getProjectList response');
}
final List<dynamic> submissionsList = message as List<dynamic>;
return submissionsList
.map((json) =>
ProjectSubmissionModel.fromJson(json as Map<String, dynamic>))
.toList();
} catch (e) {
throw Exception('Failed to get project submissions: $e');
}
}
/// 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<ProjectSubmission> updateSubmission(
ProjectSubmission submission,
) async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 600));
Future<ProjectSubmissionModel> getSubmissionDetail(String name) async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.getProjectDetail}',
data: {'name': name},
);
// In real implementation, this would call the API
return submission;
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
/// Body: ProjectSubmissionRequest.toJson()
/// Returns: Project name (ID) from response
@override
Future<void> deleteSubmission(String submissionId) async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 400));
Future<String> saveSubmission(ProjectSubmissionRequest request) async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.saveProject}',
data: request.toJson(),
);
// In real implementation, this would call the API
final data = response.data;
if (data == null) {
throw Exception('No data received from saveProject API');
}
// Check for error in response
if (data['exc_type'] != null || data['exception'] != null) {
final errorMessage =
data['_server_messages'] ?? data['exception'] ?? 'Unknown error';
throw Exception('API error: $errorMessage');
}
// Extract project name from response
// Response format: { "message": { "success": true, "data": { "name": "#DA00007" } } }
final message = data['message'] as Map<String, dynamic>?;
if (message == null) {
throw Exception('No message in saveProject response');
}
final messageData = message['data'] as Map<String, dynamic>?;
if (messageData == null || messageData['name'] == null) {
throw Exception('No project name in saveProject response');
}
return messageData['name'] as String;
} catch (e) {
throw Exception('Failed to save project submission: $e');
}
}
/// Upload a file for a project submission
///
/// Calls: POST /api/method/upload_file
/// Form-data: file, is_private, folder, doctype, docname, optimize
/// Returns: Uploaded file URL
@override
Future<String> uploadProjectFile({
required String projectName,
required String filePath,
}) async {
try {
final fileName = filePath.split('/').last;
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath, filename: fileName),
'is_private': '0',
'folder': 'Home/Attachments',
'doctype': 'Architectural Project',
'docname': projectName,
'optimize': 'true',
});
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.uploadFile}',
data: formData,
);
final data = response.data;
if (data == null) {
throw Exception('No data received from uploadFile API');
}
// Check for error in response
if (data['exc_type'] != null || data['exception'] != null) {
final errorMessage =
data['_server_messages'] ?? data['exception'] ?? 'Unknown error';
throw Exception('API error: $errorMessage');
}
// Extract file URL from response
// Response format: { "message": { "file_url": "/files/...", ... } }
final message = data['message'];
if (message == null || message['file_url'] == null) {
throw Exception('No file URL in uploadFile response');
}
return message['file_url'] as String;
} catch (e) {
throw Exception('Failed to upload project file: $e');
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -3,7 +3,7 @@
/// Represents a request for design consultation service.
library;
import 'project_submission.dart';
import 'package:worker/features/projects/domain/entities/project_type.dart';
/// Design status enum
enum DesignStatus {

View File

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

View File

@@ -0,0 +1,33 @@
/// Project Status Entity
///
/// Represents a project status option from the API.
library;
import 'package:equatable/equatable.dart';
/// Project Status Entity
///
/// Similar to OrderStatus - represents status options for project submissions.
class ProjectStatus extends Equatable {
/// Status value (e.g., "Pending approval", "Approved", "Rejected", "Cancelled")
final String status;
/// Vietnamese label (e.g., "Chờ phê duyệt", "Đã được phê duyệt", "Từ chối", "HỦY BỎ")
final String label;
/// Color indicator (e.g., "Warning", "Success", "Danger")
final String color;
/// Display order index
final int index;
const ProjectStatus({
required this.status,
required this.label,
required this.color,
required this.index,
});
@override
List<Object?> get props => [status, label, color, index];
}

View File

@@ -1,242 +1,216 @@
/// Domain Entity: Project Submission
///
/// Represents a completed project submitted for loyalty points.
/// Based on API response from building_material.building_material.api.project.get_detail
library;
/// Project type enum
enum ProjectType {
/// Residential project
residential,
import 'package:equatable/equatable.dart';
/// Commercial project
commercial,
/// Project File Entity
///
/// Represents an uploaded file attached to a project submission.
class ProjectFile extends Equatable {
/// Unique file identifier (API: name)
final String id;
/// Industrial project
industrial,
/// Full URL to the file (API: file_url)
final String fileUrl;
/// Public infrastructure
infrastructure,
const ProjectFile({
required this.id,
required this.fileUrl,
});
/// Other type
other;
/// Get display name for project type
String get displayName {
switch (this) {
case ProjectType.residential:
return 'Residential';
case ProjectType.commercial:
return 'Commercial';
case ProjectType.industrial:
return 'Industrial';
case ProjectType.infrastructure:
return 'Infrastructure';
case ProjectType.other:
return 'Other';
}
}
}
/// Submission status enum
enum SubmissionStatus {
/// Submitted, pending review
pending,
/// Under review
reviewing,
/// Approved, points awarded
approved,
/// Rejected
rejected;
/// Get display name for status
String get displayName {
switch (this) {
case SubmissionStatus.pending:
return 'Pending';
case SubmissionStatus.reviewing:
return 'Reviewing';
case SubmissionStatus.approved:
return 'Approved';
case SubmissionStatus.rejected:
return 'Rejected';
}
}
@override
List<Object?> get props => [id, fileUrl];
}
/// Project Submission Entity
///
/// Contains information about a completed project:
/// - Project details
/// - Before/after photos
/// - Invoice documentation
/// - Review status
/// - Points earned
class ProjectSubmission {
/// Unique submission identifier
/// 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
/// - 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;
/// User ID who submitted
final String userId;
/// Project name/title (API: designed_area)
final String designedArea;
/// Project name
final String projectName;
/// Project address (API: address_of_project)
final String? addressOfProject;
/// Project address/location
final String? projectAddress;
/// Project owner name (API: project_owner)
final String? projectOwner;
/// Project value/cost
final double projectValue;
/// Design firm name (API: design_firm)
final String? designFirm;
/// Project type
final ProjectType projectType;
/// Construction contractor name (API: contruction_contractor)
final String? constructionContractor;
/// Before photos URLs
final List<String> beforePhotos;
/// Design area value in square meters (API: design_area)
final double designArea;
/// After photos URLs
final List<String> afterPhotos;
/// Products included in the design (API: products_included_in_the_design)
final String? productsIncludedInTheDesign;
/// Invoice/receipt URLs
final List<String> invoices;
/// Project progress ID reference (API: project_progress)
final String? projectProgress;
/// Submission status
final SubmissionStatus status;
/// Expected commencement date (API: expected_commencement_date)
final DateTime? expectedCommencementDate;
/// Review notes from admin
final String? reviewNotes;
/// Project description (API: description)
final String? description;
/// Rejection reason (if rejected)
final String? rejectionReason;
/// Submission/request date (API: request_date)
final DateTime requestDate;
/// Points earned (if approved)
final int? pointsEarned;
/// Workflow state (API: workflow_state)
/// e.g., "Pending approval", "Approved", "Rejected", "Cancelled"
final String? workflowState;
/// Submission timestamp
final DateTime submittedAt;
/// Rejection reason if rejected (API: reason_for_rejection)
final String? reasonForRejection;
/// Review timestamp
final DateTime? reviewedAt;
/// Status label - Vietnamese (API: status)
/// e.g., "Chờ phê duyệt", "Đã được phê duyệt", "Từ chối", "HỦY BỎ"
final String status;
/// ID of admin who reviewed
final String? reviewedBy;
/// 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.userId,
required this.projectName,
this.projectAddress,
required this.projectValue,
required this.projectType,
required this.beforePhotos,
required this.afterPhotos,
required this.invoices,
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,
this.workflowState,
this.reasonForRejection,
required this.status,
this.reviewNotes,
this.rejectionReason,
this.pointsEarned,
required this.submittedAt,
this.reviewedAt,
this.reviewedBy,
required this.statusColor,
this.isAllowModify = false,
this.isAllowCancel = false,
this.filesList = const [],
});
/// Check if submission is pending
bool get isPending => status == SubmissionStatus.pending;
/// Check if submission is under review
bool get isReviewing => status == SubmissionStatus.reviewing;
/// Check if submission is pending approval
bool get isPending => statusColor == 'Warning';
/// Check if submission is approved
bool get isApproved => status == SubmissionStatus.approved;
bool get isApproved => statusColor == 'Success';
/// Check if submission is rejected
bool get isRejected => status == SubmissionStatus.rejected;
/// Check if submission has been reviewed
bool get isReviewed =>
status == SubmissionStatus.approved ||
status == SubmissionStatus.rejected;
/// Check if submission has before photos
bool get hasBeforePhotos => beforePhotos.isNotEmpty;
/// Check if submission has after photos
bool get hasAfterPhotos => afterPhotos.isNotEmpty;
/// Check if submission has invoices
bool get hasInvoices => invoices.isNotEmpty;
/// Get total number of photos
int get totalPhotos => beforePhotos.length + afterPhotos.length;
/// Get review duration
Duration? get reviewDuration {
if (reviewedAt == null) return null;
return reviewedAt!.difference(submittedAt);
}
/// Check if submission is rejected or cancelled
bool get isRejected => statusColor == 'Danger';
/// Copy with method for immutability
ProjectSubmission copyWith({
String? submissionId,
String? userId,
String? projectName,
String? projectAddress,
double? projectValue,
ProjectType? projectType,
List<String>? beforePhotos,
List<String>? afterPhotos,
List<String>? invoices,
SubmissionStatus? status,
String? reviewNotes,
String? rejectionReason,
int? pointsEarned,
DateTime? submittedAt,
DateTime? reviewedAt,
String? reviewedBy,
String? designedArea,
String? addressOfProject,
String? projectOwner,
String? designFirm,
String? constructionContractor,
double? designArea,
String? productsIncludedInTheDesign,
String? projectProgress,
DateTime? expectedCommencementDate,
String? description,
DateTime? requestDate,
String? workflowState,
String? reasonForRejection,
String? status,
String? statusColor,
bool? isAllowModify,
bool? isAllowCancel,
List<ProjectFile>? filesList,
}) {
return ProjectSubmission(
submissionId: submissionId ?? this.submissionId,
userId: userId ?? this.userId,
projectName: projectName ?? this.projectName,
projectAddress: projectAddress ?? this.projectAddress,
projectValue: projectValue ?? this.projectValue,
projectType: projectType ?? this.projectType,
beforePhotos: beforePhotos ?? this.beforePhotos,
afterPhotos: afterPhotos ?? this.afterPhotos,
invoices: invoices ?? this.invoices,
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,
workflowState: workflowState ?? this.workflowState,
reasonForRejection: reasonForRejection ?? this.reasonForRejection,
status: status ?? this.status,
reviewNotes: reviewNotes ?? this.reviewNotes,
rejectionReason: rejectionReason ?? this.rejectionReason,
pointsEarned: pointsEarned ?? this.pointsEarned,
submittedAt: submittedAt ?? this.submittedAt,
reviewedAt: reviewedAt ?? this.reviewedAt,
reviewedBy: reviewedBy ?? this.reviewedBy,
statusColor: statusColor ?? this.statusColor,
isAllowModify: isAllowModify ?? this.isAllowModify,
isAllowCancel: isAllowCancel ?? this.isAllowCancel,
filesList: filesList ?? this.filesList,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ProjectSubmission &&
other.submissionId == submissionId &&
other.userId == userId &&
other.projectName == projectName &&
other.projectValue == projectValue &&
other.status == status;
}
@override
int get hashCode {
return Object.hash(submissionId, userId, projectName, projectValue, status);
}
List<Object?> get props => [
submissionId,
designedArea,
addressOfProject,
projectOwner,
designFirm,
constructionContractor,
designArea,
productsIncludedInTheDesign,
projectProgress,
expectedCommencementDate,
description,
requestDate,
workflowState,
reasonForRejection,
status,
statusColor,
isAllowModify,
isAllowCancel,
filesList,
];
@override
String toString() {
return 'ProjectSubmission(submissionId: $submissionId, projectName: $projectName, '
'projectValue: $projectValue, projectType: $projectType, status: $status, '
'pointsEarned: $pointsEarned)';
return 'ProjectSubmission(submissionId: $submissionId, designedArea: $designedArea, '
'designArea: $designArea, status: $status, statusColor: $statusColor)';
}
}

View File

@@ -0,0 +1,38 @@
/// Project Type Enum
///
/// Represents the type of construction project.
library;
/// Project type enum
enum ProjectType {
/// Residential project
residential,
/// Commercial project
commercial,
/// Industrial project
industrial,
/// Public infrastructure
infrastructure,
/// Other type
other;
/// Get display name for project type
String get displayName {
switch (this) {
case ProjectType.residential:
return 'Residential';
case ProjectType.commercial:
return 'Commercial';
case ProjectType.industrial:
return 'Industrial';
case ProjectType.infrastructure:
return 'Infrastructure';
case ProjectType.other:
return 'Other';
}
}
}

View File

@@ -3,24 +3,55 @@
/// 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';
/// Submissions Repository
///
/// Defines contract for project submissions data operations.
abstract class SubmissionsRepository {
/// Get list of available project statuses
///
/// Uses cache-first pattern:
/// - Returns cached data if available
/// - Fetches from API and updates cache
/// - [forceRefresh] bypasses cache and fetches fresh data
Future<List<ProjectStatus>> getProjectStatusList({
bool forceRefresh = false,
});
/// Get list of construction progress stages
///
/// Uses cache-first pattern:
/// - Returns cached data if available
/// - Fetches from API and updates cache
/// - [forceRefresh] bypasses cache and fetches fresh data
Future<List<ProjectProgress>> getProjectProgressList({
bool forceRefresh = false,
});
/// Get all project submissions for the current user
Future<List<ProjectSubmission>> getSubmissions();
Future<List<ProjectSubmission>> getSubmissions({
int limitStart = 0,
int limitPageLength = 0,
});
/// Get a single submission by ID
Future<ProjectSubmission> getSubmissionById(String submissionId);
/// Get project detail by name
/// Returns the full project detail as entity for form prefilling
Future<ProjectSubmission> getSubmissionDetail(String name);
/// Create a new project submission
Future<ProjectSubmission> createSubmission(ProjectSubmission submission);
/// Save (create/update) a project submission
/// Returns the project name (ID) from the API response
Future<String> saveSubmission(ProjectSubmissionRequest request);
/// Update an existing submission
Future<ProjectSubmission> updateSubmission(ProjectSubmission submission);
/// Delete a submission
Future<void> deleteSubmission(String submissionId);
/// Upload a file for a project submission
/// [projectName] is the project ID returned from saveSubmission
/// [filePath] is the local path to the file
/// Returns the uploaded file URL
Future<String> uploadProjectFile({
required String projectName,
required String filePath,
});
}

View File

@@ -1,23 +0,0 @@
/// Get Submissions Use Case
///
/// Retrieves all project submissions for the current user.
library;
import 'package:worker/features/projects/domain/entities/project_submission.dart';
import 'package:worker/features/projects/domain/repositories/submissions_repository.dart';
/// Get Submissions Use Case
///
/// Business logic for retrieving project submissions.
class GetSubmissions {
const GetSubmissions(this._repository);
final SubmissionsRepository _repository;
/// Execute the use case
///
/// Returns list of all project submissions for the current user.
Future<List<ProjectSubmission>> call() async {
return await _repository.getSubmissions();
}
}

View File

@@ -5,16 +5,26 @@ 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';
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/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() =>
@@ -35,10 +45,75 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
final _descriptionController = TextEditingController();
// Form state
String? _selectedProgress;
ProjectProgress? _selectedProgress;
DateTime? _expectedStartDate;
final List<File> _uploadedFiles = [];
bool _showStartDateField = false;
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() {
@@ -66,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(
@@ -85,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),
@@ -217,10 +306,8 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
_buildProgressDropdown(),
if (_showStartDateField) ...[
const SizedBox(height: 16),
_buildDateField(),
],
const SizedBox(height: 16),
_buildExpectedDateField(),
],
),
),
@@ -321,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;
@@ -434,6 +554,8 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
}
Widget _buildProgressDropdown() {
final progressListAsync = ref.watch(projectProgressListProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -457,68 +579,93 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
],
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
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<ProjectProgress>(
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<ProjectProgress>(
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 +679,7 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
),
const SizedBox(height: 8),
InkWell(
onTap: _pickDate,
onTap: _pickExpectedDate,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
@@ -545,7 +692,7 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
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 +718,89 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
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<Color>(Colors.white),
),
),
),
),
);
},
),
),
// Uploaded checkmark overlay
if (isUploaded)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: AppColors.success.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(4),
),
child: const Center(
child: FaIcon(
FontAwesomeIcons.check,
size: 20,
color: Colors.white,
),
),
),
),
],
),
const SizedBox(width: 12),
Expanded(
@@ -617,26 +818,120 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
),
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);
});
},
],
),
);
}
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,
),
],
),
@@ -648,39 +943,50 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
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<Color>(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<void> _pickDate() async {
Future<void> _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 +1031,114 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
}
Future<void> _handleSubmit() async {
if (_formKey.currentState!.validate()) {
final confirmed = await showDialog<bool>(
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<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Xác nhận'),
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),
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
// 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(),
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(
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,
),
);
Navigator.pop(context);
Navigator.pop(context, true); // Return true to indicate success
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi: ${e.toString().replaceAll('Exception: ', '')}'),
backgroundColor: AppColors.danger,
),
);
}
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
// Clear upload state
ref.read(uploadProjectFilesProvider.notifier).clear();
}
}
}

View File

@@ -21,6 +21,7 @@ class SubmissionsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final submissionsAsync = ref.watch(filteredSubmissionsProvider);
final statusListAsync = ref.watch(projectStatusListProvider);
final filter = ref.watch(submissionsFilterProvider);
final selectedStatus = filter.selectedStatus;
@@ -38,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<bool>(RouteNames.submissionCreate);
if (result == true) {
// Refresh submissions list after successful creation
ref.invalidate(allSubmissionsProvider);
}
},
),
const SizedBox(width: AppSpacing.sm),
],
@@ -53,7 +60,7 @@ class SubmissionsPage extends ConsumerWidget {
padding: const EdgeInsets.all(16),
child: TextField(
decoration: InputDecoration(
hintText: 'Mã dự án hoặc tên dự án',
hintText: 'Mã dự án hoặc tên công trình',
prefixIcon: const Icon(Icons.search, color: AppColors.grey500),
filled: true,
fillColor: AppColors.white,
@@ -86,16 +93,23 @@ class SubmissionsPage extends ConsumerWidget {
onTap: () => ref.read(submissionsFilterProvider.notifier).clearStatusFilter(),
),
const SizedBox(width: 8),
...SubmissionStatus.values.map((status) => Padding(
padding: const EdgeInsets.only(right: 8),
child: _buildFilterChip(
context,
ref,
label: status.displayName,
isSelected: selectedStatus == status,
onTap: () => ref.read(submissionsFilterProvider.notifier).selectStatus(status),
// Use projectStatusListProvider to get status options
statusListAsync.when(
data: (statuses) => Row(
children: statuses.map((status) => Padding(
padding: const EdgeInsets.only(right: 8),
child: _buildFilterChip(
context,
ref,
label: status.label,
isSelected: selectedStatus == status.label,
onTap: () => ref.read(submissionsFilterProvider.notifier).selectStatus(status.label),
),
)).toList(),
),
)),
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
),
],
),
),
@@ -157,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);
},
),
);
@@ -246,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(
@@ -268,19 +287,27 @@ class SubmissionsPage extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'#${submission.submissionId}',
submission.designedArea,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
_buildStatusBadge(submission.status),
_buildStatusBadge(submission.status, submission.statusColor),
],
),
const SizedBox(height: 8),
// Text(
// 'Tên công trình: ${submission.designedArea}',
// style: const TextStyle(
// fontSize: 14,
// color: AppColors.grey900,
// ),
// ),
// const SizedBox(height: 4),
Text(
'Tên công trình: ${submission.projectName}',
'Ngày nộp: ${DateFormat('dd/MM/yyyy HH:mm').format(submission.requestDate)}',
style: const TextStyle(
fontSize: 14,
color: AppColors.grey900,
@@ -288,21 +315,13 @@ class SubmissionsPage extends ConsumerWidget {
),
const SizedBox(height: 4),
Text(
'Ngày nộp: ${DateFormat('dd/MM/yyyy').format(submission.submittedAt)}',
'Diện tích: ${submission.designArea}',
style: const TextStyle(
fontSize: 13,
color: AppColors.grey500,
color: AppColors.grey900,
),
),
const SizedBox(height: 4),
Text(
'Diện tích: ${submission.projectAddress ?? "N/A"}',
style: const TextStyle(
fontSize: 13,
color: AppColors.grey500,
),
),
if (submission.rejectionReason != null) ...[
if (submission.reasonForRejection != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
@@ -320,7 +339,7 @@ class SubmissionsPage extends ConsumerWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
submission.rejectionReason!,
submission.reasonForRejection!,
style: const TextStyle(
fontSize: 12,
color: AppColors.danger,
@@ -338,8 +357,8 @@ class SubmissionsPage extends ConsumerWidget {
);
}
Widget _buildStatusBadge(SubmissionStatus status) {
final color = _getStatusColor(status);
Widget _buildStatusBadge(String status, String statusColor) {
final color = _getColorFromStatusColor(statusColor);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
@@ -347,7 +366,7 @@ class SubmissionsPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(12),
),
child: Text(
status.displayName,
status,
style: TextStyle(
color: color,
fontSize: 12,
@@ -357,16 +376,18 @@ class SubmissionsPage extends ConsumerWidget {
);
}
Color _getStatusColor(SubmissionStatus status) {
switch (status) {
case SubmissionStatus.pending:
Color _getColorFromStatusColor(String statusColor) {
switch (statusColor) {
case 'Warning':
return AppColors.warning;
case SubmissionStatus.reviewing:
return AppColors.info;
case SubmissionStatus.approved:
case 'Success':
return AppColors.success;
case SubmissionStatus.rejected:
case 'Danger':
return AppColors.danger;
case 'Info':
return AppColors.info;
default:
return AppColors.grey500;
}
}
}

View File

@@ -4,51 +4,122 @@
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';
import 'package:worker/features/projects/domain/usecases/get_submissions.dart';
part 'submissions_provider.g.dart';
/// Project Status Local Data Source Provider
@riverpod
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
SubmissionsRemoteDataSource submissionsRemoteDataSource(Ref ref) {
return SubmissionsRemoteDataSourceImpl();
Future<SubmissionsRemoteDataSource> submissionsRemoteDataSource(Ref ref) async {
final dioClient = await ref.watch(dioClientProvider.future);
return SubmissionsRemoteDataSourceImpl(dioClient);
}
/// Submissions Repository Provider
@riverpod
SubmissionsRepository submissionsRepository(Ref ref) {
final remoteDataSource = ref.watch(submissionsRemoteDataSourceProvider);
return SubmissionsRepositoryImpl(remoteDataSource);
Future<SubmissionsRepository> submissionsRepository(Ref ref) async {
final remoteDataSource = await ref.watch(submissionsRemoteDataSourceProvider.future);
final statusLocalDataSource = ref.watch(projectStatusLocalDataSourceProvider);
final progressLocalDataSource = ref.watch(projectProgressLocalDataSourceProvider);
return SubmissionsRepositoryImpl(
remoteDataSource,
statusLocalDataSource,
progressLocalDataSource,
);
}
/// Get Submissions Use Case Provider
/// Project Status List Provider
///
/// Fetches project status options from API with cache-first pattern.
/// This is loaded before submissions to ensure filter options are available.
@riverpod
GetSubmissions getSubmissions(Ref ref) {
final repository = ref.watch(submissionsRepositoryProvider);
return GetSubmissions(repository);
class ProjectStatusList extends _$ProjectStatusList {
@override
Future<List<ProjectStatus>> build() async {
final repository = await ref.watch(submissionsRepositoryProvider.future);
return repository.getProjectStatusList();
}
/// Refresh status list from remote (force refresh)
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final repository = await ref.read(submissionsRepositoryProvider.future);
return repository.getProjectStatusList(forceRefresh: true);
});
}
}
/// Project Progress List Provider
///
/// Fetches construction progress stages from API with cache-first pattern.
/// Used for dropdown selection when creating/updating project submissions.
@riverpod
class ProjectProgressList extends _$ProjectProgressList {
@override
Future<List<ProjectProgress>> build() async {
final repository = await ref.watch(submissionsRepositoryProvider.future);
return repository.getProjectProgressList();
}
/// Refresh progress list from remote (force refresh)
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final repository = await ref.read(submissionsRepositoryProvider.future);
return repository.getProjectProgressList(forceRefresh: true);
});
}
}
/// All Submissions Provider
///
/// Fetches and manages submissions data from remote.
/// Waits for project status list and progress list to be loaded first.
@riverpod
class AllSubmissions extends _$AllSubmissions {
@override
Future<List<ProjectSubmission>> build() async {
final useCase = ref.watch(getSubmissionsProvider);
return await useCase();
// 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);
return repository.getSubmissions();
}
/// Refresh submissions from remote
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final useCase = ref.read(getSubmissionsProvider);
return await useCase();
// 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();
});
}
}
@@ -56,10 +127,11 @@ class AllSubmissions extends _$AllSubmissions {
/// Submissions Filter State
///
/// Manages search and status filter state.
/// Status filter uses the status label string from API (e.g., "Chờ phê duyệt").
@riverpod
class SubmissionsFilter extends _$SubmissionsFilter {
@override
({String searchQuery, SubmissionStatus? selectedStatus}) build() {
({String searchQuery, String? selectedStatus}) build() {
return (searchQuery: '', selectedStatus: null);
}
@@ -68,8 +140,8 @@ class SubmissionsFilter extends _$SubmissionsFilter {
state = (searchQuery: query, selectedStatus: state.selectedStatus);
}
/// Select a status filter
void selectStatus(SubmissionStatus? status) {
/// Select a status filter (uses Vietnamese label from API)
void selectStatus(String? status) {
state = (searchQuery: state.searchQuery, selectedStatus: status);
}
@@ -100,7 +172,7 @@ AsyncValue<List<ProjectSubmission>> filteredSubmissions(Ref ref) {
return dataAsync.whenData((submissions) {
var filtered = submissions;
// Filter by status
// Filter by status (matches Vietnamese label from API)
if (filter.selectedStatus != null) {
filtered = filtered.where((s) => s.status == filter.selectedStatus).toList();
}
@@ -110,13 +182,172 @@ AsyncValue<List<ProjectSubmission>> filteredSubmissions(Ref ref) {
final query = filter.searchQuery.toLowerCase();
filtered = filtered.where((s) {
return s.submissionId.toLowerCase().contains(query) ||
s.projectName.toLowerCase().contains(query);
s.designedArea.toLowerCase().contains(query);
}).toList();
}
// Sort by submitted date (newest first)
filtered.sort((a, b) => b.submittedAt.compareTo(a.submittedAt));
// Sort by request date (newest first)
filtered.sort((a, b) => b.requestDate.compareTo(a.requestDate));
return filtered;
});
}
/// 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.
@riverpod
class SaveSubmission extends _$SaveSubmission {
@override
AsyncValue<void> build() {
return const AsyncValue.data(null);
}
/// Save a new project submission
///
/// Returns the project name (ID) if successful, throws exception on failure.
Future<String> save(ProjectSubmissionRequest request) async {
state = const AsyncValue.loading();
try {
final repository = await ref.read(submissionsRepositoryProvider.future);
if (!ref.mounted) throw Exception('Provider disposed');
final projectName = await repository.saveSubmission(request);
if (!ref.mounted) return projectName;
state = const AsyncValue.data(null);
// Refresh submissions list after successful save
ref.invalidate(allSubmissionsProvider);
return projectName;
} catch (e, st) {
if (ref.mounted) {
state = AsyncValue.error(e, st);
}
rethrow;
}
}
}
/// Upload state for tracking individual file uploads
class FileUploadState {
final String filePath;
final bool isUploading;
final bool isUploaded;
final String? fileUrl;
final String? error;
const FileUploadState({
required this.filePath,
this.isUploading = false,
this.isUploaded = false,
this.fileUrl,
this.error,
});
FileUploadState copyWith({
bool? isUploading,
bool? isUploaded,
String? fileUrl,
String? error,
}) {
return FileUploadState(
filePath: filePath,
isUploading: isUploading ?? this.isUploading,
isUploaded: isUploaded ?? this.isUploaded,
fileUrl: fileUrl ?? this.fileUrl,
error: error,
);
}
}
/// Upload Project Files Provider
///
/// Handles uploading multiple files for a project submission.
/// Tracks upload state for each file individually.
@riverpod
class UploadProjectFiles extends _$UploadProjectFiles {
@override
List<FileUploadState> build() {
return [];
}
/// Initialize with file paths
void initFiles(List<String> filePaths) {
state = filePaths
.map((path) => FileUploadState(filePath: path))
.toList();
}
/// Upload all files for a project
/// Returns list of uploaded file URLs
Future<List<String>> uploadAll(String projectName) async {
final uploadedUrls = <String>[];
for (var i = 0; i < state.length; i++) {
if (!ref.mounted) break;
// Mark as uploading
state = [
...state.sublist(0, i),
state[i].copyWith(isUploading: true),
...state.sublist(i + 1),
];
try {
final repository = await ref.read(submissionsRepositoryProvider.future);
if (!ref.mounted) break;
final fileUrl = await repository.uploadProjectFile(
projectName: projectName,
filePath: state[i].filePath,
);
if (!ref.mounted) break;
// Mark as uploaded
state = [
...state.sublist(0, i),
state[i].copyWith(
isUploading: false,
isUploaded: true,
fileUrl: fileUrl,
),
...state.sublist(i + 1),
];
uploadedUrls.add(fileUrl);
} catch (e) {
if (!ref.mounted) break;
// Mark as failed
state = [
...state.sublist(0, i),
state[i].copyWith(
isUploading: false,
error: e.toString(),
),
...state.sublist(i + 1),
];
}
}
return uploadedUrls;
}
/// Clear all files
void clear() {
state = [];
}
}

View File

@@ -8,6 +8,116 @@ part of 'submissions_provider.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Project Status Local Data Source Provider
@ProviderFor(projectStatusLocalDataSource)
const projectStatusLocalDataSourceProvider =
ProjectStatusLocalDataSourceProvider._();
/// Project Status Local Data Source Provider
final class ProjectStatusLocalDataSourceProvider
extends
$FunctionalProvider<
ProjectStatusLocalDataSource,
ProjectStatusLocalDataSource,
ProjectStatusLocalDataSource
>
with $Provider<ProjectStatusLocalDataSource> {
/// Project Status Local Data Source Provider
const ProjectStatusLocalDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'projectStatusLocalDataSourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$projectStatusLocalDataSourceHash();
@$internal
@override
$ProviderElement<ProjectStatusLocalDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
ProjectStatusLocalDataSource create(Ref ref) {
return projectStatusLocalDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ProjectStatusLocalDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ProjectStatusLocalDataSource>(value),
);
}
}
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<ProjectProgressLocalDataSource> {
/// Project Progress Local Data Source Provider
const ProjectProgressLocalDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'projectProgressLocalDataSourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$projectProgressLocalDataSourceHash();
@$internal
@override
$ProviderElement<ProjectProgressLocalDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
ProjectProgressLocalDataSource create(Ref ref) {
return projectProgressLocalDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ProjectProgressLocalDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ProjectProgressLocalDataSource>(
value,
),
);
}
}
String _$projectProgressLocalDataSourceHash() =>
r'653d03b47f5642f3391e7a312649a2603489b224';
/// Submissions Remote Data Source Provider
@ProviderFor(submissionsRemoteDataSource)
@@ -19,11 +129,13 @@ const submissionsRemoteDataSourceProvider =
final class SubmissionsRemoteDataSourceProvider
extends
$FunctionalProvider<
AsyncValue<SubmissionsRemoteDataSource>,
SubmissionsRemoteDataSource,
SubmissionsRemoteDataSource,
SubmissionsRemoteDataSource
FutureOr<SubmissionsRemoteDataSource>
>
with $Provider<SubmissionsRemoteDataSource> {
with
$FutureModifier<SubmissionsRemoteDataSource>,
$FutureProvider<SubmissionsRemoteDataSource> {
/// Submissions Remote Data Source Provider
const SubmissionsRemoteDataSourceProvider._()
: super(
@@ -41,26 +153,18 @@ final class SubmissionsRemoteDataSourceProvider
@$internal
@override
$ProviderElement<SubmissionsRemoteDataSource> $createElement(
$FutureProviderElement<SubmissionsRemoteDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
) => $FutureProviderElement(pointer);
@override
SubmissionsRemoteDataSource create(Ref ref) {
FutureOr<SubmissionsRemoteDataSource> create(Ref ref) {
return submissionsRemoteDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(SubmissionsRemoteDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<SubmissionsRemoteDataSource>(value),
);
}
}
String _$submissionsRemoteDataSourceHash() =>
r'dc2dd71b6ca22d26382c1dfdf13b88d2249bb5ce';
r'ffaa92dd55ef50c8f1166773a83cd5c8cc16ded4';
/// Submissions Repository Provider
@@ -72,11 +176,13 @@ const submissionsRepositoryProvider = SubmissionsRepositoryProvider._();
final class SubmissionsRepositoryProvider
extends
$FunctionalProvider<
AsyncValue<SubmissionsRepository>,
SubmissionsRepository,
SubmissionsRepository,
SubmissionsRepository
FutureOr<SubmissionsRepository>
>
with $Provider<SubmissionsRepository> {
with
$FutureModifier<SubmissionsRepository>,
$FutureProvider<SubmissionsRepository> {
/// Submissions Repository Provider
const SubmissionsRepositoryProvider._()
: super(
@@ -94,76 +200,157 @@ final class SubmissionsRepositoryProvider
@$internal
@override
$ProviderElement<SubmissionsRepository> $createElement(
$FutureProviderElement<SubmissionsRepository> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
) => $FutureProviderElement(pointer);
@override
SubmissionsRepository create(Ref ref) {
FutureOr<SubmissionsRepository> create(Ref ref) {
return submissionsRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(SubmissionsRepository value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<SubmissionsRepository>(value),
);
}
}
String _$submissionsRepositoryHash() =>
r'4fa33107966470c07f050b27e669ec1dc4f13fda';
r'652208a4ef93cde9b40ae66164d44bba786dfed0';
/// Get Submissions Use Case Provider
/// Project Status List Provider
///
/// Fetches project status options from API with cache-first pattern.
/// This is loaded before submissions to ensure filter options are available.
@ProviderFor(getSubmissions)
const getSubmissionsProvider = GetSubmissionsProvider._();
@ProviderFor(ProjectStatusList)
const projectStatusListProvider = ProjectStatusListProvider._();
/// Get Submissions Use Case Provider
final class GetSubmissionsProvider
extends $FunctionalProvider<GetSubmissions, GetSubmissions, GetSubmissions>
with $Provider<GetSubmissions> {
/// Get Submissions Use Case Provider
const GetSubmissionsProvider._()
/// Project Status List Provider
///
/// Fetches project status options from API with cache-first pattern.
/// This is loaded before submissions to ensure filter options are available.
final class ProjectStatusListProvider
extends $AsyncNotifierProvider<ProjectStatusList, List<ProjectStatus>> {
/// Project Status List Provider
///
/// Fetches project status options from API with cache-first pattern.
/// This is loaded before submissions to ensure filter options are available.
const ProjectStatusListProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'getSubmissionsProvider',
name: r'projectStatusListProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$getSubmissionsHash();
String debugGetCreateSourceHash() => _$projectStatusListHash();
@$internal
@override
$ProviderElement<GetSubmissions> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
ProjectStatusList create() => ProjectStatusList();
}
String _$projectStatusListHash() => r'69a43b619738dec3a6643a9a780599417403b838';
/// Project Status List Provider
///
/// Fetches project status options from API with cache-first pattern.
/// This is loaded before submissions to ensure filter options are available.
abstract class _$ProjectStatusList extends $AsyncNotifier<List<ProjectStatus>> {
FutureOr<List<ProjectStatus>> build();
@$mustCallSuper
@override
GetSubmissions create(Ref ref) {
return getSubmissions(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GetSubmissions value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GetSubmissions>(value),
);
void runBuild() {
final created = build();
final ref =
this.ref as $Ref<AsyncValue<List<ProjectStatus>>, List<ProjectStatus>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<ProjectStatus>>, List<ProjectStatus>>,
AsyncValue<List<ProjectStatus>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
String _$getSubmissionsHash() => r'91b497f826ae6dc72618ba879289fc449a7ef5cb';
/// Project Progress List Provider
///
/// Fetches construction progress stages from API with cache-first pattern.
/// Used for dropdown selection when creating/updating project submissions.
@ProviderFor(ProjectProgressList)
const projectProgressListProvider = ProjectProgressListProvider._();
/// Project Progress List Provider
///
/// Fetches construction progress stages from API with cache-first pattern.
/// Used for dropdown selection when creating/updating project submissions.
final class ProjectProgressListProvider
extends $AsyncNotifierProvider<ProjectProgressList, List<ProjectProgress>> {
/// Project Progress List Provider
///
/// Fetches construction progress stages from API with cache-first pattern.
/// Used for dropdown selection when creating/updating project submissions.
const ProjectProgressListProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'projectProgressListProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$projectProgressListHash();
@$internal
@override
ProjectProgressList create() => ProjectProgressList();
}
String _$projectProgressListHash() =>
r'5ee1c23f90bfa61237f38a6b72c353f0ecb7a2a9';
/// Project Progress List Provider
///
/// Fetches construction progress stages from API with cache-first pattern.
/// Used for dropdown selection when creating/updating project submissions.
abstract class _$ProjectProgressList
extends $AsyncNotifier<List<ProjectProgress>> {
FutureOr<List<ProjectProgress>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref =
this.ref
as $Ref<AsyncValue<List<ProjectProgress>>, List<ProjectProgress>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<List<ProjectProgress>>,
List<ProjectProgress>
>,
AsyncValue<List<ProjectProgress>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// All Submissions Provider
///
/// Fetches and manages submissions data from remote.
/// Waits for project status list and progress list to be loaded first.
@ProviderFor(AllSubmissions)
const allSubmissionsProvider = AllSubmissionsProvider._();
@@ -171,11 +358,13 @@ const allSubmissionsProvider = AllSubmissionsProvider._();
/// All Submissions Provider
///
/// Fetches and manages submissions data from remote.
/// Waits for project status list and progress list to be loaded first.
final class AllSubmissionsProvider
extends $AsyncNotifierProvider<AllSubmissions, List<ProjectSubmission>> {
/// All Submissions Provider
///
/// Fetches and manages submissions data from remote.
/// Waits for project status list and progress list to be loaded first.
const AllSubmissionsProvider._()
: super(
from: null,
@@ -195,11 +384,12 @@ final class AllSubmissionsProvider
AllSubmissions create() => AllSubmissions();
}
String _$allSubmissionsHash() => r'40ea0460a8962a4105dabb482bc80573452d4c80';
String _$allSubmissionsHash() => r'ab0f1ffdc5e6bdb62dbd56ff3e586ecc1ff05bea';
/// All Submissions Provider
///
/// Fetches and manages submissions data from remote.
/// Waits for project status list and progress list to be loaded first.
abstract class _$AllSubmissions
extends $AsyncNotifier<List<ProjectSubmission>> {
@@ -232,6 +422,7 @@ abstract class _$AllSubmissions
/// Submissions Filter State
///
/// Manages search and status filter state.
/// Status filter uses the status label string from API (e.g., "Chờ phê duyệt").
@ProviderFor(SubmissionsFilter)
const submissionsFilterProvider = SubmissionsFilterProvider._();
@@ -239,15 +430,17 @@ const submissionsFilterProvider = SubmissionsFilterProvider._();
/// Submissions Filter State
///
/// Manages search and status filter state.
/// Status filter uses the status label string from API (e.g., "Chờ phê duyệt").
final class SubmissionsFilterProvider
extends
$NotifierProvider<
SubmissionsFilter,
({String searchQuery, SubmissionStatus? selectedStatus})
({String searchQuery, String? selectedStatus})
> {
/// Submissions Filter State
///
/// Manages search and status filter state.
/// Status filter uses the status label string from API (e.g., "Chờ phê duyệt").
const SubmissionsFilterProvider._()
: super(
from: null,
@@ -268,28 +461,28 @@ final class SubmissionsFilterProvider
/// {@macro riverpod.override_with_value}
Override overrideWithValue(
({String searchQuery, SubmissionStatus? selectedStatus}) value,
({String searchQuery, String? selectedStatus}) value,
) {
return $ProviderOverride(
origin: this,
providerOverride:
$SyncValueProvider<
({String searchQuery, SubmissionStatus? selectedStatus})
>(value),
$SyncValueProvider<({String searchQuery, String? selectedStatus})>(
value,
),
);
}
}
String _$submissionsFilterHash() => r'049dd9fa4f6f1bff0d49c6cba0975f9714621883';
String _$submissionsFilterHash() => r'b3c59003922b1786b71f68726f97b210eed94c89';
/// Submissions Filter State
///
/// Manages search and status filter state.
/// Status filter uses the status label string from API (e.g., "Chờ phê duyệt").
abstract class _$SubmissionsFilter
extends
$Notifier<({String searchQuery, SubmissionStatus? selectedStatus})> {
({String searchQuery, SubmissionStatus? selectedStatus}) build();
extends $Notifier<({String searchQuery, String? selectedStatus})> {
({String searchQuery, String? selectedStatus}) build();
@$mustCallSuper
@override
void runBuild() {
@@ -297,17 +490,17 @@ abstract class _$SubmissionsFilter
final ref =
this.ref
as $Ref<
({String searchQuery, SubmissionStatus? selectedStatus}),
({String searchQuery, SubmissionStatus? selectedStatus})
({String searchQuery, String? selectedStatus}),
({String searchQuery, String? selectedStatus})
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
({String searchQuery, SubmissionStatus? selectedStatus}),
({String searchQuery, SubmissionStatus? selectedStatus})
({String searchQuery, String? selectedStatus}),
({String searchQuery, String? selectedStatus})
>,
({String searchQuery, SubmissionStatus? selectedStatus}),
({String searchQuery, String? selectedStatus}),
Object?,
Object?
>;
@@ -374,4 +567,244 @@ final class FilteredSubmissionsProvider
}
String _$filteredSubmissionsHash() =>
r'd0a07ab78a0d98596f01d0ed0a25016d573db5aa';
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.
@ProviderFor(SaveSubmission)
const saveSubmissionProvider = SaveSubmissionProvider._();
/// Save Submission Provider
///
/// Handles creating new project submissions via API.
final class SaveSubmissionProvider
extends $NotifierProvider<SaveSubmission, AsyncValue<void>> {
/// Save Submission Provider
///
/// Handles creating new project submissions via API.
const SaveSubmissionProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'saveSubmissionProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$saveSubmissionHash();
@$internal
@override
SaveSubmission create() => SaveSubmission();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<void> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<void>>(value),
);
}
}
String _$saveSubmissionHash() => r'64afa1a9662c36431c143c46a8ca34a786cb0860';
/// Save Submission Provider
///
/// Handles creating new project submissions via API.
abstract class _$SaveSubmission extends $Notifier<AsyncValue<void>> {
AsyncValue<void> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<void>, AsyncValue<void>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, AsyncValue<void>>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Upload Project Files Provider
///
/// Handles uploading multiple files for a project submission.
/// Tracks upload state for each file individually.
@ProviderFor(UploadProjectFiles)
const uploadProjectFilesProvider = UploadProjectFilesProvider._();
/// Upload Project Files Provider
///
/// Handles uploading multiple files for a project submission.
/// Tracks upload state for each file individually.
final class UploadProjectFilesProvider
extends $NotifierProvider<UploadProjectFiles, List<FileUploadState>> {
/// Upload Project Files Provider
///
/// Handles uploading multiple files for a project submission.
/// Tracks upload state for each file individually.
const UploadProjectFilesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'uploadProjectFilesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$uploadProjectFilesHash();
@$internal
@override
UploadProjectFiles create() => UploadProjectFiles();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<FileUploadState> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<FileUploadState>>(value),
);
}
}
String _$uploadProjectFilesHash() =>
r'd6219bc1f0b0d6ac70b9e3cea731267c82a68e1f';
/// Upload Project Files Provider
///
/// Handles uploading multiple files for a project submission.
/// Tracks upload state for each file individually.
abstract class _$UploadProjectFiles extends $Notifier<List<FileUploadState>> {
List<FileUploadState> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<List<FileUploadState>, List<FileUploadState>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<List<FileUploadState>, List<FileUploadState>>,
List<FileUploadState>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -32,7 +32,8 @@ 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_submission_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/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';
@@ -76,7 +77,8 @@ extension HiveRegistrar on HiveInterface {
registerAdapter(PointsRecordModelAdapter());
registerAdapter(PointsStatusAdapter());
registerAdapter(ProductModelAdapter());
registerAdapter(ProjectSubmissionModelAdapter());
registerAdapter(ProjectProgressModelAdapter());
registerAdapter(ProjectStatusModelAdapter());
registerAdapter(ProjectTypeAdapter());
registerAdapter(PromotionModelAdapter());
registerAdapter(QuoteItemModelAdapter());
@@ -135,7 +137,8 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
registerAdapter(PointsRecordModelAdapter());
registerAdapter(PointsStatusAdapter());
registerAdapter(ProductModelAdapter());
registerAdapter(ProjectSubmissionModelAdapter());
registerAdapter(ProjectProgressModelAdapter());
registerAdapter(ProjectStatusModelAdapter());
registerAdapter(ProjectTypeAdapter());
registerAdapter(PromotionModelAdapter());
registerAdapter(QuoteItemModelAdapter());

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.1+16
version: 1.0.1+18
environment:
sdk: ^3.10.0