- Implemented ContactProfileResponse and related models for handling contact details. - Created ContactTagResponse and ContactTag models for managing contact tags. - Added DirectoryCommentResponse and DirectoryComment models for comment management. - Developed DirectoryFilterBottomSheet for filtering contacts. - Introduced OrganizationListModel for organization data handling. - Updated routes to include DirectoryMainScreen. - Enhanced DashboardScreen to navigate to the new directory page. - Created ContactDetailScreen for displaying detailed contact information. - Developed DirectoryMainScreen for managing and displaying contacts. - Added dependencies for font_awesome_flutter and flutter_html in pubspec.yaml.
		
			
				
	
	
		
			876 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			876 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'package:flutter/material.dart';
 | |
| import 'package:get/get.dart';
 | |
| import 'package:marco/controller/task_planing/report_task_controller.dart';
 | |
| import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
 | |
| import 'package:marco/helpers/widgets/my_button.dart';
 | |
| import 'package:marco/helpers/widgets/my_spacing.dart';
 | |
| import 'package:marco/helpers/widgets/my_text.dart';
 | |
| import 'package:marco/helpers/widgets/my_text_style.dart';
 | |
| import 'package:marco/helpers/widgets/avatar.dart';
 | |
| import 'package:marco/helpers/widgets/my_team_model_sheet.dart';
 | |
| import 'package:intl/intl.dart';
 | |
| import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
 | |
| import 'dart:io';
 | |
| import 'package:marco/model/dailyTaskPlaning/create_task_botom_sheet.dart';
 | |
| 
 | |
| class CommentTaskBottomSheet extends StatefulWidget {
 | |
|   final Map<String, dynamic> taskData;
 | |
|   final VoidCallback? onCommentSuccess;
 | |
|   final String taskDataId;
 | |
|   final String workAreaId;
 | |
|   final String activityId;
 | |
|   const CommentTaskBottomSheet({
 | |
|     super.key,
 | |
|     required this.taskData,
 | |
|     this.onCommentSuccess,
 | |
|     required this.taskDataId,
 | |
|     required this.workAreaId,
 | |
|     required this.activityId,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   State<CommentTaskBottomSheet> createState() => _CommentTaskBottomSheetState();
 | |
| }
 | |
| 
 | |
| class _Member {
 | |
|   final String firstName;
 | |
|   _Member(this.firstName);
 | |
| }
 | |
| 
 | |
| class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
 | |
|     with UIMixin {
 | |
|   late ReportTaskController controller;
 | |
|   final ScrollController _scrollController = ScrollController();
 | |
|   @override
 | |
|   void initState() {
 | |
|     super.initState();
 | |
|     controller = Get.put(ReportTaskController(),
 | |
|         tag: widget.taskData['taskId'] ?? UniqueKey().toString());
 | |
|     final data = widget.taskData;
 | |
|     controller.basicValidator.getController('assigned_date')?.text =
 | |
|         data['assignedOn'] ?? '';
 | |
|     controller.basicValidator.getController('assigned_by')?.text =
 | |
|         data['assignedBy'] ?? '';
 | |
|     controller.basicValidator.getController('work_area')?.text =
 | |
|         data['location'] ?? '';
 | |
|     controller.basicValidator.getController('activity')?.text =
 | |
|         data['activity'] ?? '';
 | |
|     controller.basicValidator.getController('planned_work')?.text =
 | |
|         data['plannedWork'] ?? '';
 | |
|     controller.basicValidator.getController('completed_work')?.text =
 | |
|         data['completedWork'] ?? '';
 | |
|     controller.basicValidator.getController('team_members')?.text =
 | |
|         (data['teamMembers'] as List<dynamic>).join(', ');
 | |
|     controller.basicValidator.getController('assigned')?.text =
 | |
|         data['assigned'] ?? '';
 | |
|     controller.basicValidator.getController('task_id')?.text =
 | |
|         data['taskId'] ?? '';
 | |
|     controller.basicValidator.getController('comment')?.clear();
 | |
|     controller.selectedImages.clear();
 | |
|     WidgetsBinding.instance.addPostFrameCallback((_) {
 | |
|       if (_scrollController.hasClients) {
 | |
|         _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
 | |
|       }
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   String timeAgo(String dateString) {
 | |
|     try {
 | |
|       DateTime date = DateTime.parse(dateString + "Z").toLocal();
 | |
|       final now = DateTime.now();
 | |
|       final difference = now.difference(date);
 | |
|       if (difference.inDays > 8) {
 | |
|         return DateFormat('dd-MM-yyyy').format(date);
 | |
|       } else if (difference.inDays >= 1) {
 | |
|         return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago';
 | |
|       } else if (difference.inHours >= 1) {
 | |
|         return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago';
 | |
|       } else if (difference.inMinutes >= 1) {
 | |
|         return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago';
 | |
|       } else {
 | |
|         return 'just now';
 | |
|       }
 | |
|     } catch (e) {
 | |
|       print('Error parsing date: $e');
 | |
|       return '';
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return Container(
 | |
|       decoration: BoxDecoration(
 | |
|         color: Colors.white,
 | |
|         borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
 | |
|       ),
 | |
|       child: SingleChildScrollView(
 | |
|         padding: EdgeInsets.only(
 | |
|           bottom: MediaQuery.of(context).viewInsets.bottom + 24,
 | |
|           left: 24,
 | |
|           right: 24,
 | |
|           top: 12,
 | |
|         ),
 | |
|         child: Column(
 | |
|           mainAxisSize: MainAxisSize.min,
 | |
|           children: [
 | |
|             // Drag handle
 | |
|             Container(
 | |
|               width: 40,
 | |
|               height: 4,
 | |
|               margin: const EdgeInsets.only(bottom: 12),
 | |
|               decoration: BoxDecoration(
 | |
|                 color: Colors.grey.shade400,
 | |
|                 borderRadius: BorderRadius.circular(2),
 | |
|               ),
 | |
|             ),
 | |
|             GetBuilder<ReportTaskController>(
 | |
|               tag: widget.taskData['taskId'] ?? '',
 | |
|               builder: (controller) {
 | |
|                 return Form(
 | |
|                   key: controller.basicValidator.formKey,
 | |
|                   child: Padding(
 | |
|                     padding: const EdgeInsets.symmetric(horizontal: 4.0),
 | |
|                     child: Column(
 | |
|                       crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                       children: [
 | |
|                         Column(
 | |
|                           crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|                           children: [
 | |
|                             MySpacing.height(12),
 | |
|                             Row(
 | |
|                               mainAxisAlignment: MainAxisAlignment.center,
 | |
|                               children: [
 | |
|                                 MyText.titleMedium(
 | |
|                                   "Comment Task",
 | |
|                                   fontWeight: 600,
 | |
|                                   fontSize: 18,
 | |
|                                 ),
 | |
|                               ],
 | |
|                             ),
 | |
|                             const SizedBox(height: 12),
 | |
| 
 | |
|                             // Second row: Right-aligned "+ Create Task" button
 | |
|                             Row(
 | |
|                               mainAxisAlignment: MainAxisAlignment.end,
 | |
|                               children: [
 | |
|                                 InkWell(
 | |
|                                   onTap: () {
 | |
|                                     showCreateTaskBottomSheet(
 | |
|                                       workArea:
 | |
|                                           widget.taskData['location'] ?? '',
 | |
|                                       activity:
 | |
|                                           widget.taskData['activity'] ?? '',
 | |
|                                       completedWork:
 | |
|                                           widget.taskData['completedWork'] ??
 | |
|                                               '',
 | |
|                                       unit: widget.taskData['unit'] ?? '',
 | |
|                                       onCategoryChanged: (category) {
 | |
|                                         debugPrint(
 | |
|                                             "Category changed to: $category");
 | |
|                                       },
 | |
|                                       parentTaskId: widget.taskDataId,
 | |
|                                       plannedTask: int.tryParse(
 | |
|                                               widget.taskData['plannedWork'] ??
 | |
|                                                   '0') ??
 | |
|                                           0,
 | |
|                                       activityId: widget.activityId,
 | |
|                                       workAreaId: widget.workAreaId,
 | |
|                                       onSubmit: () {
 | |
|                                         Navigator.of(context).pop();
 | |
|                                       },
 | |
|                                     );
 | |
|                                   },
 | |
|                                   borderRadius: BorderRadius.circular(16),
 | |
|                                   child: Container(
 | |
|                                     padding: const EdgeInsets.symmetric(
 | |
|                                         horizontal: 12, vertical: 6),
 | |
|                                     decoration: BoxDecoration(
 | |
|                                       color: Colors.blueAccent.withOpacity(0.1),
 | |
|                                       borderRadius: BorderRadius.circular(16),
 | |
|                                     ),
 | |
|                                     child: MyText.bodySmall(
 | |
|                                       "+ Create Task",
 | |
|                                       fontWeight: 600,
 | |
|                                       color: Colors.blueAccent,
 | |
|                                     ),
 | |
|                                   ),
 | |
|                                 ),
 | |
|                               ],
 | |
|                             ),
 | |
|                           ],
 | |
|                         ),
 | |
|                         buildRow(
 | |
|                           "Assigned By",
 | |
|                           controller.basicValidator
 | |
|                               .getController('assigned_by')
 | |
|                               ?.text
 | |
|                               .trim(),
 | |
|                           icon: Icons.person_outline,
 | |
|                         ),
 | |
|                         buildRow(
 | |
|                           "Work Area",
 | |
|                           controller.basicValidator
 | |
|                               .getController('work_area')
 | |
|                               ?.text
 | |
|                               .trim(),
 | |
|                           icon: Icons.place_outlined,
 | |
|                         ),
 | |
|                         buildRow(
 | |
|                           "Activity",
 | |
|                           controller.basicValidator
 | |
|                               .getController('activity')
 | |
|                               ?.text
 | |
|                               .trim(),
 | |
|                           icon: Icons.assignment_outlined,
 | |
|                         ),
 | |
|                         buildRow(
 | |
|                           "Planned Work",
 | |
|                           controller.basicValidator
 | |
|                               .getController('planned_work')
 | |
|                               ?.text
 | |
|                               .trim(),
 | |
|                           icon: Icons.schedule_outlined,
 | |
|                         ),
 | |
|                         buildRow(
 | |
|                           "Completed Work",
 | |
|                           controller.basicValidator
 | |
|                               .getController('completed_work')
 | |
|                               ?.text
 | |
|                               .trim(),
 | |
|                           icon: Icons.done_all_outlined,
 | |
|                         ),
 | |
|                         buildTeamMembers(),
 | |
|                         if ((widget.taskData['reportedPreSignedUrls']
 | |
|                                     as List<dynamic>?)
 | |
|                                 ?.isNotEmpty ==
 | |
|                             true)
 | |
|                           buildReportedImagesSection(
 | |
|                             imageUrls: List<String>.from(
 | |
|                                 widget.taskData['reportedPreSignedUrls'] ?? []),
 | |
|                             context: context,
 | |
|                           ),
 | |
|                         Row(
 | |
|                           children: [
 | |
|                             Icon(Icons.comment_outlined,
 | |
|                                 size: 18, color: Colors.grey[700]),
 | |
|                             MySpacing.width(8),
 | |
|                             MyText.titleSmall(
 | |
|                               "Comment:",
 | |
|                               fontWeight: 600,
 | |
|                             ),
 | |
|                           ],
 | |
|                         ),
 | |
|                         MySpacing.height(8),
 | |
|                         TextFormField(
 | |
|                           validator: controller.basicValidator
 | |
|                               .getValidation('comment'),
 | |
|                           controller: controller.basicValidator
 | |
|                               .getController('comment'),
 | |
|                           keyboardType: TextInputType.text,
 | |
|                           decoration: InputDecoration(
 | |
|                             hintText: "eg: Work done successfully",
 | |
|                             hintStyle: MyTextStyle.bodySmall(xMuted: true),
 | |
|                             border: outlineInputBorder,
 | |
|                             enabledBorder: outlineInputBorder,
 | |
|                             focusedBorder: focusedInputBorder,
 | |
|                             contentPadding: MySpacing.all(16),
 | |
|                             isCollapsed: true,
 | |
|                             floatingLabelBehavior: FloatingLabelBehavior.never,
 | |
|                           ),
 | |
|                         ),
 | |
|                         MySpacing.height(16),
 | |
|                         Row(
 | |
|                           crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                           children: [
 | |
|                             Icon(Icons.camera_alt_outlined,
 | |
|                                 size: 18, color: Colors.grey[700]),
 | |
|                             MySpacing.width(8),
 | |
|                             Expanded(
 | |
|                               child: Column(
 | |
|                                 crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                                 children: [
 | |
|                                   MyText.titleSmall("Attach Photos:",
 | |
|                                       fontWeight: 600),
 | |
|                                   MySpacing.height(12),
 | |
|                                 ],
 | |
|                               ),
 | |
|                             ),
 | |
|                           ],
 | |
|                         ),
 | |
|                         Obx(() {
 | |
|                           final images = controller.selectedImages;
 | |
| 
 | |
|                           return buildImagePickerSection(
 | |
|                             images: images,
 | |
|                             onCameraTap: () =>
 | |
|                                 controller.pickImages(fromCamera: true),
 | |
|                             onUploadTap: () =>
 | |
|                                 controller.pickImages(fromCamera: false),
 | |
|                             onRemoveImage: (index) =>
 | |
|                                 controller.removeImageAt(index),
 | |
|                             onPreviewImage: (index) {
 | |
|                               showDialog(
 | |
|                                 context: context,
 | |
|                                 builder: (_) => ImageViewerDialog(
 | |
|                                   imageSources: images,
 | |
|                                   initialIndex: index,
 | |
|                                 ),
 | |
|                               );
 | |
|                             },
 | |
|                           );
 | |
|                         }),
 | |
|                         MySpacing.height(24),
 | |
|                         buildCommentActionButtons(
 | |
|                           onCancel: () => Navigator.of(context).pop(),
 | |
|                           onSubmit: () async {
 | |
|                             if (controller.basicValidator.validateForm()) {
 | |
|                               await controller.commentTask(
 | |
|                                 projectId: controller.basicValidator
 | |
|                                         .getController('task_id')
 | |
|                                         ?.text ??
 | |
|                                     '',
 | |
|                                 comment: controller.basicValidator
 | |
|                                         .getController('comment')
 | |
|                                         ?.text ??
 | |
|                                     '',
 | |
|                                 images: controller.selectedImages,
 | |
|                               );
 | |
|                               if (widget.onCommentSuccess != null) {
 | |
|                                 widget.onCommentSuccess!();
 | |
|                               }
 | |
|                             }
 | |
|                           },
 | |
|                           isLoading: controller.isLoading,
 | |
|                         ),
 | |
|                         MySpacing.height(10),
 | |
|                         if ((widget.taskData['taskComments'] as List<dynamic>?)
 | |
|                                 ?.isNotEmpty ==
 | |
|                             true) ...[
 | |
|                           Row(
 | |
|                             children: [
 | |
|                               MySpacing.width(10),
 | |
|                               Icon(Icons.chat_bubble_outline,
 | |
|                                   size: 18, color: Colors.grey[700]),
 | |
|                               MySpacing.width(8),
 | |
|                               MyText.titleSmall(
 | |
|                                 "Comments",
 | |
|                                 fontWeight: 600,
 | |
|                               ),
 | |
|                             ],
 | |
|                           ),
 | |
|                           MySpacing.height(12),
 | |
|                           Builder(
 | |
|                             builder: (context) {
 | |
|                               final comments = List<Map<String, dynamic>>.from(
 | |
|                                 widget.taskData['taskComments'] as List,
 | |
|                               );
 | |
|                               return buildCommentList(comments, context);
 | |
|                             },
 | |
|                           )
 | |
|                         ],
 | |
|                       ],
 | |
|                     ),
 | |
|                   ),
 | |
|                 );
 | |
|               },
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget buildReportedImagesSection({
 | |
|     required List<String> imageUrls,
 | |
|     required BuildContext context,
 | |
|     String title = "Reported Images",
 | |
|   }) {
 | |
|     if (imageUrls.isEmpty) return const SizedBox();
 | |
| 
 | |
|     return Column(
 | |
|       crossAxisAlignment: CrossAxisAlignment.start,
 | |
|       children: [
 | |
|         MySpacing.height(8),
 | |
|         Padding(
 | |
|           padding: const EdgeInsets.symmetric(horizontal: 0.0),
 | |
|           child: Row(
 | |
|             crossAxisAlignment: CrossAxisAlignment.center,
 | |
|             children: [
 | |
|               Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]),
 | |
|               MySpacing.width(8),
 | |
|               MyText.titleSmall(
 | |
|                 title,
 | |
|                 fontWeight: 600,
 | |
|               ),
 | |
|             ],
 | |
|           ),
 | |
|         ),
 | |
|         MySpacing.height(8),
 | |
|         Padding(
 | |
|           padding: const EdgeInsets.symmetric(horizontal: 16.0),
 | |
|           child: SizedBox(
 | |
|             height: 70,
 | |
|             child: ListView.separated(
 | |
|               scrollDirection: Axis.horizontal,
 | |
|               itemCount: imageUrls.length,
 | |
|               separatorBuilder: (_, __) => const SizedBox(width: 12),
 | |
|               itemBuilder: (context, index) {
 | |
|                 final url = imageUrls[index];
 | |
|                 return GestureDetector(
 | |
|                   onTap: () {
 | |
|                     showDialog(
 | |
|                       context: context,
 | |
|                       barrierColor: Colors.black54,
 | |
|                       builder: (_) => ImageViewerDialog(
 | |
|                         imageSources: imageUrls,
 | |
|                         initialIndex: index,
 | |
|                       ),
 | |
|                     );
 | |
|                   },
 | |
|                   child: ClipRRect(
 | |
|                     borderRadius: BorderRadius.circular(12),
 | |
|                     child: Image.network(
 | |
|                       url,
 | |
|                       width: 70,
 | |
|                       height: 70,
 | |
|                       fit: BoxFit.cover,
 | |
|                       errorBuilder: (context, error, stackTrace) => Container(
 | |
|                         width: 70,
 | |
|                         height: 70,
 | |
|                         color: Colors.grey.shade200,
 | |
|                         child:
 | |
|                             Icon(Icons.broken_image, color: Colors.grey[600]),
 | |
|                       ),
 | |
|                     ),
 | |
|                   ),
 | |
|                 );
 | |
|               },
 | |
|             ),
 | |
|           ),
 | |
|         ),
 | |
|         MySpacing.height(16),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget buildTeamMembers() {
 | |
|     final teamMembersText =
 | |
|         controller.basicValidator.getController('team_members')?.text ?? '';
 | |
|     final members = teamMembersText
 | |
|         .split(',')
 | |
|         .map((e) => e.trim())
 | |
|         .where((e) => e.isNotEmpty)
 | |
|         .toList();
 | |
| 
 | |
|     return Padding(
 | |
|       padding: const EdgeInsets.only(bottom: 16),
 | |
|       child: Row(
 | |
|         crossAxisAlignment: CrossAxisAlignment.center,
 | |
|         children: [
 | |
|           MyText.titleSmall(
 | |
|             "Team Members:",
 | |
|             fontWeight: 600,
 | |
|           ),
 | |
|           MySpacing.width(12),
 | |
|           GestureDetector(
 | |
|             onTap: () {
 | |
|               TeamBottomSheet.show(
 | |
|                 context: context,
 | |
|                 teamMembers: members.map((name) => _Member(name)).toList(),
 | |
|               );
 | |
|             },
 | |
|             child: SizedBox(
 | |
|               height: 32,
 | |
|               width: 100,
 | |
|               child: Stack(
 | |
|                 children: [
 | |
|                   for (int i = 0; i < members.length.clamp(0, 3); i++)
 | |
|                     Positioned(
 | |
|                       left: i * 24.0,
 | |
|                       child: Tooltip(
 | |
|                         message: members[i],
 | |
|                         child: Avatar(
 | |
|                           firstName: members[i],
 | |
|                           lastName: '',
 | |
|                           size: 32,
 | |
|                         ),
 | |
|                       ),
 | |
|                     ),
 | |
|                   if (members.length > 3)
 | |
|                     Positioned(
 | |
|                       left: 2 * 24.0,
 | |
|                       child: CircleAvatar(
 | |
|                         radius: 16,
 | |
|                         backgroundColor: Colors.grey.shade300,
 | |
|                         child: MyText.bodyMedium(
 | |
|                           '+${members.length - 3}',
 | |
|                           style: const TextStyle(
 | |
|                               fontSize: 12, color: Colors.black87),
 | |
|                         ),
 | |
|                       ),
 | |
|                     ),
 | |
|                 ],
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|  Widget buildCommentActionButtons({
 | |
|   required VoidCallback onCancel,
 | |
|   required Future<void> Function() onSubmit,
 | |
|   required RxBool isLoading,
 | |
|   double? buttonHeight,
 | |
| }) {
 | |
|   return Row(
 | |
|     children: [
 | |
|       Expanded(
 | |
|         child: OutlinedButton.icon(
 | |
|           onPressed: onCancel,
 | |
|           icon: const Icon(Icons.close, color: Colors.red, size: 18),
 | |
|           label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
 | |
|           style: OutlinedButton.styleFrom(
 | |
|             side: const BorderSide(color: Colors.red),
 | |
|             shape: RoundedRectangleBorder(
 | |
|               borderRadius: BorderRadius.circular(12),
 | |
|             ),
 | |
|             padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
 | |
|           ),
 | |
|         ),
 | |
|       ),
 | |
|       const SizedBox(width: 16),
 | |
|       Expanded(
 | |
|         child: Obx(() {
 | |
|           return ElevatedButton.icon(
 | |
|             onPressed: isLoading.value ? null : () => onSubmit(),
 | |
|             icon: isLoading.value
 | |
|                 ? const SizedBox(
 | |
|                     width: 16,
 | |
|                     height: 16,
 | |
|                     child: CircularProgressIndicator(
 | |
|                       strokeWidth: 2,
 | |
|                       valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
 | |
|                     ),
 | |
|                   )
 | |
|                 : const Icon(Icons.check_circle_outline, color: Colors.white, size: 18),
 | |
|             label: isLoading.value
 | |
|                 ? const SizedBox()
 | |
|                 : MyText.bodyMedium("Comment", color: Colors.white, fontWeight: 600),
 | |
|             style: ElevatedButton.styleFrom(
 | |
|               backgroundColor: Colors.indigo,
 | |
|               shape: RoundedRectangleBorder(
 | |
|                 borderRadius: BorderRadius.circular(12),
 | |
|               ),
 | |
|               padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
 | |
|             ),
 | |
|           );
 | |
|         }),
 | |
|       ),
 | |
|     ],
 | |
|   );
 | |
| }
 | |
| 
 | |
|   Widget buildRow(String label, String? value, {IconData? icon}) {
 | |
|     return Padding(
 | |
|       padding: const EdgeInsets.only(bottom: 16),
 | |
|       child: Row(
 | |
|         crossAxisAlignment: CrossAxisAlignment.start,
 | |
|         children: [
 | |
|           if (icon != null)
 | |
|             Padding(
 | |
|               padding: const EdgeInsets.only(right: 8.0, top: 2),
 | |
|               child: Icon(icon, size: 18, color: Colors.grey[700]),
 | |
|             ),
 | |
|           MyText.titleSmall(
 | |
|             "$label:",
 | |
|             fontWeight: 600,
 | |
|           ),
 | |
|           MySpacing.width(12),
 | |
|           Expanded(
 | |
|             child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget buildCommentList(
 | |
|       List<Map<String, dynamic>> comments, BuildContext context) {
 | |
|     comments.sort((a, b) {
 | |
|       final aDate = DateTime.tryParse(a['date'] ?? '') ??
 | |
|           DateTime.fromMillisecondsSinceEpoch(0);
 | |
|       final bDate = DateTime.tryParse(b['date'] ?? '') ??
 | |
|           DateTime.fromMillisecondsSinceEpoch(0);
 | |
|       return bDate.compareTo(aDate); // newest first
 | |
|     });
 | |
| 
 | |
|     return SizedBox(
 | |
|       height: 300,
 | |
|       child: ListView.builder(
 | |
|         padding: const EdgeInsets.symmetric(vertical: 8),
 | |
|         itemCount: comments.length,
 | |
|         itemBuilder: (context, index) {
 | |
|           final comment = comments[index];
 | |
|           final commentText = comment['text'] ?? '-';
 | |
|           final commentedBy = comment['commentedBy'] ?? 'Unknown';
 | |
|           final relativeTime = timeAgo(comment['date'] ?? '');
 | |
|           final imageUrls = List<String>.from(comment['preSignedUrls'] ?? []);
 | |
| 
 | |
|           return Container(
 | |
|             margin: const EdgeInsets.symmetric(vertical: 8),
 | |
|             padding: const EdgeInsets.all(12),
 | |
|             decoration: BoxDecoration(
 | |
|               color: Colors.grey.shade200,
 | |
|               borderRadius: BorderRadius.circular(12),
 | |
|             ),
 | |
|             child: Row(
 | |
|               crossAxisAlignment: CrossAxisAlignment.start,
 | |
|               children: [
 | |
|                 const SizedBox(width: 12),
 | |
|                 Expanded(
 | |
|                   child: Column(
 | |
|                     crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                     children: [
 | |
|                       Row(
 | |
|                         crossAxisAlignment: CrossAxisAlignment.center,
 | |
|                         children: [
 | |
|                           Avatar(
 | |
|                             firstName: commentedBy.split(' ').first,
 | |
|                             lastName: commentedBy.split(' ').length > 1
 | |
|                                 ? commentedBy.split(' ').last
 | |
|                                 : '',
 | |
|                             size: 32,
 | |
|                           ),
 | |
|                           const SizedBox(width: 12),
 | |
|                           Expanded(
 | |
|                             child: Row(
 | |
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | |
|                               children: [
 | |
|                                 MyText.bodyMedium(
 | |
|                                   commentedBy,
 | |
|                                   fontWeight: 700,
 | |
|                                   color: Colors.black87,
 | |
|                                 ),
 | |
|                                 MyText.bodySmall(
 | |
|                                   relativeTime,
 | |
|                                   fontSize: 12,
 | |
|                                   color: Colors.black54,
 | |
|                                 ),
 | |
|                               ],
 | |
|                             ),
 | |
|                           ),
 | |
|                         ],
 | |
|                       ),
 | |
|                       const SizedBox(height: 12),
 | |
|                       Row(
 | |
|                         children: [
 | |
|                           Expanded(
 | |
|                             child: MyText.bodyMedium(
 | |
|                               commentText,
 | |
|                               fontWeight: 500,
 | |
|                               color: Colors.black87,
 | |
|                             ),
 | |
|                           ),
 | |
|                         ],
 | |
|                       ),
 | |
|                       const SizedBox(height: 12),
 | |
|                       if (imageUrls.isNotEmpty) ...[
 | |
|                         Row(
 | |
|                           crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                           children: [
 | |
|                             Icon(Icons.attach_file_outlined,
 | |
|                                 size: 18, color: Colors.grey[700]),
 | |
|                             MyText.bodyMedium(
 | |
|                               'Attachments',
 | |
|                               fontWeight: 600,
 | |
|                               color: Colors.black87,
 | |
|                             ),
 | |
|                           ],
 | |
|                         ),
 | |
|                         const SizedBox(height: 8),
 | |
|                         SizedBox(
 | |
|                           height: 60,
 | |
|                           child: ListView.separated(
 | |
|                             scrollDirection: Axis.horizontal,
 | |
|                             itemCount: imageUrls.length,
 | |
|                             itemBuilder: (context, imageIndex) {
 | |
|                               final imageUrl = imageUrls[imageIndex];
 | |
|                               return GestureDetector(
 | |
|                                 onTap: () {
 | |
|                                   showDialog(
 | |
|                                     context: context,
 | |
|                                     barrierColor: Colors.black54,
 | |
|                                     builder: (_) => ImageViewerDialog(
 | |
|                                       imageSources: imageUrls,
 | |
|                                       initialIndex: imageIndex,
 | |
|                                     ),
 | |
|                                   );
 | |
|                                 },
 | |
|                                 child: Stack(
 | |
|                                   children: [
 | |
|                                     Container(
 | |
|                                       width: 60,
 | |
|                                       height: 60,
 | |
|                                       decoration: BoxDecoration(
 | |
|                                         borderRadius: BorderRadius.circular(12),
 | |
|                                         color: Colors.grey[100],
 | |
|                                         boxShadow: [
 | |
|                                           BoxShadow(
 | |
|                                             color: Colors.black26,
 | |
|                                             blurRadius: 6,
 | |
|                                             offset: Offset(2, 2),
 | |
|                                           ),
 | |
|                                         ],
 | |
|                                       ),
 | |
|                                       child: ClipRRect(
 | |
|                                         borderRadius: BorderRadius.circular(12),
 | |
|                                         child: Image.network(
 | |
|                                           imageUrl,
 | |
|                                           fit: BoxFit.cover,
 | |
|                                           errorBuilder:
 | |
|                                               (context, error, stackTrace) =>
 | |
|                                                   Container(
 | |
|                                             color: Colors.grey[300],
 | |
|                                             child: Icon(Icons.broken_image,
 | |
|                                                 color: Colors.grey[700]),
 | |
|                                           ),
 | |
|                                         ),
 | |
|                                       ),
 | |
|                                     ),
 | |
|                                     const Positioned(
 | |
|                                       right: 4,
 | |
|                                       bottom: 4,
 | |
|                                       child: Icon(Icons.zoom_in,
 | |
|                                           color: Colors.white70, size: 16),
 | |
|                                     ),
 | |
|                                   ],
 | |
|                                 ),
 | |
|                               );
 | |
|                             },
 | |
|                             separatorBuilder: (_, __) =>
 | |
|                                 const SizedBox(width: 12),
 | |
|                           ),
 | |
|                         ),
 | |
|                         const SizedBox(height: 12),
 | |
|                       ],
 | |
|                     ],
 | |
|                   ),
 | |
|                 ),
 | |
|               ],
 | |
|             ),
 | |
|           );
 | |
|         },
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget buildImagePickerSection({
 | |
|     required List<File> images,
 | |
|     required VoidCallback onCameraTap,
 | |
|     required VoidCallback onUploadTap,
 | |
|     required void Function(int index) onRemoveImage,
 | |
|     required void Function(int initialIndex) onPreviewImage,
 | |
|   }) {
 | |
|     return Column(
 | |
|       crossAxisAlignment: CrossAxisAlignment.start,
 | |
|       children: [
 | |
|         if (images.isEmpty)
 | |
|           Container(
 | |
|             height: 70,
 | |
|             width: double.infinity,
 | |
|             decoration: BoxDecoration(
 | |
|               borderRadius: BorderRadius.circular(12),
 | |
|               border: Border.all(color: Colors.grey.shade300, width: 2),
 | |
|               color: Colors.grey.shade100,
 | |
|             ),
 | |
|             child: Center(
 | |
|               child: Icon(Icons.photo_camera_outlined,
 | |
|                   size: 48, color: Colors.grey.shade400),
 | |
|             ),
 | |
|           )
 | |
|         else
 | |
|           SizedBox(
 | |
|             height: 70,
 | |
|             child: ListView.separated(
 | |
|               scrollDirection: Axis.horizontal,
 | |
|               itemCount: images.length,
 | |
|               separatorBuilder: (_, __) => const SizedBox(width: 12),
 | |
|               itemBuilder: (context, index) {
 | |
|                 final file = images[index];
 | |
|                 return Stack(
 | |
|                   children: [
 | |
|                     GestureDetector(
 | |
|                       onTap: () => onPreviewImage(index),
 | |
|                       child: ClipRRect(
 | |
|                         borderRadius: BorderRadius.circular(12),
 | |
|                         child: Image.file(
 | |
|                           file,
 | |
|                           height: 70,
 | |
|                           width: 70,
 | |
|                           fit: BoxFit.cover,
 | |
|                         ),
 | |
|                       ),
 | |
|                     ),
 | |
|                     Positioned(
 | |
|                       top: 4,
 | |
|                       right: 4,
 | |
|                       child: GestureDetector(
 | |
|                         onTap: () => onRemoveImage(index),
 | |
|                         child: Container(
 | |
|                           decoration: const BoxDecoration(
 | |
|                             color: Colors.black54,
 | |
|                             shape: BoxShape.circle,
 | |
|                           ),
 | |
|                           child: const Icon(Icons.close,
 | |
|                               size: 20, color: Colors.white),
 | |
|                         ),
 | |
|                       ),
 | |
|                     ),
 | |
|                   ],
 | |
|                 );
 | |
|               },
 | |
|             ),
 | |
|           ),
 | |
|         MySpacing.height(16),
 | |
|         Row(
 | |
|           children: [
 | |
|             Expanded(
 | |
|               child: MyButton.outlined(
 | |
|                 onPressed: onCameraTap,
 | |
|                 padding: MySpacing.xy(12, 10),
 | |
|                 child: Row(
 | |
|                   mainAxisAlignment: MainAxisAlignment.center,
 | |
|                   children: [
 | |
|                     const Icon(Icons.camera_alt,
 | |
|                         size: 16, color: Colors.blueAccent),
 | |
|                     MySpacing.width(6),
 | |
|                     MyText.bodySmall('Capture', color: Colors.blueAccent),
 | |
|                   ],
 | |
|                 ),
 | |
|               ),
 | |
|             ),
 | |
|             MySpacing.width(12),
 | |
|             Expanded(
 | |
|               child: MyButton.outlined(
 | |
|                 onPressed: onUploadTap,
 | |
|                 padding: MySpacing.xy(12, 10),
 | |
|                 child: Row(
 | |
|                   mainAxisAlignment: MainAxisAlignment.center,
 | |
|                   children: [
 | |
|                     const Icon(Icons.upload_file,
 | |
|                         size: 16, color: Colors.blueAccent),
 | |
|                     MySpacing.width(6),
 | |
|                     MyText.bodySmall('Upload', color: Colors.blueAccent),
 | |
|                   ],
 | |
|                 ),
 | |
|               ),
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| }
 |