uodate create/detail - no upload file

This commit is contained in:
Phuoc Nguyen
2025-11-28 16:38:46 +07:00
parent 9e7bda32f2
commit 5e3e1401c1
8 changed files with 834 additions and 342 deletions

View File

@@ -3,13 +3,17 @@
/// Form to create a new design request following html/design-request-create.html.
library;
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/showrooms/presentation/providers/design_request_provider.dart';
/// Design Request Create Page
///
@@ -28,11 +32,14 @@ class DesignRequestCreatePage extends HookConsumerWidget {
final areaController = useTextEditingController();
final locationController = useTextEditingController();
final notesController = useTextEditingController();
final datelineController = useTextEditingController();
final selectedStyle = useState<String>('');
final selectedBudget = useState<String>('');
final selectedDateline = useState<DateTime?>(null);
final selectedFiles = useState<List<PlatformFile>>([]);
final isSubmitting = useState(false);
final submissionStatus = useState<String>('');
Future<void> pickFiles() async {
try {
@@ -80,14 +87,115 @@ class DesignRequestCreatePage extends HookConsumerWidget {
selectedFiles.value = files;
}
Future<void> submitForm() async {
if (formKey.currentState?.validate() ?? false) {
isSubmitting.value = true;
Future<void> pickDateline() async {
final picked = await showDatePicker(
context: context,
initialDate: selectedDateline.value ?? DateTime.now().add(const Duration(days: 30)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
locale: const Locale('vi', 'VN'),
);
if (picked != null) {
selectedDateline.value = picked;
datelineController.text = DateFormat('dd/MM/yyyy').format(picked);
}
}
// Simulate API call
await Future.delayed(const Duration(seconds: 2));
/// Get style display text for API
String getStyleText(String value) {
switch (value) {
case 'hien-dai':
return 'Hiện đại';
case 'toi-gian':
return 'Tối giản';
case 'co-dien':
return 'Cổ điển';
case 'scandinavian':
return 'Scandinavian';
case 'industrial':
return 'Industrial';
case 'tropical':
return 'Tropical';
case 'luxury':
return 'Luxury';
case 'khac':
return 'Khác';
default:
return value;
}
}
/// Get budget display text for API
String getBudgetText(String value) {
switch (value) {
case 'duoi-100tr':
return 'Dưới 100 triệu';
case '100-300tr':
return '100 - 300 triệu';
case '300-500tr':
return '300 - 500 triệu';
case '500tr-1ty':
return '500 triệu - 1 tỷ';
case 'tren-1ty':
return 'Trên 1 tỷ';
case 'trao-doi':
return 'Trao đổi trực tiếp';
default:
return value;
}
}
Future<void> submitForm() async {
if (!(formKey.currentState?.validate() ?? false)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vui lòng kiểm tra lại thông tin'),
backgroundColor: AppColors.danger,
),
);
return;
}
isSubmitting.value = true;
submissionStatus.value = 'Đang tạo yêu cầu...';
try {
// Get repository
final repository = await ref.read(designRequestRepositoryProvider.future);
// Step 1: Create design request
final requestId = await repository.createDesignRequest(
subject: projectNameController.text.trim(),
area: areaController.text.trim(),
region: locationController.text.trim(),
desiredStyle: getStyleText(selectedStyle.value),
estimatedBudget: selectedBudget.value.isNotEmpty
? getBudgetText(selectedBudget.value)
: null,
detailedRequirements: notesController.text.trim(),
dateline: DateFormat('yyyy-MM-dd').format(selectedDateline.value!),
);
// Step 2: Upload files if any
if (selectedFiles.value.isNotEmpty) {
submissionStatus.value = 'Đang tải lên tệp đính kèm...';
for (int i = 0; i < selectedFiles.value.length; i++) {
final file = selectedFiles.value[i];
submissionStatus.value = 'Đang tải lên tệp ${i + 1}/${selectedFiles.value.length}...';
if (file.path != null) {
await repository.uploadDesignRequestFile(
requestId: requestId,
filePath: file.path!,
fileName: file.name,
);
}
}
}
isSubmitting.value = false;
submissionStatus.value = '';
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -98,19 +206,23 @@ class DesignRequestCreatePage extends HookConsumerWidget {
);
// Navigate back
Future.delayed(const Duration(milliseconds: 500), () {
if (context.mounted) {
context.pop();
}
});
context.pop();
// Refresh the list (fire and forget)
unawaited(ref.read(designRequestsListProvider.notifier).refresh());
}
} catch (e) {
isSubmitting.value = false;
submissionStatus.value = '';
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi: ${e.toString().replaceAll('Exception: ', '')}'),
backgroundColor: AppColors.danger,
),
);
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vui lòng kiểm tra lại thông tin'),
backgroundColor: AppColors.danger,
),
);
}
}
@@ -140,33 +252,10 @@ class DesignRequestCreatePage extends HookConsumerWidget {
key: formKey,
child: Column(
children: [
// Progress Steps
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_ProgressStep(number: 1, isActive: true),
Container(
width: 16,
height: 2,
color: AppColors.grey100,
margin: const EdgeInsets.symmetric(horizontal: 8),
),
_ProgressStep(number: 2),
Container(
width: 16,
height: 2,
color: AppColors.grey100,
margin: const EdgeInsets.symmetric(horizontal: 8),
),
_ProgressStep(number: 3),
],
),
const SizedBox(height: 24),
// Basic Information Card
Card(
elevation: 2,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -428,6 +517,72 @@ class DesignRequestCreatePage extends HookConsumerWidget {
),
],
),
const SizedBox(height: 20),
// Dateline
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: const TextSpan(
text: 'Thời hạn mong muốn',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
children: [
TextSpan(
text: ' *',
style: TextStyle(color: AppColors.danger),
),
],
),
),
const SizedBox(height: 8),
TextFormField(
controller: datelineController,
readOnly: true,
onTap: pickDateline,
decoration: InputDecoration(
hintText: 'Chọn ngày',
suffixIcon: const Icon(Icons.calendar_today, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: AppColors.grey100,
width: 2,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: AppColors.grey100,
width: 2,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vui lòng chọn thời hạn';
}
return null;
},
),
],
),
],
),
),
@@ -438,6 +593,7 @@ class DesignRequestCreatePage extends HookConsumerWidget {
// Detailed Requirements Card
Card(
elevation: 2,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -536,6 +692,8 @@ class DesignRequestCreatePage extends HookConsumerWidget {
// File Upload Card
Card(
elevation: 2,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -634,8 +792,8 @@ class DesignRequestCreatePage extends HookConsumerWidget {
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
disabledBackgroundColor: AppColors.primaryBlue.withValues(alpha: 0.7),
disabledForegroundColor: AppColors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder(
@@ -643,13 +801,28 @@ class DesignRequestCreatePage extends HookConsumerWidget {
),
),
child: isSubmitting.value
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.white,
),
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.white,
),
),
const SizedBox(width: 12),
Text(
submissionStatus.value.isNotEmpty
? submissionStatus.value
: 'Đang xử lý...',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
)
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -678,45 +851,6 @@ class DesignRequestCreatePage extends HookConsumerWidget {
}
/// Progress Step Widget
class _ProgressStep extends StatelessWidget {
final int number;
final bool isActive;
final bool isCompleted;
const _ProgressStep({
required this.number,
this.isActive = false,
this.isCompleted = false,
});
@override
Widget build(BuildContext context) {
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isCompleted
? AppColors.success
: isActive
? AppColors.primaryBlue
: AppColors.grey100,
),
child: Center(
child: Text(
'$number',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isActive || isCompleted
? AppColors.white
: AppColors.grey500,
),
),
),
);
}
}
/// Form Field Widget
class _FormField extends StatelessWidget {

View File

@@ -1,6 +1,6 @@
/// Page: Design Request Detail Page
///
/// Displays design request details from API.
/// Displays design request details from API following html/design-request-detail.html.
library;
import 'package:cached_network_image/cached_network_image.dart';
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart';
@@ -19,7 +20,8 @@ import 'package:worker/features/showrooms/presentation/providers/design_request_
///
/// Shows complete details of a design request including:
/// - Request header with ID, date, and status
/// - Subject and description
/// - Design information (subject, description)
/// - Completion highlight for completed requests
/// - Attached files/images
class DesignRequestDetailPage extends ConsumerWidget {
const DesignRequestDetailPage({required this.requestId, super.key});
@@ -29,7 +31,7 @@ class DesignRequestDetailPage extends ConsumerWidget {
Color _getStatusColor(DesignRequestStatus status) {
switch (status) {
case DesignRequestStatus.pending:
return const Color(0xFFffc107);
return const Color(0xFFd97706);
case DesignRequestStatus.designing:
return const Color(0xFF3730a3);
case DesignRequestStatus.completed:
@@ -117,6 +119,36 @@ class DesignRequestDetailPage extends ConsumerWidget {
}
}
Future<void> _viewDesign3D(BuildContext context) async {
// TODO: Replace with actual design link from API when available
const designUrl = 'https://example.com/3d-design';
try {
final uri = Uri.parse(designUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Link thiết kế 3D chưa sẵn sàng'),
backgroundColor: AppColors.warning,
),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi khi mở link: $e'),
backgroundColor: AppColors.danger,
),
);
}
}
}
void _showImageViewer(
BuildContext context,
List<ProjectFile> files,
@@ -174,7 +206,7 @@ class DesignRequestDetailPage extends ConsumerWidget {
padding: const EdgeInsets.all(20),
child: Column(
children: [
// Request Header Card
// Request Header & Design Info Card
Card(
elevation: 2,
margin: EdgeInsets.zero,
@@ -185,46 +217,97 @@ class DesignRequestDetailPage extends ConsumerWidget {
width: double.infinity,
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Request ID
Text(
'#${request.id}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
// Header Section (centered)
Center(
child: Column(
children: [
// Request ID
Text(
'#${request.id}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
const SizedBox(height: 8),
// Date
if (request.dateline != null)
Text(
'Ngày gửi: ${request.dateline}',
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
),
const SizedBox(height: 16),
// Status Badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: _getStatusBackgroundColor(request.status),
borderRadius: BorderRadius.circular(20),
),
child: Text(
request.statusText.toUpperCase(),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _getStatusColor(request.status),
),
),
),
],
),
),
const SizedBox(height: 8),
if (request.dateline != null)
Text(
'Deadline: ${request.dateline}',
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
const SizedBox(height: 24),
const Divider(height: 1, color: AppColors.grey100),
const SizedBox(height: 24),
// Design Information Section
Row(
children: [
Icon(
Icons.info_outline,
color: AppColors.primaryBlue,
size: 20,
),
),
const SizedBox(width: 8),
const Text(
'Thông tin thiết kế',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
],
),
const SizedBox(height: 16),
// Status Badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: _getStatusBackgroundColor(request.status),
borderRadius: BorderRadius.circular(20),
),
child: Text(
request.statusText.toUpperCase(),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _getStatusColor(request.status),
),
),
// Description List
_DescriptionItem(
label: 'Tên công trình:',
value: request.subject,
),
if (request.plainDescription.isNotEmpty) ...[
const SizedBox(height: 12),
_DescriptionItem(
label: 'Mô tả chi tiết:',
value: request.plainDescription,
isMultiLine: true,
),
],
],
),
),
@@ -251,23 +334,43 @@ class DesignRequestDetailPage extends ConsumerWidget {
),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(24),
child: const Column(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Text(
'🎉 Yêu cầu đã hoàn thành!',
const Text(
'🎉 Thiết kế đã hoàn thành!',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(0xFF065f46),
),
),
SizedBox(height: 12),
Text(
'Thiết kế của bạn đã sẵn sàng',
const SizedBox(height: 12),
const Text(
'Thiết kế 3D của bạn đã sẵn sàng để xem',
style: TextStyle(color: Color(0xFF065f46)),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => _viewDesign3D(context),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10b981),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(Icons.view_in_ar, size: 20),
label: const Text(
'Xem Link Thiết kế 3D',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
),
@@ -294,7 +397,7 @@ class DesignRequestDetailPage extends ConsumerWidget {
),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.all(20),
child: const Column(
children: [
Text(
@@ -318,63 +421,46 @@ class DesignRequestDetailPage extends ConsumerWidget {
if (request.isRejected) const SizedBox(height: 20),
// Project Details Card
Card(
elevation: 2,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Subject
_SectionHeader(icon: Icons.info, title: 'Tiêu đề'),
const SizedBox(height: 12),
Text(
request.subject,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
height: 1.6,
),
),
if (request.plainDescription.isNotEmpty) ...[
const SizedBox(height: 24),
// Description
_SectionHeader(icon: Icons.edit, title: 'Mô tả yêu cầu'),
const SizedBox(height: 12),
Text(
request.plainDescription,
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
height: 1.6,
),
),
],
// Files
if (request.filesList.isNotEmpty) ...[
const SizedBox(height: 24),
_SectionHeader(
icon: Icons.attach_file,
title: 'Tài liệu đính kèm (${request.filesList.length})',
// Attached Files Card
if (request.filesList.isNotEmpty)
Card(
elevation: 2,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.attach_file,
color: AppColors.primaryBlue,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Tài liệu đính kèm',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
],
),
const SizedBox(height: 16),
_buildFilesSection(context, request.filesList),
],
],
),
),
),
),
const SizedBox(height: 20),
if (request.filesList.isNotEmpty) const SizedBox(height: 20),
// Action Button
SizedBox(
@@ -389,9 +475,9 @@ class DesignRequestDetailPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(Icons.chat_bubble),
icon: const Icon(Icons.chat_bubble_outline),
label: const Text(
'Liên hệ hỗ trợ',
'Liên hệ',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
@@ -487,40 +573,99 @@ class DesignRequestDetailPage extends ConsumerWidget {
if (otherFiles.isNotEmpty) const SizedBox(height: 16),
],
// Other Files
// Other Files as file items
...otherFiles.map(
(file) => _FileItem(
fileUrl: file.fileUrl,
icon: _getFileIcon(file.fileUrl),
),
),
// Show image files as file items too (for download/naming)
if (images.isNotEmpty && otherFiles.isEmpty)
...images.map(
(file) => _FileItem(
fileUrl: file.fileUrl,
icon: Icons.image,
),
),
],
);
}
}
/// Section Header Widget
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.icon, required this.title});
/// Description Item Widget
class _DescriptionItem extends StatelessWidget {
const _DescriptionItem({
required this.label,
required this.value,
this.isMultiLine = false,
});
final IconData icon;
final String title;
final String label;
final String value;
final bool isMultiLine;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, color: AppColors.primaryBlue, size: 20),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
if (isMultiLine) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 13,
color: AppColors.grey500,
fontWeight: FontWeight.w500,
),
),
),
],
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 15,
color: AppColors.grey900,
fontWeight: FontWeight.w500,
height: 1.6,
),
),
],
);
}
return Container(
padding: const EdgeInsets.only(bottom: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.grey100)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: const TextStyle(
fontSize: 13,
color: AppColors.grey500,
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 15,
color: AppColors.grey900,
fontWeight: FontWeight.w500,
height: 1.6,
),
),
),
],
),
);
}
}
@@ -543,7 +688,7 @@ class _FileItem extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.grey50,
color: AppColors.grey100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
@@ -555,7 +700,7 @@ class _FileItem extends StatelessWidget {
color: AppColors.primaryBlue,
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, color: Colors.white, size: 16),
child: Icon(icon, color: Colors.white, size: 14),
),
const SizedBox(width: 12),
Expanded(
@@ -577,14 +722,14 @@ class _FileItem extends StatelessWidget {
/// Image Viewer Dialog with Swipe Navigation
class _ImageViewerDialog extends StatefulWidget {
final List<ProjectFile> images;
final int initialIndex;
const _ImageViewerDialog({
required this.images,
required this.initialIndex,
});
final List<ProjectFile> images;
final int initialIndex;
@override
State<_ImageViewerDialog> createState() => _ImageViewerDialogState();
}