feat: Add timestamp functionality to images in attendance and expense controllers

- Implemented TimestampImageHelper to add timestamps to images.
- Updated AttendanceController to apply timestamps when capturing images.
- Enhanced AddExpenseController to process images with timestamps.
- Modified ReportTaskActionController and ReportTaskController to include timestamping for images.
- Updated UI components to show loading indicators while processing images.
- Refactored image picking logic to handle timestamping and loading states.
This commit is contained in:
Vaibhav Surve 2025-10-30 18:03:54 +05:30
parent 16a2e1e53a
commit d208648350
11 changed files with 520 additions and 197 deletions

View File

@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_image_compressor.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/attendance/attendance_model.dart';
import 'package:marco/model/project_model.dart'; import 'package:marco/model/project_model.dart';
@ -32,7 +33,7 @@ class AttendanceController extends GetxController {
final isLoadingOrganizations = false.obs; final isLoadingOrganizations = false.obs;
// States // States
String selectedTab = 'todaysAttendance'; String selectedTab = 'todaysAttendance';
DateTime? startDateAttendance; DateTime? startDateAttendance;
DateTime? endDateAttendance; DateTime? endDateAttendance;
@ -104,7 +105,7 @@ String selectedTab = 'todaysAttendance';
.toList(); .toList();
} }
// Computed filtered regularization logs // Computed filtered regularization logs
List<RegularizationLogModel> get filteredRegularizationLogs { List<RegularizationLogModel> get filteredRegularizationLogs {
if (searchQuery.value.isEmpty) return regularizationLogs; if (searchQuery.value.isEmpty) return regularizationLogs;
return regularizationLogs return regularizationLogs
@ -174,8 +175,12 @@ String selectedTab = 'todaysAttendance';
return false; return false;
} }
// 🔹 Add timestamp to the image
final timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: File(image.path));
final compressedBytes = final compressedBytes =
await compressImageToUnder100KB(File(image.path)); await compressImageToUnder100KB(timestampedFile);
if (compressedBytes == null) { if (compressedBytes == null) {
logSafe("Image compression failed.", level: LogLevel.error); logSafe("Image compression failed.", level: LogLevel.error);
return false; return false;

View File

@ -17,6 +17,7 @@ import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_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 { class AddExpenseController extends GetxController {
// --- Text Controllers --- // --- Text Controllers ---
@ -65,6 +66,7 @@ class AddExpenseController extends GetxController {
final paymentModes = <PaymentModeModel>[].obs; final paymentModes = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs; final allEmployees = <EmployeeModel>[].obs;
final employeeSearchResults = <EmployeeModel>[].obs; final employeeSearchResults = <EmployeeModel>[].obs;
final isProcessingAttachment = false.obs;
String? editingExpenseId; String? editingExpenseId;
@ -252,9 +254,22 @@ class AddExpenseController extends GetxController {
Future<void> pickFromCamera() async { Future<void> pickFromCamera() async {
try { try {
final pickedFile = await _picker.pickImage(source: ImageSource.camera); 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) { } catch (e) {
_errorSnackbar("Camera error: $e"); _errorSnackbar("Camera error: $e");
} finally {
isProcessingAttachment.value = false; // stop loading
} }
} }

View File

@ -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_image_compressor.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/dailyTaskPlanning/work_status_model.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 } enum ApiStatus { idle, loading, success, failure }
@ -32,9 +33,11 @@ class ReportTaskActionController extends MyController {
final Rxn<Map<String, dynamic>> selectedTask = Rxn<Map<String, dynamic>>(); final Rxn<Map<String, dynamic>> selectedTask = Rxn<Map<String, dynamic>>();
final RxString selectedWorkStatusName = ''.obs; final RxString selectedWorkStatusName = ''.obs;
final RxBool isPickingImage = false.obs;
final MyFormValidator basicValidator = MyFormValidator(); final MyFormValidator basicValidator = MyFormValidator();
final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController()); final DailyTaskPlanningController taskController =
Get.put(DailyTaskPlanningController());
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
final assignedDateController = TextEditingController(); final assignedDateController = TextEditingController();
@ -83,18 +86,31 @@ class ReportTaskActionController extends MyController {
void _initializeFormFields() { void _initializeFormFields() {
basicValidator basicValidator
..addField('assigned_date', label: "Assigned Date", controller: assignedDateController) ..addField('assigned_date',
..addField('work_area', label: "Work Area", controller: workAreaController) label: "Assigned Date", controller: assignedDateController)
..addField('work_area',
label: "Work Area", controller: workAreaController)
..addField('activity', label: "Activity", controller: activityController) ..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('task_id', label: "Task Id", controller: taskIdController)
..addField('assigned', label: "Assigned", controller: assignedController) ..addField('assigned', label: "Assigned", controller: assignedController)
..addField('completed_work', label: "Completed Work", required: true, controller: completedWorkController) ..addField('completed_work',
..addField('comment', label: "Comment", required: true, controller: commentController) label: "Completed Work",
..addField('assigned_by', label: "Assigned By", controller: assignedByController) required: true,
..addField('team_members', label: "Team Members", controller: teamMembersController) controller: completedWorkController)
..addField('planned_work', label: "Planned Work", controller: plannedWorkController) ..addField('comment',
..addField('approved_task', label: "Approved Task", required: true, controller: approvedTaskController); 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<bool> approveTask({ Future<bool> approveTask({
@ -108,7 +124,8 @@ class ReportTaskActionController extends MyController {
if (projectId.isEmpty || reportActionId.isEmpty) { if (projectId.isEmpty || reportActionId.isEmpty) {
_showError("Project ID and Report Action ID are required."); _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; return false;
} }
@ -117,13 +134,15 @@ class ReportTaskActionController extends MyController {
if (approvedTaskInt == null) { if (approvedTaskInt == null) {
_showError("Invalid approved task count."); _showError("Invalid approved task count.");
logSafe("Invalid approvedTaskCount: $approvedTaskCount", level: LogLevel.warning); logSafe("Invalid approvedTaskCount: $approvedTaskCount",
level: LogLevel.warning);
return false; return false;
} }
if (completedWorkInt != null && approvedTaskInt > completedWorkInt) { if (completedWorkInt != null && approvedTaskInt > completedWorkInt) {
_showError("Approved task count cannot exceed completed work."); _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; return false;
} }
@ -159,7 +178,8 @@ class ReportTaskActionController extends MyController {
return false; return false;
} }
} catch (e, st) { } 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."); _showError("An error occurred.");
return false; return false;
} finally { } finally {
@ -207,7 +227,8 @@ class ReportTaskActionController extends MyController {
_showError("Failed to comment task."); _showError("Failed to comment task.");
} }
} catch (e, st) { } 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."); _showError("An error occurred while commenting the task.");
} finally { } finally {
isLoading.value = false; isLoading.value = false;
@ -224,7 +245,8 @@ class ReportTaskActionController extends MyController {
workStatus.assignAll(model.data); workStatus.assignAll(model.data);
logSafe("Fetched ${model.data.length} work statuses"); logSafe("Fetched ${model.data.length} work statuses");
} else { } 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; isLoadingWorkStatus.value = false;
@ -251,7 +273,8 @@ class ReportTaskActionController extends MyController {
}; };
})); }));
logSafe("_prepareImages: Prepared ${results.whereType<Map<String, dynamic>>().length} images."); logSafe(
"_prepareImages: Prepared ${results.whereType<Map<String, dynamic>>().length} images.");
return results.whereType<Map<String, dynamic>>().toList(); return results.whereType<Map<String, dynamic>>().toList();
} }
@ -267,23 +290,40 @@ class ReportTaskActionController extends MyController {
} }
Future<void> pickImages({required bool fromCamera}) async { Future<void> pickImages({required bool fromCamera}) async {
logSafe("Opening image picker..."); try {
if (fromCamera) { isPickingImage.value = true; // start loading
final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75); logSafe("Opening image picker...");
if (pickedFile != null) {
selectedImages.add(File(pickedFile.path)); if (fromCamera) {
logSafe("Image added from camera: ${pickedFile.path}", ); 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 { } catch (e, st) {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); logSafe("Error picking images: $e",
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); level: LogLevel.error, stackTrace: st);
logSafe("${pickedFiles.length} images added from gallery.", ); } finally {
isPickingImage.value = false; // stop loading
} }
} }
void removeImageAt(int index) { void removeImageAt(int index) {
if (index >= 0 && index < selectedImages.length) { if (index >= 0 && index < selectedImages.length) {
logSafe("Removing image at index $index", ); logSafe(
"Removing image at index $index",
);
selectedImages.removeAt(index); selectedImages.removeAt(index);
} }
} }

View File

@ -11,10 +11,12 @@ import 'package:image_picker/image_picker.dart';
import 'dart:io'; import 'dart:io';
import 'dart:convert'; import 'dart:convert';
import 'package:marco/helpers/widgets/my_image_compressor.dart'; 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 } enum ApiStatus { idle, loading, success, failure }
final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController()); final DailyTaskPlanningController taskController =
Get.put(DailyTaskPlanningController());
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
class ReportTaskController extends MyController { class ReportTaskController extends MyController {
@ -23,6 +25,7 @@ class ReportTaskController extends MyController {
RxBool isLoading = false.obs; RxBool isLoading = false.obs;
Rx<ApiStatus> reportStatus = ApiStatus.idle.obs; Rx<ApiStatus> reportStatus = ApiStatus.idle.obs;
Rx<ApiStatus> commentStatus = ApiStatus.idle.obs; Rx<ApiStatus> commentStatus = ApiStatus.idle.obs;
final RxBool isPickingImage = false.obs;
RxList<File> selectedImages = <File>[].obs; RxList<File> selectedImages = <File>[].obs;
@ -43,17 +46,27 @@ class ReportTaskController extends MyController {
super.onInit(); super.onInit();
logSafe("Initializing ReportTaskController..."); logSafe("Initializing ReportTaskController...");
basicValidator basicValidator
..addField('assigned_date', label: "Assigned Date", controller: assignedDateController) ..addField('assigned_date',
..addField('work_area', label: "Work Area", controller: workAreaController) label: "Assigned Date", controller: assignedDateController)
..addField('work_area',
label: "Work Area", controller: workAreaController)
..addField('activity', label: "Activity", controller: activityController) ..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('task_id', label: "Task Id", controller: taskIdController)
..addField('assigned', label: "Assigned", controller: assignedController) ..addField('assigned', label: "Assigned", controller: assignedController)
..addField('completed_work', label: "Completed Work", required: true, controller: completedWorkController) ..addField('completed_work',
..addField('comment', label: "Comment", required: true, controller: commentController) label: "Completed Work",
..addField('assigned_by', label: "Assigned By", controller: assignedByController) required: true,
..addField('team_members', label: "Team Members", controller: teamMembersController) controller: completedWorkController)
..addField('planned_work', label: "Planned Work", controller: plannedWorkController); ..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."); logSafe("Form fields initialized.");
} }
@ -83,9 +96,13 @@ class ReportTaskController extends MyController {
required DateTime reportedDate, required DateTime reportedDate,
List<File>? images, List<File>? images,
}) async { }) async {
logSafe("Reporting task for projectId", ); logSafe(
"Reporting task for projectId",
);
final completedWork = completedWorkController.text.trim(); 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."); _showError("Completed work must be a positive number.");
return false; return false;
} }
@ -121,7 +138,8 @@ class ReportTaskController extends MyController {
return false; return false;
} }
} catch (e, s) { } 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; reportStatus.value = ApiStatus.failure;
_showError("An error occurred while reporting the task."); _showError("An error occurred while reporting the task.");
return false; return false;
@ -138,7 +156,9 @@ class ReportTaskController extends MyController {
required String comment, required String comment,
List<File>? images, List<File>? images,
}) async { }) async {
logSafe("Submitting comment for project", ); logSafe(
"Submitting comment for project",
);
final commentField = commentController.text.trim(); final commentField = commentController.text.trim();
if (commentField.isEmpty) { if (commentField.isEmpty) {
@ -166,14 +186,16 @@ class ReportTaskController extends MyController {
_showError("Failed to comment task."); _showError("Failed to comment task.");
} }
} catch (e, s) { } 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."); _showError("An error occurred while commenting the task.");
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
} }
Future<List<Map<String, dynamic>>?> _prepareImages(List<File>? images, String context) async { Future<List<Map<String, dynamic>>?> _prepareImages(
List<File>? images, String context) async {
if (images == null || images.isEmpty) return null; if (images == null || images.isEmpty) return null;
logSafe("Preparing images for $context upload..."); logSafe("Preparing images for $context upload...");
@ -191,7 +213,8 @@ class ReportTaskController extends MyController {
"description": "Image uploaded for $context", "description": "Image uploaded for $context",
}; };
} catch (e) { } 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; return null;
} }
})); }));
@ -212,18 +235,31 @@ class ReportTaskController extends MyController {
Future<void> pickImages({required bool fromCamera}) async { Future<void> pickImages({required bool fromCamera}) async {
try { try {
isPickingImage.value = true; // Start loading
if (fromCamera) { 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) { 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 { } else {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
// Gallery images added as-is without timestamp
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
} }
logSafe("Images picked: ${selectedImages.length}", );
logSafe("Images picked: ${selectedImages.length}");
} catch (e) { } catch (e) {
logSafe("Error picking images", level: LogLevel.warning, error: e); logSafe("Error picking images", level: LogLevel.warning, error: e);
} finally {
isPickingImage.value = false; // Stop loading
} }
} }

View File

@ -1,6 +1,6 @@
class ApiEndpoints { class ApiEndpoints {
static const String baseUrl = "https://stageapi.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://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api";
// Dashboard Module API Endpoints // Dashboard Module API Endpoints

View File

@ -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<File> 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
}
}
}

View File

@ -281,11 +281,30 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
_buildSectionHeader("Attach Photos", Icons.camera_alt_outlined), _buildSectionHeader("Attach Photos", Icons.camera_alt_outlined),
MySpacing.height(12), MySpacing.height(12),
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; final images = controller.selectedImages;
return Column( return Column(
children: [ children: [
// --- Refactoring Note ---
// Using the reusable _ImageHorizontalListView for picked images.
_ImageHorizontalListView( _ImageHorizontalListView(
imageSources: images.toList(), imageSources: images.toList(),
onPreview: (index) => _showImageViewer(images.toList(), index), onPreview: (index) => _showImageViewer(images.toList(), index),
@ -299,8 +318,11 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
color: Colors.grey.shade100, color: Colors.grey.shade100,
), ),
child: Center( child: Center(
child: Icon(Icons.photo_library_outlined, child: Icon(
size: 36, color: Colors.grey.shade400), Icons.photo_library_outlined,
size: 36,
color: Colors.grey.shade400,
),
), ),
), ),
), ),

View File

@ -290,6 +290,7 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
onCameraTap: () => controller.pickImages(fromCamera: true), onCameraTap: () => controller.pickImages(fromCamera: true),
onUploadTap: () => controller.pickImages(fromCamera: false), onUploadTap: () => controller.pickImages(fromCamera: false),
onRemoveImage: (index) => controller.removeImageAt(index), onRemoveImage: (index) => controller.removeImageAt(index),
isProcessing: controller.isPickingImage.value,
onPreviewImage: (index) => showDialog( onPreviewImage: (index) => showDialog(
context: context, context: context,
builder: (_) => ImageViewerDialog( builder: (_) => ImageViewerDialog(

View File

@ -92,17 +92,48 @@ Widget buildReportedImagesSection({
} }
/// Local image picker preview (with file images) /// Local image picker preview (with file images)
/// Local image picker preview with processing loader
Widget buildImagePickerSection({ Widget buildImagePickerSection({
required List<File> images, required List<File> images,
required VoidCallback onCameraTap, required VoidCallback onCameraTap,
required VoidCallback onUploadTap, required VoidCallback onUploadTap,
required void Function(int index) onRemoveImage, required void Function(int index) onRemoveImage,
required void Function(int initialIndex) onPreviewImage, required void Function(int initialIndex) onPreviewImage,
required bool isProcessing, // New: show loader while image is being processed
}) { }) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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( Container(
height: 70, height: 70,
width: double.infinity, width: double.infinity,
@ -117,6 +148,7 @@ Widget buildImagePickerSection({
), ),
) )
else else
// Display selected images
SizedBox( SizedBox(
height: 70, height: 70,
child: ListView.separated( child: ListView.separated(
@ -160,6 +192,8 @@ Widget buildImagePickerSection({
), ),
), ),
MySpacing.height(16), MySpacing.height(16),
// Camera & Upload Buttons
Row( Row(
children: [ children: [
Expanded( Expanded(
@ -200,8 +234,8 @@ Widget buildImagePickerSection({
} }
/// Comment list widget /// Comment list widget
Widget buildCommentList( Widget buildCommentList(List<Map<String, dynamic>> comments,
List<Map<String, dynamic>> comments, BuildContext context, String Function(String) timeAgo) { BuildContext context, String Function(String) timeAgo) {
comments.sort((a, b) { comments.sort((a, b) {
final aDate = DateTime.tryParse(a['date'] ?? '') ?? final aDate = DateTime.tryParse(a['date'] ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0); DateTime.fromMillisecondsSinceEpoch(0);

View File

@ -52,9 +52,8 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() => BaseBottomSheet(
return BaseBottomSheet(
title: "Report Task", title: "Report Task",
isSubmitting: controller.reportStatus.value == ApiStatus.loading, isSubmitting: controller.reportStatus.value == ApiStatus.loading,
onCancel: () => Navigator.of(context).pop(), onCancel: () => Navigator.of(context).pop(),
@ -64,25 +63,33 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildRow("Assigned Date", controller.basicValidator.getController('assigned_date')?.text), _buildRow(
_buildRow("Assigned By", controller.basicValidator.getController('assigned_by')?.text), "Assigned Date",
_buildRow("Work Area", controller.basicValidator.getController('work_area')?.text), controller.basicValidator
_buildRow("Activity", controller.basicValidator.getController('activity')?.text), .getController('assigned_date')
_buildRow("Team Size", controller.basicValidator.getController('team_size')?.text), ?.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( _buildRow(
"Assigned", "Assigned",
"${controller.basicValidator.getController('assigned')?.text ?? '-'} " "${controller.basicValidator.getController('assigned')?.text ?? '-'} "
"of ${widget.taskData['pendingWork'] ?? '-'} Pending", "of ${widget.taskData['pendingWork'] ?? '-'} Pending",
), ),
_buildCompletedWorkField(), _buildCompletedWorkField(),
_buildCommentField(), _buildCommentField(),
Obx(() => _buildImageSection()), _buildImageSection(),
], ],
), ),
), ),
); ));
}); }
}
Future<void> _handleSubmit() async { Future<void> _handleSubmit() async {
final v = controller.basicValidator; final v = controller.basicValidator;
@ -91,7 +98,8 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
final success = await controller.reportTask( final success = await controller.reportTask(
projectId: v.getController('task_id')?.text ?? '', projectId: v.getController('task_id')?.text ?? '',
comment: v.getController('comment')?.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: [], checklist: [],
reportedDate: DateTime.now(), reportedDate: DateTime.now(),
images: controller.selectedImages, images: controller.selectedImages,
@ -118,12 +126,14 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), MySpacing.width(8),
MyText.titleSmall("$label:", fontWeight: 600), MyText.titleSmall("$label:", fontWeight: 600),
MySpacing.width(12), MySpacing.width(12),
Expanded( 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<ReportTaskBottomSheet>
controller: controller.basicValidator.getController('completed_work'), controller: controller.basicValidator.getController('completed_work'),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
validator: (value) { 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()); final completed = int.tryParse(value.trim());
if (completed == null) return 'Enter a valid number'; 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; return null;
}, },
decoration: InputDecoration( decoration: InputDecoration(
@ -203,8 +215,6 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
} }
Widget _buildImageSection() { Widget _buildImageSection() {
final images = controller.selectedImages;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -216,94 +226,130 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
], ],
), ),
MySpacing.height(12), MySpacing.height(12),
if (images.isEmpty) // Only wrap reactive parts with Obx
Container( Obx(() {
height: 70, if (controller.isPickingImage.value) {
width: double.infinity, return Center(
decoration: BoxDecoration( child: Column(
borderRadius: BorderRadius.circular(12), children: [
border: Border.all(color: Colors.grey.shade300, width: 2), CircularProgressIndicator(color: Colors.blueAccent),
color: Colors.grey.shade100, const SizedBox(height: 8),
), Text(
child: Center( "Processing image, please wait...",
child: Icon(Icons.photo_camera_outlined, size: 48, color: Colors.grey.shade400), style: TextStyle(
), fontSize: 14,
) color: Colors.blueAccent,
else ),
SizedBox( ),
height: 70, ],
child: ListView.separated( ),
scrollDirection: Axis.horizontal, );
itemCount: images.length, }
separatorBuilder: (_, __) => MySpacing.width(12),
itemBuilder: (context, index) { final images = controller.selectedImages;
final file = images[index];
return Stack( return Column(
children: [ children: [
GestureDetector( if (images.isEmpty)
onTap: () { Container(
showDialog( height: 70,
context: context, width: double.infinity,
builder: (_) => Dialog( decoration: BoxDecoration(
child: InteractiveViewer(child: Image.file(file)), 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),
),
), ),
); Positioned(
}, top: 4,
child: ClipRRect( right: 4,
borderRadius: BorderRadius.circular(12), child: GestureDetector(
child: Image.file(file, height: 70, width: 70, fit: BoxFit.cover), 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, MySpacing.width(12),
right: 4, Expanded(
child: GestureDetector( child: MyButton.outlined(
onTap: () => controller.removeImageAt(index), onPressed: () => controller.pickImages(fromCamera: false),
child: Container( padding: MySpacing.xy(12, 10),
decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle), child: Row(
child: const Icon(Icons.close, size: 20, color: Colors.white), 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),
],
),
),
),
],
),
], ],
); );
} }

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/expense/add_expense_controller.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/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/payment_types_model.dart';
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart'; import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
@ -40,7 +41,8 @@ class _AddExpenseBottomSheet extends StatefulWidget {
State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState(); State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState();
} }
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
with UIMixin {
final AddExpenseController controller = Get.put(AddExpenseController()); final AddExpenseController controller = Get.put(AddExpenseController());
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
@ -326,8 +328,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
CustomTextField( CustomTextField(
controller: controller, controller: controller,
hint: hint ?? "", hint: hint ?? "",
keyboardType: keyboardType: keyboardType ?? TextInputType.text,
keyboardType ?? TextInputType.text,
validator: validator, validator: validator,
maxLines: maxLines, maxLines: maxLines,
), ),
@ -426,39 +427,65 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SectionTitle( const SectionTitle(
icon: Icons.attach_file, title: "Attachments", requiredField: true), icon: Icons.attach_file,
MySpacing.height(6), title: "Attachments",
AttachmentsSection( requiredField: true,
attachments: controller.attachments, ),
existingAttachments: controller.existingAttachments, MySpacing.height(10),
onRemoveNew: controller.removeAttachment, Obx(() {
onRemoveExisting: (item) async { if (controller.isProcessingAttachment.value) {
await showDialog( return Center(
context: context, child: Column(
barrierDismissible: false, children: [
builder: (_) => ConfirmDialog( CircularProgressIndicator(
title: "Remove Attachment", color: contentTheme.primary,
message: "Are you sure you want to remove this attachment?", ),
confirmText: "Remove", const SizedBox(height: 8),
icon: Icons.delete, Text(
confirmColor: Colors.redAccent, "Processing image, please wait...",
onConfirm: () async { style: TextStyle(
final index = controller.existingAttachments.indexOf(item); fontSize: 14,
if (index != -1) { color: contentTheme.primary,
controller.existingAttachments[index]['isActive'] = false; ),
controller.existingAttachments.refresh(); ),
} ],
showAppSnackbar(
title: 'Removed',
message: 'Attachment has been removed.',
type: SnackbarType.success,
);
},
), ),
); );
}, }
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,
);
}),
], ],
); );
} }