add model/design

This commit is contained in:
Phuoc Nguyen
2025-11-03 17:31:12 +07:00
parent fb90c72f54
commit 24a8508fce
6 changed files with 1781 additions and 11 deletions

View File

@@ -30,6 +30,8 @@ import 'package:worker/features/products/presentation/pages/product_detail_page.
import 'package:worker/features/products/presentation/pages/products_page.dart';
import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart';
import 'package:worker/features/quotes/presentation/pages/quotes_page.dart';
import 'package:worker/features/showrooms/presentation/pages/design_request_create_page.dart';
import 'package:worker/features/showrooms/presentation/pages/design_request_detail_page.dart';
import 'package:worker/features/showrooms/presentation/pages/model_houses_page.dart';
/// App Router
@@ -272,6 +274,27 @@ class AppRouter {
MaterialPage(key: state.pageKey, child: const ModelHousesPage()),
),
// Design Request Create Route
GoRoute(
path: RouteNames.designRequestCreate,
name: RouteNames.designRequestCreate,
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const DesignRequestCreatePage()),
),
// Design Request Detail Route
GoRoute(
path: RouteNames.designRequestDetail,
name: RouteNames.designRequestDetail,
pageBuilder: (context, state) {
final requestId = state.pathParameters['id'];
return MaterialPage(
key: state.pageKey,
child: DesignRequestDetailPage(requestId: requestId ?? 'YC001'),
);
},
),
// TODO: Add more routes as features are implemented
],
@@ -396,8 +419,10 @@ class RouteNames {
// Chat Route
static const String chat = '/chat';
// Model Houses Route
// Model Houses & Design Requests Routes
static const String modelHouses = '/model-houses';
static const String designRequestCreate = '/model-houses/design-request/create';
static const String designRequestDetail = '/model-houses/design-request/:id';
// Authentication Routes (TODO: implement when auth feature is ready)
static const String login = '/login';

View File

@@ -0,0 +1,814 @@
/// Page: Design Request Create Page
///
/// Form to create a new design request following html/design-request-create.html.
library;
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:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
/// Design Request Create Page
///
/// Form with:
/// - Progress steps indicator
/// - Basic information (name, area, location, style, budget)
/// - Detailed requirements (notes)
/// - File upload with preview
class DesignRequestCreatePage extends HookConsumerWidget {
const DesignRequestCreatePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final projectNameController = useTextEditingController();
final areaController = useTextEditingController();
final locationController = useTextEditingController();
final notesController = useTextEditingController();
final selectedStyle = useState<String>('');
final selectedBudget = useState<String>('');
final selectedFiles = useState<List<PlatformFile>>([]);
final isSubmitting = useState(false);
Future<void> pickFiles() async {
try {
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf'],
);
if (result != null) {
// Validate file sizes
final validFiles = <PlatformFile>[];
for (final file in result.files) {
if (file.size <= 10 * 1024 * 1024) { // 10MB max
validFiles.add(file);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${file.name} quá lớn (tối đa 10MB)'),
backgroundColor: AppColors.danger,
),
);
}
}
}
selectedFiles.value = [...selectedFiles.value, ...validFiles];
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi khi chọn file: $e'),
backgroundColor: AppColors.danger,
),
);
}
}
}
void removeFile(int index) {
final files = List<PlatformFile>.from(selectedFiles.value);
files.removeAt(index);
selectedFiles.value = files;
}
Future<void> submitForm() async {
if (formKey.currentState?.validate() ?? false) {
isSubmitting.value = true;
// Simulate API call
await Future.delayed(const Duration(seconds: 2));
isSubmitting.value = false;
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Yêu cầu thiết kế đã được gửi thành công!'),
backgroundColor: AppColors.success,
),
);
// Navigate back
Future.delayed(const Duration(milliseconds: 500), () {
if (context.mounted) {
context.pop();
}
});
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vui lòng kiểm tra lại thông tin'),
backgroundColor: AppColors.danger,
),
);
}
}
return Scaffold(
backgroundColor: AppColors.grey50,
appBar: AppBar(
backgroundColor: AppColors.white,
elevation: AppBarSpecs.elevation,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => Navigator.of(context).pop(),
),
centerTitle: false,
title: const Text(
'Tạo yêu cầu thiết kế mới',
style: TextStyle(
color: Colors.black,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
actions: const [
SizedBox(width: AppSpacing.sm),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
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,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
color: AppColors.primaryBlue,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Thông tin cơ bản',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
],
),
const SizedBox(height: 20),
// Project Name
_FormField(
label: 'Tên dự án/Khách hàng',
required: true,
controller: projectNameController,
hint: 'VD: Thiết kế nhà anh Minh - Quận 7',
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vui lòng nhập tên dự án';
}
return null;
},
),
const SizedBox(height: 20),
// Area
_FormField(
label: 'Diện tích (m²)',
required: true,
controller: areaController,
hint: 'VD: 120',
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vui lòng nhập diện tích';
}
final area = double.tryParse(value);
if (area == null || area <= 0) {
return 'Diện tích phải là số dương';
}
return null;
},
),
const SizedBox(height: 20),
// Location
_FormField(
label: 'Khu vực (Tỉnh/ Thành phố)',
required: true,
controller: locationController,
hint: 'VD: Hà Nội',
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vui lòng nhập khu vực';
}
return null;
},
),
const SizedBox(height: 20),
// Style
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: const TextSpan(
text: 'Phong cách 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),
DropdownButtonFormField<String>(
value: selectedStyle.value.isEmpty ? null : selectedStyle.value,
decoration: InputDecoration(
hintText: '-- Chọn phong cách --',
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,
),
),
items: const [
DropdownMenuItem(value: 'hien-dai', child: Text('Hiện đại')),
DropdownMenuItem(value: 'toi-gian', child: Text('Tối giản')),
DropdownMenuItem(value: 'co-dien', child: Text('Cổ điển')),
DropdownMenuItem(value: 'scandinavian', child: Text('Scandinavian')),
DropdownMenuItem(value: 'industrial', child: Text('Industrial')),
DropdownMenuItem(value: 'tropical', child: Text('Tropical')),
DropdownMenuItem(value: 'luxury', child: Text('Luxury')),
DropdownMenuItem(value: 'khac', child: Text('Khác (ghi rõ trong ghi chú)')),
],
onChanged: (value) {
selectedStyle.value = value ?? '';
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vui lòng chọn phong cách';
}
return null;
},
),
],
),
const SizedBox(height: 20),
// Budget
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Ngân sách dự kiến',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: selectedBudget.value.isEmpty ? null : selectedBudget.value,
decoration: InputDecoration(
hintText: '-- Chọn ngân sách --',
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,
),
),
items: const [
DropdownMenuItem(value: 'duoi-100tr', child: Text('Dưới 100 triệu')),
DropdownMenuItem(value: '100-300tr', child: Text('100 - 300 triệu')),
DropdownMenuItem(value: '300-500tr', child: Text('300 - 500 triệu')),
DropdownMenuItem(value: '500tr-1ty', child: Text('500 triệu - 1 tỷ')),
DropdownMenuItem(value: 'tren-1ty', child: Text('Trên 1 tỷ')),
DropdownMenuItem(value: 'trao-doi', child: Text('Trao đổi trực tiếp')),
],
onChanged: (value) {
selectedBudget.value = value ?? '';
},
),
],
),
],
),
),
),
const SizedBox(height: 20),
// Detailed Requirements Card
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.edit_outlined,
color: AppColors.primaryBlue,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Yêu cầu chi tiết',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
],
),
const SizedBox(height: 20),
// Notes
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: const TextSpan(
text: 'Ghi chú chi tiết',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
children: [
TextSpan(
text: ' *',
style: TextStyle(color: AppColors.danger),
),
],
),
),
const SizedBox(height: 8),
TextFormField(
controller: notesController,
maxLines: 5,
decoration: InputDecoration(
hintText: 'Mô tả chi tiết về yêu cầu thiết kế, số phòng, công năng sử dụng, sở thích cá nhân...',
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.all(16),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vui lòng mô tả yêu cầu chi tiết';
}
return null;
},
),
],
),
],
),
),
),
const SizedBox(height: 20),
// File Upload Card
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.cloud_upload_outlined,
color: AppColors.primaryBlue,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Đính kèm tài liệu',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
],
),
const SizedBox(height: 20),
// Upload Area
InkWell(
onTap: pickFiles,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
border: Border.all(
color: AppColors.grey100,
width: 2,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(8),
color: AppColors.grey50,
),
child: Column(
children: [
Icon(
Icons.cloud_upload_outlined,
size: 32,
color: AppColors.grey500,
),
const SizedBox(height: 12),
const Text(
'Nhấn để chọn file hoặc kéo thả vào đây',
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
),
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,
),
),
],
),
),
),
// File Preview
if (selectedFiles.value.isNotEmpty) ...[
const SizedBox(height: 16),
...selectedFiles.value.asMap().entries.map((entry) {
final index = entry.key;
final file = entry.value;
return _FilePreviewItem(
file: file,
onRemove: () => removeFile(index),
);
}),
],
],
),
),
),
const SizedBox(height: 32),
// Submit Button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isSubmitting.value ? null : submitForm,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: isSubmitting.value
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.white,
),
)
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.send, size: 20),
SizedBox(width: 8),
Text(
'Gửi yêu cầu',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
const SizedBox(height: 40),
],
),
),
),
);
}
}
/// 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 {
final String label;
final bool required;
final TextEditingController controller;
final String hint;
final TextInputType? keyboardType;
final String? Function(String?)? validator;
const _FormField({
required this.label,
this.required = false,
required this.controller,
required this.hint,
this.keyboardType,
this.validator,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
children: required
? const [
TextSpan(
text: ' *',
style: TextStyle(color: AppColors.danger),
),
]
: null,
),
),
const SizedBox(height: 8),
TextFormField(
controller: controller,
keyboardType: keyboardType,
decoration: InputDecoration(
hintText: hint,
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),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.danger, width: 2),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.danger, width: 2),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
validator: validator,
),
],
);
}
}
/// File Preview Item Widget
class _FilePreviewItem extends StatelessWidget {
final PlatformFile file;
final VoidCallback onRemove;
const _FilePreviewItem({
required this.file,
required this.onRemove,
});
IconData _getFileIcon() {
final extension = file.extension?.toLowerCase();
if (extension == 'pdf') return Icons.picture_as_pdf;
if (extension == 'jpg' || extension == 'jpeg' || extension == 'png') {
return Icons.image;
}
return Icons.insert_drive_file;
}
String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.grey100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.primaryBlue,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
_getFileIcon(),
color: AppColors.white,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
_formatFileSize(file.size),
style: const TextStyle(
fontSize: 12,
color: AppColors.grey500,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
color: AppColors.danger,
onPressed: onRemove,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,895 @@
/// Page: Design Request Detail Page
///
/// Displays design request details following html/design-request-detail.html.
library;
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';
import 'package:worker/features/showrooms/presentation/pages/model_houses_page.dart';
/// Design Request Detail Page
///
/// Shows complete details of a design request including:
/// - Request header with ID, date, and status
/// - Project information grid (area, style, budget, status)
/// - Completion highlight (if completed) with 3D design link
/// - Project details (name, description, contact, files)
/// - Status timeline
/// - Action buttons (edit, contact)
class DesignRequestDetailPage extends ConsumerWidget {
const DesignRequestDetailPage({
required this.requestId,
super.key,
});
final String requestId;
// Mock data - in real app, this would come from a provider
Map<String, dynamic> _getRequestData() {
final mockData = {
'YC001': {
'id': 'YC001',
'name': 'Thiết kế nhà phố 3 tầng - Anh Minh (Quận 7)',
'area': '120m²',
'style': 'Hiện đại',
'budget': '300-500 triệu',
'status': DesignRequestStatus.completed,
'statusText': 'Đã hoàn thành',
'description':
'Thiết kế nhà phố 3 tầng phong cách hiện đại với 4 phòng ngủ, 3 phòng tắm, phòng khách rộng rãi và khu bếp mở. '
'Ưu tiên sử dụng gạch men màu sáng để tạo cảm giác thoáng đãng. '
'Tầng 1: garage, phòng khách, bếp. '
'Tầng 2: 2 phòng ngủ, 2 phòng tắm. '
'Tầng 3: phòng ngủ master, phòng làm việc, sân thượng.',
'contact': 'SĐT: 0901234567 | Email: minh.nguyen@email.com',
'createdDate': '20/10/2024',
'files': ['mat-bang-hien-tai.jpg', 'ban-ve-kien-truc.dwg'],
'designLink': 'https://example.com/3d-design/YC001',
'timeline': [
{
'title': 'Thiết kế hoàn thành',
'description': 'File thiết kế 3D đã được gửi đến khách hàng',
'date': '25/10/2024 - 14:30',
'status': DesignRequestStatus.completed,
},
{
'title': 'Bắt đầu thiết kế',
'description': 'KTS Nguyễn Văn An đã nhận và bắt đầu thiết kế',
'date': '22/10/2024 - 09:00',
'status': DesignRequestStatus.designing,
},
{
'title': 'Tiếp nhận yêu cầu',
'description': 'Yêu cầu thiết kế đã được tiếp nhận và xem xét',
'date': '20/10/2024 - 16:45',
'status': DesignRequestStatus.pending,
},
{
'title': 'Gửi yêu cầu',
'description': 'Yêu cầu thiết kế đã được gửi thành công',
'date': '20/10/2024 - 16:30',
'status': DesignRequestStatus.pending,
},
],
},
'YC002': {
'id': 'YC002',
'name': 'Cải tạo căn hộ chung cư - Chị Lan (Quận 2)',
'area': '85m²',
'style': 'Scandinavian',
'budget': '100-300 triệu',
'status': DesignRequestStatus.designing,
'statusText': 'Đang thiết kế',
'description':
'Cải tạo căn hộ chung cư 3PN theo phong cách Scandinavian. '
'Tối ưu không gian lưu trữ, sử dụng gạch men màu sáng và gỗ tự nhiên.',
'contact': 'SĐT: 0987654321',
'createdDate': '25/10/2024',
'files': ['hinh-anh-hien-trang.jpg'],
'designLink': null,
'timeline': [
{
'title': 'Bắt đầu thiết kế',
'description': 'KTS đã nhận và đang tiến hành thiết kế',
'date': '26/10/2024 - 10:00',
'status': DesignRequestStatus.designing,
},
{
'title': 'Tiếp nhận yêu cầu',
'description': 'Yêu cầu thiết kế đã được tiếp nhận',
'date': '25/10/2024 - 14:30',
'status': DesignRequestStatus.pending,
},
{
'title': 'Gửi yêu cầu',
'description': 'Yêu cầu thiết kế đã được gửi thành công',
'date': '25/10/2024 - 14:15',
'status': DesignRequestStatus.pending,
},
],
},
'YC003': {
'id': 'YC003',
'name': 'Thiết kế biệt thự 2 tầng - Anh Đức (Bình Dương)',
'area': '200m²',
'style': 'Luxury',
'budget': 'Trên 1 tỷ',
'status': DesignRequestStatus.pending,
'statusText': 'Chờ tiếp nhận',
'description':
'Thiết kế biệt thự 2 tầng phong cách luxury với hồ bơi và sân vườn. '
'5 phòng ngủ, 4 phòng tắm, phòng giải trí và garage 2 xe.',
'contact': 'SĐT: 0923456789 | Email: duc.le@gmail.com',
'createdDate': '28/10/2024',
'files': ['mat-bang-dat.pdf', 'y-tuong-thiet-ke.jpg'],
'designLink': null,
'timeline': [
{
'title': 'Gửi yêu cầu',
'description': 'Yêu cầu thiết kế đã được gửi thành công',
'date': '28/10/2024 - 11:20',
'status': DesignRequestStatus.pending,
},
],
},
};
return mockData[requestId] ?? mockData['YC001']!;
}
Color _getStatusColor(DesignRequestStatus status) {
switch (status) {
case DesignRequestStatus.pending:
return const Color(0xFFffc107);
case DesignRequestStatus.designing:
return const Color(0xFF3730a3);
case DesignRequestStatus.completed:
return const Color(0xFF065f46);
}
}
Color _getStatusBackgroundColor(DesignRequestStatus status) {
switch (status) {
case DesignRequestStatus.pending:
return const Color(0xFFfef3c7);
case DesignRequestStatus.designing:
return const Color(0xFFe0e7ff);
case DesignRequestStatus.completed:
return const Color(0xFFd1fae5);
}
}
IconData _getTimelineIcon(DesignRequestStatus status, int index) {
if (status == DesignRequestStatus.completed) {
return Icons.check;
} else if (status == DesignRequestStatus.designing) {
return Icons.architecture;
} else {
return index == 0 ? Icons.send : Icons.access_time;
}
}
IconData _getFileIcon(String fileName) {
final extension = fileName.split('.').last.toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif'].contains(extension)) {
return Icons.image;
} else if (extension == 'pdf') {
return Icons.picture_as_pdf;
} else if (extension == 'dwg') {
return Icons.architecture;
} else if (['doc', 'docx'].contains(extension)) {
return Icons.description;
}
return Icons.insert_drive_file;
}
Future<void> _viewDesign3D(BuildContext context, String? designLink) async {
if (designLink != null) {
final uri = Uri.parse(designLink);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Không thể mở link thiết kế 3D'),
backgroundColor: AppColors.danger,
),
);
}
}
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Link thiết kế 3D chưa có sẵn'),
backgroundColor: AppColors.warning,
),
);
}
}
}
void _editRequest(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Chức năng chỉnh sửa yêu cầu sẽ được triển khai trong phiên bản tiếp theo',
),
),
);
}
void _contactSupport(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Liên hệ hỗ trợ'),
content: const Text(
'Bạn có muốn liên hệ hỗ trợ về yêu cầu thiết kế này?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Hủy'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
context.push(RouteNames.chat);
},
child: const Text('Liên hệ'),
),
],
),
);
}
Future<void> _shareRequest(BuildContext context, String requestId, String name) async {
try {
await Share.share(
'Yêu cầu thiết kế #$requestId\n$name',
subject: 'Chia sẻ yêu cầu thiết kế',
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi khi chia sẻ: $e'),
backgroundColor: AppColors.danger,
),
);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final request = _getRequestData();
final status = request['status'] as DesignRequestStatus;
final timeline = request['timeline'] as List<Map<String, dynamic>>;
final files = request['files'] as List<String>;
return Scaffold(
backgroundColor: AppColors.grey50,
appBar: AppBar(
backgroundColor: AppColors.white,
elevation: AppBarSpecs.elevation,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => Navigator.of(context).pop(),
),
centerTitle: false,
title: const Text(
'Chi tiết Yêu cầu',
style: TextStyle(
color: Colors.black,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
actions: [
IconButton(
icon: const Icon(Icons.share, color: Colors.black),
onPressed: () => _shareRequest(
context,
request['id'] as String,
request['name'] as String,
),
),
const SizedBox(width: AppSpacing.sm),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
children: [
// Request Header Card
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Request ID and Date
Text(
'#${request['id']}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
const SizedBox(height: 8),
Text(
'Ngày gửi: ${request['createdDate']}',
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(status),
borderRadius: BorderRadius.circular(20),
),
child: Text(
request['statusText'] as String,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _getStatusColor(status),
),
),
),
const SizedBox(height: 24),
// Info Grid
_InfoGrid(
area: request['area'] as String,
style: request['style'] as String,
budget: request['budget'] as String,
statusText: request['statusText'] as String,
),
],
),
),
),
const SizedBox(height: 20),
// Completion Highlight (only if completed)
if (status == DesignRequestStatus.completed)
Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFd1fae5), Color(0xFFa7f3d0)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
border: Border.all(color: const Color(0xFF10b981), width: 2),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(20),
child: Column(
children: [
const Text(
'🎉 Thiết kế đã hoàn thành!',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(0xFF065f46),
),
),
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,
request['designLink'] as String?,
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10b981),
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(Icons.view_in_ar, color: Colors.white),
label: const Text(
'Xem Link Thiết kế 3D',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
],
),
),
if (status == DesignRequestStatus.completed)
const SizedBox(height: 20),
// Project Details Card
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Project Name
_SectionHeader(
icon: Icons.info,
title: 'Thông tin dự án',
),
const SizedBox(height: 12),
RichText(
text: TextSpan(
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
height: 1.6,
),
children: [
const TextSpan(
text: 'Tên dự án: ',
style: TextStyle(fontWeight: FontWeight.w600),
),
TextSpan(text: request['name'] as String),
],
),
),
const SizedBox(height: 24),
// Description
_SectionHeader(
icon: Icons.edit,
title: 'Mô tả yêu cầu',
),
const SizedBox(height: 12),
Text(
request['description'] as String,
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
height: 1.6,
),
),
const SizedBox(height: 24),
// Contact Info
_SectionHeader(
icon: Icons.phone,
title: 'Thông tin liên hệ',
),
const SizedBox(height: 12),
Text(
request['contact'] as String,
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
height: 1.6,
),
),
const SizedBox(height: 24),
// Files
_SectionHeader(
icon: Icons.attach_file,
title: 'Tài liệu đính kèm',
),
const SizedBox(height: 16),
if (files.isEmpty)
const Text(
'Không có tài liệu đính kèm',
style: TextStyle(
color: AppColors.grey500,
fontStyle: FontStyle.italic,
),
)
else
...files.map(
(file) => _FileItem(
fileName: file,
icon: _getFileIcon(file),
),
),
],
),
),
),
const SizedBox(height: 20),
// Timeline Card
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader(
icon: Icons.history,
title: 'Lịch sử trạng thái',
),
const SizedBox(height: 16),
...List.generate(
timeline.length,
(index) {
final item = timeline[index];
return _TimelineItem(
title: item['title'] as String,
description: item['description'] as String,
date: item['date'] as String,
status: item['status'] as DesignRequestStatus,
icon: _getTimelineIcon(
item['status'] as DesignRequestStatus,
timeline.length - index - 1,
),
isLast: index == timeline.length - 1,
getStatusColor: _getStatusColor,
getStatusBackgroundColor: _getStatusBackgroundColor,
);
},
),
],
),
),
),
const SizedBox(height: 20),
// Action Buttons
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _editRequest(context),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.grey900,
side: const BorderSide(color: AppColors.grey100, width: 2),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(Icons.edit),
label: const Text(
'Chỉnh sửa',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () => _contactSupport(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(Icons.chat_bubble),
label: const Text(
'Liên hệ',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
),
],
),
const SizedBox(height: 20),
],
),
),
);
}
}
/// Info Grid Widget
class _InfoGrid extends StatelessWidget {
const _InfoGrid({
required this.area,
required this.style,
required this.budget,
required this.statusText,
});
final String area;
final String style;
final String budget;
final String statusText;
@override
Widget build(BuildContext context) {
return Column(
children: [
Row(
children: [
Expanded(
child: _InfoItem(label: 'Diện tích', value: area),
),
const SizedBox(width: 16),
Expanded(
child: _InfoItem(label: 'Phong cách', value: style),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _InfoItem(label: 'Ngân sách', value: budget),
),
const SizedBox(width: 16),
Expanded(
child: _InfoItem(label: 'Trạng thái', value: statusText),
),
],
),
],
);
}
}
/// Info Item Widget
class _InfoItem extends StatelessWidget {
const _InfoItem({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
decoration: BoxDecoration(
color: AppColors.grey50,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppColors.grey500,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
textAlign: TextAlign.center,
),
],
),
);
}
}
/// Section Header Widget
class _SectionHeader extends StatelessWidget {
const _SectionHeader({
required this.icon,
required this.title,
});
final IconData icon;
final String title;
@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,
),
),
],
);
}
}
/// File Item Widget
class _FileItem extends StatelessWidget {
const _FileItem({
required this.fileName,
required this.icon,
});
final String fileName;
final IconData icon;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.grey50,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: AppColors.primaryBlue,
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, color: Colors.white, size: 16),
),
const SizedBox(width: 12),
Expanded(
child: Text(
fileName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
),
],
),
);
}
}
/// Timeline Item Widget
class _TimelineItem extends StatelessWidget {
const _TimelineItem({
required this.title,
required this.description,
required this.date,
required this.status,
required this.icon,
required this.isLast,
required this.getStatusColor,
required this.getStatusBackgroundColor,
});
final String title;
final String description;
final String date;
final DesignRequestStatus status;
final IconData icon;
final bool isLast;
final Color Function(DesignRequestStatus) getStatusColor;
final Color Function(DesignRequestStatus) getStatusBackgroundColor;
@override
Widget build(BuildContext context) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Icon and line
Column(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: getStatusBackgroundColor(status),
shape: BoxShape.circle,
),
child: Icon(
icon,
color: getStatusColor(status),
size: 20,
),
),
if (!isLast)
Expanded(
child: Container(
width: 2,
margin: const EdgeInsets.symmetric(vertical: 4),
color: AppColors.grey100,
),
),
],
),
const SizedBox(width: 16),
// Content
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
const SizedBox(height: 4),
Text(
description,
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
),
const SizedBox(height: 4),
Text(
date,
style: const TextStyle(
fontSize: 12,
color: AppColors.grey500,
),
),
],
),
),
),
],
),
);
}
}

View File

@@ -6,7 +6,9 @@ library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart';
/// Model Houses Page
@@ -73,11 +75,7 @@ class _ModelHousesPageState extends ConsumerState<ModelHousesPage>
}
void _createNewRequest() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Chức năng tạo yêu cầu thiết kế sẽ được triển khai trong phiên bản tiếp theo'),
),
);
context.push(RouteNames.designRequestCreate);
}
@override
@@ -446,11 +444,7 @@ class _RequestCard extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 16),
child: InkWell(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Chức năng xem chi tiết yêu cầu sẽ được triển khai trong phiên bản tiếp theo'),
),
);
context.push('/model-houses/design-request/${code.replaceAll('#', '')}');
},
borderRadius: BorderRadius.circular(12),
child: Padding(

View File

@@ -377,6 +377,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: "825aec673606875c33cd8d3c4083f1a3c3999015a84178b317b7ef396b7384f3"
url: "https://pub.dev"
source: hosted
version: "8.0.7"
file_selector_linux:
dependency: transitive
description:
@@ -1353,6 +1361,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
url: "https://pub.dev"
source: hosted
version: "6.3.24"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9"
url: "https://pub.dev"
source: hosted
version: "6.3.5"
url_launcher_linux:
dependency: transitive
description:
@@ -1361,6 +1393,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9"
url: "https://pub.dev"
source: hosted
version: "3.2.4"
url_launcher_platform_interface:
dependency: transitive
description:

View File

@@ -65,6 +65,8 @@ dependencies:
intl: ^0.20.0
share_plus: ^9.0.0
image_picker: ^1.1.2
file_picker: ^8.0.0
url_launcher: ^6.3.0
path_provider: ^2.1.3
shared_preferences: ^2.2.3