uodate create/detail - no upload file
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
/// Handles remote API calls for design requests.
|
||||
library;
|
||||
|
||||
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/showrooms/data/models/design_request_model.dart';
|
||||
@@ -17,6 +18,24 @@ abstract class DesignRequestRemoteDataSource {
|
||||
|
||||
/// Fetch detail of a design request by name
|
||||
Future<DesignRequestModel> getDesignRequestDetail(String name);
|
||||
|
||||
/// Create a new design request
|
||||
Future<String> createDesignRequest({
|
||||
required String subject,
|
||||
required String area,
|
||||
required String region,
|
||||
required String desiredStyle,
|
||||
String? estimatedBudget,
|
||||
required String detailedRequirements,
|
||||
required String dateline,
|
||||
});
|
||||
|
||||
/// Upload file attachment for a design request
|
||||
Future<void> uploadDesignRequestFile({
|
||||
required String requestId,
|
||||
required String filePath,
|
||||
required String fileName,
|
||||
});
|
||||
}
|
||||
|
||||
/// Design Request Remote Data Source Implementation
|
||||
@@ -93,4 +112,94 @@ class DesignRequestRemoteDataSourceImpl implements DesignRequestRemoteDataSource
|
||||
throw Exception('Failed to get design request detail: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new design request
|
||||
///
|
||||
/// Calls: POST /api/method/building_material.building_material.api.design_request.create
|
||||
/// Body: { "subject": "...", "area": "...", "region": "...", ... }
|
||||
/// Returns: Created request ID (name)
|
||||
@override
|
||||
Future<String> createDesignRequest({
|
||||
required String subject,
|
||||
required String area,
|
||||
required String region,
|
||||
required String desiredStyle,
|
||||
String? estimatedBudget,
|
||||
required String detailedRequirements,
|
||||
required String dateline,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dioClient.post<Map<String, dynamic>>(
|
||||
'${ApiConstants.frappeApiMethod}${ApiConstants.createDesignRequest}',
|
||||
data: {
|
||||
'subject': subject,
|
||||
'area': area,
|
||||
'region': region,
|
||||
'desired_style': desiredStyle,
|
||||
if (estimatedBudget != null) 'estimated_budget': estimatedBudget,
|
||||
'detailed_requirements': detailedRequirements,
|
||||
'dateline': dateline,
|
||||
},
|
||||
);
|
||||
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw Exception('No data received from createDesignRequest API');
|
||||
}
|
||||
|
||||
// API returns: { "message": { "success": true, "data": { "name": "ISS-2025-00006" } } }
|
||||
final message = data['message'] as Map<String, dynamic>?;
|
||||
if (message == null) {
|
||||
throw Exception('No message field in createDesignRequest response');
|
||||
}
|
||||
|
||||
final success = message['success'] as bool? ?? false;
|
||||
if (!success) {
|
||||
throw Exception('Failed to create design request');
|
||||
}
|
||||
|
||||
final requestData = message['data'] as Map<String, dynamic>?;
|
||||
if (requestData == null) {
|
||||
throw Exception('No data field in createDesignRequest response');
|
||||
}
|
||||
|
||||
final name = requestData['name'] as String?;
|
||||
if (name == null || name.isEmpty) {
|
||||
throw Exception('No name returned from createDesignRequest');
|
||||
}
|
||||
|
||||
return name;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to create design request: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload file attachment for a design request
|
||||
///
|
||||
/// Calls: POST /api/method/upload_file
|
||||
/// Form-data: { file, is_private, folder, doctype, docname, optimize }
|
||||
@override
|
||||
Future<void> uploadDesignRequestFile({
|
||||
required String requestId,
|
||||
required String filePath,
|
||||
required String fileName,
|
||||
}) async {
|
||||
try {
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(filePath, filename: fileName),
|
||||
'is_private': '0',
|
||||
'folder': 'Home/Attachments',
|
||||
'doctype': 'Issue',
|
||||
'docname': requestId,
|
||||
'optimize': 'true',
|
||||
});
|
||||
|
||||
await _dioClient.post<Map<String, dynamic>>(
|
||||
'${ApiConstants.frappeApiMethod}${ApiConstants.uploadFile}',
|
||||
data: formData,
|
||||
);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to upload file: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,4 +38,46 @@ class DesignRequestRepositoryImpl implements DesignRequestRepository {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> createDesignRequest({
|
||||
required String subject,
|
||||
required String area,
|
||||
required String region,
|
||||
required String desiredStyle,
|
||||
String? estimatedBudget,
|
||||
required String detailedRequirements,
|
||||
required String dateline,
|
||||
}) async {
|
||||
try {
|
||||
return await _remoteDataSource.createDesignRequest(
|
||||
subject: subject,
|
||||
area: area,
|
||||
region: region,
|
||||
desiredStyle: desiredStyle,
|
||||
estimatedBudget: estimatedBudget,
|
||||
detailedRequirements: detailedRequirements,
|
||||
dateline: dateline,
|
||||
);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> uploadDesignRequestFile({
|
||||
required String requestId,
|
||||
required String filePath,
|
||||
required String fileName,
|
||||
}) async {
|
||||
try {
|
||||
await _remoteDataSource.uploadDesignRequestFile(
|
||||
requestId: requestId,
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,24 @@ abstract class DesignRequestRepository {
|
||||
///
|
||||
/// Returns full design request detail with files_list.
|
||||
Future<DesignRequest> getDesignRequestDetail(String name);
|
||||
|
||||
/// Create a new design request
|
||||
///
|
||||
/// Returns created request ID (name).
|
||||
Future<String> createDesignRequest({
|
||||
required String subject,
|
||||
required String area,
|
||||
required String region,
|
||||
required String desiredStyle,
|
||||
String? estimatedBudget,
|
||||
required String detailedRequirements,
|
||||
required String dateline,
|
||||
});
|
||||
|
||||
/// Upload file attachment for a design request
|
||||
Future<void> uploadDesignRequestFile({
|
||||
required String requestId,
|
||||
required String filePath,
|
||||
required String fileName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user