From 9e7bda32f20f84caa21b4afdda660278e365814c Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Fri, 28 Nov 2025 15:47:51 +0700 Subject: [PATCH] request detail --- html/nha-mau.html | 16 +- lib/core/constants/api_constants.dart | 18 + .../design_request_remote_datasource.dart | 96 ++ .../data/models/design_request_model.dart | 103 ++ .../data/models/sample_project_model.dart | 24 +- .../design_request_repository_impl.dart | 41 + .../domain/entities/design_request.dart | 118 ++ .../domain/entities/sample_project.dart | 14 +- .../design_request_repository.dart | 26 + .../pages/design_request_detail_page.dart | 1062 +++++++---------- .../pages/model_house_detail_page.dart | 4 +- .../presentation/pages/model_houses_page.dart | 192 +-- .../providers/design_request_provider.dart | 58 + .../providers/design_request_provider.g.dart | 266 +++++ 14 files changed, 1320 insertions(+), 718 deletions(-) create mode 100644 lib/features/showrooms/data/datasources/design_request_remote_datasource.dart create mode 100644 lib/features/showrooms/data/models/design_request_model.dart create mode 100644 lib/features/showrooms/data/repositories/design_request_repository_impl.dart create mode 100644 lib/features/showrooms/domain/entities/design_request.dart create mode 100644 lib/features/showrooms/domain/repositories/design_request_repository.dart create mode 100644 lib/features/showrooms/presentation/providers/design_request_provider.dart create mode 100644 lib/features/showrooms/presentation/providers/design_request_provider.g.dart diff --git a/html/nha-mau.html b/html/nha-mau.html index 5ed3618..5803370 100644 --- a/html/nha-mau.html +++ b/html/nha-mau.html @@ -316,13 +316,13 @@

Căn hộ Studio

-
+
@@ -336,13 +336,13 @@

Biệt thự Hiện đại

-
+
@@ -356,13 +356,13 @@

Nhà phố Tối giản

-
+
@@ -376,13 +376,13 @@

Chung cư Cao cấp

-
+
diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index 5d90e09..eca9722 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -338,6 +338,24 @@ class ApiConstants { static const String getSampleProjectDetail = '/building_material.building_material.api.sample_project.get_detail'; + // ============================================================================ + // Design Request Endpoints (Frappe ERPNext) + // ============================================================================ + + /// Get list of design requests (requires sid and csrf_token) + /// POST /api/method/building_material.building_material.api.design_request.get_list + /// Body: { "limit_start": 0, "limit_page_length": 0 } + /// Returns: { "message": [{ "name": "...", "subject": "...", "description": "...", "dateline": "...", "status": "...", "status_color": "..." }] } + static const String getDesignRequestList = + '/building_material.building_material.api.design_request.get_list'; + + /// Get detail of a design request (requires sid and csrf_token) + /// POST /api/method/building_material.building_material.api.design_request.get_detail + /// Body: { "name": "ISS-2025-00005" } + /// Returns: { "message": { "name": "...", "subject": "...", "description": "...", "dateline": "...", "status": "...", "status_color": "...", "files_list": [...] } } + static const String getDesignRequestDetail = + '/building_material.building_material.api.design_request.get_detail'; + /// Create new project (legacy endpoint - may be deprecated) /// POST /projects static const String createProject = '/projects'; diff --git a/lib/features/showrooms/data/datasources/design_request_remote_datasource.dart b/lib/features/showrooms/data/datasources/design_request_remote_datasource.dart new file mode 100644 index 0000000..de14bc7 --- /dev/null +++ b/lib/features/showrooms/data/datasources/design_request_remote_datasource.dart @@ -0,0 +1,96 @@ +/// Design Request Remote Data Source +/// +/// Handles remote API calls for design requests. +library; + +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'; + +/// Design Request Remote Data Source Interface +abstract class DesignRequestRemoteDataSource { + /// Fetch list of design requests from API + Future> getDesignRequests({ + int limitStart = 0, + int limitPageLength = 0, + }); + + /// Fetch detail of a design request by name + Future getDesignRequestDetail(String name); +} + +/// Design Request Remote Data Source Implementation +class DesignRequestRemoteDataSourceImpl implements DesignRequestRemoteDataSource { + const DesignRequestRemoteDataSourceImpl(this._dioClient); + + final DioClient _dioClient; + + /// Get list of design requests + /// + /// Calls: POST /api/method/building_material.building_material.api.design_request.get_list + /// Body: { "limit_start": 0, "limit_page_length": 0 } + /// Returns: List of design requests + @override + Future> getDesignRequests({ + int limitStart = 0, + int limitPageLength = 0, + }) async { + try { + final response = await _dioClient.post>( + '${ApiConstants.frappeApiMethod}${ApiConstants.getDesignRequestList}', + data: { + 'limit_start': limitStart, + 'limit_page_length': limitPageLength, + }, + ); + + final data = response.data; + if (data == null) { + throw Exception('No data received from getDesignRequestList API'); + } + + // API returns: { "message": [...] } + final message = data['message']; + if (message == null) { + throw Exception('No message field in getDesignRequestList response'); + } + + final List requestsList = message as List; + return requestsList + .map((json) => DesignRequestModel.fromJson(json as Map)) + .toList(); + } catch (e) { + throw Exception('Failed to get design requests: $e'); + } + } + + /// Get detail of a design request by name + /// + /// Calls: POST /api/method/building_material.building_material.api.design_request.get_detail + /// Body: { "name": "ISS-2025-00005" } + /// Returns: Full design request detail with files_list + @override + Future getDesignRequestDetail(String name) async { + try { + final response = await _dioClient.post>( + '${ApiConstants.frappeApiMethod}${ApiConstants.getDesignRequestDetail}', + data: {'name': name}, + ); + + final data = response.data; + if (data == null) { + throw Exception('No data received from getDesignRequestDetail API'); + } + + // API returns: { "message": {...} } + final message = data['message']; + if (message == null) { + throw Exception('No message field in getDesignRequestDetail response'); + } + + return DesignRequestModel.fromJson(message as Map); + } catch (e) { + throw Exception('Failed to get design request detail: $e'); + } + } +} diff --git a/lib/features/showrooms/data/models/design_request_model.dart b/lib/features/showrooms/data/models/design_request_model.dart new file mode 100644 index 0000000..e615eff --- /dev/null +++ b/lib/features/showrooms/data/models/design_request_model.dart @@ -0,0 +1,103 @@ +/// Data Model: Design Request Model +/// +/// JSON serialization model for design request API responses. +library; + +import 'package:worker/features/showrooms/data/models/sample_project_model.dart'; +import 'package:worker/features/showrooms/domain/entities/design_request.dart'; + +/// Design Request Model +/// +/// Handles JSON serialization/deserialization for API communication. +class DesignRequestModel { + /// Unique request identifier (API: name) + final String name; + + /// Request subject/title (API: subject) + final String subject; + + /// Request description - may contain HTML (API: description) + final String? description; + + /// Deadline date string (API: dateline) + final String? dateline; + + /// Status display text (API: status) + final String status; + + /// Status color code (API: status_color) + final String statusColor; + + /// List of attached files (API: files_list) - available in detail + final List filesList; + + const DesignRequestModel({ + required this.name, + required this.subject, + this.description, + this.dateline, + required this.status, + required this.statusColor, + this.filesList = const [], + }); + + /// Create model from JSON map + factory DesignRequestModel.fromJson(Map json) { + final filesListJson = json['files_list'] as List?; + + return DesignRequestModel( + name: json['name'] as String? ?? '', + subject: json['subject'] as String? ?? '', + description: json['description'] as String?, + dateline: json['dateline'] as String?, + status: json['status'] as String? ?? '', + statusColor: json['status_color'] as String? ?? '', + filesList: filesListJson != null + ? filesListJson + .map((f) => ProjectFileModel.fromJson(f as Map)) + .toList() + : [], + ); + } + + /// Convert model to JSON map + Map toJson() { + return { + 'name': name, + 'subject': subject, + 'description': description, + 'dateline': dateline, + 'status': status, + 'status_color': statusColor, + 'files_list': filesList.map((f) => f.toJson()).toList(), + }; + } + + /// Convert to domain entity + DesignRequest toEntity() { + return DesignRequest( + id: name, + subject: subject, + description: description, + dateline: dateline, + statusText: status, + statusColor: statusColor, + filesList: filesList.map((f) => f.toEntity()).toList(), + ); + } + + /// Create model from domain entity + factory DesignRequestModel.fromEntity(DesignRequest entity) { + return DesignRequestModel( + name: entity.id, + subject: entity.subject, + description: entity.description, + dateline: entity.dateline, + status: entity.statusText, + statusColor: entity.statusColor, + filesList: entity.filesList + .map((f) => ProjectFileModel(name: f.id, fileUrl: f.fileUrl)) + .toList(), + ); + } +} diff --git a/lib/features/showrooms/data/models/sample_project_model.dart b/lib/features/showrooms/data/models/sample_project_model.dart index 6c6c3b0..0c5e462 100644 --- a/lib/features/showrooms/data/models/sample_project_model.dart +++ b/lib/features/showrooms/data/models/sample_project_model.dart @@ -5,24 +5,26 @@ library; import 'package:worker/features/showrooms/domain/entities/sample_project.dart'; -/// Sample Project File Model +/// Project File Model /// -/// Handles JSON serialization for file attachments. -class SampleProjectFileModel { +/// Shared model for file attachments used by: +/// - SampleProjectModel (model houses) +/// - DesignRequestModel (design requests) +class ProjectFileModel { /// Unique file identifier (API: name) final String name; /// Full URL to the file (API: file_url) final String fileUrl; - const SampleProjectFileModel({ + const ProjectFileModel({ required this.name, required this.fileUrl, }); /// Create model from JSON map - factory SampleProjectFileModel.fromJson(Map json) { - return SampleProjectFileModel( + factory ProjectFileModel.fromJson(Map json) { + return ProjectFileModel( name: json['name'] as String? ?? '', fileUrl: json['file_url'] as String? ?? '', ); @@ -37,8 +39,8 @@ class SampleProjectFileModel { } /// Convert to domain entity - SampleProjectFile toEntity() { - return SampleProjectFile( + ProjectFile toEntity() { + return ProjectFile( id: name, fileUrl: fileUrl, ); @@ -65,7 +67,7 @@ class SampleProjectModel { final String? thumbnail; /// List of attached files/images (API: files_list) - available in detail - final List filesList; + final List filesList; const SampleProjectModel({ required this.name, @@ -88,7 +90,7 @@ class SampleProjectModel { thumbnail: json['thumbnail'] as String?, filesList: filesListJson != null ? filesListJson - .map((f) => SampleProjectFileModel.fromJson(f as Map)) + .map((f) => ProjectFileModel.fromJson(f as Map)) .toList() : [], ); @@ -127,7 +129,7 @@ class SampleProjectModel { link: entity.viewUrl, thumbnail: entity.thumbnailUrl, filesList: entity.filesList - .map((f) => SampleProjectFileModel(name: f.id, fileUrl: f.fileUrl)) + .map((f) => ProjectFileModel(name: f.id, fileUrl: f.fileUrl)) .toList(), ); } diff --git a/lib/features/showrooms/data/repositories/design_request_repository_impl.dart b/lib/features/showrooms/data/repositories/design_request_repository_impl.dart new file mode 100644 index 0000000..e59274c --- /dev/null +++ b/lib/features/showrooms/data/repositories/design_request_repository_impl.dart @@ -0,0 +1,41 @@ +/// Design Request Repository Implementation +/// +/// Implements the design request repository interface. +library; + +import 'package:worker/features/showrooms/data/datasources/design_request_remote_datasource.dart'; +import 'package:worker/features/showrooms/domain/entities/design_request.dart'; +import 'package:worker/features/showrooms/domain/repositories/design_request_repository.dart'; + +/// Design Request Repository Implementation +class DesignRequestRepositoryImpl implements DesignRequestRepository { + const DesignRequestRepositoryImpl(this._remoteDataSource); + + final DesignRequestRemoteDataSource _remoteDataSource; + + @override + Future> getDesignRequests({ + int limitStart = 0, + int limitPageLength = 0, + }) async { + try { + final models = await _remoteDataSource.getDesignRequests( + limitStart: limitStart, + limitPageLength: limitPageLength, + ); + return models.map((model) => model.toEntity()).toList(); + } catch (e) { + rethrow; + } + } + + @override + Future getDesignRequestDetail(String name) async { + try { + final model = await _remoteDataSource.getDesignRequestDetail(name); + return model.toEntity(); + } catch (e) { + rethrow; + } + } +} diff --git a/lib/features/showrooms/domain/entities/design_request.dart b/lib/features/showrooms/domain/entities/design_request.dart new file mode 100644 index 0000000..875e2dc --- /dev/null +++ b/lib/features/showrooms/domain/entities/design_request.dart @@ -0,0 +1,118 @@ +/// Domain Entity: Design Request +/// +/// Represents a design request/ticket submitted by user. +/// Based on API response from building_material.building_material.api.design_request +library; + +import 'package:equatable/equatable.dart'; +import 'package:worker/features/showrooms/domain/entities/sample_project.dart'; + +/// Design Request Status +/// +/// Maps from API status_color field: +/// - "Success" -> completed +/// - "Warning" -> pending +/// - "Danger" -> rejected +/// - Other -> designing +enum DesignRequestStatus { + pending, + designing, + completed, + rejected, +} + +/// Design Request Entity +/// +/// Contains information about a design request ticket. +/// API field mapping: +/// - name -> id +/// - subject -> subject +/// - description -> description (HTML content) +/// - dateline -> dateline +/// - status -> statusText +/// - status_color -> statusColor (mapped to enum) +/// - files_list -> filesList (detail only) +class DesignRequest extends Equatable { + /// Unique request identifier (API: name) + final String id; + + /// Request subject/title (API: subject) + final String subject; + + /// Request description - may contain HTML (API: description) + final String? description; + + /// Deadline date string (API: dateline) + final String? dateline; + + /// Status display text (API: status) + final String statusText; + + /// Status color code (API: status_color) + final String statusColor; + + /// List of attached files (API: files_list) - available in detail + final List filesList; + + const DesignRequest({ + required this.id, + required this.subject, + this.description, + this.dateline, + required this.statusText, + required this.statusColor, + this.filesList = const [], + }); + + /// Get status enum from statusColor + DesignRequestStatus get status { + switch (statusColor.toLowerCase()) { + case 'success': + return DesignRequestStatus.completed; + case 'warning': + return DesignRequestStatus.pending; + case 'danger': + return DesignRequestStatus.rejected; + default: + return DesignRequestStatus.designing; + } + } + + /// Check if request is completed + bool get isCompleted => status == DesignRequestStatus.completed; + + /// Check if request is pending + bool get isPending => status == DesignRequestStatus.pending; + + /// Check if request is rejected + bool get isRejected => status == DesignRequestStatus.rejected; + + /// Get plain text description (strips HTML tags) + String get plainDescription { + if (description == null) return ''; + // Simple HTML tag removal + return description! + .replaceAll(RegExp(r'<[^>]*>'), '') + .replaceAll(' ', ' ') + .trim(); + } + + /// Get all file URLs + List get fileUrls => filesList.map((f) => f.fileUrl).toList(); + + @override + List get props => [ + id, + subject, + description, + dateline, + statusText, + statusColor, + filesList, + ]; + + @override + String toString() { + return 'DesignRequest(id: $id, subject: $subject, status: $statusText, filesCount: ${filesList.length})'; + } +} diff --git a/lib/features/showrooms/domain/entities/sample_project.dart b/lib/features/showrooms/domain/entities/sample_project.dart index ef7622f..79020c6 100644 --- a/lib/features/showrooms/domain/entities/sample_project.dart +++ b/lib/features/showrooms/domain/entities/sample_project.dart @@ -8,15 +8,21 @@ import 'package:equatable/equatable.dart'; /// Project File Entity /// -/// Represents an uploaded file attached to a sample project. -class SampleProjectFile extends Equatable { +/// Shared entity for file attachments used by: +/// - SampleProject (model houses) +/// - DesignRequest (design requests) +/// +/// API field mapping: +/// - name -> id +/// - file_url -> fileUrl +class ProjectFile extends Equatable { /// Unique file identifier (API: name) final String id; /// Full URL to the file (API: file_url) final String fileUrl; - const SampleProjectFile({ + const ProjectFile({ required this.id, required this.fileUrl, }); @@ -52,7 +58,7 @@ class SampleProject extends Equatable { final String? thumbnailUrl; /// List of attached files/images (API: files_list) - available in detail - final List filesList; + final List filesList; const SampleProject({ required this.id, diff --git a/lib/features/showrooms/domain/repositories/design_request_repository.dart b/lib/features/showrooms/domain/repositories/design_request_repository.dart new file mode 100644 index 0000000..88e17ec --- /dev/null +++ b/lib/features/showrooms/domain/repositories/design_request_repository.dart @@ -0,0 +1,26 @@ +/// Design Request Repository Interface +/// +/// Defines contract for design request data operations. +library; + +import 'package:worker/features/showrooms/domain/entities/design_request.dart'; + +/// Design Request Repository +/// +/// Repository interface for design request operations. +abstract class DesignRequestRepository { + /// Get list of design requests + /// + /// Returns list of design requests. + /// [limitStart] - Pagination offset + /// [limitPageLength] - Number of items per page (0 = all) + Future> getDesignRequests({ + int limitStart = 0, + int limitPageLength = 0, + }); + + /// Get detail of a design request by name + /// + /// Returns full design request detail with files_list. + Future getDesignRequestDetail(String name); +} diff --git a/lib/features/showrooms/presentation/pages/design_request_detail_page.dart b/lib/features/showrooms/presentation/pages/design_request_detail_page.dart index d60a314..5085ba2 100644 --- a/lib/features/showrooms/presentation/pages/design_request_detail_page.dart +++ b/lib/features/showrooms/presentation/pages/design_request_detail_page.dart @@ -1,145 +1,31 @@ /// Page: Design Request Detail Page /// -/// Displays design request details following html/design-request-detail.html. +/// Displays design request details from API. 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: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'; +import 'package:worker/features/showrooms/domain/entities/design_request.dart'; +import 'package:worker/features/showrooms/domain/entities/sample_project.dart'; +import 'package:worker/features/showrooms/presentation/providers/design_request_provider.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) +/// - Subject and description +/// - Attached files/images 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 _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: @@ -148,6 +34,8 @@ class DesignRequestDetailPage extends ConsumerWidget { return const Color(0xFF3730a3); case DesignRequestStatus.completed: return const Color(0xFF065f46); + case DesignRequestStatus.rejected: + return const Color(0xFFdc2626); } } @@ -159,23 +47,15 @@ class DesignRequestDetailPage extends ConsumerWidget { return const Color(0xFFe0e7ff); case DesignRequestStatus.completed: return const Color(0xFFd1fae5); + case DesignRequestStatus.rejected: + return const Color(0xFFfee2e2); } } - 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 fileUrl) { + final extension = fileUrl.split('.').last.toLowerCase(); - IconData _getFileIcon(String fileName) { - final extension = fileName.split('.').last.toLowerCase(); - - if (['jpg', 'jpeg', 'png', 'gif'].contains(extension)) { + if (['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(extension)) { return Icons.image; } else if (extension == 'pdf') { return Icons.picture_as_pdf; @@ -187,41 +67,9 @@ class DesignRequestDetailPage extends ConsumerWidget { return Icons.insert_drive_file; } - Future _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', - ), - ), - ); + bool _isImageFile(String fileUrl) { + final extension = fileUrl.split('.').last.toLowerCase(); + return ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(extension); } void _contactSupport(BuildContext context) { @@ -251,13 +99,11 @@ class DesignRequestDetailPage extends ConsumerWidget { Future _shareRequest( BuildContext context, - String requestId, - String name, + DesignRequest request, ) 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ế', + await SharePlus.instance.share( + ShareParams(text: 'Yêu cầu thiết kế #${request.id}\n${request.subject}'), ); } catch (e) { if (context.mounted) { @@ -271,12 +117,28 @@ class DesignRequestDetailPage extends ConsumerWidget { } } + void _showImageViewer( + BuildContext context, + List files, + int initialIndex, + ) { + // Filter only image files + final images = files.where((f) => _isImageFile(f.fileUrl)).toList(); + if (images.isEmpty) return; + + showDialog( + context: context, + barrierColor: Colors.black87, + builder: (context) => _ImageViewerDialog( + images: images, + initialIndex: initialIndex, + ), + ); + } + @override Widget build(BuildContext context, WidgetRef ref) { - final request = _getRequestData(); - final status = request['status'] as DesignRequestStatus; - final timeline = request['timeline'] as List>; - final files = request['files'] as List; + final detailAsync = ref.watch(designRequestDetailProvider(requestId)); return Scaffold( backgroundColor: AppColors.grey50, @@ -297,418 +159,346 @@ class DesignRequestDetailPage extends ConsumerWidget { ), ), actions: [ - IconButton( - icon: const Icon(Icons.share, color: Colors.black), - onPressed: () => _shareRequest( - context, - request['id'] as String, - request['name'] as String, + detailAsync.maybeWhen( + data: (request) => IconButton( + icon: const Icon(Icons.share, color: Colors.black), + onPressed: () => _shareRequest(context, request), ), + orElse: () => const SizedBox.shrink(), ), 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), + body: detailAsync.when( + data: (request) => SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Request Header Card + Card( + elevation: 2, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( 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), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // Request ID + Text( + '#${request.id}', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + color: AppColors.grey900, + ), ), - ), - 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), + const SizedBox(height: 8), + if (request.dateline != null) + Text( + 'Deadline: ${request.dateline}', + style: const TextStyle( + fontSize: 14, + color: AppColors.grey500, + ), + ), + const SizedBox(height: 16), + + // Status Badge + Container( padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, + horizontal: 16, + vertical: 8, ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(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), + ), ), ), - 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( + // Completion Highlight (only if completed) + if (request.isCompleted) + Card( + elevation: 2, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: Color(0xFF10b981), width: 2), + ), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFd1fae5), Color(0xFFa7f3d0)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(24), + child: const Column( + children: [ + Text( + '🎉 Yêu cầu đã 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', + style: TextStyle(color: Color(0xFF065f46)), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + + if (request.isCompleted) const SizedBox(height: 20), + + // Rejected notice + if (request.isRejected) + Card( + elevation: 2, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: Color(0xFFef4444), width: 2), + ), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFfee2e2), Color(0xFFfecaca)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(24), + child: const Column( + children: [ + Text( + '❌ Yêu cầu bị từ chối', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Color(0xFFdc2626), + ), + ), + SizedBox(height: 12), + Text( + 'Vui lòng liên hệ hỗ trợ để biết thêm chi tiết', + style: TextStyle(color: Color(0xFFdc2626)), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + + 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: 14, - color: AppColors.grey500, + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.grey900, height: 1.6, ), - children: [ - const TextSpan( - text: 'Tên dự án: ', - style: TextStyle(fontWeight: FontWeight.w600), + ), + + 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, ), - 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)), - ), - ], + ], + + // 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})', + ), + const SizedBox(height: 16), + _buildFilesSection(context, request.filesList), + ], + ], + ), ), ), - ), - const SizedBox(height: 20), + 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', + // Action Button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _contactSupport(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), - 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, - ); - }), - ], + ), + icon: const Icon(Icons.chat_bubble), + label: const Text( + 'Liên hệ hỗ trợ', + style: TextStyle(fontWeight: FontWeight.w600), + ), ), ), - ), - const SizedBox(height: 20), - - // Action Buttons - Row( + const SizedBox(height: 20), + ], + ), + ), + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stack) => Center( + child: Padding( + padding: const EdgeInsets.all(40), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, 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 Icon( + Icons.error_outline, + size: 64, + color: AppColors.danger, + ), + const SizedBox(height: 16), + Text( + 'Lỗi tải dữ liệu: ${error.toString().replaceAll('Exception: ', '')}', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14, + color: AppColors.grey500, ), ), - 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: 16), + ElevatedButton( + onPressed: () => ref.invalidate(designRequestDetailProvider(requestId)), + child: const Text('Thử lại'), ), ], ), - - 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, - }); + Widget _buildFilesSection(BuildContext context, List files) { + // Separate images and other files + final images = files.where((f) => _isImageFile(f.fileUrl)).toList(); + final otherFiles = files.where((f) => !_isImageFile(f.fileUrl)).toList(); - final String area; - final String style; - final String budget; - final String statusText; - - @override - Widget build(BuildContext context) { return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Expanded( - child: _InfoItem(label: 'Diện tích', value: area), + // Image Gallery + if (images.isNotEmpty) ...[ + SizedBox( + height: 100, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: images.length, + itemBuilder: (context, index) { + final image = images[index]; + return Padding( + padding: const EdgeInsets.only(right: 12), + child: GestureDetector( + onTap: () => _showImageViewer(context, images, index), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 100, + height: 100, + child: CachedNetworkImage( + imageUrl: image.fileUrl, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: AppColors.grey100, + child: const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + errorWidget: (context, url, error) => Container( + color: AppColors.grey100, + child: const Icon(Icons.error), + ), + ), + ), + ), + ), + ); + }, ), - 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), - ), - ], + ), + if (otherFiles.isNotEmpty) const SizedBox(height: 16), + ], + + // Other Files + ...otherFiles.map( + (file) => _FileItem( + fileUrl: file.fileUrl, + icon: _getFileIcon(file.fileUrl), + ), ), ], ); } } -/// 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}); @@ -737,11 +527,16 @@ class _SectionHeader extends StatelessWidget { /// File Item Widget class _FileItem extends StatelessWidget { - const _FileItem({required this.fileName, required this.icon}); + const _FileItem({required this.fileUrl, required this.icon}); - final String fileName; + final String fileUrl; final IconData icon; + String get fileName { + final uri = Uri.parse(fileUrl); + return uri.pathSegments.isNotEmpty ? uri.pathSegments.last : fileUrl; + } + @override Widget build(BuildContext context) { return Container( @@ -771,6 +566,7 @@ class _FileItem extends StatelessWidget { fontWeight: FontWeight.w600, color: AppColors.grey900, ), + overflow: TextOverflow.ellipsis, ), ), ], @@ -779,94 +575,118 @@ class _FileItem extends StatelessWidget { } } -/// 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, +/// Image Viewer Dialog with Swipe Navigation +class _ImageViewerDialog extends StatefulWidget { + final List images; + final int initialIndex; + + const _ImageViewerDialog({ + required this.images, + required this.initialIndex, }); - 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 + State<_ImageViewerDialog> createState() => _ImageViewerDialogState(); +} + +class _ImageViewerDialogState extends State<_ImageViewerDialog> { + late PageController _pageController; + late int _currentIndex; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + _pageController = PageController(initialPage: widget.initialIndex); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } @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, + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: EdgeInsets.zero, + child: Container( + color: Colors.black, + child: Stack( + children: [ + // Main PageView + Center( + child: PageView.builder( + controller: _pageController, + onPageChanged: (index) { + setState(() { + _currentIndex = index; + }); + }, + itemCount: widget.images.length, + itemBuilder: (context, index) { + return Center( + child: CachedNetworkImage( + imageUrl: widget.images[index].fileUrl, + fit: BoxFit.contain, + placeholder: (context, url) => const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + errorWidget: (context, url, error) => const Icon( + Icons.error, + color: Colors.white, + size: 48, + ), ), - ), - 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, - ), - ), - ], + ); + }, ), ), - ), - ], + + // Top bar with counter and close button + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.7), + Colors.transparent, + ], + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${_currentIndex + 1} / ${widget.images.length}', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + ), + ), + ], + ), ), ); } diff --git a/lib/features/showrooms/presentation/pages/model_house_detail_page.dart b/lib/features/showrooms/presentation/pages/model_house_detail_page.dart index 05c2edb..f7be84a 100644 --- a/lib/features/showrooms/presentation/pages/model_house_detail_page.dart +++ b/lib/features/showrooms/presentation/pages/model_house_detail_page.dart @@ -442,7 +442,7 @@ class ModelHouseDetailPage extends ConsumerWidget { void _showImageViewer( BuildContext context, - List images, + List images, int initialIndex, ) { showDialog( @@ -465,7 +465,7 @@ class ModelHouseDetailPage extends ConsumerWidget { /// Image Viewer Dialog with Swipe Navigation class _ImageViewerDialog extends StatefulWidget { - final List images; + final List images; final int initialIndex; const _ImageViewerDialog({ diff --git a/lib/features/showrooms/presentation/pages/model_houses_page.dart b/lib/features/showrooms/presentation/pages/model_houses_page.dart index f01e0ce..4536d1a 100644 --- a/lib/features/showrooms/presentation/pages/model_houses_page.dart +++ b/lib/features/showrooms/presentation/pages/model_houses_page.dart @@ -10,7 +10,9 @@ 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'; +import 'package:worker/features/showrooms/domain/entities/design_request.dart'; import 'package:worker/features/showrooms/domain/entities/sample_project.dart'; +import 'package:worker/features/showrooms/presentation/providers/design_request_provider.dart'; import 'package:worker/features/showrooms/presentation/providers/sample_project_provider.dart'; /// Model Houses Page @@ -370,90 +372,118 @@ class _LibraryCard extends StatelessWidget { } /// Design Requests Tab -class _DesignRequestsTab extends StatelessWidget { +class _DesignRequestsTab extends ConsumerWidget { const _DesignRequestsTab(); @override - Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.all(20), - children: const [ - _RequestCard( - code: '#YC001', - status: DesignRequestStatus.completed, - date: '20/10/2024', - description: 'Thiết kế nhà phố 3 tầng - Anh Minh (Quận 7)', + Widget build(BuildContext context, WidgetRef ref) { + final requestsAsync = ref.watch(designRequestsListProvider); + + return requestsAsync.when( + data: (requests) { + if (requests.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(40), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.design_services_outlined, + size: 64, + color: AppColors.grey500, + ), + SizedBox(height: 16), + Text( + 'Chưa có yêu cầu thiết kế nào', + style: TextStyle( + fontSize: 16, + color: AppColors.grey500, + ), + ), + ], + ), + ), + ); + } + + return RefreshIndicator( + onRefresh: () => ref.read(designRequestsListProvider.notifier).refresh(), + child: ListView.builder( + padding: const EdgeInsets.all(20), + itemCount: requests.length, + itemBuilder: (context, index) { + final request = requests[index]; + return _RequestCard(request: request); + }, + ), + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stack) => Center( + child: Padding( + padding: const EdgeInsets.all(40), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 64, + color: AppColors.danger, + ), + const SizedBox(height: 16), + Text( + 'Lỗi tải dữ liệu: ${error.toString().replaceAll('Exception: ', '')}', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14, + color: AppColors.grey500, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => ref.invalidate(designRequestsListProvider), + child: const Text('Thử lại'), + ), + ], + ), ), - _RequestCard( - code: '#YC002', - status: DesignRequestStatus.designing, - date: '25/10/2024', - description: 'Cải tạo căn hộ chung cư - Chị Lan (Quận 2)', - ), - _RequestCard( - code: '#YC003', - status: DesignRequestStatus.pending, - date: '28/10/2024', - description: 'Thiết kế biệt thự 2 tầng - Anh Đức (Bình Dương)', - ), - _RequestCard( - code: '#YC004', - status: DesignRequestStatus.pending, - date: '01/11/2024', - description: 'Thiết kế cửa hàng kinh doanh - Chị Mai (Quận 1)', - ), - ], + ), ); } } -/// Design Request Status -enum DesignRequestStatus { pending, designing, completed } - /// Request Card Widget class _RequestCard extends StatelessWidget { - const _RequestCard({ - required this.code, - required this.status, - required this.date, - required this.description, - }); + const _RequestCard({required this.request}); - final String code; - final DesignRequestStatus status; - final String date; - final String description; + final DesignRequest request; Color _getStatusColor() { - switch (status) { + switch (request.status) { case DesignRequestStatus.pending: return const Color(0xFFffc107); // Warning yellow case DesignRequestStatus.designing: return const Color(0xFF3730a3); // Indigo case DesignRequestStatus.completed: return const Color(0xFF065f46); // Success green + case DesignRequestStatus.rejected: + return const Color(0xFFdc2626); // Danger red } } Color _getStatusBackgroundColor() { - switch (status) { + switch (request.status) { case DesignRequestStatus.pending: return const Color(0xFFfef3c7); // Light yellow case DesignRequestStatus.designing: return const Color(0xFFe0e7ff); // Light indigo case DesignRequestStatus.completed: return const Color(0xFFd1fae5); // Light green - } - } - - String _getStatusText() { - switch (status) { - case DesignRequestStatus.pending: - return 'CHỜ TIẾP NHẬN'; - case DesignRequestStatus.designing: - return 'ĐANG THIẾT KẾ'; - case DesignRequestStatus.completed: - return 'HOÀN THÀNH'; + case DesignRequestStatus.rejected: + return const Color(0xFFfee2e2); // Light red } } @@ -465,9 +495,7 @@ class _RequestCard extends StatelessWidget { margin: const EdgeInsets.only(bottom: 16), child: InkWell( onTap: () { - context.push( - '/model-houses/design-request/${code.replaceAll('#', '')}', - ); + context.push('/model-houses/design-request/${request.id}'); }, borderRadius: BorderRadius.circular(12), child: Padding( @@ -479,14 +507,18 @@ class _RequestCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - 'Mã yêu cầu: $code', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: AppColors.grey900, + Expanded( + child: Text( + 'Mã yêu cầu: #${request.id}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.grey900, + ), + overflow: TextOverflow.ellipsis, ), ), + const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( horizontal: 12, @@ -497,7 +529,7 @@ class _RequestCard extends StatelessWidget { borderRadius: BorderRadius.circular(20), ), child: Text( - _getStatusText(), + request.statusText.toUpperCase(), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, @@ -511,18 +543,34 @@ class _RequestCard extends StatelessWidget { const SizedBox(height: 8), // Date - Text( - 'Ngày gửi: $date', - style: const TextStyle(fontSize: 14, color: AppColors.grey500), - ), + if (request.dateline != null) + Text( + 'Deadline: ${request.dateline}', + style: const TextStyle(fontSize: 14, color: AppColors.grey500), + ), const SizedBox(height: 8), - // Description + // Subject Text( - description, - style: const TextStyle(fontSize: 14, color: AppColors.grey900), + request.subject, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), ), + + if (request.plainDescription.isNotEmpty) ...[ + const SizedBox(height: 4), + // Description + Text( + request.plainDescription, + style: const TextStyle(fontSize: 14, color: AppColors.grey500), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], ], ), ), diff --git a/lib/features/showrooms/presentation/providers/design_request_provider.dart b/lib/features/showrooms/presentation/providers/design_request_provider.dart new file mode 100644 index 0000000..86dcbaa --- /dev/null +++ b/lib/features/showrooms/presentation/providers/design_request_provider.dart @@ -0,0 +1,58 @@ +/// Providers: Design Request +/// +/// Riverpod providers for managing design request state. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/features/showrooms/data/datasources/design_request_remote_datasource.dart'; +import 'package:worker/features/showrooms/data/repositories/design_request_repository_impl.dart'; +import 'package:worker/features/showrooms/domain/entities/design_request.dart'; +import 'package:worker/features/showrooms/domain/repositories/design_request_repository.dart'; + +part 'design_request_provider.g.dart'; + +/// Design Request Remote Data Source Provider +@riverpod +Future designRequestRemoteDataSource(Ref ref) async { + final dioClient = await ref.watch(dioClientProvider.future); + return DesignRequestRemoteDataSourceImpl(dioClient); +} + +/// Design Request Repository Provider +@riverpod +Future designRequestRepository(Ref ref) async { + final remoteDataSource = await ref.watch(designRequestRemoteDataSourceProvider.future); + return DesignRequestRepositoryImpl(remoteDataSource); +} + +/// Design Requests List Provider +/// +/// Fetches and manages design requests from API. +@riverpod +class DesignRequestsList extends _$DesignRequestsList { + @override + Future> build() async { + final repository = await ref.watch(designRequestRepositoryProvider.future); + return repository.getDesignRequests(); + } + + /// Refresh design requests from remote + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = await ref.read(designRequestRepositoryProvider.future); + return repository.getDesignRequests(); + }); + } +} + +/// Design Request Detail Provider +/// +/// Fetches detail of a specific design request by name. +/// Uses family modifier to cache by request name. +@riverpod +Future designRequestDetail(Ref ref, String name) async { + final repository = await ref.watch(designRequestRepositoryProvider.future); + return repository.getDesignRequestDetail(name); +} diff --git a/lib/features/showrooms/presentation/providers/design_request_provider.g.dart b/lib/features/showrooms/presentation/providers/design_request_provider.g.dart new file mode 100644 index 0000000..e57076a --- /dev/null +++ b/lib/features/showrooms/presentation/providers/design_request_provider.g.dart @@ -0,0 +1,266 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'design_request_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Design Request Remote Data Source Provider + +@ProviderFor(designRequestRemoteDataSource) +const designRequestRemoteDataSourceProvider = + DesignRequestRemoteDataSourceProvider._(); + +/// Design Request Remote Data Source Provider + +final class DesignRequestRemoteDataSourceProvider + extends + $FunctionalProvider< + AsyncValue, + DesignRequestRemoteDataSource, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// Design Request Remote Data Source Provider + const DesignRequestRemoteDataSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'designRequestRemoteDataSourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$designRequestRemoteDataSourceHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return designRequestRemoteDataSource(ref); + } +} + +String _$designRequestRemoteDataSourceHash() => + r'fb33861da35c711e637f01b182e81263345980fa'; + +/// Design Request Repository Provider + +@ProviderFor(designRequestRepository) +const designRequestRepositoryProvider = DesignRequestRepositoryProvider._(); + +/// Design Request Repository Provider + +final class DesignRequestRepositoryProvider + extends + $FunctionalProvider< + AsyncValue, + DesignRequestRepository, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// Design Request Repository Provider + const DesignRequestRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'designRequestRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$designRequestRepositoryHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return designRequestRepository(ref); + } +} + +String _$designRequestRepositoryHash() => + r'c1f68c7c45d8148871882086d3727272c194934d'; + +/// Design Requests List Provider +/// +/// Fetches and manages design requests from API. + +@ProviderFor(DesignRequestsList) +const designRequestsListProvider = DesignRequestsListProvider._(); + +/// Design Requests List Provider +/// +/// Fetches and manages design requests from API. +final class DesignRequestsListProvider + extends $AsyncNotifierProvider> { + /// Design Requests List Provider + /// + /// Fetches and manages design requests from API. + const DesignRequestsListProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'designRequestsListProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$designRequestsListHash(); + + @$internal + @override + DesignRequestsList create() => DesignRequestsList(); +} + +String _$designRequestsListHash() => + r'368656997bd73619c7b27a3923066149a403bb5f'; + +/// Design Requests List Provider +/// +/// Fetches and manages design requests from API. + +abstract class _$DesignRequestsList + extends $AsyncNotifier> { + FutureOr> build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = + this.ref as $Ref>, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier>, List>, + AsyncValue>, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +/// Design Request Detail Provider +/// +/// Fetches detail of a specific design request by name. +/// Uses family modifier to cache by request name. + +@ProviderFor(designRequestDetail) +const designRequestDetailProvider = DesignRequestDetailFamily._(); + +/// Design Request Detail Provider +/// +/// Fetches detail of a specific design request by name. +/// Uses family modifier to cache by request name. + +final class DesignRequestDetailProvider + extends + $FunctionalProvider< + AsyncValue, + DesignRequest, + FutureOr + > + with $FutureModifier, $FutureProvider { + /// Design Request Detail Provider + /// + /// Fetches detail of a specific design request by name. + /// Uses family modifier to cache by request name. + const DesignRequestDetailProvider._({ + required DesignRequestDetailFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'designRequestDetailProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$designRequestDetailHash(); + + @override + String toString() { + return r'designRequestDetailProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return designRequestDetail(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is DesignRequestDetailProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$designRequestDetailHash() => + r'ddf1fdd91e1e9dc15acf50ef69d85602f02041c6'; + +/// Design Request Detail Provider +/// +/// Fetches detail of a specific design request by name. +/// Uses family modifier to cache by request name. + +final class DesignRequestDetailFamily extends $Family + with $FunctionalFamilyOverride, String> { + const DesignRequestDetailFamily._() + : super( + retry: null, + name: r'designRequestDetailProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Design Request Detail Provider + /// + /// Fetches detail of a specific design request by name. + /// Uses family modifier to cache by request name. + + DesignRequestDetailProvider call(String name) => + DesignRequestDetailProvider._(argument: name, from: this); + + @override + String toString() => r'designRequestDetailProvider'; +}