diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 9889a8d..9934e01 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"image_picker_ios","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\image_picker_ios-0.8.13+3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.4.3\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.6\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_ios","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_ios-6.3.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"flutter_plugin_android_lifecycle","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_plugin_android_lifecycle-2.0.33\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_android","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\image_picker_android-0.8.13+10\\\\","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_android","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.2.20\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_android","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_android-2.4.17\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_android-6.3.24\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"file_selector_macos","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\file_selector_macos-0.9.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_macos","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\image_picker_macos-0.2.2+1\\\\","native_build":false,"dependencies":["file_selector_macos"],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.4.3\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.6\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_macos-3.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"file_selector_linux","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\file_selector_linux-0.9.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_linux","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\image_picker_linux-0.2.2\\\\","native_build":false,"dependencies":["file_selector_linux"],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_linux-2.4.1\\\\","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"url_launcher_linux","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_linux-3.2.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"file_selector_windows","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\file_selector_windows-0.9.3+5\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_windows","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\image_picker_windows-0.2.2\\\\","native_build":false,"dependencies":["file_selector_windows"],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_windows-2.4.1\\\\","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"url_launcher_windows","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_windows-3.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"image_picker_for_web","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\image_picker_for_web-3.1.1\\\\","dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_web-2.4.3\\\\","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_web-2.4.1\\\\","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"image_picker","dependencies":["image_picker_android","image_picker_for_web","image_picker_ios","image_picker_linux","image_picker_macos","image_picker_windows"]},{"name":"image_picker_android","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"image_picker_for_web","dependencies":[]},{"name":"image_picker_ios","dependencies":[]},{"name":"image_picker_linux","dependencies":["file_selector_linux"]},{"name":"image_picker_macos","dependencies":["file_selector_macos"]},{"name":"image_picker_windows","dependencies":["file_selector_windows"]},{"name":"package_info_plus","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2026-01-09 09:07:50.854582","version":"3.35.4","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"image_picker_ios","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\image_picker_ios-0.8.13+3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.5.1\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.6\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_ios","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_ios-6.3.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"flutter_plugin_android_lifecycle","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_plugin_android_lifecycle-2.0.33\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_android","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\image_picker_android-0.8.13+14\\\\","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_android","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.2.22\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_android","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_android-2.4.21\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_android-6.3.28\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"file_selector_macos","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\file_selector_macos-0.9.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_macos","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\image_picker_macos-0.2.2+1\\\\","native_build":false,"dependencies":["file_selector_macos"],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.5.1\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.6\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_macos-3.2.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"file_selector_linux","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\file_selector_linux-0.9.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_linux","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\image_picker_linux-0.2.2\\\\","native_build":false,"dependencies":["file_selector_linux"],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_linux-2.4.1\\\\","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"url_launcher_linux","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_linux-3.2.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"file_selector_windows","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\file_selector_windows-0.9.3+5\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_windows","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\image_picker_windows-0.2.2\\\\","native_build":false,"dependencies":["file_selector_windows"],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_windows-2.4.1\\\\","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"url_launcher_windows","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_windows-3.1.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"image_picker_for_web","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\image_picker_for_web-3.1.1\\\\","dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_web-2.4.3\\\\","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"C:\\\\Users\\\\navee\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_web-2.4.1\\\\","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"image_picker","dependencies":["image_picker_android","image_picker_for_web","image_picker_ios","image_picker_linux","image_picker_macos","image_picker_windows"]},{"name":"image_picker_android","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"image_picker_for_web","dependencies":[]},{"name":"image_picker_ios","dependencies":[]},{"name":"image_picker_linux","dependencies":["file_selector_linux"]},{"name":"image_picker_macos","dependencies":["file_selector_macos"]},{"name":"image_picker_windows","dependencies":["file_selector_windows"]},{"name":"package_info_plus","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2026-03-03 11:08:12.172596","version":"3.35.4","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index c20899c..8bfb7b7 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -104,7 +104,14 @@ class ApiService { } else if (response.statusCode == 404) { throw ApiException('Not found'); } else if (response.statusCode == 500) { - throw ApiException('Server error'); + try { + final body = json.decode(response.body); + final err = body['error']; + throw ApiException(err != null ? 'Server error: $err' : 'Server error'); + } catch (e) { + if (e is ApiException) rethrow; + throw ApiException('Server error'); + } } else { throw ApiException('Error: ${response.statusCode}'); } diff --git a/lib/core/services/auth_service.dart b/lib/core/services/auth_service.dart index 3df2766..50b1f02 100644 --- a/lib/core/services/auth_service.dart +++ b/lib/core/services/auth_service.dart @@ -13,6 +13,7 @@ class UserProfile { final String? profilePicture; final int? branchId; final int? branchManagerProfileId; // The ID of the BranchManager profile + final int? maintenanceExecutiveProfileId; // The ID of the MaintenanceExecutive profile UserProfile({ required this.id, @@ -23,18 +24,27 @@ class UserProfile { this.profilePicture, this.branchId, this.branchManagerProfileId, + this.maintenanceExecutiveProfileId, }); factory UserProfile.fromJson(Map json) { - // Extract branchId from branchManagerProfile if present + // Extract branchId and branchManagerProfileId from branchManagerProfile if present int? branchId; int? branchManagerProfileId; + int? maintenanceExecutiveProfileId; final branchManagerProfile = json['branchManagerProfile'] as Map?; if (branchManagerProfile != null) { branchId = branchManagerProfile['branchId'] as int?; branchManagerProfileId = branchManagerProfile['id'] as int?; } + + final meProfile = json['maintenanceExecutiveProfile'] as Map?; + if (meProfile != null) { + maintenanceExecutiveProfileId = meProfile['id'] as int?; + // Also extract branchId from ME profile if not already set + branchId ??= meProfile['branchId'] as int?; + } return UserProfile( id: json['id'] as int, @@ -45,6 +55,7 @@ class UserProfile { profilePicture: json['profilePicture'] as String?, branchId: branchId ?? json['branchId'] as int?, branchManagerProfileId: branchManagerProfileId, + maintenanceExecutiveProfileId: maintenanceExecutiveProfileId, ); } @@ -58,6 +69,7 @@ class UserProfile { 'profilePicture': profilePicture, 'branchId': branchId, 'branchManagerProfileId': branchManagerProfileId, + 'maintenanceExecutiveProfileId': maintenanceExecutiveProfileId, }; } } diff --git a/lib/features/chat/view/pages/chat_box.dart b/lib/features/chat/view/pages/chat_box.dart index 553a5ed..beda861 100644 --- a/lib/features/chat/view/pages/chat_box.dart +++ b/lib/features/chat/view/pages/chat_box.dart @@ -12,6 +12,7 @@ import 'package:mobile/features/chat/view/widgets/issue_closed_bubble.dart'; import 'package:mobile/features/chat/view/widgets/status_update_bubble.dart'; import 'package:mobile/features/chat/view/widgets/outside_party_suggestion_bubble.dart'; import 'package:mobile/features/chat/view/widgets/petty_cash_request_bubble.dart'; +import 'package:mobile/features/chat/view/widgets/pending_approval_bubble.dart'; import 'package:mobile/features/tickets/model/issue_model.dart'; import 'package:mobile/features/tickets/service/issue_api_service.dart'; import 'package:socket_io_client/socket_io_client.dart' as IO; @@ -30,6 +31,9 @@ class _ChatPageState extends State { IO.Socket? _socket; final List _realtimeMessages = []; bool _isConnected = false; + // IDs of status entries added optimistically (API-returned) but not yet committed + // to _issue.statuses via setState. Used to suppress duplicate socket events. + final Set _pendingStatusIds = {}; final ScrollController _scrollController = ScrollController(); final IssueApiService _issueApiService = IssueApiService(); @@ -70,14 +74,16 @@ class _ChatPageState extends State { print('Connecting to socket: $socketUrl'); - _socket = IO.io(socketUrl, IO.OptionBuilder() - .setTransports(['websocket']) - .setAuth({ - 'userId': currentUser.id.toString(), - 'role': currentUser.role, - }) - .disableAutoConnect() - .build() + _socket = IO.io( + socketUrl, + IO.OptionBuilder() + .setTransports(['websocket']) + .setAuth({ + 'userId': currentUser.id.toString(), + 'role': currentUser.role, + }) + .disableAutoConnect() + .build(), ); _socket!.connect(); @@ -105,12 +111,12 @@ class _ChatPageState extends State { if (data is Map) { final text = data['text']; final senderId = int.tryParse(data['from'].toString()) ?? 0; - + // Ignore own messages as they are added optimistically if (senderId == AuthService.instance.currentUser?.id) { return; } - + // Find sender name String senderName = 'Unknown'; if (_issue?.manager?.user?.id == senderId) { @@ -122,16 +128,16 @@ class _ChatPageState extends State { } else if (AuthService.instance.currentUser?.id == senderId) { senderName = AuthService.instance.currentUser!.name; } - + final newMessage = MessageModel( - id: DateTime.now().millisecondsSinceEpoch, - body: text.toString(), - senderId: senderId, - createdAt: DateTime.now(), - sender: UserInfo(id: senderId, name: senderName, email: ''), - receiverId: null, + id: DateTime.now().millisecondsSinceEpoch, + body: text.toString(), + senderId: senderId, + createdAt: DateTime.now(), + sender: UserInfo(id: senderId, name: senderName, email: ''), + receiverId: null, ); - + if (mounted) { setState(() { _realtimeMessages.add(newMessage); @@ -140,60 +146,100 @@ class _ChatPageState extends State { } } }); - + _socket!.on('issue_update', (data) { print('Received issue update: $data'); // Handle Petty Cash Update - if (data is Map && data.containsKey('amount') && data.containsKey('technician_id')) { - if (mounted && _issue != null) { - try { - final newRequest = PettyCashRequestModel.fromJson(data); - final currentRequests = List.from(_issue!.pettyCashRequests ?? []); - - final index = currentRequests.indexWhere((r) => r.id == newRequest.id); - if (index != -1) { - currentRequests[index] = newRequest; - } else { - currentRequests.add(newRequest); - } - - setState(() { - _issue = _issue!.copyWith(pettyCashRequests: currentRequests); - }); - _scrollToBottom(); - } catch (e) { - print('Error parsing petty cash update: $e'); + if (data is Map && + data.containsKey('amount') && + data.containsKey('technician_id')) { + if (mounted && _issue != null) { + try { + final newRequest = PettyCashRequestModel.fromJson(data); + final currentRequests = List.from( + _issue!.pettyCashRequests ?? [], + ); + + final index = currentRequests.indexWhere( + (r) => r.id == newRequest.id, + ); + if (index != -1) { + currentRequests[index] = newRequest; + } else { + currentRequests.add(newRequest); } - } - return; + + setState(() { + _issue = _issue!.copyWith(pettyCashRequests: currentRequests); + }); + _scrollToBottom(); + } catch (e) { + print('Error parsing petty cash update: $e'); + } + } + return; + } + }); + + // ── Outside party update listener ── + _socket!.on('outside_party_update', (data) { + print('Outside party update: $data'); + if (data is Map && mounted && _issue != null) { + try { + final newReq = OutsidePartyRequestModel.fromJson(data); + final current = List.from( + _issue!.outsidePartyRequests ?? [], + ); + final idx = current.indexWhere((r) => r.id == newReq.id); + if (idx != -1) { + current[idx] = newReq; + } else { + current.add(newReq); + } + setState(() { + _issue = _issue!.copyWith(outsidePartyRequests: current); + }); + _scrollToBottom(); + } catch (e) { + print('Error parsing outside_party_update: $e'); + } } + }); + // ── issue_update: general issue field updates ── + _socket!.on('issue_update_fields', (data) { if (data is Map && data['success'] == true) { final updateData = data['data'] as Map; if (mounted && _issue != null) { setState(() { _issue = _issue!.copyWith( - status: updateData['status'] != null - ? IssueStatus.fromString(updateData['status']) + status: updateData['status'] != null + ? IssueStatus.fromString(updateData['status']) : null, maintenanceExecutiveId: updateData['maintenance_executive_id'], technicianId: updateData['technician_id'], thirdPartyId: updateData['third_party_id'], - maintenanceExecutiveAssignedAt: updateData['maintenance_executive_assigned_at'] != null - ? DateTime.parse(updateData['maintenance_executive_assigned_at']) + maintenanceExecutiveAssignedAt: + updateData['maintenance_executive_assigned_at'] != null + ? DateTime.parse( + updateData['maintenance_executive_assigned_at'], + ) : null, technicianAssignedAt: updateData['technician_assigned_at'] != null ? DateTime.parse(updateData['technician_assigned_at']) : null, - thirdPartyAssignedAt: updateData['third_party_assigned_at'] != null + thirdPartyAssignedAt: + updateData['third_party_assigned_at'] != null ? DateTime.parse(updateData['third_party_assigned_at']) : null, updatedAt: updateData['updatedAt'] != null ? DateTime.parse(updateData['updatedAt']) : null, maintenanceExecutive: updateData['maintenanceExecutive'] != null - ? MaintenanceExecutiveInfo.fromJson(updateData['maintenanceExecutive']) + ? MaintenanceExecutiveInfo.fromJson( + updateData['maintenanceExecutive'], + ) : null, technician: updateData['technician'] != null ? TechnicianInfo.fromJson(updateData['technician']) @@ -207,24 +253,67 @@ class _ChatPageState extends State { } } }); + + // ── status_update_log: real-time status update bubbles ── + _socket!.on('status_update_log', (data) { + if (data is Map && mounted && _issue != null) { + try { + final newStatusEntry = StatusModel.fromJson(data); + + // Skip if this exact ID was already added optimistically by the current user + if (_pendingStatusIds.contains(newStatusEntry.id)) { + _pendingStatusIds.remove(newStatusEntry.id); + return; + } + + // Also suppress if we are mid-await for our own addStatusUpdate call. + // Sentinel negative IDs (-1, -2, -3) are placed in _pendingStatusIds + // before the await and removed once we have the real ID. + // The local setState will render the bubble; we don't need the socket event. + if (_pendingStatusIds.any((id) => id < 0)) { + return; + } + + // Avoid duplicating if already present in the list + final currentStatuses = List.from( + _issue!.statuses ?? [], + ); + final exists = currentStatuses.any((s) => s.id == newStatusEntry.id); + + if (!exists) { + currentStatuses.add(newStatusEntry); + setState(() { + _issue = _issue!.copyWith(statuses: currentStatuses); + }); + _scrollToBottom(); + } + } catch (e) { + print('Error parsing status_update_log: $e'); + } + } + }); } void _sendMessage(String text, String? target) { if (_issue?.maintenanceExecutive == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Cannot send message: No Maintenance Executive accepted.')), + const SnackBar( + content: Text( + 'Cannot send message: No Maintenance Executive accepted.', + ), + ), ); return; } if (_socket == null || !_isConnected) { - print('Not connected'); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Not connected to chat server')), - ); - return; + print('Not connected'); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not connected to chat server')), + ); + return; } - + final authService = AuthService.instance; final currentUser = authService.currentUser; if (currentUser == null) return; @@ -245,19 +334,19 @@ class _ChatPageState extends State { }; if (targetUserId != null) { - // Send to specific user room - _socket!.emit('send_message_to_user', [payload, targetUserId]); + // Send to specific user room + _socket!.emit('send_message_to_user', [payload, targetUserId]); } else { - // Fallback to broadcast if no specific target found or implied - _socket!.emit('send_message_to_all', payload); + // Fallback to broadcast if no specific target found or implied + _socket!.emit('send_message_to_all', payload); } - + // Optimistic update UserInfo? receiverInfo; if (targetUserId != null) { String receiverName = 'Unknown'; String receiverEmail = ''; - + if (_issue?.manager?.user?.id.toString() == targetUserId) { receiverName = _issue!.manager!.user!.name; receiverEmail = _issue!.manager!.user!.email; @@ -265,27 +354,32 @@ class _ChatPageState extends State { receiverName = _issue!.technician!.user!.name; // technician user might not have email in this model structure if not loaded, but UserInfo requires it. // Assuming it's available or empty string. - receiverEmail = _issue!.technician!.user!.email; - } else if (_issue?.maintenanceExecutive?.user?.id.toString() == targetUserId) { + receiverEmail = _issue!.technician!.user!.email; + } else if (_issue?.maintenanceExecutive?.user?.id.toString() == + targetUserId) { receiverName = _issue!.maintenanceExecutive!.user!.name; receiverEmail = _issue!.maintenanceExecutive!.user!.email; } - + receiverInfo = UserInfo( - id: int.parse(targetUserId), - name: receiverName, - email: receiverEmail + id: int.parse(targetUserId), + name: receiverName, + email: receiverEmail, ); } final newMessage = MessageModel( - id: DateTime.now().millisecondsSinceEpoch, - body: text, - senderId: currentUser.id, - createdAt: DateTime.now(), - sender: UserInfo(id: currentUser.id, name: currentUser.name, email: currentUser.email), - receiverId: targetUserId != null ? int.tryParse(targetUserId) : null, - receiver: receiverInfo, + id: DateTime.now().millisecondsSinceEpoch, + body: text, + senderId: currentUser.id, + createdAt: DateTime.now(), + sender: UserInfo( + id: currentUser.id, + name: currentUser.name, + email: currentUser.email, + ), + receiverId: targetUserId != null ? int.tryParse(targetUserId) : null, + receiver: receiverInfo, ); setState(() { @@ -316,26 +410,28 @@ class _ChatPageState extends State { try { // Show loading indicator _showLoadingDialog('Assigning technician...'); - + // Call API to assign technician final updatedIssue = await _issueApiService.assignTechnician( _issue!.id, technician.id, ); - + // Dismiss loading if (mounted) Navigator.of(context).pop(); - + // Update local state setState(() { _issue = updatedIssue; }); - + // Show success message if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Technician ${technician.name} assigned successfully'), + content: Text( + 'Technician ${technician.name} assigned successfully', + ), backgroundColor: Colors.green, ), ); @@ -343,7 +439,7 @@ class _ChatPageState extends State { } catch (e) { // Dismiss loading if (mounted) Navigator.of(context).pop(); - + // Show error message if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -362,26 +458,28 @@ class _ChatPageState extends State { try { // Show loading indicator _showLoadingDialog('Assigning third party...'); - + // Call API to assign third party final updatedIssue = await _issueApiService.assignThirdParty( _issue!.id, thirdParty.id, ); - + // Dismiss loading if (mounted) Navigator.of(context).pop(); - + // Update local state setState(() { _issue = updatedIssue; }); - + // Show success message if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Third party ${thirdParty.organization} assigned successfully'), + content: Text( + 'Third party ${thirdParty.organization} assigned successfully', + ), backgroundColor: Colors.green, ), ); @@ -389,12 +487,14 @@ class _ChatPageState extends State { } catch (e) { // Dismiss loading if (mounted) Navigator.of(context).pop(); - + // Show error message if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to assign third party: ${e.toString()}'), + content: Text( + 'Failed to assign third party: ${e.toString()}', + ), backgroundColor: Colors.red, ), ); @@ -408,7 +508,7 @@ class _ChatPageState extends State { try { // Show loading indicator _showLoadingDialog('Closing issue...'); - + // Upload image if provided String? imageUrl; if (imageFile != null) { @@ -423,21 +523,55 @@ class _ChatPageState extends State { // Continue with closing even if image upload fails } } - - // Call API to update issue status to closed + + // 1. Update issue status to closed final updatedIssue = await _issueApiService.updateIssueStatus( _issue!.id, IssueStatus.closed, ); - + + // 2. Create a status log entry so the bubble shows in chat + StatusModel? newStatusEntry; + final currentUser = AuthService.instance.currentUser; + if (currentUser != null) { + final desc = (description?.trim().isNotEmpty == true) + ? description! + : 'Issue closed'; + const int tempId = -4; + _pendingStatusIds.add(tempId); + try { + newStatusEntry = await _issueApiService.addStatusUpdate( + issueId: _issue!.id, + userId: currentUser.id, + description: desc, + imageUrl: imageUrl, + statusType: 'Closed', + ); + _pendingStatusIds.remove(tempId); + if (newStatusEntry != null) { + _pendingStatusIds.add(newStatusEntry.id); + } + } catch (statusErr) { + _pendingStatusIds.remove(tempId); + debugPrint( + 'Status log entry failed (non-blocking): $statusErr', + ); + } + } + // Dismiss loading if (mounted) Navigator.of(context).pop(); - - // Update local state + + // Update local state — merge new status entry into issue setState(() { - _issue = updatedIssue; + final currentStatuses = List.from( + _issue!.statuses ?? [], + ); + if (newStatusEntry != null) currentStatuses.add(newStatusEntry); + _issue = updatedIssue.copyWith(statuses: currentStatuses); }); - + _scrollToBottom(); + // Show success message if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -450,7 +584,7 @@ class _ChatPageState extends State { } catch (e) { // Dismiss loading if (mounted) Navigator.of(context).pop(); - + // Show error message if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -465,93 +599,148 @@ class _ChatPageState extends State { break; case 'Update the Status': - showUpdateStatusDialog( - context, - _issue!.status.value, - (newStatus, description, imageFile) async { - try { - // Show loading indicator - _showLoadingDialog('Updating status...'); - - // Upload image if provided - String? imageUrl; - if (imageFile != null) { - try { + showUpdateStatusDialog(context, _issue!.status.value, ( + newStatus, + description, + imageFiles, + ) async { + try { + // Show loading indicator + _showLoadingDialog('Updating status...'); + + // Upload images if provided + List? imageUrls; + if (imageFiles.isNotEmpty) { + try { + imageUrls = []; + for (final imageFile in imageFiles) { final uploadedFile = await UploadService.instance.uploadFile( imageFile, issueId: _issue!.id, ); - imageUrl = uploadedFile.url; - } catch (uploadError) { - debugPrint('Failed to upload image: $uploadError'); - // Continue with status update even if image upload fails + imageUrls.add(uploadedFile.url); } + } catch (uploadError) { + debugPrint('Failed to upload images: $uploadError'); + // Continue even if image upload fails + imageUrls = null; } - - // Call API to update issue status - final updatedIssue = await _issueApiService.updateIssueStatus( - _issue!.id, - IssueStatus.fromString(newStatus), - ); - - // Dismiss loading - if (mounted) Navigator.of(context).pop(); - - // Update local state - setState(() { - _issue = updatedIssue; - }); - - // Show success message - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Status updated to ${newStatus.replaceAll('_', ' ')}'), - backgroundColor: Colors.green, - ), + } + + // Map dialog status value to backend status + final backendStatus = _mapDialogStatusToBackend(newStatus); + + // Map dialog status value to Statuses table status_type + final statusType = _mapDialogStatusToStatusType(newStatus); + + // 1. Update the issue status + final updatedIssue = await _issueApiService.updateIssueStatus( + _issue!.id, + IssueStatus.fromString(backendStatus), + ); + + // 2. Create a status log entry + StatusModel? newStatusEntry; + final currentUser = AuthService.instance.currentUser; + if (currentUser != null) { + final desc = (description?.trim().isNotEmpty == true) + ? description! + : 'Status updated to $statusType'; + // Use a temporary placeholder ID to mark this as our own event. + // Any socket event that arrives while awaiting will be suppressed + // by the seen-IDs deduplication in build(). + const int tempId = -1; + _pendingStatusIds.add(tempId); + try { + newStatusEntry = await _issueApiService.addStatusUpdate( + issueId: _issue!.id, + userId: currentUser.id, + description: desc, + imageUrls: imageUrls, + statusType: statusType, ); - } - } catch (e) { - // Dismiss loading - if (mounted) Navigator.of(context).pop(); - - // Show error message - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to update status: ${e.toString()}'), - backgroundColor: Colors.red, - ), + // Replace temp with the real ID so the socket listener can match it + _pendingStatusIds.remove(tempId); + if (newStatusEntry != null) { + _pendingStatusIds.add(newStatusEntry.id); + } + } catch (statusErr) { + _pendingStatusIds.remove(tempId); + debugPrint( + 'Status log entry failed (non-blocking): $statusErr', ); } } - }, - ); + + // Dismiss loading + if (mounted) Navigator.of(context).pop(); + + // Update local state — merge new status entry into issue + setState(() { + final currentStatuses = List.from( + _issue!.statuses ?? [], + ); + if (newStatusEntry != null) currentStatuses.add(newStatusEntry); + _issue = updatedIssue.copyWith(statuses: currentStatuses); + }); + _scrollToBottom(); + + // Show success message + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Status updated to ${statusType.replaceAll('_', ' ')}', + ), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + // Dismiss loading + if (mounted) Navigator.of(context).pop(); + + // Show error message + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update status: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + }); break; case 'Suggest Outside Support': // For technicians suggesting outside support - showSuggestOutsidePartyDialog(context, (description, suggestedParty) async { + showSuggestOutsidePartyDialog(context, ( + description, + suggestedParty, + ) async { try { _showLoadingDialog('Submitting suggestion...'); - + // TODO: Implement API endpoint for suggesting third party // For now, we'll just show a success message // In the future, this should create a suggestion/request record - + if (mounted) Navigator.of(context).pop(); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Suggestion for "$suggestedParty" submitted successfully'), + content: Text( + 'Suggestion for "$suggestedParty" submitted successfully', + ), backgroundColor: Colors.green, ), ); } } catch (e) { if (mounted) Navigator.of(context).pop(); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -568,28 +757,47 @@ class _ChatPageState extends State { showRequestPettyCashDialog(context, (amount, description) async { try { _showLoadingDialog('Submitting petty cash request...'); - - // Emit socket event for petty cash request - _socket?.emit('petty_cash_request', { - 'issue_id': _issue!.id, - 'amount': amount, - 'description': description, - 'technician_id': AuthService.instance.currentUser?.id, - }); - + + final currentUser = AuthService.instance.currentUser; + if (currentUser == null) { + if (mounted) Navigator.of(context).pop(); + return; + } + + // Use issue's assigned technician id (backend expects Technicians.id, not User.id) + final technicianId = _issue!.technicianId ?? currentUser.id; + final newRequest = await _issueApiService.createPettyCashRequest( + issueId: _issue!.id, + amount: amount, + description: description, + technicianId: technicianId, + ); + if (mounted) Navigator.of(context).pop(); - + + // Update local state with the API response + setState(() { + final currentRequests = List.from( + _issue!.pettyCashRequests ?? [], + ); + currentRequests.add(newRequest); + _issue = _issue!.copyWith(pettyCashRequests: currentRequests); + }); + _scrollToBottom(); + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Petty cash request for Rs. ${amount.toStringAsFixed(2)} submitted'), + content: Text( + 'Petty cash request for Rs. ${amount.toStringAsFixed(2)} submitted', + ), backgroundColor: Colors.green, ), ); } } catch (e) { if (mounted) Navigator.of(context).pop(); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -601,6 +809,167 @@ class _ChatPageState extends State { } }); break; + + case 'Suggest Outside Party': + _showSuggestOutsidePartyDialog(); + break; + } + } + + /// Handle approval actions (approve/reject Pending Resolution or Pending Close) + Future _handleApprovalAction(String action) async { + if (_issue == null) return; + + final currentUser = AuthService.instance.currentUser; + if (currentUser == null) return; + + if (action.startsWith('approve_')) { + final isResolution = action == 'approve_resolution'; + final newBackendStatus = isResolution + ? IssueStatus.done + : IssueStatus.closed; + final newStatusType = isResolution ? 'Resolved' : 'Closed'; + final description = isResolution + ? 'Resolution approved by ${currentUser.name}' + : 'Close approved by ${currentUser.name}'; + + try { + _showLoadingDialog('Approving...'); + + // 1. Update issue status + final updatedIssue = await _issueApiService.updateIssueStatus( + _issue!.id, + newBackendStatus, + ); + + // 2. Add status log entry + StatusModel? newStatusEntry; + const int _approveTemp = -2; + _pendingStatusIds.add(_approveTemp); + try { + newStatusEntry = await _issueApiService.addStatusUpdate( + issueId: _issue!.id, + userId: currentUser.id, + description: description, + statusType: newStatusType, + ); + _pendingStatusIds.remove(_approveTemp); + if (newStatusEntry != null) _pendingStatusIds.add(newStatusEntry.id); + } catch (e) { + _pendingStatusIds.remove(_approveTemp); + debugPrint('Status log entry failed: $e'); + } + + if (mounted) Navigator.of(context).pop(); + + setState(() { + final currentStatuses = List.from( + _issue!.statuses ?? [], + ); + if (newStatusEntry != null) currentStatuses.add(newStatusEntry); + _issue = updatedIssue.copyWith(statuses: currentStatuses); + }); + _scrollToBottom(); + } catch (e) { + if (mounted) Navigator.of(context).pop(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to approve: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } else if (action.startsWith('reject_')) { + final isResolution = action == 'reject_resolution'; + final statusTypeLabel = isResolution ? 'Resolution' : 'Close request'; + + // prompt for rejection reason + final reasonController = TextEditingController(); + final reason = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('Reject $statusTypeLabel'), + content: TextField( + controller: reasonController, + decoration: const InputDecoration( + hintText: 'Enter rejection reason', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(null), + child: const Text('Cancel'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + onPressed: () { + if (reasonController.text.trim().isNotEmpty) { + Navigator.of(ctx).pop(reasonController.text.trim()); + } + }, + child: const Text('Reject'), + ), + ], + ), + ); + + if (reason == null || reason.isEmpty) return; + + try { + _showLoadingDialog('Rejecting...'); + + // 1. Revert issue status to In Progress + final updatedIssue = await _issueApiService.updateIssueStatus( + _issue!.id, + IssueStatus.inProgress, + ); + + // 2. Add status log entry + StatusModel? newStatusEntry; + const int _rejectTemp = -3; + _pendingStatusIds.add(_rejectTemp); + try { + newStatusEntry = await _issueApiService.addStatusUpdate( + issueId: _issue!.id, + userId: currentUser.id, + description: '$statusTypeLabel rejected. Reason: $reason', + statusType: 'In Progress', + ); + _pendingStatusIds.remove(_rejectTemp); + if (newStatusEntry != null) _pendingStatusIds.add(newStatusEntry.id); + } catch (e) { + _pendingStatusIds.remove(_rejectTemp); + debugPrint('Status log entry failed: $e'); + } + + if (mounted) Navigator.of(context).pop(); + + setState(() { + final currentStatuses = List.from( + _issue!.statuses ?? [], + ); + if (newStatusEntry != null) currentStatuses.add(newStatusEntry); + _issue = updatedIssue.copyWith(statuses: currentStatuses); + }); + _scrollToBottom(); + } catch (e) { + if (mounted) Navigator.of(context).pop(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to reject: $e'), + backgroundColor: Colors.red, + ), + ); + } + } } } @@ -616,39 +985,111 @@ class _ChatPageState extends State { return; } - final actionLabel = action == 'approve' ? 'Approving' : 'Rejecting'; + final actionLabel = action == 'approve' + ? 'Approving' + : action == 'cancel' + ? 'Canceling' + : action == 'undo' + ? 'Undoing' + : 'Rejecting'; _showLoadingDialog('$actionLabel petty cash request...'); try { - // Emit socket event for petty cash action - _socket?.emit('petty_cash_action', { + final currentUser = AuthService.instance.currentUser; + if (currentUser == null) { + if (mounted) Navigator.of(context).pop(); + return; + } + + // Call REST API to update petty cash request + final updatedRequest = await _issueApiService.updatePettyCashRequest( + requestId: requestId, + action: action, + issueId: _issue!.id, + userId: currentUser.id, + ); + + if (mounted) Navigator.of(context).pop(); + + // Update local state with the API response + setState(() { + final currentRequests = List.from( + _issue!.pettyCashRequests ?? [], + ); + final index = currentRequests.indexWhere( + (r) => r.id == updatedRequest.id, + ); + if (index != -1) { + currentRequests[index] = updatedRequest; + } + _issue = _issue!.copyWith(pettyCashRequests: currentRequests); + }); + + final successMessage = action == 'approve' + ? 'Petty cash request approved' + : action == 'cancel' + ? 'Petty cash request canceled' + : action == 'undo' + ? 'Decision undone — request is now pending' + : 'Petty cash request rejected'; + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(successMessage), + backgroundColor: action == 'approve' ? Colors.green : Colors.orange, + ), + ); + } + } catch (e) { + if (mounted) Navigator.of(context).pop(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to $action request: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + /// Handle approve / reject for outside party requests + void _handleOutsidePartyAction(dynamic requestId, String action) async { + if (requestId == null) return; + + _showLoadingDialog( + '${action == 'approve' ? 'Approving' : 'Rejecting'} outside party request...', + ); + + try { + _socket?.emit('outside_party_action', { 'request_id': requestId, 'action': action, 'issue_id': _issue!.id, 'user_id': AuthService.instance.currentUser?.id, }); - + if (mounted) Navigator.of(context).pop(); - - final successMessage = action == 'approve' - ? 'Petty cash request approved' - : 'Petty cash request rejected'; - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(successMessage), + content: Text( + action == 'approve' + ? 'Outside party request approved ✓' + : 'Outside party request rejected', + ), backgroundColor: action == 'approve' ? Colors.green : Colors.orange, ), ); } } catch (e) { if (mounted) Navigator.of(context).pop(); - if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to ${action} request: ${e.toString()}'), + content: Text('Error: ${e.toString()}'), backgroundColor: Colors.red, ), ); @@ -656,7 +1097,112 @@ class _ChatPageState extends State { } } + /// Show the "Suggest Outside Party" dialog for technicians + void _showSuggestOutsidePartyDialog() { + final vendorController = TextEditingController(); + final descController = TextEditingController(); + final formKey = GlobalKey(); + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text( + 'Suggest Outside Party', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + content: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: vendorController, + decoration: InputDecoration( + labelText: 'Suggested Party / Vendor *', + hintText: 'e.g. Oven Builders Pvt Ltd', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Vendor name is required' + : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: descController, + maxLines: 3, + decoration: InputDecoration( + labelText: 'Description *', + hintText: 'Why is an outside party needed?', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Description is required' + : null, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF8B5CF6), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 0, + ), + onPressed: () { + if (formKey.currentState!.validate()) { + Navigator.of(ctx).pop(); + _handleSuggestOutsideParty( + vendorController.text.trim(), + descController.text.trim(), + ); + } + }, + child: const Text('Submit'), + ), + ], + ), + ); + } + + /// Emit the outside_party_suggest socket event + void _handleSuggestOutsideParty(String vendorName, String description) { + _socket?.emit('outside_party_suggest', { + 'issue_id': _issue!.id, + 'suggested_by': AuthService.instance.currentUser?.id, + 'vendor_name': vendorName, + 'description': description, + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Outside party suggestion submitted ✓'), + backgroundColor: Color(0xFF8B5CF6), + ), + ); + } + /// Show a loading dialog + void _showLoadingDialog(String message) { showDialog( context: context, @@ -678,8 +1224,6 @@ class _ChatPageState extends State { @override Widget build(BuildContext context) { final issue = _issue; - // ignore: avoid_print - print('Received issue in ChatPage: ${issue?.toJson()}'); if (issue == null) { return Scaffold( @@ -688,16 +1232,15 @@ class _ChatPageState extends State { ); } - - // Get current user from AuthService final authService = AuthService.instance; final currentUser = authService.currentUser; if (currentUser == null) { WidgetsBinding.instance.addPostFrameCallback((_) { - Navigator.of(context) - .pushNamedAndRemoveUntil('/login', (route) => false); + Navigator.of( + context, + ).pushNamedAndRemoveUntil('/login', (route) => false); }); return const Scaffold(body: Center(child: CircularProgressIndicator())); } @@ -719,8 +1262,9 @@ class _ChatPageState extends State { if (role == null) { WidgetsBinding.instance.addPostFrameCallback((_) { authService.clearAuth(); - Navigator.of(context) - .pushNamedAndRemoveUntil('/login', (route) => false); + Navigator.of( + context, + ).pushNamedAndRemoveUntil('/login', (route) => false); }); return const Scaffold(body: Center(child: CircularProgressIndicator())); } @@ -741,32 +1285,33 @@ class _ChatPageState extends State { }; } else if (currentUserId == 'me') { // Fallback for test mode - participants['u_me'] = { - 'name': 'You', - 'avatarUrl': null, - 'userId': 'me' - }; + participants['u_me'] = {'name': 'You', 'avatarUrl': null, 'userId': 'me'}; } // Populate participants from issue data with real profile pictures if (issue.manager?.user != null) { participants['u_mgr_${issue.manager!.id}'] = { 'name': issue.manager!.user!.name, - 'avatarUrl': issue.manager!.user!.profilePicture, // Use real profile picture + 'avatarUrl': + issue.manager!.user!.profilePicture, // Use real profile picture 'userId': issue.manager!.user!.id.toString(), }; } if (issue.technician?.user != null) { participants['u_tech_${issue.technician!.id}'] = { 'name': issue.technician!.user!.name, - 'avatarUrl': issue.technician!.user!.profilePicture, // Use real profile picture + 'avatarUrl': + issue.technician!.user!.profilePicture, // Use real profile picture 'userId': issue.technician!.user!.id.toString(), }; } if (issue.maintenanceExecutive?.user != null) { participants['u_exec_${issue.maintenanceExecutive!.id}'] = { 'name': issue.maintenanceExecutive!.user!.name, - 'avatarUrl': issue.maintenanceExecutive!.user!.profilePicture, // Use real profile picture + 'avatarUrl': issue + .maintenanceExecutive! + .user! + .profilePicture, // Use real profile picture 'userId': issue.maintenanceExecutive!.user!.id.toString(), }; } @@ -788,7 +1333,10 @@ class _ChatPageState extends State { '${issue.createdAt.hour}:${issue.createdAt.minute.toString().padLeft(2, '0')} ${issue.createdAt.hour < 12 ? 'AM' : 'PM'}', 'severity': severityInfo['label'], // Dynamic severity based on status 'severityColor': severityInfo['color'], // Dynamic color based on status - 'attachments': [], // Empty list - attachments will be populated when file upload is implemented + 'attachments': + < + String + >[], // Empty list - attachments will be populated when file upload is implemented 'creatorId': 'u_mgr_${issue.managerId}', 'occurrenceTimeText': '${issue.createdAt.hour}:${issue.createdAt.minute.toString().padLeft(2, '0')} ${issue.createdAt.hour < 12 ? 'AM' : 'PM'}', @@ -803,7 +1351,7 @@ class _ChatPageState extends State { 'technicianName': issue.technician?.user?.name ?? 'Technician', 'timeText': '${issue.technicianAssignedAt!.hour}:${issue.technicianAssignedAt!.minute.toString().padLeft(2, '0')} ${issue.technicianAssignedAt!.hour < 12 ? 'AM' : 'PM'}', - 'creatorId': 'u_mgr_${issue.managerId}', + 'creatorId': 'u_mgr_${issue.managerId}', 'alignRight': false, }); } @@ -813,10 +1361,11 @@ class _ChatPageState extends State { 'type': 'assignment', 'createdAt': issue.maintenanceExecutiveAssignedAt, 'title': 'Maintenance Executive Accepted', - 'technicianName': issue.maintenanceExecutive?.user?.name ?? 'Maintenance Executive', + 'technicianName': + issue.maintenanceExecutive?.user?.name ?? 'Maintenance Executive', 'timeText': '${issue.maintenanceExecutiveAssignedAt!.hour}:${issue.maintenanceExecutiveAssignedAt!.minute.toString().padLeft(2, '0')} ${issue.maintenanceExecutiveAssignedAt!.hour < 12 ? 'AM' : 'PM'}', - 'creatorId': 'u_mgr_${issue.managerId}', + 'creatorId': 'u_mgr_${issue.managerId}', 'alignRight': false, }); } @@ -829,12 +1378,13 @@ class _ChatPageState extends State { 'technicianName': issue.thirdParty?.organization ?? 'Third Party', 'timeText': '${issue.thirdPartyAssignedAt!.hour}:${issue.thirdPartyAssignedAt!.minute.toString().padLeft(2, '0')} ${issue.thirdPartyAssignedAt!.hour < 12 ? 'AM' : 'PM'}', - 'creatorId': 'u_mgr_${issue.managerId}', + 'creatorId': 'u_mgr_${issue.managerId}', 'alignRight': false, }); } - if (issue.status == IssueStatus.done || issue.status == IssueStatus.closed) { + if (issue.status == IssueStatus.done || + issue.status == IssueStatus.closed) { chatItems.add({ 'type': 'issue_closed', 'createdAt': issue.updatedAt, @@ -849,64 +1399,69 @@ class _ChatPageState extends State { // Add messages from the issue AND realtime messages final allMessages = [...?issue.messages, ..._realtimeMessages]; - + for (final message in allMessages) { - // Filter messages for non-executive roles - if (myRole != UserRole.executive) { - final msgSenderId = message.sender.id.toString(); - final msgReceiverId = message.receiver?.id.toString(); - // Only show if I am the sender or the receiver OR if it is a broadcast - if (msgSenderId != currentUserId && msgReceiverId != currentUserId && msgReceiverId != null) { - continue; - } + // Filter messages for non-executive roles + if (myRole != UserRole.executive) { + final msgSenderId = message.sender.id.toString(); + final msgReceiverId = message.receiver?.id.toString(); + // Only show if I am the sender or the receiver OR if it is a broadcast + if (msgSenderId != currentUserId && + msgReceiverId != currentUserId && + msgReceiverId != null) { + continue; } + } - // Find or create participant key for sender - final senderIdStr = message.sender.id.toString(); - final senderKey = participants.keys.firstWhere( - (k) => participants[k]?['userId'] == senderIdStr, + // Find or create participant key for sender + final senderIdStr = message.sender.id.toString(); + final senderKey = participants.keys.firstWhere( + (k) => participants[k]?['userId'] == senderIdStr, + orElse: () { + final k = 'u_${senderIdStr}'; + participants[k] = { + 'name': message.sender.name, + 'avatarUrl': null, + 'userId': senderIdStr, + }; + return k; + }, + ); + + String? receiverKey; + if (message.receiver != null) { + final receiverIdStr = message.receiver!.id.toString(); + receiverKey = participants.keys.firstWhere( + (k) => participants[k]?['userId'] == receiverIdStr, orElse: () { - final k = 'u_${senderIdStr}'; + final k = 'u_${receiverIdStr}'; participants[k] = { - 'name': message.sender.name, + 'name': message.receiver!.name, 'avatarUrl': null, - 'userId': senderIdStr, + 'userId': receiverIdStr, }; return k; }, ); - - String? receiverKey; - if (message.receiver != null) { - final receiverIdStr = message.receiver!.id.toString(); - receiverKey = participants.keys.firstWhere( - (k) => participants[k]?['userId'] == receiverIdStr, - orElse: () { - final k = 'u_${receiverIdStr}'; - participants[k] = { - 'name': message.receiver!.name, - 'avatarUrl': null, - 'userId': receiverIdStr, - }; - return k; - }, - ); - } - - chatItems.add({ - 'type': 'text', - 'createdAt': message.createdAt, - 'text': message.body, - 'time': - '${message.createdAt.hour}:${message.createdAt.minute.toString().padLeft(2, '0')} ${message.createdAt.hour < 12 ? 'AM' : 'PM'}', - 'senderId': senderKey, - 'receiverId': receiverKey, - }); } - // Add petty cash requests + chatItems.add({ + 'type': 'text', + 'createdAt': message.createdAt, + 'text': message.body, + 'time': + '${message.createdAt.hour}:${message.createdAt.minute.toString().padLeft(2, '0')} ${message.createdAt.hour < 12 ? 'AM' : 'PM'}', + 'senderId': senderKey, + 'receiverId': receiverKey, + }); + } + + // Add petty cash requests — technician (sender) sees on right, others on left if (issue.pettyCashRequests != null) { for (final request in issue.pettyCashRequests!) { + final isSender = + myRole == UserRole.technician && + request.technicianId == issue.technicianId; chatItems.add({ 'type': 'petty_cash', 'createdAt': request.createdAt, @@ -917,11 +1472,77 @@ class _ChatPageState extends State { 'description': request.description, 'status': request.status, 'requestId': request.id, + 'alignRight': isSender, + }); + } + } + + // Add outside party requests + if (issue.outsidePartyRequests != null) { + for (final req in issue.outsidePartyRequests!) { + final timeText = + '${req.createdAt.hour}:${req.createdAt.minute.toString().padLeft(2, '0')} ${req.createdAt.hour < 12 ? 'AM' : 'PM'}'; + final creatorKey = participants.keys.firstWhere( + (k) => participants[k]?['userId'] == req.suggestedBy.toString(), + orElse: () { + final k = 'u_opr_${req.suggestedBy}'; + participants[k] = { + 'name': 'Technician', + 'avatarUrl': null, + 'userId': req.suggestedBy.toString(), + }; + return k; + }, + ); + chatItems.add({ + 'type': 'outside_party', + 'createdAt': req.createdAt, + 'requestId': req.id, + 'vendorName': req.vendorName, + 'description': req.description, + 'status': req.status, + 'timeText': timeText, + 'creatorId': creatorKey, 'alignRight': false, }); } } + // Add status update log entries — deduplicate by ID to prevent double-render + if (issue.statuses != null) { + final seenStatusIds = {}; + for (final statusEntry in issue.statuses!) { + // Skip if we've already added a bubble for this ID + if (!seenStatusIds.add(statusEntry.id)) continue; + + final creatorUserId = statusEntry.userId.toString(); + // Find or create participant key for the status updater + final creatorKey = participants.keys.firstWhere( + (k) => participants[k]?['userId'] == creatorUserId, + orElse: () { + final k = 'u_$creatorUserId'; + participants[k] = { + 'name': statusEntry.user?.name ?? 'User', + 'avatarUrl': statusEntry.user?.profilePicture, + 'userId': creatorUserId, + }; + return k; + }, + ); + chatItems.add({ + 'type': 'status_update', + 'createdAt': statusEntry.createdAt, + 'description': statusEntry.description, + 'timeText': + '${statusEntry.createdAt.hour}:${statusEntry.createdAt.minute.toString().padLeft(2, '0')} ${statusEntry.createdAt.hour < 12 ? 'AM' : 'PM'}', + 'creatorId': creatorKey, + 'attachments': statusEntry.imageUrls, + 'alignRight': creatorUserId == currentUserId, + 'statusType': statusEntry.statusType, + }); + } + } + // Sort items by time chatItems.sort((a, b) { final dateA = a['createdAt'] as DateTime?; @@ -932,6 +1553,46 @@ class _ChatPageState extends State { return dateA.compareTo(dateB); }); + // Inject a dedicated pending-approval bubble for both Technicians AND Approvers + final isApprover = + myRole == UserRole.executive || myRole == UserRole.branchManager; + final isTechnician = myRole == UserRole.technician; + final isIssuePendingResolution = + issue.status == IssueStatus.pendingResolution; + final isIssuePendingClose = issue.status == IssueStatus.pendingClose; + + if (isIssuePendingResolution || isIssuePendingClose) { + String techName = issue.technician?.user?.name ?? 'Technician'; + + chatItems.add({ + 'type': 'pending_approval', + 'createdAt': issue.updatedAt.add(const Duration(seconds: 1)), + 'pendingType': isIssuePendingResolution + ? 'Pending Resolution' + : 'Pending Close', + 'requesterName': techName, + 'timeText': + '${issue.updatedAt.hour}:${issue.updatedAt.minute.toString().padLeft(2, '0')} ${issue.updatedAt.hour < 12 ? 'AM' : 'PM'}', + 'creatorId': issue.technicianId != null + ? 'u_tech_${issue.technicianId}' + : null, + // Technician sees it aligned right (their own request), manager/exec sees it aligned left + 'alignRight': isTechnician, + // Only pass action flags for approvers + 'showActions': isApprover, + }); + + // Re-sort so this appears at the end + chatItems.sort((a, b) { + final dateA = a['createdAt'] as DateTime?; + final dateB = b['createdAt'] as DateTime?; + if (dateA == null && dateB == null) return 0; + if (dateA == null) return -1; + if (dateB == null) return 1; + return dateA.compareTo(dateB); + }); + } + final managerKey = 'u_mgr_${issue.managerId}'; final managerAvatar = participants[managerKey]?['avatarUrl']; @@ -956,12 +1617,15 @@ class _ChatPageState extends State { if (it['type'] == 'assignment') { final creatorId = it['creatorId'] as String?; - final creator = - creatorId != null ? participants[creatorId] : null; - final creatorAvatarUrl = - creator != null ? creator['avatarUrl'] : null; - final creatorName = - creator != null ? creator['name'] : null; + final creator = creatorId != null + ? participants[creatorId] + : null; + final creatorAvatarUrl = creator != null + ? creator['avatarUrl'] + : null; + final creatorName = creator != null + ? creator['name'] + : null; final alignRight = (it['alignRight'] as bool?) ?? true; return AssignmentBubble( @@ -977,12 +1641,15 @@ class _ChatPageState extends State { if (it['type'] == 'ticket') { final creatorId = it['creatorId'] as String?; - final creator = - creatorId != null ? participants[creatorId] : null; - final creatorAvatarUrl = - creator != null ? creator['avatarUrl'] : null; - final creatorName = - creator != null ? creator['name'] : null; + final creator = creatorId != null + ? participants[creatorId] + : null; + final creatorAvatarUrl = creator != null + ? creator['avatarUrl'] + : null; + final creatorName = creator != null + ? creator['name'] + : null; return TicketBubble( creatorAvatarUrl: creatorAvatarUrl, @@ -1001,13 +1668,21 @@ class _ChatPageState extends State { if (it['type'] == 'status_update') { final creatorId = it['creatorId'] as String?; - final creator = - creatorId != null ? participants[creatorId] : null; - final creatorAvatarUrl = - creator != null ? creator['avatarUrl'] : null; - final creatorName = - creator != null ? creator['name'] : null; + final creator = creatorId != null + ? participants[creatorId] + : null; + final creatorAvatarUrl = creator != null + ? creator['avatarUrl'] + : null; + final creatorName = creator != null + ? creator['name'] + : null; final alignRight = (it['alignRight'] as bool?) ?? false; + final showApprovalActions = + (it['showApprovalActions'] as bool?) ?? false; + final pendingType = + it['pendingType'] as String? ?? + it['statusType'] as String?; return StatusUpdateBubble( creatorAvatarUrl: creatorAvatarUrl, @@ -1016,17 +1691,76 @@ class _ChatPageState extends State { timeText: it['timeText'] as String, attachments: (it['attachments'] as List?) ?? [], alignRight: alignRight, + statusType: it['statusType'] as String?, + showApprovalActions: showApprovalActions, + onApprove: showApprovalActions + ? () => _handleApprovalAction( + pendingType == 'Pending Resolution' + ? 'approve_resolution' + : 'approve_close', + ) + : null, + onReject: showApprovalActions + ? () => _handleApprovalAction( + pendingType == 'Pending Resolution' + ? 'reject_resolution' + : 'reject_close', + ) + : null, + ); + } + + if (it['type'] == 'pending_approval') { + final creatorId = it['creatorId'] as String?; + final creator = creatorId != null + ? participants[creatorId] + : null; + final creatorAvatarUrl = creator != null + ? creator['avatarUrl'] + : null; + final pendingType = it['pendingType'] as String; + final requesterName = + it['requesterName'] as String? ?? 'Technician'; + final alignRight = (it['alignRight'] as bool?) ?? false; + final showActions = (it['showActions'] as bool?) ?? false; + + return PendingApprovalBubble( + pendingType: pendingType, + requesterName: requesterName, + timeText: it['timeText'] as String, + creatorAvatarUrl: creatorAvatarUrl, + alignRight: alignRight, + senderInfoText: alignRight + ? '${it['timeText']} · You' + : '${it['timeText']} · $requesterName', + onReject: showActions + ? () => _handleApprovalAction( + pendingType == 'Pending Resolution' + ? 'reject_resolution' + : 'reject_close', + ) + : null, + onApprove: showActions + ? () => _handleApprovalAction( + pendingType == 'Pending Resolution' + ? 'approve_resolution' + : 'approve_close', + ) + : null, ); } if (it['type'] == 'issue_closed') { final creatorId = it['creatorId'] as String?; - final creator = - creatorId != null ? participants[creatorId] : null; - final creatorAvatarUrl = - creator != null ? creator['avatarUrl'] : null; - final creatorName = - creator != null ? creator['name'] : null; + final creator = creatorId != null + ? participants[creatorId] + : null; + final creatorAvatarUrl = creator != null + ? creator['avatarUrl'] + : null; + final creatorName = creator != null + ? creator['name'] + : null; final alignRight = (it['alignRight'] as bool?) ?? true; return IssueClosedBubble( @@ -1042,32 +1776,64 @@ class _ChatPageState extends State { if (it['type'] == 'outside_party') { final creatorId = it['creatorId'] as String?; - final creator = - creatorId != null ? participants[creatorId] : null; - final creatorAvatarUrl = - creator != null ? creator['avatarUrl'] : null; + final creator = creatorId != null + ? participants[creatorId] + : null; + final creatorAvatarUrl = creator != null + ? creator['avatarUrl'] as String? + : null; + final creatorName = creator != null + ? creator['name'] as String? + : null; final alignRight = (it['alignRight'] as bool?) ?? false; + final requestId = it['requestId'] as String?; + final requestStatus = it['status'] as String?; + + final isManager = + myRole == UserRole.executive || + myRole == UserRole.branchManager; return OutsidePartySuggestionBubble( - creatorAvatarUrl: creatorAvatarUrl, - partyName: it['partyName'] as String, - suggestedTime: it['timeText'] as String, + vendorName: it['vendorName'] as String, + description: it['description'] as String? ?? '', + timeText: it['timeText'] as String, + status: requestStatus, alignRight: alignRight, + creatorAvatarUrl: creatorAvatarUrl, + creatorName: creatorName, + senderInfoText: creatorName != null + ? '${it['timeText']} · $creatorName' + : null, + onApprove: requestStatus == 'pending' && isManager + ? () => + _handleOutsidePartyAction(requestId, 'approve') + : null, + onReject: requestStatus == 'pending' && isManager + ? () => _handleOutsidePartyAction(requestId, 'reject') + : null, ); } if (it['type'] == 'petty_cash') { final creatorId = it['creatorId'] as String?; - final creator = - creatorId != null ? participants[creatorId] : null; - final creatorAvatarUrl = - creator != null ? creator['avatarUrl'] : null; - final creatorName = - creator != null ? creator['name'] : null; + final creator = creatorId != null + ? participants[creatorId] + : null; + final creatorAvatarUrl = creator != null + ? creator['avatarUrl'] + : null; + final creatorName = creator != null + ? creator['name'] + : null; final alignRight = (it['alignRight'] as bool?) ?? false; final requestId = it['requestId']; final requestStatus = it['status'] as String?; + final isManager = + myRole == UserRole.executive || + myRole == UserRole.branchManager; + final isTechnician = myRole == UserRole.technician; + return PettyCashRequestBubble( creatorAvatarUrl: creatorAvatarUrl, creatorName: creatorName ?? 'Unknown', @@ -1076,13 +1842,24 @@ class _ChatPageState extends State { description: it['description'] as String?, status: requestStatus, alignRight: alignRight, - onClose: requestStatus == 'pending' && myRole == UserRole.executive + senderInfoText: creatorName != null + ? '${it['timeText']} From ${creatorName}' + : null, + onCancel: requestStatus == 'pending' && isTechnician + ? () => _handlePettyCashAction(requestId, 'cancel') + : null, + onReject: requestStatus == 'pending' && isManager ? () => _handlePettyCashAction(requestId, 'reject') : null, - onAccept: requestStatus == 'pending' && myRole == UserRole.executive + onAccept: requestStatus == 'pending' && isManager ? () => _handlePettyCashAction(requestId, 'approve') : null, - onRequestCash: null, + onUndo: + (requestStatus == 'approved' || + requestStatus == 'rejected') && + isManager + ? () => _handlePettyCashAction(requestId, 'undo') + : null, ); } @@ -1095,13 +1872,14 @@ class _ChatPageState extends State { final isMe = senderUserId == currentUserId; final sender = participants[senderIdKey]; - final receiver = - receiverIdKey != null ? participants[receiverIdKey] : null; + final receiver = receiverIdKey != null + ? participants[receiverIdKey] + : null; // Determine whose avatar to show (the other person) final otherIdKey = isMe ? receiverIdKey : senderIdKey; - final otherAvatar = otherIdKey != null && - participants[otherIdKey] != null + final otherAvatar = + otherIdKey != null && participants[otherIdKey] != null ? participants[otherIdKey]!['avatarUrl'] : null; @@ -1149,6 +1927,46 @@ class _ChatPageState extends State { } } + /// Map the dialog dropdown status value (e.g. 'open', 'in_progress', 'resolved', 'closed') + /// to the backend Issue ENUM value ('Open', 'In Progress', 'Pending Resolution', 'Pending Close', 'Done', 'Closed') + String _mapDialogStatusToBackend(String dialogStatus) { + final role = AuthService.instance.currentUser?.role; + final isTechnician = role == 'technician'; + + switch (dialogStatus.toLowerCase()) { + case 'open': + return 'Open'; + case 'in_progress': + return 'In Progress'; + case 'resolved': + return isTechnician ? 'Pending Resolution' : 'Done'; + case 'closed': + return isTechnician ? 'Pending Close' : 'Closed'; + default: + return 'Open'; + } + } + + /// Map the dialog dropdown status value to the Statuses table status_type ENUM + /// ('Open', 'Assigned', 'In Progress', 'Pending Resolution', 'Pending Close', 'Resolved', 'Closed') + String _mapDialogStatusToStatusType(String dialogStatus) { + final role = AuthService.instance.currentUser?.role; + final isTechnician = role == 'technician'; + + switch (dialogStatus.toLowerCase()) { + case 'open': + return 'Open'; + case 'in_progress': + return 'In Progress'; + case 'resolved': + return isTechnician ? 'Pending Resolution' : 'Resolved'; + case 'closed': + return isTechnician ? 'Pending Close' : 'Closed'; + default: + return 'Open'; + } + } + /// Helper function to get severity info from issue status Map _getSeverityFromStatus(IssueStatus status) { switch (status) { @@ -1172,6 +1990,16 @@ class _ChatPageState extends State { 'label': 'Closed', 'color': const Color(0xFF78909C), // Grey - closed }; + case IssueStatus.pendingResolution: + return { + 'label': 'Pending Resolution', + 'color': const Color(0xFFAB47BC), // Purple + }; + case IssueStatus.pendingClose: + return { + 'label': 'Pending Close', + 'color': const Color(0xFFAB47BC), // Purple + }; } } } diff --git a/lib/features/chat/view/pages/image_viewer.dart b/lib/features/chat/view/pages/image_viewer.dart index 125c33d..f865088 100644 --- a/lib/features/chat/view/pages/image_viewer.dart +++ b/lib/features/chat/view/pages/image_viewer.dart @@ -3,11 +3,13 @@ import 'package:flutter/material.dart'; class ImageGalleryViewer extends StatefulWidget { final List urls; final int initialIndex; + final String heroTagPrefix; const ImageGalleryViewer({ super.key, required this.urls, this.initialIndex = 0, + this.heroTagPrefix = '', }); @override @@ -48,7 +50,7 @@ class _ImageGalleryViewerState extends State { minScale: 0.8, maxScale: 4, child: Hero( - tag: url, + tag: '${widget.heroTagPrefix}${url}_$i', child: Image.network( url, fit: BoxFit.contain, diff --git a/lib/features/chat/view/widgets/action_dialogs.dart b/lib/features/chat/view/widgets/action_dialogs.dart index 5db7bcd..f44455a 100644 --- a/lib/features/chat/view/widgets/action_dialogs.dart +++ b/lib/features/chat/view/widgets/action_dialogs.dart @@ -930,7 +930,7 @@ class _SuggestOutsidePartyDialogState extends State { /// Dialog for updating issue status class UpdateStatusDialog extends StatefulWidget { final String currentStatus; - final Function(String newStatus, String? description, XFile? imageFile) onUpdate; + final Function(String newStatus, String? description, List imageFiles) onUpdate; const UpdateStatusDialog({ super.key, @@ -943,58 +943,65 @@ class UpdateStatusDialog extends StatefulWidget { } class _UpdateStatusDialogState extends State { - final TextEditingController _descriptionController = TextEditingController(); final ImagePicker _imagePicker = ImagePicker(); - XFile? _selectedImage; + final TextEditingController _descriptionController = TextEditingController(); + List _selectedImages = []; + List _imageBytesList = []; late String _selectedStatus; final List> _statusOptions = [ - {'value': 'open', 'label': 'Open', 'color': Colors.blue}, - {'value': 'in_progress', 'label': 'In Progress', 'color': Colors.orange}, - {'value': 'resolved', 'label': 'Resolved', 'color': Colors.green}, - {'value': 'closed', 'label': 'Closed', 'color': Colors.grey}, + {'value': 'open', 'label': 'Open', 'color': Color(0xFF3EA8D0)}, + {'value': 'in_progress', 'label': 'In Progress', 'color': Color(0xFFFFA726)}, + {'value': 'resolved', 'label': 'Resolved', 'color': Color(0xFF66BB6A)}, + {'value': 'closed', 'label': 'Closed', 'color': Color(0xFF78909C)}, ]; @override void initState() { super.initState(); - // Normalize the status value to match dropdown options _selectedStatus = _normalizeStatus(widget.currentStatus); } - /// Normalize status value to match dropdown options - String _normalizeStatus(String status) { - final normalized = status.toLowerCase().replaceAll(' ', '_'); - // Verify it's a valid status, otherwise default to 'open' - final validStatuses = _statusOptions.map((opt) => opt['value'] as String).toList(); - return validStatuses.contains(normalized) ? normalized : 'open'; - } - @override void dispose() { _descriptionController.dispose(); super.dispose(); } + String _normalizeStatus(String status) { + if (status.toLowerCase().contains('resolved') || status.toLowerCase().contains('done')) { + return 'resolved'; + } + final normalized = status.toLowerCase().replaceAll(' ', '_'); + final validStatuses = _statusOptions.map((opt) => opt['value'] as String).toList(); + return validStatuses.contains(normalized) ? normalized : 'open'; + } + Future _pickImage() async { try { - final XFile? pickedFile = await _imagePicker.pickImage( - source: ImageSource.gallery, + final List pickedFiles = await _imagePicker.pickMultiImage( maxWidth: 1920, maxHeight: 1080, imageQuality: 85, ); - - if (pickedFile != null && mounted) { + + if (pickedFiles.isNotEmpty && mounted) { + final List bytesList = []; + for (final file in pickedFiles) { + final bytes = await file.readAsBytes(); + bytesList.add(bytes); + } + setState(() { - _selectedImage = pickedFile; + _selectedImages.addAll(pickedFiles); + _imageBytesList.addAll(bytesList); }); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to pick image: ${e.toString()}'), + content: Text('Failed to pick images: ${e.toString()}'), backgroundColor: Colors.red, ), ); @@ -1002,160 +1009,264 @@ class _UpdateStatusDialogState extends State { } } + void _removeImage(int index) { + setState(() { + _selectedImages.removeAt(index); + _imageBytesList.removeAt(index); + }); + } + @override Widget build(BuildContext context) { return Dialog( + backgroundColor: const Color(0xFFEBEFF2), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), child: Container( width: double.infinity, - constraints: const BoxConstraints(maxWidth: 400), + constraints: const BoxConstraints(maxWidth: 420), padding: const EdgeInsets.all(24), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Title + // ── Title ── Text( 'Update the Status', style: GoogleFonts.outfit( fontSize: 20, fontWeight: FontWeight.w600, - color: AppColors.textTitle, + color: const Color(0xFF1E1E1E), ), ), - const SizedBox(height: 8), - // Subtitle + const SizedBox(height: 4), Text( 'Update the current status of the reported issue', style: GoogleFonts.outfit( - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.textSecondary, + fontSize: 13, + color: const Color(0xFF757575), ), ), - const SizedBox(height: 24), + const SizedBox(height: 20), - // Status selection dropdown + // ── Status Radio List ── Container( decoration: BoxDecoration( - border: Border.all(color: AppColors.grey), - borderRadius: BorderRadius.circular(8), + color: Colors.white, + borderRadius: BorderRadius.circular(10), ), - padding: const EdgeInsets.symmetric(horizontal: 12), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedStatus, - isExpanded: true, - icon: const Icon(Icons.arrow_drop_down, color: AppColors.textSecondary), - items: _statusOptions.map((option) { - return DropdownMenuItem( - value: option['value'] as String, - child: Row( - children: [ - Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: option['color'] as Color, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Text( - option['label'] as String, - style: GoogleFonts.outfit( - fontSize: 14, - color: AppColors.textPrimary, - ), + child: Column( + children: _statusOptions.asMap().entries.map((entry) { + final idx = entry.key; + final option = entry.value; + final value = option['value'] as String; + final label = option['label'] as String; + final color = option['color'] as Color; + final isSelected = _selectedStatus == value; + final isLast = idx == _statusOptions.length - 1; + + return Column( + children: [ + InkWell( + borderRadius: BorderRadius.vertical( + top: idx == 0 ? const Radius.circular(10) : Radius.zero, + bottom: isLast ? const Radius.circular(10) : Radius.zero, + ), + onTap: () => setState(() => _selectedStatus = value), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + // Radio dot + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: color, width: 2), + color: isSelected ? color : Colors.transparent, + ), + child: isSelected + ? Center( + child: Container( + width: 7, + height: 7, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + ) + : null, + ), + const SizedBox(width: 12), + // Color dot + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Text( + label, + style: GoogleFonts.outfit( + fontSize: 14, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: const Color(0xFF1E1E1E), + ), + ), + ], ), - ], + ), ), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() { - _selectedStatus = value; - }); - } - }, - ), + if (!isLast) + Divider(height: 1, color: Colors.grey.shade100), + ], + ); + }).toList(), ), ), const SizedBox(height: 16), - // Description text field - TextField( - controller: _descriptionController, - maxLines: 5, - decoration: InputDecoration( - hintText: 'Description (optional)', - hintStyle: GoogleFonts.outfit( - fontSize: 14, - color: AppColors.textDisabled, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.grey), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.grey), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.secondary), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + // ── Description field ── + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), ), - style: GoogleFonts.outfit( - fontSize: 14, - color: AppColors.textPrimary, + child: TextField( + controller: _descriptionController, + maxLines: 4, + decoration: InputDecoration( + hintText: 'Add a note (optional)', + hintStyle: GoogleFonts.outfit( + fontSize: 14, + color: const Color(0xFF9E9E9E), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + style: GoogleFonts.outfit(fontSize: 14, color: const Color(0xFF1E1E1E)), ), ), const SizedBox(height: 16), - // Upload Image button - InkWell( - onTap: _pickImage, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - border: Border.all(color: AppColors.grey), - borderRadius: BorderRadius.circular(8), + // ── Image picker / preview ── + if (_imageBytesList.isNotEmpty) ...[ + // Images preview grid with remove buttons + Container( + constraints: const BoxConstraints(maxHeight: 300), + child: GridView.builder( + shrinkWrap: true, + physics: const AlwaysScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 1, + ), + itemCount: _imageBytesList.length, + itemBuilder: (context, index) { + return Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory( + _imageBytesList[index], + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () => _removeImage(index), + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(4), + child: const Icon(Icons.close, color: Colors.white, size: 14), + ), + ), + ), + ], + ); + }, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _selectedImage == null ? 'Upload Image' : 'Image Selected', - style: GoogleFonts.outfit( - fontSize: 14, - color: _selectedImage == null - ? AppColors.textSecondary - : AppColors.secondary, - fontWeight: _selectedImage == null - ? FontWeight.w400 - : FontWeight.w500, + ), + const SizedBox(height: 12), + // Add more images button + InkWell( + onTap: _pickImage, + borderRadius: BorderRadius.circular(8), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFF3EA8D0)), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.add_photo_alternate, size: 18, color: Color(0xFF3EA8D0)), + const SizedBox(width: 6), + Text( + 'Add More Images', + style: GoogleFonts.outfit( + fontSize: 13, + color: const Color(0xFF3EA8D0), + fontWeight: FontWeight.w500, + ), ), - ), - Icon( - _selectedImage == null ? Icons.upload : Icons.check_circle, - size: 20, - color: _selectedImage == null - ? AppColors.textSecondary - : AppColors.secondary, - ), - ], + ], + ), ), ), - ), + ] else ...[ + // Pick image button + InkWell( + onTap: _pickImage, + borderRadius: BorderRadius.circular(10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade200), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.image_outlined, size: 22, color: Colors.grey.shade500), + const SizedBox(width: 10), + Text( + 'Attach Images (optional)', + style: GoogleFonts.outfit( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + ), + ], const SizedBox(height: 24), - // Action buttons + // ── Buttons ── Row( children: [ Expanded( @@ -1163,17 +1274,15 @@ class _UpdateStatusDialogState extends State { onPressed: () => Navigator.of(context).pop(), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - side: const BorderSide(color: AppColors.grey), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + side: BorderSide(color: Colors.grey.shade300), ), child: Text( 'Cancel', style: GoogleFonts.outfit( fontSize: 14, fontWeight: FontWeight.w500, - color: AppColors.textPrimary, + color: const Color(0xFF1E1E1E), ), ), ), @@ -1182,26 +1291,22 @@ class _UpdateStatusDialogState extends State { Expanded( child: ElevatedButton( onPressed: () { - final description = _descriptionController.text.trim().isEmpty + final desc = _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(); - widget.onUpdate(_selectedStatus, description, _selectedImage); + widget.onUpdate(_selectedStatus, desc, _selectedImages); Navigator.of(context).pop(); }, style: ElevatedButton.styleFrom( - backgroundColor: AppColors.secondary, + backgroundColor: const Color(0xFF3EA8D0), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + elevation: 0, ), child: Text( 'Finish', - style: GoogleFonts.outfit( - fontSize: 14, - fontWeight: FontWeight.w500, - ), + style: GoogleFonts.outfit(fontSize: 14, fontWeight: FontWeight.w500), ), ), ), @@ -1215,6 +1320,7 @@ class _UpdateStatusDialogState extends State { } } + /// Dialog for requesting petty cash (for technicians) class RequestPettyCashDialog extends StatefulWidget { final Function(double amount, String description) onRequest; @@ -1483,7 +1589,7 @@ Future showCloseIssueDialog(BuildContext context, Function(String?, XFile? Future showUpdateStatusDialog( BuildContext context, String currentStatus, - Function(String, String?, XFile?) onUpdate, + Function(String, String?, List) onUpdate, ) { return showDialog( context: context, diff --git a/lib/features/chat/view/widgets/messge_input_field.dart b/lib/features/chat/view/widgets/messge_input_field.dart index d20d50e..b2a5dd2 100644 --- a/lib/features/chat/view/widgets/messge_input_field.dart +++ b/lib/features/chat/view/widgets/messge_input_field.dart @@ -83,7 +83,7 @@ class _MessageInputFieldState extends State { break; case UserRole.technician: actions = const [ - 'Suggest Outside Support', + 'Suggest Outside Party', 'Request Petty Cash', 'Update the Status', ]; @@ -152,6 +152,8 @@ class _MessageInputFieldState extends State { switch (widget.role) { case UserRole.branchManager: // GDM actions = const [ + 'Update the Status', + 'Get Outside Support', 'Close Issue', ]; break; @@ -165,7 +167,7 @@ class _MessageInputFieldState extends State { break; case UserRole.technician: actions = const [ - 'Suggest Outside Support', + 'Suggest Outside Party', 'Request Petty Cash', 'Update the Status', ]; diff --git a/lib/features/chat/view/widgets/outside_party_suggestion_bubble.dart b/lib/features/chat/view/widgets/outside_party_suggestion_bubble.dart index 54292d2..350cc5a 100644 --- a/lib/features/chat/view/widgets/outside_party_suggestion_bubble.dart +++ b/lib/features/chat/view/widgets/outside_party_suggestion_bubble.dart @@ -1,123 +1,337 @@ import 'package:flutter/material.dart'; +/// Chat bubble shown when a user suggests an outside party vendor. +/// Shows Approve/Reject buttons for Branch Manager & Maintenance Executive. +/// Shows resolved state (Approved/Rejected) once action is taken. class OutsidePartySuggestionBubble extends StatelessWidget { - final String partyName; // e.g. "Oven Builders PVT Ltd." - final String suggestedTime; // e.g. "9:50 AM" + final String vendorName; + final String description; + final String timeText; + final String? status; // pending | approved | rejected final bool alignRight; final String? creatorAvatarUrl; - final VoidCallback? onTap; + final String? creatorName; + final String? senderInfoText; // e.g. "9:50 AM · Technician" + final VoidCallback? onApprove; + final VoidCallback? onReject; const OutsidePartySuggestionBubble({ super.key, - required this.partyName, - required this.suggestedTime, + required this.vendorName, + required this.description, + required this.timeText, + this.status, this.alignRight = false, this.creatorAvatarUrl, - this.onTap, + this.creatorName, + this.senderInfoText, + this.onApprove, + this.onReject, }); + static const _timeColor = Color(0xFF3EA8D0); + static const _approveColor = Color(0xFF66BB6A); // green + + bool get _isPending => status == null || status!.toLowerCase() == 'pending'; + bool get _isApproved => status?.toLowerCase() == 'approved'; + bool get _isRejected => status?.toLowerCase() == 'rejected'; + @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; - const avatarWithSpacing = 40.0; // ~32 avatar + 8 spacing - final maxWidth = (screenWidth * 0.86) - avatarWithSpacing; + const avatarSize = 32.0; + const avatarSpacing = 8.0; + final maxWidth = (screenWidth * 0.86) - avatarSize - avatarSpacing; return Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - child: Row( - mainAxisAlignment: alignRight - ? MainAxisAlignment.end - : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + child: Column( + crossAxisAlignment: + alignRight ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ - if (!alignRight) ...[ - _buildAvatar(creatorAvatarUrl), - const SizedBox(width: 8), - ], - ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth), - child: GestureDetector( - onTap: onTap, - child: Container( - decoration: BoxDecoration( - color: const Color(0xFFECE6F0), - borderRadius: const BorderRadius.all(Radius.circular(22)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Suggested an Outside Party', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.black, - ), + Row( + mainAxisAlignment: + alignRight ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!alignRight) ...[ + _buildAvatar(creatorAvatarUrl), + const SizedBox(width: avatarSpacing), + ], + + ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFECE6F0), + borderRadius: const BorderRadius.all(Radius.circular(20)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 3), ), - const SizedBox(height: 6), - Text( - partyName, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade700, - height: 1.2, + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // ── Title row ── + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 9, + height: 9, + decoration: const BoxDecoration( + color: Color(0xFF8B5CF6), // purple accent + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + const Text( + 'Suggested an Outside Party', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Color(0xFF1A1A1A), + ), + ), + ], ), - ), - const SizedBox(height: 10), - // icon + time aligned as in the mock - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon( - Icons.access_time_outlined, - size: 16, - color: Color(0xFF3EA8D0), + const SizedBox(height: 6), + + // ── Vendor name ── + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Vendor: ', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF444444), + ), + ), + Flexible( + child: Text( + vendorName, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF1A1A1A), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + + // ── Description ── + Text( + description, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF6B6B6B), + height: 1.3, + ), + ), + + const SizedBox(height: 8), + + // ── Pending chip ── + if (_isPending) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFAB47BC) + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: const Color(0xFFAB47BC), width: 1), + ), + child: const Text( + 'Awaiting Approval', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFFAB47BC), + ), + ), ), - const SizedBox(width: 6), - Text( - suggestedTime, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Color(0xFF3EA8D0), + + const SizedBox(height: 8), + + // ── Time row ── + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.access_time_outlined, + size: 15, color: _timeColor), + const SizedBox(width: 4), + Text( + timeText, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: _timeColor, + ), ), + ], + ), + + // ── RESOLVED STATE ── + if (_isApproved || _isRejected) ...[ + const SizedBox(height: 10), + const Text('· · ·', + style: TextStyle( + fontSize: 16, + color: Color(0xFFAAAAAA), + letterSpacing: 4)), + const SizedBox(height: 6), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _isApproved + ? Icons.check_box_outlined + : Icons.cancel_outlined, + size: 17, + color: _isApproved + ? _approveColor + : const Color(0xFFFF7489), + ), + const SizedBox(width: 5), + Flexible( + child: Text( + _isApproved + ? 'Outside Party Approved' + : 'Outside Party Rejected', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: _isApproved + ? _approveColor + : const Color(0xFFFF7489), + ), + ), + ), + ], ), ], - ), - ], + + // ── PENDING: action buttons (Manager only) ── + if (_isPending && + (onReject != null || onApprove != null)) ...[ + const SizedBox(height: 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (onReject != null) ...[ + _buildOutlinedButton( + label: 'Reject', + onPressed: onReject!, + ), + const SizedBox(width: 6), + ], + if (onApprove != null) + _buildFilledButton( + label: 'Approve', + color: _approveColor, + onPressed: onApprove!, + ), + ], + ), + ], + ], + ), ), ), ), - ), + + if (alignRight) ...[ + const SizedBox(width: avatarSpacing), + _buildAvatar(creatorAvatarUrl), + ], + ], ), - if (alignRight) ...[ - const SizedBox(width: 8), - _buildAvatar(creatorAvatarUrl), + + // ── Sender info footer ── + if (senderInfoText != null) ...[ + const SizedBox(height: 3), + Padding( + padding: EdgeInsets.only( + left: alignRight ? 0 : avatarSize + avatarSpacing, + right: alignRight ? avatarSize + avatarSpacing : 0, + ), + child: Text( + senderInfoText!, + style: const TextStyle(fontSize: 11, color: Color(0xFFAAAAAA)), + ), + ), ], ], ), ); } + Widget _buildOutlinedButton({ + required String label, + required VoidCallback onPressed, + }) { + return OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1A1A1A), + side: const BorderSide(color: Color(0xFFCCCCCC)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: Text(label, + style: + const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + ); + } + + Widget _buildFilledButton({ + required String label, + required Color color, + required VoidCallback onPressed, + }) { + return ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: Text(label, + style: + const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + ); + } + Widget _buildAvatar(String? url) { return CircleAvatar( radius: 16, - backgroundColor: const Color(0xFFFF7489), - backgroundImage: (url != null && url.isNotEmpty) - ? NetworkImage(url) - : null, + backgroundColor: const Color(0xFFE8D5FF), + backgroundImage: + (url != null && url.isNotEmpty) ? NetworkImage(url) : null, child: (url == null || url.isEmpty) - ? const Icon(Icons.person, size: 18, color: Color(0xFF8AA4B8)) + ? const Icon(Icons.person, size: 18, color: Color(0xFF8B5CF6)) : null, ); } diff --git a/lib/features/chat/view/widgets/pending_approval_bubble.dart b/lib/features/chat/view/widgets/pending_approval_bubble.dart new file mode 100644 index 0000000..7097987 --- /dev/null +++ b/lib/features/chat/view/widgets/pending_approval_bubble.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; + +/// Chat bubble shown when a technician requests Resolved or Closed approval. +/// Styled identically to [PettyCashRequestBubble]. +class PendingApprovalBubble extends StatelessWidget { + /// 'Pending Resolution' or 'Pending Close' + final String pendingType; + + final String timeText; + final bool alignRight; + final String? creatorAvatarUrl; + final String? requesterName; + final String? senderInfoText; // e.g. "9:50 AM From Technician" + + /// Branch Manager / Maintenance Executive only + final VoidCallback? onReject; + final VoidCallback? onApprove; + + const PendingApprovalBubble({ + super.key, + required this.pendingType, + required this.timeText, + this.requesterName, + this.alignRight = false, + this.creatorAvatarUrl, + this.senderInfoText, + this.onReject, + this.onApprove, + }); + + static const _timeColor = Color(0xFF3EA8D0); + + bool get _isResolution => pendingType == 'Pending Resolution'; + + String get _title => + _isResolution ? 'Requested Resolution' : 'Requested Close'; + + String get _description => _isResolution + ? 'Technician requests to mark this issue as Resolved.' + : 'Technician requests to close this issue.'; + + Color get _accentColor => + _isResolution ? const Color(0xFF66BB6A) : const Color(0xFF78909C); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + const avatarSize = 32.0; + const avatarSpacing = 8.0; + final maxWidth = (screenWidth * 0.86) - avatarSize - avatarSpacing; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + child: Column( + crossAxisAlignment: + alignRight ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: + alignRight ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Avatar left + if (!alignRight) ...[ + _buildAvatar(creatorAvatarUrl), + const SizedBox(width: avatarSpacing), + ], + + // Bubble + ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFECE6F0), + borderRadius: const BorderRadius.all(Radius.circular(20)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // ── Title row with accent dot ── + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 9, + height: 9, + decoration: BoxDecoration( + color: _accentColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + _title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Color(0xFF1A1A1A), + ), + ), + ], + ), + const SizedBox(height: 5), + + // ── Description ── + Text( + _description, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF6B6B6B), + height: 1.3, + ), + ), + + const SizedBox(height: 8), + + // ── "Awaiting Approval" chip ── + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: + const Color(0xFFAB47BC).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: const Color(0xFFAB47BC), width: 1), + ), + child: const Text( + 'Awaiting Approval', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFFAB47BC), + ), + ), + ), + + const SizedBox(height: 8), + + // ── Time row ── + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.access_time_outlined, + size: 15, + color: _timeColor, + ), + const SizedBox(width: 4), + Text( + timeText, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: _timeColor, + ), + ), + ], + ), + + // ── Action buttons (Manager / Executive only) ── + if (onReject != null || onApprove != null) ...[ + const SizedBox(height: 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (onReject != null) ...[ + _buildOutlinedButton( + label: 'Reject', + onPressed: onReject!, + ), + const SizedBox(width: 6), + ], + if (onApprove != null) + _buildFilledButton( + label: 'Approve', + color: _accentColor, + onPressed: onApprove!, + ), + ], + ), + ], + ], + ), + ), + ), + ), + + // Avatar right + if (alignRight) ...[ + const SizedBox(width: avatarSpacing), + _buildAvatar(creatorAvatarUrl), + ], + ], + ), + + // ── Sender info footer ── + if (senderInfoText != null) ...[ + const SizedBox(height: 3), + Padding( + padding: EdgeInsets.only( + left: alignRight ? 0 : avatarSize + avatarSpacing, + right: alignRight ? avatarSize + avatarSpacing : 0, + ), + child: Text( + senderInfoText!, + style: const TextStyle( + fontSize: 11, + color: Color(0xFFAAAAAA), + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildOutlinedButton({ + required String label, + required VoidCallback onPressed, + }) { + return OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1A1A1A), + side: const BorderSide(color: Color(0xFFCCCCCC)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + label, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), + ); + } + + Widget _buildFilledButton({ + required String label, + required Color color, + required VoidCallback onPressed, + }) { + return ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + label, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), + ); + } + + Widget _buildAvatar(String? url) { + return CircleAvatar( + radius: 16, + backgroundColor: const Color(0xFFFFD6DC), + backgroundImage: + (url != null && url.isNotEmpty) ? NetworkImage(url) : null, + child: (url == null || url.isEmpty) + ? const Icon(Icons.person, size: 18, color: Color(0xFFFF7489)) + : null, + ); + } +} diff --git a/lib/features/chat/view/widgets/petty_cash_request_bubble.dart b/lib/features/chat/view/widgets/petty_cash_request_bubble.dart index f768724..39de115 100644 --- a/lib/features/chat/view/widgets/petty_cash_request_bubble.dart +++ b/lib/features/chat/view/widgets/petty_cash_request_bubble.dart @@ -8,9 +8,11 @@ class PettyCashRequestBubble extends StatelessWidget { final String? creatorName; final String? description; final String? status; - final VoidCallback? onClose; + final String? senderInfoText; // e.g. "8:58 AM From GPM" + final VoidCallback? onReject; final VoidCallback? onAccept; - final VoidCallback? onRequestCash; + final VoidCallback? onCancel; + final VoidCallback? onUndo; const PettyCashRequestBubble({ super.key, @@ -19,269 +21,314 @@ class PettyCashRequestBubble extends StatelessWidget { this.alignRight = false, this.creatorAvatarUrl, this.creatorName, - this.description, - this.status, - this.onClose, + this.description, + this.status, + this.senderInfoText, + this.onReject, this.onAccept, - this.onRequestCash, + this.onCancel, + this.onUndo, }); static const _timeColor = Color(0xFF3EA8D0); - static const _requestIconAsset = 'assets/icons/mail-icon.png'; // <-- updated + + bool get _isApproved => status?.toLowerCase() == 'approved'; + bool get _isRejected => status?.toLowerCase() == 'rejected'; + bool get _isPending => status?.toLowerCase() == 'pending' || status == null; @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; - const avatarWithSpacing = 40.0; // ~32 avatar + 8 spacing - final maxWidth = (screenWidth * 0.86) - avatarWithSpacing; + const avatarSize = 32.0; + const avatarSpacing = 8.0; + final maxWidth = (screenWidth * 0.86) - avatarSize - avatarSpacing; return Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - child: Row( - mainAxisAlignment: alignRight - ? MainAxisAlignment.end - : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + child: Column( + crossAxisAlignment: + alignRight ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ - if (!alignRight) ...[ - _buildAvatar(creatorAvatarUrl), - const SizedBox(width: 8), - ], - ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth), - child: Container( - decoration: BoxDecoration( - color: const Color(0xFFECE6F0), - borderRadius: const BorderRadius.all(Radius.circular(22)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Requested by: ${creatorName ?? 'Unknown'}', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Colors.black54, - ), - ), - const SizedBox(height: 4), - const Text( - 'Requested Petty Cash', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.black, - ), - ), - const SizedBox(height: 6), - Text( - amountLabel, - style: TextStyle( - fontSize: 16, - color: Colors.grey.shade600, - height: 1.2, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - if (description != null && description!.trim().isNotEmpty) - Text( - description!, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade700, - ), - ), - if (status != null && status!.trim().isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: _buildStatusChip(status!), + Row( + mainAxisAlignment: + alignRight ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Avatar on left + if (!alignRight) ...[ + _buildAvatar(creatorAvatarUrl), + const SizedBox(width: avatarSpacing), + ], + + // Bubble + ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFECE6F0), + borderRadius: const BorderRadius.all(Radius.circular(20)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 3), ), - const SizedBox(height: 10), - Row( + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - const Icon( - Icons.access_time_outlined, - size: 16, - color: _timeColor, + // ── Title ── + const Text( + 'Requested Petty Cash', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Color(0xFF1A1A1A), + ), ), - const SizedBox(width: 6), + const SizedBox(height: 3), + + // ── Amount ── Text( - timeText, + amountLabel, style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: _timeColor, + fontSize: 15, + color: Color(0xFF6B6B6B), + fontWeight: FontWeight.w500, ), ), - ], - ), - const SizedBox(height: 12), - // Buttons in one line (reduced size) - Row( - children: [ - Flexible( - flex: 4, - child: OutlinedButton( - onPressed: onClose, - style: OutlinedButton.styleFrom( - backgroundColor: const Color(0xFFFF7489), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 10, - ), - minimumSize: const Size(0, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), - ), - child: const FittedBox( - child: Text( - 'Reject', - style: TextStyle(fontSize: 14), - maxLines: 1, - ), + + // ── Optional description ── + if (description != null && + description!.trim().isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + description!, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF888888), ), ), - ), - const SizedBox(width: 8), - Flexible( - flex: 4, - child: ElevatedButton( - onPressed: onAccept, - style: ElevatedButton.styleFrom( - backgroundColor: _timeColor, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - minimumSize: const Size(0, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), + ], + + const SizedBox(height: 8), + + // ── Time row ── + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.access_time_outlined, + size: 15, + color: _timeColor, ), - child: const Text( - 'Accept', - style: TextStyle(fontSize: 14), + const SizedBox(width: 4), + Text( + timeText, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: _timeColor, + ), ), - ), + ], ), - const SizedBox(width: 8), - Flexible( - flex: 6, - child: OutlinedButton.icon( - onPressed: onRequestCash, - icon: Image.asset( - _requestIconAsset, - width: 18, - height: 18, - errorBuilder: (_, error, stackTrace) => - const Icon( - Icons.payments_outlined, - size: 18, - color: Color(0xFF727272), - ), - ), - label: const Text( - 'Request', - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 14), + + // ── RESOLVED STATE ── + if (_isApproved || _isRejected) ...[ + const SizedBox(height: 10), + const Text( + '· · ·', + style: TextStyle( + fontSize: 16, + color: Color(0xFFAAAAAA), + letterSpacing: 4, ), - style: OutlinedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: const Color(0xFF727272), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 10, + ), + const SizedBox(height: 6), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _isApproved + ? Icons.check_box_outlined + : Icons.cancel_outlined, + size: 17, + color: _isApproved + ? _timeColor + : const Color(0xFFFF7489), ), - minimumSize: const Size(0, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), + const SizedBox(width: 5), + Flexible( + child: Text( + _isApproved + ? 'Cash Request Accepted' + : 'Cash Request Rejected', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: _isApproved + ? _timeColor + : const Color(0xFFFF7489), + ), + ), ), - ), + if (onUndo != null) ...[ + const SizedBox(width: 8), + _buildSmallOutlinedButton( + label: 'Undo', + color: const Color(0xFF666666), + borderColor: const Color(0xFFCCCCCC), + bgColor: Colors.white, + onPressed: onUndo!, + ), + ], + ], ), - ), + ], + + // ── PENDING: action buttons ── + if (_isPending && + (onReject != null || + onAccept != null || + onCancel != null)) ...[ + const SizedBox(height: 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Technician: Cancel + if (onCancel != null) ...[ + _buildSmallOutlinedButton( + label: 'Cancel', + color: const Color(0xFF1A1A1A), + borderColor: const Color(0xFFCCCCCC), + bgColor: Colors.white, + onPressed: onCancel!, + ), + ], + // Manager: Reject + if (onReject != null) ...[ + _buildSmallOutlinedButton( + label: 'Close', + color: const Color(0xFF1A1A1A), + borderColor: const Color(0xFFCCCCCC), + bgColor: Colors.white, + onPressed: onReject!, + ), + const SizedBox(width: 6), + ], + // Manager: Accept + if (onAccept != null) ...[ + _buildFilledButton( + label: 'Accept', + onPressed: onAccept!, + ), + ], + ], + ), + ], ], ), - ], + ), ), ), - ), + + // Avatar on right + if (alignRight) ...[ + const SizedBox(width: avatarSpacing), + _buildAvatar(creatorAvatarUrl), + ], + ], ), - if (alignRight) ...[ - const SizedBox(width: 8), - _buildAvatar(creatorAvatarUrl), + + // ── Sender info footer ── + if (senderInfoText != null) ...[ + const SizedBox(height: 3), + Padding( + padding: EdgeInsets.only( + left: alignRight ? 0 : avatarSize + avatarSpacing, + right: alignRight ? avatarSize + avatarSpacing : 0, + ), + child: Text( + senderInfoText!, + style: const TextStyle( + fontSize: 11, + color: Color(0xFFAAAAAA), + ), + ), + ), ], ], ), ); } - Widget _buildAvatar(String? url) { - return CircleAvatar( - radius: 16, - backgroundColor: const Color(0xFFFF7489), - backgroundImage: (url != null && url.isNotEmpty) - ? NetworkImage(url) - : null, - child: (url == null || url.isEmpty) - ? const Icon(Icons.person, size: 18, color: Color(0xFF8AA4B8)) - : null, + Widget _buildSmallOutlinedButton({ + required String label, + required Color color, + required Color borderColor, + required Color bgColor, + required VoidCallback onPressed, + }) { + return OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + backgroundColor: bgColor, + foregroundColor: color, + side: BorderSide(color: borderColor), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + label, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), ); } - Widget _buildStatusChip(String status) { - final s = status.toLowerCase(); - Color bgColor; - Color textColor = Colors.black87; - - if (s == 'pending') { - bgColor = Colors.orange.shade100; - textColor = Colors.orange.shade800; - } else if (s == 'approved' || s == 'accepted') { - bgColor = Colors.green.shade100; - textColor = Colors.green.shade800; - } else if (s == 'rejected' || s == 'declined') { - bgColor = Colors.red.shade100; - textColor = Colors.red.shade800; - } else { - bgColor = Colors.grey.shade200; - textColor = Colors.black87; - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(12), + Widget _buildFilledButton({ + required String label, + required VoidCallback onPressed, + }) { + return ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: _timeColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), child: Text( - status[0].toUpperCase() + status.substring(1), - style: TextStyle( - color: textColor, - fontWeight: FontWeight.w700, - fontSize: 12, - ), + label, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), ), ); } + + Widget _buildAvatar(String? url) { + return CircleAvatar( + radius: 16, + backgroundColor: const Color(0xFFFFD6DC), + backgroundImage: + (url != null && url.isNotEmpty) ? NetworkImage(url) : null, + child: (url == null || url.isEmpty) + ? const Icon(Icons.person, size: 18, color: Color(0xFFFF7489)) + : null, + ); + } } diff --git a/lib/features/chat/view/widgets/status_update_bubble.dart b/lib/features/chat/view/widgets/status_update_bubble.dart index 348ad3a..f240afc 100644 --- a/lib/features/chat/view/widgets/status_update_bubble.dart +++ b/lib/features/chat/view/widgets/status_update_bubble.dart @@ -8,6 +8,10 @@ class StatusUpdateBubble extends StatelessWidget { final String? creatorAvatarUrl; final bool alignRight; final String? creatorName; // kept for API compatibility + final String? statusType; // e.g. 'Open', 'In Progress', 'Resolved', 'Closed' + final bool showApprovalActions; + final VoidCallback? onApprove; + final VoidCallback? onReject; const StatusUpdateBubble({ super.key, @@ -17,6 +21,10 @@ class StatusUpdateBubble extends StatelessWidget { this.creatorAvatarUrl, this.alignRight = false, this.creatorName, + this.statusType, + this.showApprovalActions = false, + this.onApprove, + this.onReject, }); @override @@ -25,6 +33,9 @@ class StatusUpdateBubble extends StatelessWidget { const avatarWithSpacing = 40.0; // ~32 avatar + 8 spacing final maxWidth = (screenWidth * 0.86) - avatarWithSpacing; + // Filter out any empty URLs + final validAttachments = attachments.where((u) => u.trim().isNotEmpty).toList(); + return Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), child: Row( @@ -39,38 +50,66 @@ class StatusUpdateBubble extends StatelessWidget { ], ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth), - child: Column( - crossAxisAlignment: alignRight - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - // Bubble - Container( - decoration: BoxDecoration( - color: const Color(0xFFECE6F0), - borderRadius: const BorderRadius.all(Radius.circular(22)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFECE6F0), + borderRadius: const BorderRadius.all(Radius.circular(22)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, 4), ), - child: Padding( - padding: const EdgeInsets.all(16), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Text content ───────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Status Update', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.black, - ), + // Header row: "Status Update" + badge + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Status Update', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + if (statusType != null) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: _statusColor(statusType!) + .withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _statusColor(statusType!), + width: 1), + ), + child: Text( + statusType!, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: _statusColor(statusType!), + ), + ), + ), + ], + ], ), const SizedBox(height: 8), + // Description Text( description, style: TextStyle( @@ -79,7 +118,7 @@ class StatusUpdateBubble extends StatelessWidget { color: Colors.grey.shade700, ), ), - // Updated time inside the box + // Time if ((timeText ?? '').trim().isNotEmpty) ...[ const SizedBox(height: 12), Row( @@ -102,72 +141,138 @@ class StatusUpdateBubble extends StatelessWidget { ], ), ], - ], - ), - ), - ), - - // Attachments thumbnails with viewer (kept below the bubble) - if (attachments.isNotEmpty) ...[ - const SizedBox(height: 10), - SizedBox( - height: 86, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: attachments.length, - separatorBuilder: (_, index) => const SizedBox(width: 12), - itemBuilder: (context, i) { - final url = attachments[i]; - final hasImage = url.isNotEmpty; - final thumb = ClipRRect( - borderRadius: BorderRadius.circular(18), - child: Container( - width: 100, - height: 86, - color: const Color(0xFFF0ECF8), - child: hasImage - ? Hero( - tag: url, - child: Image.network( - url, - fit: BoxFit.cover, - errorBuilder: (_, error, stackTrace) => - Icon( - Icons.broken_image_outlined, - color: Colors.grey.shade500, - ), + // Approval Actions + if (showApprovalActions) ...[ + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onReject, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), - ) - : Icon( - Icons.image_outlined, - color: Colors.grey.shade500, + padding: const EdgeInsets.symmetric( + vertical: 10), ), - ), - ); - - return hasImage - ? GestureDetector( - onTap: () { - final imgs = attachments - .where((u) => u.isNotEmpty) - .toList(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ImageGalleryViewer( - urls: imgs, - initialIndex: imgs.indexOf(url), - ), + child: const Text('Reject'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: onApprove, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), - ); - }, - child: thumb, - ) - : thumb; - }, + padding: const EdgeInsets.symmetric( + vertical: 10), + elevation: 0, + ), + child: const Text('Approve'), + ), + ), + ], + ), + ], + // Bottom padding when no images follow + if (validAttachments.isEmpty) + const SizedBox(height: 16), + ], ), ), + + // ── Image previews (inside bubble) ─────────────────── + if (validAttachments.isNotEmpty) ...[ + const SizedBox(height: 10), + + // Single image: full-width tall preview + if (validAttachments.length == 1) + Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 10), + child: _buildImageTile( + context, + validAttachments[0], + validAttachments, + 0, + height: 180, + radius: 14, + ), + ), + + // Two images: side-by-side + if (validAttachments.length == 2) + Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 10), + child: Row( + children: [ + Expanded( + child: _buildImageTile( + context, + validAttachments[0], + validAttachments, + 0, + height: 120, + radius: 12, + ), + ), + const SizedBox(width: 6), + Expanded( + child: _buildImageTile( + context, + validAttachments[1], + validAttachments, + 1, + height: 120, + radius: 12, + ), + ), + ], + ), + ), + + // 3+ images: first large + scrollable strip below + if (validAttachments.length >= 3) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 6), + child: _buildImageTile( + context, + validAttachments[0], + validAttachments, + 0, + height: 150, + radius: 12, + ), + ), + SizedBox( + height: 80, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(10, 0, 10, 10), + itemCount: validAttachments.length - 1, + separatorBuilder: (_, __) => + const SizedBox(width: 6), + itemBuilder: (ctx, i) => _buildImageTile( + ctx, + validAttachments[i + 1], + validAttachments, + i + 1, + height: 70, + width: 90, + radius: 10, + ), + ), + ), + ], + ], ], - ], + ), ), ), if (alignRight) ...[ @@ -179,13 +284,107 @@ class StatusUpdateBubble extends StatelessWidget { ); } + // ── Helpers ──────────────────────────────────────────────────────────────── + + Widget _buildImageTile( + BuildContext context, + String url, + List allUrls, + int index, { + required double height, + double? width, + required double radius, + }) { + return GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ImageGalleryViewer( + urls: allUrls, + initialIndex: index, + heroTagPrefix: 'status_', + ), + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: SizedBox( + width: width, + height: height, + child: Hero( + tag: 'status_${url}_$index', + child: Image.network( + url, + fit: BoxFit.cover, + width: width ?? double.infinity, + height: height, + loadingBuilder: (ctx, child, progress) { + if (progress == null) return child; + return Container( + color: const Color(0xFFDDD6F3), + child: Center( + child: CircularProgressIndicator( + value: progress.expectedTotalBytes != null + ? progress.cumulativeBytesLoaded / + progress.expectedTotalBytes! + : null, + strokeWidth: 2, + color: const Color(0xFF7C5CBF), + ), + ), + ); + }, + errorBuilder: (_, __, ___) => Container( + color: const Color(0xFFE8E0F0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.broken_image_outlined, + color: Colors.grey.shade500, size: 28), + const SizedBox(height: 4), + Text( + 'Failed to load', + style: TextStyle( + fontSize: 10, color: Colors.grey.shade500), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + Color _statusColor(String type) { + switch (type.toLowerCase()) { + case 'open': + return const Color(0xFF3EA8D0); + case 'assigned': + return const Color(0xFF9C27B0); + case 'in progress': + return const Color(0xFFFFA726); + case 'resolved': + return const Color(0xFF66BB6A); + case 'closed': + return const Color(0xFF78909C); + case 'pending resolution': + return const Color(0xFFAB47BC); + case 'pending close': + return const Color(0xFFAB47BC); + default: + return const Color(0xFF3EA8D0); + } + } + Widget _buildAvatar(String? url) { return CircleAvatar( radius: 16, backgroundColor: const Color(0xFFFF7489), - backgroundImage: (url != null && url.isNotEmpty) - ? NetworkImage(url) - : null, + backgroundImage: + (url != null && url.isNotEmpty) ? NetworkImage(url) : null, child: (url == null || url.isEmpty) ? const Icon(Icons.person, size: 18, color: Color(0xFF8AA4B8)) : null, diff --git a/lib/features/chat/view/widgets/ticket_bubble.dart b/lib/features/chat/view/widgets/ticket_bubble.dart index 752eeca..f01831e 100644 --- a/lib/features/chat/view/widgets/ticket_bubble.dart +++ b/lib/features/chat/view/widgets/ticket_bubble.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:mobile/features/chat/view/pages/image_viewer.dart'; // added +import 'package:mobile/features/chat/view/pages/image_viewer.dart'; class TicketBubble extends StatelessWidget { final String title; @@ -12,9 +12,8 @@ class TicketBubble extends StatelessWidget { final bool alignRight; // if you ever need to align to right final VoidCallback? onTap; final String? creatorAvatarUrl; - final String? creatorName; // added - final String? - occurrenceTimeText; // NEW: issue occurrence time (inside the box) + final String? creatorName; + final String? occurrenceTimeText; // issue occurrence time (inside the box) const TicketBubble({ super.key, @@ -28,8 +27,8 @@ class TicketBubble extends StatelessWidget { this.alignRight = false, this.onTap, this.creatorAvatarUrl, - this.creatorName, // added - this.occurrenceTimeText, // NEW + this.creatorName, + this.occurrenceTimeText, }); @override @@ -38,12 +37,15 @@ class TicketBubble extends StatelessWidget { const avatarWithSpacing = 40.0; // ~32 avatar + 8 spacing final maxWidth = (screenWidth * 0.86) - avatarWithSpacing; + // Filter out empty URLs + final validAttachments = + attachments.where((u) => u.trim().isNotEmpty).toList(); + return Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), child: Row( - mainAxisAlignment: alignRight - ? MainAxisAlignment.end - : MainAxisAlignment.start, + mainAxisAlignment: + alignRight ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!alignRight) ...[ @@ -62,12 +64,8 @@ class TicketBubble extends StatelessWidget { child: Container( decoration: BoxDecoration( color: const Color(0xFFECE6F0), - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(22), - topRight: Radius.circular(22), - bottomLeft: Radius.circular(22), - bottomRight: Radius.circular(22), - ), + borderRadius: + const BorderRadius.all(Radius.circular(22)), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.04), @@ -76,21 +74,49 @@ class TicketBubble extends StatelessWidget { ), ], ), - child: Stack( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Main content + // ── Text content ── Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.black, - ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: severityColor, + borderRadius: + BorderRadius.circular(16), + ), + child: Text( + severityLabel, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], ), const SizedBox(height: 8), Text( @@ -102,9 +128,10 @@ class TicketBubble extends StatelessWidget { ), ), const SizedBox(height: 12), - // Branch + occurrence time shown together inside the box (blue) + // Branch + occurrence time Wrap( - crossAxisAlignment: WrapCrossAlignment.center, + crossAxisAlignment: + WrapCrossAlignment.center, spacing: 12, runSpacing: 6, children: [ @@ -155,104 +182,104 @@ class TicketBubble extends StatelessWidget { ), ), - // Severity chip near top-right corner - PositionedDirectional( - top: 12, // was 6 - end: 18, // was 12 - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 3, + // ── Image previews (inside bubble) ── + if (validAttachments.isNotEmpty) ...[ + // Single image: full-width tall preview + if (validAttachments.length == 1) + Padding( + padding: + const EdgeInsets.fromLTRB(10, 0, 10, 10), + child: _buildImageTile( + context, + validAttachments[0], + validAttachments, + 0, + height: 180, + radius: 14, + ), ), - decoration: BoxDecoration( - color: severityColor, - borderRadius: BorderRadius.circular(16), + + // Two images: side-by-side + if (validAttachments.length == 2) + Padding( + padding: + const EdgeInsets.fromLTRB(10, 0, 10, 10), + child: Row( + children: [ + Expanded( + child: _buildImageTile( + context, + validAttachments[0], + validAttachments, + 0, + height: 120, + radius: 12, + ), + ), + const SizedBox(width: 6), + Expanded( + child: _buildImageTile( + context, + validAttachments[1], + validAttachments, + 1, + height: 120, + radius: 12, + ), + ), + ], + ), ), - child: Text( - severityLabel, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.white, + + // 3+ images: large first + scrollable strip + if (validAttachments.length >= 3) ...[ + Padding( + padding: + const EdgeInsets.fromLTRB(10, 0, 10, 6), + child: _buildImageTile( + context, + validAttachments[0], + validAttachments, + 0, + height: 150, + radius: 12, ), ), - ), - ), + SizedBox( + height: 80, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB( + 10, 0, 10, 10), + itemCount: validAttachments.length - 1, + separatorBuilder: (_, __) => + const SizedBox(width: 6), + itemBuilder: (ctx, i) => _buildImageTile( + ctx, + validAttachments[i + 1], + validAttachments, + i + 1, + height: 70, + width: 90, + radius: 10, + ), + ), + ), + ], + ], ], ), ), ), const SizedBox(height: 4), - // Attachments row (tap to view) - if (attachments.isNotEmpty) ...[ - const SizedBox(height: 10), - SizedBox( - height: 86, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: attachments.length, - separatorBuilder: (_, index) => const SizedBox(width: 12), - itemBuilder: (context, i) { - final url = attachments[i]; - final hasImage = url.isNotEmpty; - final thumb = ClipRRect( - borderRadius: BorderRadius.circular(18), - child: Container( - width: 100, - height: 86, - color: const Color(0xFFF0ECF8), - child: hasImage - ? Hero( - tag: url, - child: Image.network( - url, - fit: BoxFit.cover, - errorBuilder: (_, error, stackTrace) => - Icon( - Icons.broken_image_outlined, - color: Colors.grey.shade500, - ), - ), - ) - : Icon( - Icons.image_outlined, - color: Colors.grey.shade500, - ), - ), - ); - - return hasImage - ? GestureDetector( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ImageGalleryViewer( - urls: attachments - .where((u) => u.isNotEmpty) - .toList(), - initialIndex: attachments - .where((u) => u.isNotEmpty) - .toList() - .indexOf(url), - ), - ), - ); - }, - child: thumb, - ) - : thumb; - }, - ), - ), - const SizedBox(height: 6), - ], + // Meta text (time + sender name below the bubble) Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Text( _buildMetaText( timeText: timeText, senderName: creatorName, - ), // creation time below (after images) + ), style: TextStyle(fontSize: 11, color: Colors.grey[600]), ), ), @@ -268,13 +295,86 @@ class TicketBubble extends StatelessWidget { ); } + // ── Helpers ── + + Widget _buildImageTile( + BuildContext context, + String url, + List allUrls, + int index, { + required double height, + double? width, + required double radius, + }) { + return GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ImageGalleryViewer( + urls: allUrls, + initialIndex: index, + heroTagPrefix: 'ticket_', + ), + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: SizedBox( + width: width, + height: height, + child: Hero( + tag: 'ticket_${url}_$index', + child: Image.network( + url, + fit: BoxFit.cover, + width: width ?? double.infinity, + height: height, + loadingBuilder: (ctx, child, progress) { + if (progress == null) return child; + return Container( + color: const Color(0xFFDDD6F3), + child: Center( + child: CircularProgressIndicator( + value: progress.expectedTotalBytes != null + ? progress.cumulativeBytesLoaded / + progress.expectedTotalBytes! + : null, + strokeWidth: 2, + color: const Color(0xFF7C5CBF), + ), + ), + ); + }, + errorBuilder: (_, __, ___) => Container( + color: const Color(0xFFE8E0F0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.broken_image_outlined, + color: Colors.grey.shade500, size: 28), + const SizedBox(height: 4), + Text( + 'Failed to load', + style: TextStyle( + fontSize: 10, color: Colors.grey.shade500), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + Widget _buildAvatar(String? url) { return CircleAvatar( radius: 16, backgroundColor: const Color(0xFFFF7489), - backgroundImage: (url != null && url.isNotEmpty) - ? NetworkImage(url) - : null, + backgroundImage: + (url != null && url.isNotEmpty) ? NetworkImage(url) : null, child: (url == null || url.isEmpty) ? const Icon(Icons.person, size: 18, color: Color(0xFF8AA4B8)) : null, diff --git a/lib/features/home/view/branch_manager_home_page.dart b/lib/features/home/view/branch_manager_home_page.dart index 78ee753..524e7e7 100644 --- a/lib/features/home/view/branch_manager_home_page.dart +++ b/lib/features/home/view/branch_manager_home_page.dart @@ -248,7 +248,14 @@ class _BranchManagerHomePageState extends State { return ListenableBuilder( listenable: _issueController, builder: (context, _) { - final issues = _issueController.issues.where((issue) => issue.status == status).toList(); + final issues = _issueController.issues.where((issue) { + if (status == IssueStatus.inProgress) { + return issue.status == IssueStatus.inProgress || + issue.status == IssueStatus.pendingResolution || + issue.status == IssueStatus.pendingClose; + } + return issue.status == status; + }).toList(); if (issues.isEmpty) { return CustomScrollView( @@ -319,11 +326,16 @@ class _BranchManagerHomePageState extends State { Navigator.pop(context); // Dismiss the loading indicator - Navigator.pushNamed( + await Navigator.pushNamed( context, ChatPage.routeName, arguments: detailedIssue, ); + + // Refresh the issue list after returning to update tabs + if (mounted) { + _issueController.refreshIssues(); + } } catch (e) { Navigator.pop(context); // Dismiss the loading indicator ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/features/home/view/maintenance_executive_home_page.dart b/lib/features/home/view/maintenance_executive_home_page.dart index b812dde..0e6c247 100644 --- a/lib/features/home/view/maintenance_executive_home_page.dart +++ b/lib/features/home/view/maintenance_executive_home_page.dart @@ -157,9 +157,14 @@ class _MaintenanceExecutiveHomePageState return ListenableBuilder( listenable: _issueController, builder: (context, _) { - final issues = _issueController.issues - .where((issue) => issue.status == status) - .toList(); + final issues = _issueController.issues.where((issue) { + if (status == IssueStatus.inProgress) { + return issue.status == IssueStatus.inProgress || + issue.status == IssueStatus.pendingResolution || + issue.status == IssueStatus.pendingClose; + } + return issue.status == status; + }).toList(); if (issues.isEmpty) { return Center( @@ -221,11 +226,16 @@ class _MaintenanceExecutiveHomePageState Navigator.pop(context); // Dismiss the loading indicator - Navigator.pushNamed( + await Navigator.pushNamed( context, ChatPage.routeName, arguments: detailedIssue, ); + + // Refresh the issue list after returning to update tabs + if (mounted) { + _issueController.refreshIssues(); + } } catch (e) { Navigator.pop(context); // Dismiss the loading indicator ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/features/home/view/technician_home_page.dart b/lib/features/home/view/technician_home_page.dart index fce00b4..f9df6de 100644 --- a/lib/features/home/view/technician_home_page.dart +++ b/lib/features/home/view/technician_home_page.dart @@ -149,9 +149,14 @@ class _TechnicianHomePageState extends State { return ListenableBuilder( listenable: _issueController, builder: (context, _) { - final issues = _issueController.issues - .where((issue) => issue.status == status) - .toList(); + final issues = _issueController.issues.where((issue) { + if (status == IssueStatus.inProgress) { + return issue.status == IssueStatus.inProgress || + issue.status == IssueStatus.pendingResolution || + issue.status == IssueStatus.pendingClose; + } + return issue.status == status; + }).toList(); if (issues.isEmpty) { return Center( @@ -210,11 +215,16 @@ class _TechnicianHomePageState extends State { Navigator.pop(context); // Dismiss the loading indicator - Navigator.pushNamed( + await Navigator.pushNamed( context, ChatPage.routeName, arguments: detailedIssue, ); + + // Refresh the issue list after returning to update tabs + if (mounted) { + _issueController.refreshIssues(); + } } catch (e) { Navigator.pop(context); // Dismiss the loading indicator ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/features/list_pages/views/network_tabs_page.dart b/lib/features/list_pages/views/network_tabs_page.dart index 6bd1d0a..445c946 100644 --- a/lib/features/list_pages/views/network_tabs_page.dart +++ b/lib/features/list_pages/views/network_tabs_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../core/routing/app_router.dart'; +import '../../../core/services/auth_service.dart'; import '../../../core/services/branch_manager_service.dart'; import '../../../core/services/branch_service.dart'; import '../../../core/services/maintenance_executive_service.dart'; @@ -257,10 +258,17 @@ class _Header extends StatelessWidget { onTap: () { Navigator.pushNamed(context, RouteNames.profile); }, - child: const CircleAvatar( + child: CircleAvatar( radius: 28, backgroundColor: _NetworkBlue.k, - child: Icon(Icons.person, color: Colors.white, size: 28), + backgroundImage: (AuthService.instance.currentUser?.profilePicture != null && + AuthService.instance.currentUser!.profilePicture!.isNotEmpty) + ? NetworkImage(AuthService.instance.currentUser!.profilePicture!) + : null, + child: (AuthService.instance.currentUser?.profilePicture == null || + AuthService.instance.currentUser!.profilePicture!.isEmpty) + ? const Icon(Icons.person, color: Colors.white, size: 28) + : null, ), ), const SizedBox(width: 12), @@ -275,9 +283,9 @@ class _Header extends StatelessWidget { fontWeight: FontWeight.w600, // slightly bolder per Figma ), ), - const Text( - 'Induwara Ranasinghe', - style: TextStyle( + Text( + AuthService.instance.currentUser?.name ?? 'User', + style: const TextStyle( fontSize: 18, // +1pt vs before fontWeight: FontWeight.w700, // name is clearly bold in Figma color: Colors.black87, diff --git a/lib/features/tickets/model/issue_model.dart b/lib/features/tickets/model/issue_model.dart index c41f927..90bea3c 100644 --- a/lib/features/tickets/model/issue_model.dart +++ b/lib/features/tickets/model/issue_model.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + class IssueModel { final int id; final int branchId; @@ -17,6 +19,8 @@ class IssueModel { // Relation arrays final List? messages; final List? pettyCashRequests; + final List? statuses; + final List? outsidePartyRequests; // Optional relation objects (when include_relations=true) final BranchInfo? branch; @@ -42,6 +46,8 @@ class IssueModel { required this.updatedAt, this.messages, this.pettyCashRequests, + this.statuses, + this.outsidePartyRequests, this.branch, this.manager, this.technician, @@ -77,6 +83,12 @@ class IssueModel { pettyCashRequests: (json['pettyCashRequests'] as List?) ?.map((x) => PettyCashRequestModel.fromJson(x as Map)) .toList(), + statuses: (json['statuses'] as List?) + ?.map((x) => StatusModel.fromJson(x as Map)) + .toList(), + outsidePartyRequests: (json['outsidePartyRequests'] as List?) + ?.map((x) => OutsidePartyRequestModel.fromJson(x as Map)) + .toList(), branch: json['branch'] != null ? BranchInfo.fromJson(json['branch']) : null, manager: json['manager'] != null ? ManagerInfo.fromJson(json['manager']) : null, technician: json['technician'] != null ? TechnicianInfo.fromJson(json['technician']) : null, @@ -123,6 +135,8 @@ class IssueModel { DateTime? updatedAt, List? messages, List? pettyCashRequests, + List? statuses, + List? outsidePartyRequests, BranchInfo? branch, ManagerInfo? manager, TechnicianInfo? technician, @@ -146,6 +160,8 @@ class IssueModel { updatedAt: updatedAt ?? this.updatedAt, messages: messages ?? this.messages, pettyCashRequests: pettyCashRequests ?? this.pettyCashRequests, + statuses: statuses ?? this.statuses, + outsidePartyRequests: outsidePartyRequests ?? this.outsidePartyRequests, branch: branch ?? this.branch, manager: manager ?? this.manager, technician: technician ?? this.technician, @@ -155,6 +171,87 @@ class IssueModel { } } +/// Model for a status update log entry. +class StatusModel { + final int id; + final int issueId; + final int userId; + final String description; + final List imageUrls; // Changed to support multiple images + final String statusType; + final DateTime createdAt; + final UserInfo? user; + + StatusModel({ + required this.id, + required this.issueId, + required this.userId, + required this.description, + List? imageUrls, + required this.statusType, + required this.createdAt, + this.user, + }) : imageUrls = imageUrls ?? []; + + /// Backward compatibility: get first image URL or null + String? get imageUrl => imageUrls.isNotEmpty ? imageUrls.first : null; + + factory StatusModel.fromJson(Map json) { + // Parse image_url field which can be: + // - null + // - a single URL string + // - a JSON array of URLs + List parseImageUrls(dynamic imageUrlData) { + if (imageUrlData == null) return []; + + if (imageUrlData is List) { + return imageUrlData.map((e) => e.toString()).toList(); + } + + if (imageUrlData is String) { + // Try to parse as JSON array first + if (imageUrlData.startsWith('[')) { + try { + final List parsed = jsonDecode(imageUrlData); + return parsed.map((e) => e.toString()).toList(); + } catch (_) { + // If parsing fails, treat as single URL + return [imageUrlData]; + } + } + // Single URL string + return [imageUrlData]; + } + + return []; + } + + return StatusModel( + id: json['id'] as int, + issueId: json['issue_id'] as int? ?? 0, + userId: json['user_id'] as int, + description: json['description'] as String, + imageUrls: parseImageUrls(json['image_url']), + statusType: json['status_type'] as String? ?? 'Open', + createdAt: DateTime.parse(json['createdAt'] as String), + user: json['user'] != null ? UserInfo.fromJson(json['user'] as Map) : null, + ); + } + + Map toJson() => { + 'id': id, + 'issue_id': issueId, + 'user_id': userId, + 'description': description, + 'image_url': imageUrls.isNotEmpty + ? (imageUrls.length == 1 ? imageUrls.first : jsonEncode(imageUrls)) + : null, + 'status_type': statusType, + 'createdAt': createdAt.toIso8601String(), + }; +} + + // Relation models class BranchInfo { final int id; @@ -257,6 +354,58 @@ class ThirdPartyInfo { } } +class OutsidePartyRequestModel { + final String id; + final int issueId; + final int suggestedBy; + final String vendorName; + final String description; + final String status; // pending | approved | rejected + final int? approvedBy; + final String? approvalComment; + final DateTime createdAt; + + OutsidePartyRequestModel({ + required this.id, + required this.issueId, + required this.suggestedBy, + required this.vendorName, + required this.description, + required this.status, + this.approvedBy, + this.approvalComment, + required this.createdAt, + }); + + factory OutsidePartyRequestModel.fromJson(Map json) { + return OutsidePartyRequestModel( + id: json['id'] as String, + issueId: json['issue_id'] as int, + suggestedBy: json['suggested_by'] as int, + vendorName: json['vendor_name'] as String, + description: json['description'] as String, + status: json['status'] as String, + approvedBy: json['approved_by'] as int?, + approvalComment: json['approval_comment'] as String?, + createdAt: DateTime.parse(json['createdAt'] ?? json['created_at'] as String), + ); + } + + OutsidePartyRequestModel copyWith({String? status, int? approvedBy, String? approvalComment}) { + return OutsidePartyRequestModel( + id: id, + issueId: issueId, + suggestedBy: suggestedBy, + vendorName: vendorName, + description: description, + status: status ?? this.status, + approvedBy: approvedBy ?? this.approvedBy, + approvalComment: approvalComment ?? this.approvalComment, + createdAt: createdAt, + ); + } +} + class PettyCashRequestModel { final String id; final int technicianId; @@ -323,6 +472,8 @@ class MessageModel { enum IssueStatus { open('Open'), inProgress('In Progress'), + pendingResolution('Pending Resolution'), + pendingClose('Pending Close'), done('Done'), closed('Closed'); diff --git a/lib/features/tickets/service/issue_api_service.dart b/lib/features/tickets/service/issue_api_service.dart index 3ab1aa2..551be64 100644 --- a/lib/features/tickets/service/issue_api_service.dart +++ b/lib/features/tickets/service/issue_api_service.dart @@ -1,4 +1,5 @@ import '../../../core/services/api_service.dart'; +import '../../../core/services/auth_service.dart'; import '../model/issue_model.dart'; /// Service for issue-related API calls @@ -19,9 +20,11 @@ class IssueApiService { final queryParams = {}; if (branchId != null) queryParams['branch_id'] = branchId.toString(); if (managerId != null) queryParams['manager_id'] = managerId.toString(); - if (technicianId != null) queryParams['technician_id'] = technicianId.toString(); + if (technicianId != null) + queryParams['technician_id'] = technicianId.toString(); if (maintenanceExecutiveId != null) { - queryParams['maintenance_executive_id'] = maintenanceExecutiveId.toString(); + queryParams['maintenance_executive_id'] = maintenanceExecutiveId + .toString(); } if (status != null) queryParams['status'] = status; if (includeRelations) queryParams['include_relations'] = 'true'; @@ -29,13 +32,13 @@ class IssueApiService { final queryString = queryParams.entries .map((e) => '${e.key}=${Uri.encodeComponent(e.value)}') .join('&'); - + final endpoint = queryString.isEmpty ? '/issues' : '/issues?$queryString'; final response = await _apiService.get(endpoint); - + // Backend returns: { success: true, data: [...], count, total, message } final List issuesJson = response['data'] as List; - + return issuesJson.map((json) => IssueModel.fromJson(json)).toList(); } catch (e) { throw Exception('Failed to fetch issues: ${e.toString()}'); @@ -46,10 +49,10 @@ class IssueApiService { Future createIssue(IssueModel issue) async { try { final response = await _apiService.post('/issues', issue.toJson()); - + // Backend returns: { success: true, data: {...}, message } final issueData = response['data'] as Map; - + return IssueModel.fromJson(issueData); } catch (e) { throw Exception('Failed to create issue: ${e.toString()}'); @@ -57,29 +60,74 @@ class IssueApiService { } /// Update issue - Future updateIssue(int issueId, Map updates) async { + Future updateIssue( + int issueId, + Map updates, + ) async { try { final response = await _apiService.put('/issues/$issueId', updates); - + // Backend returns: { success: true, data: {...}, message } final issueData = response['data'] as Map; - + return IssueModel.fromJson(issueData); } catch (e) { throw Exception('Failed to update issue: ${e.toString()}'); } } - /// Update issue status - Future updateIssueStatus(int issueId, IssueStatus status) async { + /// Maps app status to backend status_type for POST /statuses (Open, Assigned, In Progress, Resolved, Closed). + static String _statusTypeForLog(IssueStatus status) { + switch (status) { + case IssueStatus.done: + return 'Resolved'; + case IssueStatus.pendingResolution: + return 'In Progress'; + case IssueStatus.pendingClose: + return 'Closed'; + case IssueStatus.open: + return 'Open'; + case IssueStatus.inProgress: + return 'In Progress'; + case IssueStatus.closed: + return 'Closed'; + } + } + + /// Update issue status. + /// Logs the change via POST /issues/:id/statuses then updates the issue via PUT /issues/:id/status. + /// [userId] and [description] are optional; if not provided, current user and a default description are used. + Future updateIssueStatus( + int issueId, + IssueStatus status, { + int? userId, + String? description, + }) async { try { + final effectiveUserId = userId ?? AuthService.instance.currentUser?.id; + if (effectiveUserId == null) { + throw Exception('User ID required for status update (not logged in)'); + } + final effectiveDescription = + description ?? 'Status updated to ${status.value}'; + + // POST /issues/:id/statuses - add status update log entry + // Backend accepts: Open, Assigned, In Progress, Resolved, Closed + final statusTypeForLog = _statusTypeForLog(status); + await _apiService.post('/issues/$issueId/statuses', { + 'user_id': effectiveUserId, + 'description': effectiveDescription, + 'status_type': statusTypeForLog, + }); + + // PUT /issues/:id/status - update issue status and get updated issue final response = await _apiService.put('/issues/$issueId/status', { 'status': status.value, }); - + // Backend returns: { success: true, data: {...}, message } final issueData = response['data'] as Map; - + return IssueModel.fromJson(issueData); } catch (e) { throw Exception('Failed to update issue status: ${e.toString()}'); @@ -96,17 +144,20 @@ class IssueApiService { } /// Get issue by ID with relations - Future getIssueById(int issueId, {bool includeRelations = true}) async { + Future getIssueById( + int issueId, { + bool includeRelations = true, + }) async { try { - final endpoint = includeRelations + final endpoint = includeRelations ? '/issues/$issueId?include_relations=true' : '/issues/$issueId'; - + final response = await _apiService.get(endpoint); - + // Backend returns: { success: true, data: {...}, message } final issueData = response['data'] as Map; - + return IssueModel.fromJson(issueData); } catch (e) { throw Exception('Failed to fetch issue: ${e.toString()}'); @@ -120,7 +171,7 @@ class IssueApiService { '/issues/$issueId/assign-technician', {'technician_id': technicianId}, ); - + final issueData = response['data'] as Map; return IssueModel.fromJson(issueData); } catch (e) { @@ -129,17 +180,22 @@ class IssueApiService { } /// Assign maintenance executive to issue - Future assignMaintenanceExecutive(int issueId, int executiveId) async { + Future assignMaintenanceExecutive( + int issueId, + int executiveId, + ) async { try { final response = await _apiService.post( '/issues/$issueId/assign-maintenance-executive', {'executive_id': executiveId}, ); - + final issueData = response['data'] as Map; return IssueModel.fromJson(issueData); } catch (e) { - throw Exception('Failed to assign maintenance executive: ${e.toString()}'); + throw Exception( + 'Failed to assign maintenance executive: ${e.toString()}', + ); } } @@ -150,11 +206,112 @@ class IssueApiService { '/issues/$issueId/assign-third-party', {'third_party_id': thirdPartyId}, ); - + final issueData = response['data'] as Map; return IssueModel.fromJson(issueData); } catch (e) { throw Exception('Failed to assign third party: ${e.toString()}'); } } + + /// Add a status update log entry for an issue. + /// [issueId] - the issue to log against + /// [userId] - ID of the user making the update + /// [description] - required description of what changed + /// [imageUrl] - optional single uploaded image URL (deprecated, use imageUrls) + /// [imageUrls] - optional list of uploaded image URLs + /// [statusType] - one of: Open, Assigned, In Progress, Resolved, Closed + Future addStatusUpdate({ + required int issueId, + required int userId, + required String description, + String? imageUrl, + List? imageUrls, + String statusType = 'Open', + }) async { + try { + final Map requestBody = { + 'user_id': userId, + 'description': description, + 'status_type': statusType, + }; + + // Support both single imageUrl and multiple imageUrls + if (imageUrls != null && imageUrls.isNotEmpty) { + requestBody['image_urls'] = imageUrls; + } else if (imageUrl != null) { + requestBody['image_url'] = imageUrl; + } + + final response = await _apiService.post( + '/issues/$issueId/statuses', + requestBody, + ); + + final data = response['data'] as Map; + return StatusModel.fromJson(data); + } catch (e) { + throw Exception('Failed to create status update: ${e.toString()}'); + } + } + + /// Fetch all status updates for an issue. + Future> getStatusUpdates(int issueId) async { + try { + final response = await _apiService.get('/issues/$issueId/statuses'); + final List data = response['data'] as List; + return data + .map((x) => StatusModel.fromJson(x as Map)) + .toList(); + } catch (e) { + throw Exception('Failed to fetch status updates: ${e.toString()}'); + } + } + + /// Create a petty cash request for an issue. + Future createPettyCashRequest({ + required int issueId, + required double amount, + required String description, + required int technicianId, + }) async { + try { + final response = await _apiService.post('/cash-requests', { + 'issue_id': issueId, + 'amount': amount, + 'description': description, + 'technician_id': technicianId, + }); + + final data = response['data'] as Map; + return PettyCashRequestModel.fromJson(data); + } catch (e) { + throw Exception('Failed to create petty cash request: ${e.toString()}'); + } + } + + /// Update a petty cash request (approve, reject, cancel, undo). + /// Backend expects PUT with optional amount, description, status. + Future updatePettyCashRequest({ + required dynamic requestId, + required String action, + required int issueId, + required int userId, + }) async { + try { + final String status = action == 'approve' + ? 'approved' + : action == 'reject' + ? 'rejected' + : 'pending'; + final response = await _apiService.put('/cash-requests/$requestId', { + 'status': status, + }); + + final data = response['data'] as Map; + return PettyCashRequestModel.fromJson(data); + } catch (e) { + throw Exception('Failed to $action petty cash request: ${e.toString()}'); + } + } } diff --git a/lib/features/tickets/view/report_new_issue_page.dart b/lib/features/tickets/view/report_new_issue_page.dart index 75e9337..ffbdfd1 100644 --- a/lib/features/tickets/view/report_new_issue_page.dart +++ b/lib/features/tickets/view/report_new_issue_page.dart @@ -8,7 +8,6 @@ import '../controller/issue_controller.dart'; import '../model/issue_model.dart'; import '../../../theme/app_colors.dart'; import '../../../core/services/auth_service.dart'; -import '../../../core/services/user_service.dart'; import '../../../core/services/upload_service.dart'; class ReportNewIssuePage extends StatefulWidget { @@ -21,7 +20,6 @@ class ReportNewIssuePage extends StatefulWidget { class _ReportNewIssuePageState extends State { String _selectedIssueType = 'Critical'; // Critical or General late DateTime _selectedDate; // Initialize with current date - MaintenanceExecutive? _selectedExecutive; final List _selectedFiles = []; // XFile works on both web and mobile final List _uploadedFiles = []; // Uploaded file info from server final TextEditingController _taskNameController = TextEditingController(); @@ -29,38 +27,12 @@ class _ReportNewIssuePageState extends State { final IssueController _issueController = IssueController(); final ImagePicker _imagePicker = ImagePicker(); - // Dynamic data from backend - List _executives = []; - bool _isLoadingExecutives = false; bool _isUploadingFiles = false; @override void initState() { super.initState(); _selectedDate = DateTime.now(); // Use current date as default - _loadExecutives(); - } - - Future _loadExecutives() async { - setState(() { - _isLoadingExecutives = true; - }); - - try { - final executives = await UserService.instance.getMaintenanceExecutives(); - if (mounted) { - setState(() { - _executives = executives; - _isLoadingExecutives = false; - }); - } - } catch (e) { - if (mounted) { - setState(() { - _isLoadingExecutives = false; - }); - } - } } @override @@ -285,32 +257,6 @@ class _ReportNewIssuePageState extends State { const SizedBox(height: 8), ..._uploadedFiles.map((file) => _buildUploadedFileChip(file)), ], - const SizedBox(height: 20), - - // Assigned Maintenance Executive - _buildSectionLabel('Assigned Maintenance Executive'), - const SizedBox(height: 8), - ElevatedButton.icon( - onPressed: _selectExecutive, - icon: const Icon(Icons.add, size: 18), - label: const Text('Add'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF4FC3F7), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - elevation: 0, - ), - ), - if (_selectedExecutive != null) ...[ - const SizedBox(height: 12), - _buildExecutiveChip(_selectedExecutive!.name), - ], const SizedBox(height: 60), ], ), @@ -497,6 +443,7 @@ class _ReportNewIssuePageState extends State { return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } + Widget _buildExecutiveChip(String name) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -512,11 +459,7 @@ class _ReportNewIssuePageState extends State { Text(name, style: const TextStyle(fontSize: 12)), const SizedBox(width: 8), GestureDetector( - onTap: () { - setState(() { - _selectedExecutive = null; - }); - }, + onTap: () {}, child: const Icon(Icons.close, size: 16, color: Colors.grey), ), ], @@ -680,70 +623,6 @@ class _ReportNewIssuePageState extends State { } } - void _selectExecutive() { - // Show dialog to select executive from dynamic list - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: const Color(0xFFFCE4EC), // Light pink background - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: const Text( - 'Select Maintenance Executive', - style: TextStyle(color: Colors.black87, fontWeight: FontWeight.w600), - ), - content: _isLoadingExecutives - ? const SizedBox( - height: 100, - child: Center( - child: CircularProgressIndicator(), - ), - ) - : _executives.isEmpty - ? const Padding( - padding: EdgeInsets.all(16.0), - child: Text( - 'No maintenance executives available.', - style: TextStyle(color: Colors.grey), - ), - ) - : SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: _executives.map((executive) { - return ListTile( - leading: CircleAvatar( - backgroundImage: executive.profilePicture != null - ? NetworkImage(executive.profilePicture!) - : null, - child: executive.profilePicture == null - ? const Icon(Icons.person) - : null, - ), - title: Text(executive.name), - subtitle: Text( - executive.email, - style: const TextStyle(fontSize: 12), - ), - onTap: () { - setState(() { - _selectedExecutive = executive; - }); - Navigator.pop(context); - }, - ); - }).toList(), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - ], - ), - ); - } - void _submitForm() async { try { // Validate required fields @@ -760,32 +639,29 @@ class _ReportNewIssuePageState extends State { // Get current user info for branchId and managerId final currentUser = AuthService.instance.currentUser; - // Validate user has required profile data + // Validate user is logged in if (currentUser == null) { _showErrorDialog('User not logged in'); return; } - - if (currentUser.branchManagerProfileId == null) { - _showErrorDialog('User does not have a branch manager profile. Please contact support.'); - return; - } // Use branch 1 as default if user doesn't have a branch assigned - // The backend will validate and may warn, but will allow creation final branchId = currentUser.branchId ?? 1; + // Determine manager_id based on user role: + // - Branch managers use their own profile ID + // - All other roles (maintenance executive, etc.) send 0/null so backend auto-assigns + final int? managerId = currentUser.branchManagerProfileId; + // Create new issue // Note: id, createdAt, updatedAt will be set by backend - // manager_id should be the BranchManager profile ID, not User ID final newIssue = IssueModel( id: 0, // Backend will assign the real ID - branchId: branchId, // Use user's branch or default to 1 - managerId: currentUser.branchManagerProfileId!, // Use BranchManager profile ID + branchId: branchId, + managerId: managerId ?? 0, // 0 signals backend to auto-assign a branch manager title: _taskNameController.text.trim(), description: _descriptionController.text.trim(), status: IssueStatus.open, - maintenanceExecutiveId: _selectedExecutive?.id, // Use selected executive's real ID createdAt: DateTime.now(), updatedAt: DateTime.now(), ); diff --git a/lib/features/tickets/view/reported_issues_page.dart b/lib/features/tickets/view/reported_issues_page.dart index d871b24..179d105 100644 --- a/lib/features/tickets/view/reported_issues_page.dart +++ b/lib/features/tickets/view/reported_issues_page.dart @@ -22,6 +22,10 @@ class _ReportedIssuesPageState extends State { return 'Done'; case IssueStatus.closed: return 'Closed'; + case IssueStatus.pendingResolution: + return 'Pending Resolution'; + case IssueStatus.pendingClose: + return 'Pending Close'; } }