Files
worker/lib/features/projects/presentation/pages/submission_create_page.dart
2025-11-27 16:56:01 +07:00

969 lines
31 KiB
Dart

/// Submission Create Page
///
/// Form for creating new project submissions.
library;
import 'dart:io';
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/presentation/providers/submissions_provider.dart';
/// Project Submission Create Page
class SubmissionCreatePage extends ConsumerStatefulWidget {
const SubmissionCreatePage({super.key});
@override
ConsumerState<SubmissionCreatePage> createState() =>
_SubmissionCreatePageState();
}
class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
final _formKey = GlobalKey<FormState>();
// Form controllers
final _projectNameController = TextEditingController();
final _addressController = TextEditingController();
final _ownerController = TextEditingController();
final _designUnitController = TextEditingController();
final _constructionUnitController = TextEditingController();
final _areaController = TextEditingController();
final _productsController = TextEditingController();
final _descriptionController = TextEditingController();
// Form state
ProjectProgress? _selectedProgress;
DateTime? _expectedStartDate;
final List<File> _uploadedFiles = [];
bool _isSubmitting = false;
@override
void dispose() {
_projectNameController.dispose();
_addressController.dispose();
_ownerController.dispose();
_designUnitController.dispose();
_constructionUnitController.dispose();
_areaController.dispose();
_productsController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
appBar: AppBar(
leading: IconButton(
icon: const FaIcon(
FontAwesomeIcons.arrowLeft,
color: Colors.black,
size: 20,
),
onPressed: () => Navigator.of(context).pop(),
),
title: const Text(
'Đăng ký Công trình',
style: TextStyle(color: Colors.black),
),
actions: [
IconButton(
icon: const FaIcon(
FontAwesomeIcons.circleInfo,
color: Colors.black,
size: 20,
),
onPressed: _showInfoDialog,
),
const SizedBox(width: AppSpacing.sm),
],
elevation: AppBarSpecs.elevation,
backgroundColor: AppColors.white,
centerTitle: false,
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(4),
children: [
// Basic Information
_buildBasicInfoCard(),
const SizedBox(height: 16),
// Project Details
_buildProjectDetailsCard(),
const SizedBox(height: 16),
// Additional Information
_buildAdditionalInfoCard(),
const SizedBox(height: 16),
// File Upload
_buildFileUploadCard(),
const SizedBox(height: 24),
// Submit Button
_buildSubmitButton(),
const SizedBox(height: 40),
],
),
),
);
}
Widget _buildBasicInfoCard() {
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Thông tin cơ bản',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
const SizedBox(height: 16),
_buildTextField(
controller: _projectNameController,
label: 'Tên công trình',
required: true,
hint: 'Nhập tên công trình',
),
const SizedBox(height: 16),
_buildTextField(
controller: _addressController,
label: 'Địa chỉ',
required: true,
hint: 'Nhập địa chỉ đầy đủ',
maxLines: 2,
),
const SizedBox(height: 16),
_buildTextField(
controller: _ownerController,
label: 'Chủ đầu tư',
required: true,
hint: 'Tên chủ đầu tư',
),
const SizedBox(height: 16),
_buildTextField(
controller: _designUnitController,
label: 'Đơn vị thiết kế',
hint: 'Tên đơn vị thiết kế',
),
const SizedBox(height: 16),
_buildTextField(
controller: _constructionUnitController,
label: 'Đơn vị thi công',
hint: 'Tên đơn vị thi công',
),
],
),
),
);
}
Widget _buildProjectDetailsCard() {
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Chi tiết dự án',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
const SizedBox(height: 16),
_buildTextField(
controller: _areaController,
label: 'Tổng diện tích',
required: true,
hint: 'Nhập diện tích m²',
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
_buildTextField(
controller: _productsController,
label: 'Sản phẩm đưa vào thiết kế',
required: true,
hint: 'Liệt kê các sản phẩm gạch đã sử dụng trong công trình (tên sản phẩm, mã SP, số lượng...)',
maxLines: 4,
helperText: 'Ví dụ: Gạch granite 60x60 - GP-001 - 100m², Gạch mosaic - MS-002 - 50m²',
),
const SizedBox(height: 16),
_buildProgressDropdown(),
const SizedBox(height: 16),
_buildExpectedDateField(),
],
),
),
);
}
Widget _buildAdditionalInfoCard() {
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Thông tin bổ sung',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
const SizedBox(height: 16),
_buildTextField(
controller: _descriptionController,
label: 'Mô tả công trình',
hint: 'Mô tả ngắn gọn về công trình, diện tích, đặc điểm nổi bật...',
maxLines: 3,
),
],
),
),
);
}
Widget _buildFileUploadCard() {
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Ảnh minh chứng',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
const SizedBox(height: 16),
// Upload Area
InkWell(
onTap: _pickFiles,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20),
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(
color: AppColors.grey100,
width: 2,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(8),
),
child: const Column(
children: [
FaIcon(
FontAwesomeIcons.cloudArrowUp,
size: 48,
color: AppColors.grey500,
),
SizedBox(height: 12),
Text(
'Kéo thả ảnh vào đây',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
),
),
SizedBox(height: 4),
Text(
'hoặc nhấn để chọn file',
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
),
],
),
),
),
if (_uploadedFiles.isNotEmpty) ...[
const SizedBox(height: 16),
..._uploadedFiles.asMap().entries.map((entry) {
final index = entry.key;
final file = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildFileItem(file, index),
);
}),
],
const SizedBox(height: 8),
const Text(
'Hỗ trợ: JPG, PNG, PDF. Tối đa 10MB mỗi file.',
style: TextStyle(
fontSize: 12,
color: AppColors.grey500,
),
),
],
),
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String label,
String? hint,
bool required = false,
int maxLines = 1,
TextInputType? keyboardType,
String? helperText,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
if (required)
const Text(
' *',
style: TextStyle(
fontSize: 14,
color: AppColors.danger,
),
),
],
),
const SizedBox(height: 8),
TextFormField(
controller: controller,
maxLines: maxLines,
keyboardType: keyboardType,
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: AppColors.grey500),
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),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.danger),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
validator: required
? (value) {
if (value == null || value.trim().isEmpty) {
return 'Vui lòng nhập $label';
}
return null;
}
: null,
),
if (helperText != null) ...[
const SizedBox(height: 4),
Text(
helperText,
style: const TextStyle(
fontSize: 12,
color: AppColors.grey500,
),
),
],
],
);
}
Widget _buildProgressDropdown() {
final progressListAsync = ref.watch(projectProgressListProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Text(
'Tiến độ công trình',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
Text(
' *',
style: TextStyle(
fontSize: 14,
color: AppColors.danger,
),
),
],
),
const SizedBox(height: 8),
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,
),
),
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),
),
child: const Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('Đang tải...', style: TextStyle(color: AppColors.grey500)),
],
),
),
error: (error, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.white,
border: Border.all(color: AppColors.danger),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const FaIcon(FontAwesomeIcons.circleExclamation,
size: 16, color: AppColors.danger),
const SizedBox(width: 12),
const Expanded(
child: Text('Không thể tải danh sách tiến độ',
style: TextStyle(color: AppColors.danger)),
),
TextButton(
onPressed: () =>
ref.invalidate(projectProgressListProvider),
child: const Text('Thử lại'),
),
],
),
),
),
],
);
}
Widget _buildExpectedDateField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Ngày dự kiến khởi công',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
const SizedBox(height: 8),
InkWell(
onTap: _pickExpectedDate,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.white,
border: Border.all(color: AppColors.grey100),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_expectedStartDate != null
? '${_expectedStartDate!.day.toString().padLeft(2, '0')}/${_expectedStartDate!.month.toString().padLeft(2, '0')}/${_expectedStartDate!.year}'
: 'Chọn ngày',
style: TextStyle(
color: _expectedStartDate != null
? AppColors.grey900
: AppColors.grey500,
),
),
const FaIcon(
FontAwesomeIcons.calendar,
size: 16,
color: AppColors.grey500,
),
],
),
),
),
],
);
}
Widget _buildFileItem(File file, int index) {
final fileName = file.path.split('/').last;
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: hasError
? AppColors.danger
: isUploaded
? AppColors.success
: AppColors.grey100,
),
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
// Image with upload overlay
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.file(
file,
width: 48,
height: 48,
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(
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),
Text(
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: hasError
? AppColors.danger
: isUploaded
? AppColors.success
: AppColors.grey500,
),
),
],
),
),
// 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);
});
},
),
],
),
);
}
Widget _buildSubmitButton() {
return SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
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: _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> _pickExpectedDate() async {
final date = await showDatePicker(
context: context,
initialDate: _expectedStartDate ?? DateTime.now(),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 3)),
);
if (date != null) {
setState(() {
_expectedStartDate = date;
});
}
}
Future<void> _pickFiles() async {
try {
final ImagePicker picker = ImagePicker();
final List<XFile> images = await picker.pickMultiImage(
maxWidth: 1920,
maxHeight: 1920,
imageQuality: 85,
);
if (images.isNotEmpty) {
setState(() {
for (final image in images) {
_uploadedFiles.add(File(image.path));
}
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Đã thêm ${images.length} ảnh'),
duration: const Duration(seconds: 2),
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Lỗi khi chọn ảnh: $e')),
);
}
}
}
Future<void> _handleSubmit() async {
if (!_formKey.currentState!.validate()) return;
// Validate progress selection
if (_selectedProgress == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vui lòng chọn tiến độ công trình'),
backgroundColor: AppColors.danger,
),
);
return;
}
final confirmed = await showDialog<bool>(
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, 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();
}
}
}
void _showInfoDialog() {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Hướng dẫn đăng ký'),
content: const SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Đây là nội dung hướng dẫn sử dụng cho tính năng Đăng ký Công trình:',
),
SizedBox(height: 12),
Text('• Điền đầy đủ thông tin công trình theo yêu cầu'),
Text('• Upload ảnh minh chứng chất lượng cao'),
Text('• Mô tả chi tiết sản phẩm đã sử dụng'),
Text('• Chọn đúng tiến độ hiện tại của công trình'),
Text('• Kiểm tra kỹ thông tin trước khi gửi'),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Đóng'),
),
],
),
);
}
}