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/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<RegularizationLogModel> 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;

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/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 = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs;
final employeeSearchResults = <EmployeeModel>[].obs;
final isProcessingAttachment = false.obs;
String? editingExpenseId;
@ -252,9 +254,22 @@ class AddExpenseController extends GetxController {
Future<void> 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
}
}

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_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<Map<String, dynamic>> selectedTask = Rxn<Map<String, dynamic>>();
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<bool> 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<Map<String, dynamic>>().length} images.");
logSafe(
"_prepareImages: Prepared ${results.whereType<Map<String, dynamic>>().length} images.");
return results.whereType<Map<String, dynamic>>().toList();
}
@ -267,23 +290,40 @@ class ReportTaskActionController extends MyController {
}
Future<void> 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);
}
}

View File

@ -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<ApiStatus> reportStatus = ApiStatus.idle.obs;
Rx<ApiStatus> commentStatus = ApiStatus.idle.obs;
final RxBool isPickingImage = false.obs;
RxList<File> selectedImages = <File>[].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<File>? 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<File>? 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<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;
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<void> 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
}
}

View File

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

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),
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<CommentTaskBottomSheet>
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,
),
),
),
),

View File

@ -290,6 +290,7 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
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(

View File

@ -92,17 +92,48 @@ Widget buildReportedImagesSection({
}
/// Local image picker preview (with file images)
/// Local image picker preview with processing loader
Widget buildImagePickerSection({
required List<File> 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<Map<String, dynamic>> comments, BuildContext context, String Function(String) timeAgo) {
Widget buildCommentList(List<Map<String, dynamic>> comments,
BuildContext context, String Function(String) timeAgo) {
comments.sort((a, b) {
final aDate = DateTime.tryParse(a['date'] ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);

View File

@ -52,9 +52,8 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
}
@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<ReportTaskBottomSheet>
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<void> _handleSubmit() async {
final v = controller.basicValidator;
@ -91,7 +98,8 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
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<ReportTaskBottomSheet>
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<ReportTaskBottomSheet>
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<ReportTaskBottomSheet>
}
Widget _buildImageSection() {
final images = controller.selectedImages;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -216,95 +226,131 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
],
),
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),
],
),
),
),
],
),
],
);
}),
],
);
}
}
}

View File

@ -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<FormState>();
@ -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,
);
}),
],
);
}