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:
parent
16a2e1e53a
commit
d208648350
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
try {
|
||||
isPickingImage.value = true; // start loading
|
||||
logSafe("Opening image picker...");
|
||||
|
||||
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));
|
||||
logSafe("Image added from camera: ${pickedFile.path}", );
|
||||
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.", );
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
97
lib/helpers/widgets/time_stamp_image_helper.dart
Normal file
97
lib/helpers/widgets/time_stamp_image_helper.dart
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,11 +63,19 @@ 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 ?? '-'} "
|
||||
@ -76,13 +83,13 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
|
||||
),
|
||||
_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,6 +226,30 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
|
||||
],
|
||||
),
|
||||
MySpacing.height(12),
|
||||
// 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,
|
||||
@ -226,7 +260,8 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(Icons.photo_camera_outlined, size: 48, color: Colors.grey.shade400),
|
||||
child: Icon(Icons.photo_camera_outlined,
|
||||
size: 48, color: Colors.grey.shade400),
|
||||
),
|
||||
)
|
||||
else
|
||||
@ -245,23 +280,29 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => Dialog(
|
||||
child: InteractiveViewer(child: Image.file(file)),
|
||||
child: InteractiveViewer(
|
||||
child: Image.file(file)),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.file(file, height: 70, width: 70, fit: BoxFit.cover),
|
||||
child: Image.file(file,
|
||||
height: 70, width: 70, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: GestureDetector(
|
||||
onTap: () => controller.removeImageAt(index),
|
||||
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),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle),
|
||||
child: const Icon(Icons.close,
|
||||
size: 20, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -280,7 +321,8 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.camera_alt, size: 16, color: Colors.blueAccent),
|
||||
const Icon(Icons.camera_alt,
|
||||
size: 16, color: Colors.blueAccent),
|
||||
MySpacing.width(6),
|
||||
MyText.bodySmall('Capture', color: Colors.blueAccent),
|
||||
],
|
||||
@ -295,7 +337,8 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.upload_file, size: 16, color: Colors.blueAccent),
|
||||
const Icon(Icons.upload_file,
|
||||
size: 16, color: Colors.blueAccent),
|
||||
MySpacing.width(6),
|
||||
MyText.bodySmall('Upload', color: Colors.blueAccent),
|
||||
],
|
||||
@ -306,5 +349,8 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,9 +427,33 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionTitle(
|
||||
icon: Icons.attach_file, title: "Attachments", requiredField: true),
|
||||
MySpacing.height(6),
|
||||
AttachmentsSection(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AttachmentsSection(
|
||||
attachments: controller.attachments,
|
||||
existingAttachments: controller.existingAttachments,
|
||||
onRemoveNew: controller.removeAttachment,
|
||||
@ -453,12 +478,14 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
message: 'Attachment has been removed.',
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
onAdd: controller.pickAttachments,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user