diff --git a/lib/controller/attendance/attendance_screen_controller.dart b/lib/controller/attendance/attendance_screen_controller.dart index 501a94f..8e45d48 100644 --- a/lib/controller/attendance/attendance_screen_controller.dart +++ b/lib/controller/attendance/attendance_screen_controller.dart @@ -8,6 +8,7 @@ import 'package:intl/intl.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/widgets/my_image_compressor.dart'; +import 'package:marco/helpers/widgets/time_stamp_image_helper.dart'; import 'package:marco/model/attendance/attendance_model.dart'; import 'package:marco/model/project_model.dart'; @@ -32,7 +33,7 @@ class AttendanceController extends GetxController { final isLoadingOrganizations = false.obs; // States -String selectedTab = 'todaysAttendance'; + String selectedTab = 'todaysAttendance'; DateTime? startDateAttendance; DateTime? endDateAttendance; @@ -104,7 +105,7 @@ String selectedTab = 'todaysAttendance'; .toList(); } -// Computed filtered regularization logs + // Computed filtered regularization logs List get filteredRegularizationLogs { if (searchQuery.value.isEmpty) return regularizationLogs; return regularizationLogs @@ -174,8 +175,12 @@ String selectedTab = 'todaysAttendance'; return false; } + // 🔹 Add timestamp to the image + final timestampedFile = await TimestampImageHelper.addTimestamp( + imageFile: File(image.path)); + final compressedBytes = - await compressImageToUnder100KB(File(image.path)); + await compressImageToUnder100KB(timestampedFile); if (compressedBytes == null) { logSafe("Image compression failed.", level: LogLevel.error); return false; diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 139c2d9..75e0500 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -17,6 +17,7 @@ import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/helpers/widgets/time_stamp_image_helper.dart'; class AddExpenseController extends GetxController { // --- Text Controllers --- @@ -65,6 +66,7 @@ class AddExpenseController extends GetxController { final paymentModes = [].obs; final allEmployees = [].obs; final employeeSearchResults = [].obs; + final isProcessingAttachment = false.obs; String? editingExpenseId; @@ -252,9 +254,22 @@ class AddExpenseController extends GetxController { Future pickFromCamera() async { try { final pickedFile = await _picker.pickImage(source: ImageSource.camera); - if (pickedFile != null) attachments.add(File(pickedFile.path)); + if (pickedFile != null) { + isProcessingAttachment.value = true; // start loading + File imageFile = File(pickedFile.path); + + // Add timestamp to the captured image + File timestampedFile = await TimestampImageHelper.addTimestamp( + imageFile: imageFile, + ); + + attachments.add(timestampedFile); + attachments.refresh(); // refresh UI + } } catch (e) { _errorSnackbar("Camera error: $e"); + } finally { + isProcessingAttachment.value = false; // stop loading } } diff --git a/lib/controller/task_planning/report_task_action_controller.dart b/lib/controller/task_planning/report_task_action_controller.dart index 9722dd0..491e618 100644 --- a/lib/controller/task_planning/report_task_action_controller.dart +++ b/lib/controller/task_planning/report_task_action_controller.dart @@ -13,6 +13,7 @@ import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:marco/helpers/widgets/my_image_compressor.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/dailyTaskPlanning/work_status_model.dart'; +import 'package:marco/helpers/widgets/time_stamp_image_helper.dart'; enum ApiStatus { idle, loading, success, failure } @@ -32,9 +33,11 @@ class ReportTaskActionController extends MyController { final Rxn> selectedTask = Rxn>(); final RxString selectedWorkStatusName = ''.obs; + final RxBool isPickingImage = false.obs; final MyFormValidator basicValidator = MyFormValidator(); - final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController()); + final DailyTaskPlanningController taskController = + Get.put(DailyTaskPlanningController()); final ImagePicker _picker = ImagePicker(); final assignedDateController = TextEditingController(); @@ -83,18 +86,31 @@ class ReportTaskActionController extends MyController { void _initializeFormFields() { basicValidator - ..addField('assigned_date', label: "Assigned Date", controller: assignedDateController) - ..addField('work_area', label: "Work Area", controller: workAreaController) + ..addField('assigned_date', + label: "Assigned Date", controller: assignedDateController) + ..addField('work_area', + label: "Work Area", controller: workAreaController) ..addField('activity', label: "Activity", controller: activityController) - ..addField('team_size', label: "Team Size", controller: teamSizeController) + ..addField('team_size', + label: "Team Size", controller: teamSizeController) ..addField('task_id', label: "Task Id", controller: taskIdController) ..addField('assigned', label: "Assigned", controller: assignedController) - ..addField('completed_work', label: "Completed Work", required: true, controller: completedWorkController) - ..addField('comment', label: "Comment", required: true, controller: commentController) - ..addField('assigned_by', label: "Assigned By", controller: assignedByController) - ..addField('team_members', label: "Team Members", controller: teamMembersController) - ..addField('planned_work', label: "Planned Work", controller: plannedWorkController) - ..addField('approved_task', label: "Approved Task", required: true, controller: approvedTaskController); + ..addField('completed_work', + label: "Completed Work", + required: true, + controller: completedWorkController) + ..addField('comment', + label: "Comment", required: true, controller: commentController) + ..addField('assigned_by', + label: "Assigned By", controller: assignedByController) + ..addField('team_members', + label: "Team Members", controller: teamMembersController) + ..addField('planned_work', + label: "Planned Work", controller: plannedWorkController) + ..addField('approved_task', + label: "Approved Task", + required: true, + controller: approvedTaskController); } Future approveTask({ @@ -108,7 +124,8 @@ class ReportTaskActionController extends MyController { if (projectId.isEmpty || reportActionId.isEmpty) { _showError("Project ID and Report Action ID are required."); - logSafe("Missing required projectId or reportActionId", level: LogLevel.warning); + logSafe("Missing required projectId or reportActionId", + level: LogLevel.warning); return false; } @@ -117,13 +134,15 @@ class ReportTaskActionController extends MyController { if (approvedTaskInt == null) { _showError("Invalid approved task count."); - logSafe("Invalid approvedTaskCount: $approvedTaskCount", level: LogLevel.warning); + logSafe("Invalid approvedTaskCount: $approvedTaskCount", + level: LogLevel.warning); return false; } if (completedWorkInt != null && approvedTaskInt > completedWorkInt) { _showError("Approved task count cannot exceed completed work."); - logSafe("Validation failed: approved > completed", level: LogLevel.warning); + logSafe("Validation failed: approved > completed", + level: LogLevel.warning); return false; } @@ -159,7 +178,8 @@ class ReportTaskActionController extends MyController { return false; } } catch (e, st) { - logSafe("Error in approveTask: $e", level: LogLevel.error, error: e, stackTrace: st); + logSafe("Error in approveTask: $e", + level: LogLevel.error, error: e, stackTrace: st); _showError("An error occurred."); return false; } finally { @@ -207,7 +227,8 @@ class ReportTaskActionController extends MyController { _showError("Failed to comment task."); } } catch (e, st) { - logSafe("Error in commentTask: $e", level: LogLevel.error, error: e, stackTrace: st); + logSafe("Error in commentTask: $e", + level: LogLevel.error, error: e, stackTrace: st); _showError("An error occurred while commenting the task."); } finally { isLoading.value = false; @@ -224,7 +245,8 @@ class ReportTaskActionController extends MyController { workStatus.assignAll(model.data); logSafe("Fetched ${model.data.length} work statuses"); } else { - logSafe("No work statuses found or API call failed", level: LogLevel.warning); + logSafe("No work statuses found or API call failed", + level: LogLevel.warning); } isLoadingWorkStatus.value = false; @@ -251,7 +273,8 @@ class ReportTaskActionController extends MyController { }; })); - logSafe("_prepareImages: Prepared ${results.whereType>().length} images."); + logSafe( + "_prepareImages: Prepared ${results.whereType>().length} images."); return results.whereType>().toList(); } @@ -267,23 +290,40 @@ class ReportTaskActionController extends MyController { } Future pickImages({required bool fromCamera}) async { - logSafe("Opening image picker..."); - if (fromCamera) { - final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75); - if (pickedFile != null) { - selectedImages.add(File(pickedFile.path)); - logSafe("Image added from camera: ${pickedFile.path}", ); + try { + isPickingImage.value = true; // start loading + logSafe("Opening image picker..."); + + if (fromCamera) { + final pickedFile = await _picker.pickImage( + source: ImageSource.camera, + imageQuality: 75, + ); + if (pickedFile != null) { + final timestampedFile = await TimestampImageHelper.addTimestamp( + imageFile: File(pickedFile.path), + ); + selectedImages.add(timestampedFile); + logSafe("Image added from camera with timestamp: ${pickedFile.path}"); + } + } else { + final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); + selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); + logSafe("${pickedFiles.length} images added from gallery."); } - } else { - final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); - selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); - logSafe("${pickedFiles.length} images added from gallery.", ); + } catch (e, st) { + logSafe("Error picking images: $e", + level: LogLevel.error, stackTrace: st); + } finally { + isPickingImage.value = false; // stop loading } } void removeImageAt(int index) { if (index >= 0 && index < selectedImages.length) { - logSafe("Removing image at index $index", ); + logSafe( + "Removing image at index $index", + ); selectedImages.removeAt(index); } } diff --git a/lib/controller/task_planning/report_task_controller.dart b/lib/controller/task_planning/report_task_controller.dart index 5956b4b..8aec032 100644 --- a/lib/controller/task_planning/report_task_controller.dart +++ b/lib/controller/task_planning/report_task_controller.dart @@ -11,10 +11,12 @@ import 'package:image_picker/image_picker.dart'; import 'dart:io'; import 'dart:convert'; import 'package:marco/helpers/widgets/my_image_compressor.dart'; +import 'package:marco/helpers/widgets/time_stamp_image_helper.dart'; enum ApiStatus { idle, loading, success, failure } -final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController()); +final DailyTaskPlanningController taskController = + Get.put(DailyTaskPlanningController()); final ImagePicker _picker = ImagePicker(); class ReportTaskController extends MyController { @@ -23,6 +25,7 @@ class ReportTaskController extends MyController { RxBool isLoading = false.obs; Rx reportStatus = ApiStatus.idle.obs; Rx commentStatus = ApiStatus.idle.obs; + final RxBool isPickingImage = false.obs; RxList selectedImages = [].obs; @@ -43,17 +46,27 @@ class ReportTaskController extends MyController { super.onInit(); logSafe("Initializing ReportTaskController..."); basicValidator - ..addField('assigned_date', label: "Assigned Date", controller: assignedDateController) - ..addField('work_area', label: "Work Area", controller: workAreaController) + ..addField('assigned_date', + label: "Assigned Date", controller: assignedDateController) + ..addField('work_area', + label: "Work Area", controller: workAreaController) ..addField('activity', label: "Activity", controller: activityController) - ..addField('team_size', label: "Team Size", controller: teamSizeController) + ..addField('team_size', + label: "Team Size", controller: teamSizeController) ..addField('task_id', label: "Task Id", controller: taskIdController) ..addField('assigned', label: "Assigned", controller: assignedController) - ..addField('completed_work', label: "Completed Work", required: true, controller: completedWorkController) - ..addField('comment', label: "Comment", required: true, controller: commentController) - ..addField('assigned_by', label: "Assigned By", controller: assignedByController) - ..addField('team_members', label: "Team Members", controller: teamMembersController) - ..addField('planned_work', label: "Planned Work", controller: plannedWorkController); + ..addField('completed_work', + label: "Completed Work", + required: true, + controller: completedWorkController) + ..addField('comment', + label: "Comment", required: true, controller: commentController) + ..addField('assigned_by', + label: "Assigned By", controller: assignedByController) + ..addField('team_members', + label: "Team Members", controller: teamMembersController) + ..addField('planned_work', + label: "Planned Work", controller: plannedWorkController); logSafe("Form fields initialized."); } @@ -83,9 +96,13 @@ class ReportTaskController extends MyController { required DateTime reportedDate, List? images, }) async { - logSafe("Reporting task for projectId", ); + logSafe( + "Reporting task for projectId", + ); final completedWork = completedWorkController.text.trim(); - if (completedWork.isEmpty || int.tryParse(completedWork) == null || int.parse(completedWork) < 0) { + if (completedWork.isEmpty || + int.tryParse(completedWork) == null || + int.parse(completedWork) < 0) { _showError("Completed work must be a positive number."); return false; } @@ -121,7 +138,8 @@ class ReportTaskController extends MyController { return false; } } catch (e, s) { - logSafe("Exception while reporting task", level: LogLevel.error, error: e, stackTrace: s); + logSafe("Exception while reporting task", + level: LogLevel.error, error: e, stackTrace: s); reportStatus.value = ApiStatus.failure; _showError("An error occurred while reporting the task."); return false; @@ -138,7 +156,9 @@ class ReportTaskController extends MyController { required String comment, List? images, }) async { - logSafe("Submitting comment for project", ); + logSafe( + "Submitting comment for project", + ); final commentField = commentController.text.trim(); if (commentField.isEmpty) { @@ -166,14 +186,16 @@ class ReportTaskController extends MyController { _showError("Failed to comment task."); } } catch (e, s) { - logSafe("Exception while commenting task", level: LogLevel.error, error: e, stackTrace: s); + logSafe("Exception while commenting task", + level: LogLevel.error, error: e, stackTrace: s); _showError("An error occurred while commenting the task."); } finally { isLoading.value = false; } } - Future>?> _prepareImages(List? images, String context) async { + Future>?> _prepareImages( + List? images, String context) async { if (images == null || images.isEmpty) return null; logSafe("Preparing images for $context upload..."); @@ -191,7 +213,8 @@ class ReportTaskController extends MyController { "description": "Image uploaded for $context", }; } catch (e) { - logSafe("Image processing failed: ${file.path}", level: LogLevel.warning, error: e); + logSafe("Image processing failed: ${file.path}", + level: LogLevel.warning, error: e); return null; } })); @@ -212,18 +235,31 @@ class ReportTaskController extends MyController { Future pickImages({required bool fromCamera}) async { try { + isPickingImage.value = true; // Start loading + if (fromCamera) { - final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75); + final pickedFile = await _picker.pickImage( + source: ImageSource.camera, + imageQuality: 75, + ); if (pickedFile != null) { - selectedImages.add(File(pickedFile.path)); + // Only camera images get timestamp + final timestampedFile = await TimestampImageHelper.addTimestamp( + imageFile: File(pickedFile.path), + ); + selectedImages.add(timestampedFile); } } else { final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); + // Gallery images added as-is without timestamp selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); } - logSafe("Images picked: ${selectedImages.length}", ); + + logSafe("Images picked: ${selectedImages.length}"); } catch (e) { logSafe("Error picking images", level: LogLevel.warning, error: e); + } finally { + isPickingImage.value = false; // Stop loading } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 321312e..5d91f58 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,6 +1,6 @@ class ApiEndpoints { - static const String baseUrl = "https://stageapi.marcoaiot.com/api"; - // static const String baseUrl = "https://api.marcoaiot.com/api"; + // static const String baseUrl = "https://stageapi.marcoaiot.com/api"; + static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api"; // Dashboard Module API Endpoints diff --git a/lib/helpers/widgets/time_stamp_image_helper.dart b/lib/helpers/widgets/time_stamp_image_helper.dart new file mode 100644 index 0000000..954a205 --- /dev/null +++ b/lib/helpers/widgets/time_stamp_image_helper.dart @@ -0,0 +1,97 @@ +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/helpers/services/app_logger.dart'; + +class TimestampImageHelper { + /// Adds a timestamp to an image file and returns a new File + static Future addTimestamp({ + required File imageFile, + Color textColor = Colors.white, + double fontSize = 60, + Color backgroundColor = Colors.black54, + double padding = 40, + double bottomPadding = 60, + }) async { + try { + // Read the image file + final bytes = await imageFile.readAsBytes(); + final originalImage = await decodeImageFromList(bytes); + + // Create a canvas + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + // Draw original image + final paint = Paint(); + canvas.drawImage(originalImage, Offset.zero, paint); + + // Timestamp text + final now = DateTime.now(); + final timestamp = DateFormat('dd MMM yyyy hh:mm:ss a').format(now); + + final textStyle = ui.TextStyle( + color: textColor, + fontSize: fontSize, + fontWeight: FontWeight.bold, + shadows: [ + const ui.Shadow( + color: Colors.black, + offset: Offset(3, 3), + blurRadius: 6, + ), + ], + ); + + final paragraphStyle = ui.ParagraphStyle(textAlign: TextAlign.left); + final paragraphBuilder = ui.ParagraphBuilder(paragraphStyle) + ..pushStyle(textStyle) + ..addText(timestamp); + + final paragraph = paragraphBuilder.build(); + paragraph.layout(const ui.ParagraphConstraints(width: double.infinity)); + + final textWidth = paragraph.maxIntrinsicWidth; + final yPosition = originalImage.height - paragraph.height - bottomPadding; + final xPosition = (originalImage.width - textWidth) / 2; + + // Draw background + final backgroundPaint = Paint() + ..color = backgroundColor + ..style = PaintingStyle.fill; + + final backgroundRect = Rect.fromLTWH( + xPosition - padding, + yPosition - 15, + textWidth + padding * 2, + paragraph.height + 30, + ); + + canvas.drawRRect( + RRect.fromRectAndRadius(backgroundRect, const Radius.circular(8)), + backgroundPaint, + ); + + // Draw timestamp text + canvas.drawParagraph(paragraph, Offset(xPosition, yPosition)); + + // Convert canvas to image + final picture = recorder.endRecording(); + final img = await picture.toImage(originalImage.width, originalImage.height); + + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + final buffer = byteData!.buffer.asUint8List(); + + // Save to temporary file + final tempDir = await Directory.systemTemp.createTemp(); + final timestampedFile = File('${tempDir.path}/timestamped_${DateTime.now().millisecondsSinceEpoch}.png'); + await timestampedFile.writeAsBytes(buffer); + + return timestampedFile; + } catch (e, stacktrace) { + logSafe("Error adding timestamp to image", level: LogLevel.error, error: e, stackTrace: stacktrace); + return imageFile; // fallback + } + } +} diff --git a/lib/model/dailyTaskPlanning/comment_task_bottom_sheet.dart b/lib/model/dailyTaskPlanning/comment_task_bottom_sheet.dart index 4184d49..24d37d6 100644 --- a/lib/model/dailyTaskPlanning/comment_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlanning/comment_task_bottom_sheet.dart @@ -281,11 +281,30 @@ class _CommentTaskBottomSheetState extends State _buildSectionHeader("Attach Photos", Icons.camera_alt_outlined), MySpacing.height(12), Obx(() { + if (controller.isPickingImage.value) { + return Center( + child: Column( + children: [ + CircularProgressIndicator( + color: Colors.blueAccent, + ), + const SizedBox(height: 8), + Text( + "Processing image, please wait...", + style: TextStyle( + fontSize: 14, + color: Colors.blueAccent, + ), + ), + ], + ), + ); + } + final images = controller.selectedImages; + return Column( children: [ - // --- Refactoring Note --- - // Using the reusable _ImageHorizontalListView for picked images. _ImageHorizontalListView( imageSources: images.toList(), onPreview: (index) => _showImageViewer(images.toList(), index), @@ -299,8 +318,11 @@ class _CommentTaskBottomSheetState extends State color: Colors.grey.shade100, ), child: Center( - child: Icon(Icons.photo_library_outlined, - size: 36, color: Colors.grey.shade400), + child: Icon( + Icons.photo_library_outlined, + size: 36, + color: Colors.grey.shade400, + ), ), ), ), diff --git a/lib/model/dailyTaskPlanning/report_action_bottom_sheet.dart b/lib/model/dailyTaskPlanning/report_action_bottom_sheet.dart index 3c25f8b..b063b9a 100644 --- a/lib/model/dailyTaskPlanning/report_action_bottom_sheet.dart +++ b/lib/model/dailyTaskPlanning/report_action_bottom_sheet.dart @@ -290,6 +290,7 @@ class _ReportActionBottomSheetState extends State onCameraTap: () => controller.pickImages(fromCamera: true), onUploadTap: () => controller.pickImages(fromCamera: false), onRemoveImage: (index) => controller.removeImageAt(index), + isProcessing: controller.isPickingImage.value, onPreviewImage: (index) => showDialog( context: context, builder: (_) => ImageViewerDialog( diff --git a/lib/model/dailyTaskPlanning/report_action_widgets.dart b/lib/model/dailyTaskPlanning/report_action_widgets.dart index 3192e90..96af289 100644 --- a/lib/model/dailyTaskPlanning/report_action_widgets.dart +++ b/lib/model/dailyTaskPlanning/report_action_widgets.dart @@ -92,17 +92,48 @@ Widget buildReportedImagesSection({ } /// Local image picker preview (with file images) +/// Local image picker preview with processing loader Widget buildImagePickerSection({ required List images, required VoidCallback onCameraTap, required VoidCallback onUploadTap, required void Function(int index) onRemoveImage, required void Function(int initialIndex) onPreviewImage, + required bool isProcessing, // New: show loader while image is being processed }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (images.isEmpty) + // Loader placeholder when processing new images + if (isProcessing) + 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: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(color: Colors.indigo), + ), + const SizedBox(height: 6), + Text( + "Processing image, please wait...", + style: TextStyle(fontSize: 12, color: Colors.indigo), + ), + ], + ), + ), + ) + else if (images.isEmpty) + // Empty placeholder when no images Container( height: 70, width: double.infinity, @@ -117,6 +148,7 @@ Widget buildImagePickerSection({ ), ) else + // Display selected images SizedBox( height: 70, child: ListView.separated( @@ -160,6 +192,8 @@ Widget buildImagePickerSection({ ), ), MySpacing.height(16), + + // Camera & Upload Buttons Row( children: [ Expanded( @@ -200,8 +234,8 @@ Widget buildImagePickerSection({ } /// Comment list widget -Widget buildCommentList( - List> comments, BuildContext context, String Function(String) timeAgo) { +Widget buildCommentList(List> comments, + BuildContext context, String Function(String) timeAgo) { comments.sort((a, b) { final aDate = DateTime.tryParse(a['date'] ?? '') ?? DateTime.fromMillisecondsSinceEpoch(0); diff --git a/lib/model/dailyTaskPlanning/report_task_bottom_sheet.dart b/lib/model/dailyTaskPlanning/report_task_bottom_sheet.dart index bd05421..62841e6 100644 --- a/lib/model/dailyTaskPlanning/report_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlanning/report_task_bottom_sheet.dart @@ -52,9 +52,8 @@ class _ReportTaskBottomSheetState extends State } @override - Widget build(BuildContext context) { - return Obx(() { - return BaseBottomSheet( +Widget build(BuildContext context) { + return Obx(() => BaseBottomSheet( title: "Report Task", isSubmitting: controller.reportStatus.value == ApiStatus.loading, onCancel: () => Navigator.of(context).pop(), @@ -64,25 +63,33 @@ class _ReportTaskBottomSheetState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildRow("Assigned Date", controller.basicValidator.getController('assigned_date')?.text), - _buildRow("Assigned By", controller.basicValidator.getController('assigned_by')?.text), - _buildRow("Work Area", controller.basicValidator.getController('work_area')?.text), - _buildRow("Activity", controller.basicValidator.getController('activity')?.text), - _buildRow("Team Size", controller.basicValidator.getController('team_size')?.text), + _buildRow( + "Assigned Date", + controller.basicValidator + .getController('assigned_date') + ?.text), + _buildRow("Assigned By", + controller.basicValidator.getController('assigned_by')?.text), + _buildRow("Work Area", + controller.basicValidator.getController('work_area')?.text), + _buildRow("Activity", + controller.basicValidator.getController('activity')?.text), + _buildRow("Team Size", + controller.basicValidator.getController('team_size')?.text), _buildRow( "Assigned", "${controller.basicValidator.getController('assigned')?.text ?? '-'} " - "of ${widget.taskData['pendingWork'] ?? '-'} Pending", + "of ${widget.taskData['pendingWork'] ?? '-'} Pending", ), _buildCompletedWorkField(), _buildCommentField(), - Obx(() => _buildImageSection()), + _buildImageSection(), ], ), ), - ); - }); - } + )); +} + Future _handleSubmit() async { final v = controller.basicValidator; @@ -91,7 +98,8 @@ class _ReportTaskBottomSheetState extends State final success = await controller.reportTask( projectId: v.getController('task_id')?.text ?? '', comment: v.getController('comment')?.text ?? '', - completedTask: int.tryParse(v.getController('completed_work')?.text ?? '') ?? 0, + completedTask: + int.tryParse(v.getController('completed_work')?.text ?? '') ?? 0, checklist: [], reportedDate: DateTime.now(), images: controller.selectedImages, @@ -118,12 +126,14 @@ class _ReportTaskBottomSheetState extends State child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icons[label] ?? Icons.info_outline, size: 18, color: Colors.grey[700]), + Icon(icons[label] ?? Icons.info_outline, + size: 18, color: Colors.grey[700]), MySpacing.width(8), MyText.titleSmall("$label:", fontWeight: 600), MySpacing.width(12), Expanded( - child: MyText.bodyMedium(value?.trim().isNotEmpty == true ? value!.trim() : "-"), + child: MyText.bodyMedium( + value?.trim().isNotEmpty == true ? value!.trim() : "-"), ), ], ), @@ -148,10 +158,12 @@ class _ReportTaskBottomSheetState extends State controller: controller.basicValidator.getController('completed_work'), keyboardType: TextInputType.number, validator: (value) { - if (value == null || value.trim().isEmpty) return 'Please enter completed work'; + if (value == null || value.trim().isEmpty) + return 'Please enter completed work'; final completed = int.tryParse(value.trim()); if (completed == null) return 'Enter a valid number'; - if (completed > pending) return 'Completed work cannot exceed pending work $pending'; + if (completed > pending) + return 'Completed work cannot exceed pending work $pending'; return null; }, decoration: InputDecoration( @@ -203,8 +215,6 @@ class _ReportTaskBottomSheetState extends State } Widget _buildImageSection() { - final images = controller.selectedImages; - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -216,95 +226,131 @@ class _ReportTaskBottomSheetState extends State ], ), MySpacing.height(12), - 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: (_, __) => MySpacing.width(12), - itemBuilder: (context, index) { - final file = images[index]; - return Stack( - children: [ - GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (_) => Dialog( - child: InteractiveViewer(child: Image.file(file)), + // Only wrap reactive parts with Obx + Obx(() { + if (controller.isPickingImage.value) { + return Center( + child: Column( + children: [ + CircularProgressIndicator(color: Colors.blueAccent), + const SizedBox(height: 8), + Text( + "Processing image, please wait...", + style: TextStyle( + fontSize: 14, + color: Colors.blueAccent, + ), + ), + ], + ), + ); + } + + final images = controller.selectedImages; + + return Column( + 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: (_, __) => MySpacing.width(12), + itemBuilder: (context, index) { + final file = images[index]; + return Stack( + children: [ + GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (_) => Dialog( + child: InteractiveViewer( + child: Image.file(file)), + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file(file, + height: 70, width: 70, fit: BoxFit.cover), + ), ), - ); - }, - 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: () => + controller.selectedImages.removeAt(index), + child: Container( + decoration: 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: () => controller.pickImages(fromCamera: true), + 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), + ], ), ), - Positioned( - top: 4, - right: 4, - child: GestureDetector( - onTap: () => controller.removeImageAt(index), - child: Container( - decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle), - child: const Icon(Icons.close, size: 20, color: Colors.white), - ), + ), + MySpacing.width(12), + Expanded( + child: MyButton.outlined( + onPressed: () => controller.pickImages(fromCamera: false), + 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), + ], ), ), - ], - ); - }, - ), - ), - MySpacing.height(16), - Row( - children: [ - Expanded( - child: MyButton.outlined( - onPressed: () => controller.pickImages(fromCamera: true), - 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: () => controller.pickImages(fromCamera: false), - 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), - ], - ), - ), - ), - ], - ), + ], + ); + }), ], ); } -} \ No newline at end of file +} diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 09a5438..7d0ef4b 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/expense/add_expense_controller.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/employee_selector_bottom_sheet.dart'; @@ -40,7 +41,8 @@ class _AddExpenseBottomSheet extends StatefulWidget { State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState(); } -class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { +class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> + with UIMixin { final AddExpenseController controller = Get.put(AddExpenseController()); final _formKey = GlobalKey(); @@ -326,8 +328,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { CustomTextField( controller: controller, hint: hint ?? "", - keyboardType: - keyboardType ?? TextInputType.text, + keyboardType: keyboardType ?? TextInputType.text, validator: validator, maxLines: maxLines, ), @@ -426,39 +427,65 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SectionTitle( - icon: Icons.attach_file, title: "Attachments", requiredField: true), - MySpacing.height(6), - AttachmentsSection( - attachments: controller.attachments, - existingAttachments: controller.existingAttachments, - onRemoveNew: controller.removeAttachment, - onRemoveExisting: (item) async { - await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => ConfirmDialog( - title: "Remove Attachment", - message: "Are you sure you want to remove this attachment?", - confirmText: "Remove", - icon: Icons.delete, - confirmColor: Colors.redAccent, - onConfirm: () async { - final index = controller.existingAttachments.indexOf(item); - if (index != -1) { - controller.existingAttachments[index]['isActive'] = false; - controller.existingAttachments.refresh(); - } - showAppSnackbar( - title: 'Removed', - message: 'Attachment has been removed.', - type: SnackbarType.success, - ); - }, + icon: Icons.attach_file, + title: "Attachments", + requiredField: true, + ), + MySpacing.height(10), + Obx(() { + if (controller.isProcessingAttachment.value) { + return Center( + child: Column( + children: [ + CircularProgressIndicator( + color: contentTheme.primary, + ), + const SizedBox(height: 8), + Text( + "Processing image, please wait...", + style: TextStyle( + fontSize: 14, + color: contentTheme.primary, + ), + ), + ], ), ); - }, - onAdd: controller.pickAttachments, - ), + } + + return AttachmentsSection( + attachments: controller.attachments, + existingAttachments: controller.existingAttachments, + onRemoveNew: controller.removeAttachment, + onRemoveExisting: (item) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => ConfirmDialog( + title: "Remove Attachment", + message: "Are you sure you want to remove this attachment?", + confirmText: "Remove", + icon: Icons.delete, + confirmColor: Colors.redAccent, + onConfirm: () async { + final index = controller.existingAttachments.indexOf(item); + if (index != -1) { + controller.existingAttachments[index]['isActive'] = false; + controller.existingAttachments.refresh(); + } + showAppSnackbar( + title: 'Removed', + message: 'Attachment has been removed.', + type: SnackbarType.success, + ); + Navigator.pop(context); + }, + ), + ); + }, + onAdd: controller.pickAttachments, + ); + }), ], ); }