create submission

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

View File

@@ -11,6 +11,9 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:image_picker/image_picker.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/projects/data/models/project_submission_request.dart';
import 'package:worker/features/projects/domain/entities/project_progress.dart';
import 'package:worker/features/projects/presentation/providers/submissions_provider.dart';
/// Project Submission Create Page
class SubmissionCreatePage extends ConsumerStatefulWidget {
@@ -35,10 +38,10 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
final _descriptionController = TextEditingController();
// Form state
String? _selectedProgress;
ProjectProgress? _selectedProgress;
DateTime? _expectedStartDate;
final List<File> _uploadedFiles = [];
bool _showStartDateField = false;
bool _isSubmitting = false;
@override
void dispose() {
@@ -217,10 +220,8 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
_buildProgressDropdown(),
if (_showStartDateField) ...[
const SizedBox(height: 16),
_buildDateField(),
],
const SizedBox(height: 16),
_buildExpectedDateField(),
],
),
),
@@ -434,6 +435,8 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
}
Widget _buildProgressDropdown() {
final progressListAsync = ref.watch(projectProgressListProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -457,68 +460,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 +560,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 +573,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 +599,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,27 +699,39 @@ 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);
});
},
),
],
),
);
@@ -648,39 +742,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 +830,106 @@ 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: const Text('Xác nhận gửi đăng ký công trình?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Hủy'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Xác nhận'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _isSubmitting = true);
try {
// Parse area as double
final area = double.tryParse(_areaController.text.trim()) ?? 0.0;
// Create submission request
final request = ProjectSubmissionRequest(
designedArea: _projectNameController.text.trim(),
addressOfProject: _addressController.text.trim(),
projectOwner: _ownerController.text.trim(),
designFirm: _designUnitController.text.trim().isNotEmpty
? _designUnitController.text.trim()
: null,
contractionContractor: _constructionUnitController.text.trim().isNotEmpty
? _constructionUnitController.text.trim()
: null,
designArea: area,
productsIncludedInTheDesign: _productsController.text.trim(),
projectProgress: _selectedProgress!.id, // Use ProjectProgress.id (name from API)
expectedCommencementDate: _expectedStartDate,
description: _descriptionController.text.trim().isNotEmpty
? _descriptionController.text.trim()
: null,
requestDate: DateTime.now(),
);
// Step 1: Save project and get project name
final projectName = await ref.read(saveSubmissionProvider.notifier).save(request);
if (!mounted) return;
// Step 2: Upload files if any
if (_uploadedFiles.isNotEmpty) {
// Initialize upload provider with file paths
final filePaths = _uploadedFiles.map((f) => f.path).toList();
ref.read(uploadProjectFilesProvider.notifier).initFiles(filePaths);
// Upload all files
await ref.read(uploadProjectFilesProvider.notifier).uploadAll(projectName);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Đăng ký công trình đã được gửi thành công!\nChúng tôi sẽ xem xét và liên hệ với bạn sớm nhất.',
),
backgroundColor: AppColors.success,
),
);
Navigator.pop(context);
Navigator.pop(context, true); // Return true to indicate success
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi: ${e.toString().replaceAll('Exception: ', '')}'),
backgroundColor: AppColors.danger,
),
);
}
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
// Clear upload state
ref.read(uploadProjectFilesProvider.notifier).clear();
}
}
}