diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 0e9ca46..56f8e58 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -29,6 +29,12 @@ class ExpenseController extends GetxController { final RxList selectedPaidByEmployees = [].obs; final RxList selectedCreatedByEmployees = [].obs; + final RxString selectedDateType = 'Transaction Date'.obs; + + final List dateTypes = [ + 'Transaction Date', + 'Created At', + ]; int _pageSize = 20; int _pageNumber = 1; @@ -85,6 +91,7 @@ class ExpenseController extends GetxController { paidByIds ?? selectedPaidByEmployees.map((e) => e.id).toList(), "startDate": (startDate ?? this.startDate.value)?.toIso8601String(), "endDate": (endDate ?? this.endDate.value)?.toIso8601String(), + "isTransactionDate": selectedDateType.value == 'Transaction Date', }; try { diff --git a/lib/controller/task_planing/daily_task_planing_controller.dart b/lib/controller/task_planing/daily_task_planing_controller.dart index 2e0a05c..3a302b6 100644 --- a/lib/controller/task_planing/daily_task_planing_controller.dart +++ b/lib/controller/task_planing/daily_task_planing_controller.dart @@ -17,6 +17,7 @@ class DailyTaskPlaningController extends GetxController { MyFormValidator basicValidator = MyFormValidator(); List> roles = []; + RxBool isAssigningTask = false.obs; RxnString selectedRoleId = RxnString(); RxBool isLoading = false.obs; @@ -46,16 +47,21 @@ class DailyTaskPlaningController extends GetxController { } void updateSelectedEmployees() { - final selected = employees - .where((e) => uploadingStates[e.id]?.value == true) - .toList(); + final selected = + employees.where((e) => uploadingStates[e.id]?.value == true).toList(); selectedEmployees.value = selected; - logSafe("Updated selected employees", level: LogLevel.debug, ); + logSafe( + "Updated selected employees", + level: LogLevel.debug, + ); } void onRoleSelected(String? roleId) { selectedRoleId.value = roleId; - logSafe("Role selected", level: LogLevel.info, ); + logSafe( + "Role selected", + level: LogLevel.info, + ); } Future fetchRoles() async { @@ -77,6 +83,7 @@ class DailyTaskPlaningController extends GetxController { required List taskTeam, DateTime? assignmentDate, }) async { + isAssigningTask.value = true; logSafe("Starting assign task...", level: LogLevel.info); final response = await ApiService.assignDailyTask( @@ -87,6 +94,8 @@ class DailyTaskPlaningController extends GetxController { assignmentDate: assignmentDate, ); + isAssigningTask.value = false; + if (response == true) { logSafe("Task assigned successfully", level: LogLevel.info); showAppSnackbar( @@ -111,15 +120,18 @@ class DailyTaskPlaningController extends GetxController { try { final response = await ApiService.getProjects(); if (response?.isEmpty ?? true) { - logSafe("No project data found or API call failed", level: LogLevel.warning); + logSafe("No project data found or API call failed", + level: LogLevel.warning); return; } projects = response!.map((json) => ProjectModel.fromJson(json)).toList(); - logSafe("Projects fetched: ${projects.length} projects loaded", level: LogLevel.info); + logSafe("Projects fetched: ${projects.length} projects loaded", + level: LogLevel.info); update(); } catch (e, stack) { - logSafe("Error fetching projects", level: LogLevel.error, error: e, stackTrace: stack); + logSafe("Error fetching projects", + level: LogLevel.error, error: e, stackTrace: stack); } finally { isLoading.value = false; } @@ -137,12 +149,16 @@ class DailyTaskPlaningController extends GetxController { final data = response?['data']; if (data != null) { dailyTasks = [TaskPlanningDetailsModel.fromJson(data)]; - logSafe("Daily task Planning Details fetched", level: LogLevel.info, ); + logSafe( + "Daily task Planning Details fetched", + level: LogLevel.info, + ); } else { logSafe("Data field is null", level: LogLevel.warning); } } catch (e, stack) { - logSafe("Error fetching daily task data", level: LogLevel.error, error: e, stackTrace: stack); + logSafe("Error fetching daily task data", + level: LogLevel.error, error: e, stackTrace: stack); } finally { isLoading.value = false; update(); @@ -151,7 +167,8 @@ class DailyTaskPlaningController extends GetxController { Future fetchEmployeesByProject(String? projectId) async { if (projectId == null || projectId.isEmpty) { - logSafe("Project ID is required but was null or empty", level: LogLevel.error); + logSafe("Project ID is required but was null or empty", + level: LogLevel.error); return; } @@ -159,19 +176,29 @@ class DailyTaskPlaningController extends GetxController { try { final response = await ApiService.getAllEmployeesByProject(projectId); if (response != null && response.isNotEmpty) { - employees = response.map((json) => EmployeeModel.fromJson(json)).toList(); + employees = + response.map((json) => EmployeeModel.fromJson(json)).toList(); for (var emp in employees) { uploadingStates[emp.id] = false.obs; } - logSafe("Employees fetched: ${employees.length} for project $projectId", - level: LogLevel.info, ); + logSafe( + "Employees fetched: ${employees.length} for project $projectId", + level: LogLevel.info, + ); } else { employees = []; - logSafe("No employees found for project $projectId", level: LogLevel.warning, ); + logSafe( + "No employees found for project $projectId", + level: LogLevel.warning, + ); } } catch (e, stack) { - logSafe("Error fetching employees for project $projectId", - level: LogLevel.error, error: e, stackTrace: stack, ); + logSafe( + "Error fetching employees for project $projectId", + level: LogLevel.error, + error: e, + stackTrace: stack, + ); } finally { isLoading.value = false; update(); diff --git a/lib/helpers/utils/base_bottom_sheet.dart b/lib/helpers/utils/base_bottom_sheet.dart index 485f0dd..06f32a4 100644 --- a/lib/helpers/utils/base_bottom_sheet.dart +++ b/lib/helpers/utils/base_bottom_sheet.dart @@ -11,7 +11,8 @@ class BaseBottomSheet extends StatelessWidget { final String submitText; final Color submitColor; final IconData submitIcon; - final bool showButtons; + final bool showButtons; + final Widget? bottomContent; const BaseBottomSheet({ super.key, @@ -23,7 +24,8 @@ class BaseBottomSheet extends StatelessWidget { this.submitText = 'Submit', this.submitColor = Colors.indigo, this.submitIcon = Icons.check_circle_outline, - this.showButtons = true, + this.showButtons = true, + this.bottomContent, }); @override @@ -65,8 +67,11 @@ class BaseBottomSheet extends StatelessWidget { MyText.titleLarge(title, fontWeight: 700), MySpacing.height(12), child, - MySpacing.height(24), - if (showButtons) + + MySpacing.height(12), + + // 👇 Buttons (if enabled) + if (showButtons) ...[ Row( children: [ Expanded( @@ -108,6 +113,12 @@ class BaseBottomSheet extends StatelessWidget { ), ], ), + // 👇 Optional Bottom Content + if (bottomContent != null) ...[ + MySpacing.height(12), + bottomContent!, + ], + ], ], ), ), diff --git a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart index 2e52a8a..f42826c 100644 --- a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart +++ b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/task_planing/daily_task_planing_controller.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/helpers/widgets/my_spacing.dart'; -import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AssignTaskBottomSheet extends StatefulWidget { final String workLocation; @@ -37,17 +38,9 @@ class _AssignTaskBottomSheetState extends State { final ProjectController projectController = Get.find(); final TextEditingController targetController = TextEditingController(); final TextEditingController descriptionController = TextEditingController(); - String? selectedProjectId; - final ScrollController _employeeListScrollController = ScrollController(); - @override - void dispose() { - _employeeListScrollController.dispose(); - targetController.dispose(); - descriptionController.dispose(); - super.dispose(); - } + String? selectedProjectId; @override void initState() { @@ -61,180 +54,105 @@ class _AssignTaskBottomSheetState extends State { }); } + @override + void dispose() { + _employeeListScrollController.dispose(); + targetController.dispose(); + descriptionController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return SafeArea( - child: Container( - padding: MediaQuery.of(context).viewInsets.add(MySpacing.all(16)), - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Obx(() => BaseBottomSheet( + title: "Assign Task", + child: _buildAssignTaskForm(), + onCancel: () => Get.back(), + onSubmit: _onAssignTaskPressed, + isSubmitting: controller.isAssigningTask.value, + submitText: "Assign Task", + submitIcon: Icons.check_circle_outline, + submitColor: Colors.indigo, + )); + } + + Widget _buildAssignTaskForm() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _infoRow(Icons.location_on, "Work Location", + "${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"), + Divider(), + _infoRow(Icons.pending_actions, "Pending Task of Activity", + "${widget.pendingTask}"), + Divider(), + GestureDetector( + onTap: _onRoleMenuPressed, + child: Row( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon(Icons.assignment, color: Colors.black54), - SizedBox(width: 8), - MyText.titleMedium("Assign Task", - fontSize: 18, fontWeight: 600), - ], - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Get.back(), - ), - ], - ), - Divider(), - _infoRow(Icons.location_on, "Work Location", - "${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"), - Divider(), - _infoRow(Icons.pending_actions, "Pending Task of Activity", - "${widget.pendingTask}"), - Divider(), - GestureDetector( - onTap: () { - final RenderBox overlay = Overlay.of(context) - .context - .findRenderObject() as RenderBox; - final Size screenSize = overlay.size; - - showMenu( - context: context, - position: RelativeRect.fromLTRB( - screenSize.width / 2 - 100, - screenSize.height / 2 - 20, - screenSize.width / 2 - 100, - screenSize.height / 2 - 20, - ), - items: [ - const PopupMenuItem( - value: 'all', - child: Text("All Roles"), - ), - ...controller.roles.map((role) { - return PopupMenuItem( - value: role['id'].toString(), - child: Text(role['name'] ?? 'Unknown Role'), - ); - }), - ], - ).then((value) { - if (value != null) { - controller.onRoleSelected(value == 'all' ? null : value); - } - }); - }, - child: Row( - children: [ - MyText.titleMedium("Select Team :", fontWeight: 600), - const SizedBox(width: 4), - Icon(Icons.tune, - color: const Color.fromARGB(255, 95, 132, 255)), - ], - ), - ), - MySpacing.height(8), - Container( - constraints: BoxConstraints(maxHeight: 150), - child: _buildEmployeeList(), - ), - MySpacing.height(8), - Obx(() { - if (controller.selectedEmployees.isEmpty) return Container(); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Wrap( - spacing: 4, - runSpacing: 4, - children: controller.selectedEmployees.map((e) { - return Obx(() { - final isSelected = - controller.uploadingStates[e.id]?.value ?? false; - if (!isSelected) return Container(); - - return Chip( - label: Text(e.name, - style: const TextStyle(color: Colors.white)), - backgroundColor: - const Color.fromARGB(255, 95, 132, 255), - deleteIcon: - const Icon(Icons.close, color: Colors.white), - onDeleted: () { - controller.uploadingStates[e.id]?.value = false; - controller.updateSelectedEmployees(); - }, - ); - }); - }).toList(), - ), - ); - }), - _buildTextField( - icon: Icons.track_changes, - label: "Target for Today :", - controller: targetController, - hintText: "Enter target", - keyboardType: TextInputType.number, - validatorType: "target", - ), - MySpacing.height(24), - _buildTextField( - icon: Icons.description, - label: "Description :", - controller: descriptionController, - hintText: "Enter task description", - maxLines: 3, - validatorType: "description", - ), - MySpacing.height(24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton.icon( - onPressed: () => Get.back(), - icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", color: Colors.red), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 14), - ), - ), - ElevatedButton.icon( - onPressed: _onAssignTaskPressed, - icon: const Icon(Icons.check_circle_outline, - color: Colors.white), - label: - MyText.bodyMedium("Assign Task", color: Colors.white), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 28, vertical: 14), - ), - ), - ], - ), + MyText.titleMedium("Select Team :", fontWeight: 600), + const SizedBox(width: 4), + const Icon(Icons.tune, color: Color.fromARGB(255, 95, 132, 255)), ], ), ), - ), + MySpacing.height(8), + Container( + constraints: const BoxConstraints(maxHeight: 150), + child: _buildEmployeeList(), + ), + MySpacing.height(8), + _buildSelectedEmployees(), + _buildTextField( + icon: Icons.track_changes, + label: "Target for Today :", + controller: targetController, + hintText: "Enter target", + keyboardType: TextInputType.number, + validatorType: "target", + ), + MySpacing.height(24), + _buildTextField( + icon: Icons.description, + label: "Description :", + controller: descriptionController, + hintText: "Enter task description", + maxLines: 3, + validatorType: "description", + ), + ], ); } + void _onRoleMenuPressed() { + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + final Size screenSize = overlay.size; + + showMenu( + context: context, + position: RelativeRect.fromLTRB( + screenSize.width / 2 - 100, + screenSize.height / 2 - 20, + screenSize.width / 2 - 100, + screenSize.height / 2 - 20, + ), + items: [ + const PopupMenuItem(value: 'all', child: Text("All Roles")), + ...controller.roles.map((role) { + return PopupMenuItem( + value: role['id'].toString(), + child: Text(role['name'] ?? 'Unknown Role'), + ); + }), + ], + ).then((value) { + if (value != null) { + controller.onRoleSelected(value == 'all' ? null : value); + } + }); + } + Widget _buildEmployeeList() { return Obx(() { if (controller.isLoading.value) { @@ -255,49 +173,43 @@ class _AssignTaskBottomSheetState extends State { return Scrollbar( controller: _employeeListScrollController, thumbVisibility: true, - interactive: true, child: ListView.builder( controller: _employeeListScrollController, shrinkWrap: true, - physics: const AlwaysScrollableScrollPhysics(), itemCount: filteredEmployees.length, itemBuilder: (context, index) { final employee = filteredEmployees[index]; final rxBool = controller.uploadingStates[employee.id]; + return Obx(() => Padding( - padding: const EdgeInsets.symmetric(vertical: 0), + padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ - Theme( - data: Theme.of(context) - .copyWith(unselectedWidgetColor: Colors.black), - child: Checkbox( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - side: const BorderSide(color: Colors.black), - ), - value: rxBool?.value ?? false, - onChanged: (bool? selected) { - if (rxBool != null) { - rxBool.value = selected ?? false; - controller.updateSelectedEmployees(); - } - }, - fillColor: - WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return const Color.fromARGB(255, 95, 132, 255); - } - return Colors.transparent; - }), - checkColor: Colors.white, - side: const BorderSide(color: Colors.black), + Checkbox( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), ), + value: rxBool?.value ?? false, + onChanged: (bool? selected) { + if (rxBool != null) { + rxBool.value = selected ?? false; + controller.updateSelectedEmployees(); + } + }, + fillColor: + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return const Color.fromARGB(255, 95, 132, 255); + } + return Colors.transparent; + }), + checkColor: Colors.white, + side: const BorderSide(color: Colors.black), ), const SizedBox(width: 8), Expanded( child: Text(employee.name, - style: TextStyle(fontSize: 14))), + style: const TextStyle(fontSize: 14))), ], ), )); @@ -307,6 +219,38 @@ class _AssignTaskBottomSheetState extends State { }); } + Widget _buildSelectedEmployees() { + return Obx(() { + if (controller.selectedEmployees.isEmpty) return Container(); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: controller.selectedEmployees.map((e) { + return Obx(() { + final isSelected = + controller.uploadingStates[e.id]?.value ?? false; + if (!isSelected) return Container(); + + return Chip( + label: + Text(e.name, style: const TextStyle(color: Colors.white)), + backgroundColor: const Color.fromARGB(255, 95, 132, 255), + deleteIcon: const Icon(Icons.close, color: Colors.white), + onDeleted: () { + controller.uploadingStates[e.id]?.value = false; + controller.updateSelectedEmployees(); + }, + ); + }); + }).toList(), + ), + ); + }); + } + Widget _buildTextField({ required IconData icon, required String label, @@ -331,13 +275,12 @@ class _AssignTaskBottomSheetState extends State { controller: controller, keyboardType: keyboardType, maxLines: maxLines, - decoration: InputDecoration( - hintText: hintText, - border: const OutlineInputBorder(), + decoration: const InputDecoration( + hintText: '', + border: OutlineInputBorder(), ), - validator: (value) => this - .controller - .formFieldValidator(value, fieldType: validatorType), + validator: (value) => + this.controller.formFieldValidator(value, fieldType: validatorType), ), ], ); diff --git a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart index a7f8ee1..d5c37cb 100644 --- a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart @@ -1,5 +1,9 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'dart:io'; +import 'dart:math' as math; +// --- Assumed Imports (ensure these paths are correct in your project) --- import 'package:marco/controller/task_planing/report_task_controller.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_button.dart'; @@ -8,17 +12,32 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/my_team_model_sheet.dart'; -import 'package:intl/intl.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; -import 'dart:io'; import 'package:marco/model/dailyTaskPlaning/create_task_botom_sheet.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +// --- Form Field Keys (Unchanged) --- +class _FormFieldKeys { + static const String assignedDate = 'assigned_date'; + static const String assignedBy = 'assigned_by'; + static const String workArea = 'work_area'; + static const String activity = 'activity'; + static const String plannedWork = 'planned_work'; + static const String completedWork = 'completed_work'; + static const String teamMembers = 'team_members'; + static const String assigned = 'assigned'; + static const String taskId = 'task_id'; + static const String comment = 'comment'; +} + +// --- Main Widget: CommentTaskBottomSheet --- class CommentTaskBottomSheet extends StatefulWidget { final Map taskData; final VoidCallback? onCommentSuccess; final String taskDataId; final String workAreaId; final String activityId; + const CommentTaskBottomSheet({ super.key, required this.taskData, @@ -39,413 +58,216 @@ class _Member { class _CommentTaskBottomSheetState extends State with UIMixin { - late ReportTaskController controller; - final ScrollController _scrollController = ScrollController(); + late final ReportTaskController controller; + List> _sortedComments = []; + @override void initState() { super.initState(); controller = Get.put(ReportTaskController(), tag: widget.taskData['taskId'] ?? UniqueKey().toString()); - final data = widget.taskData; - controller.basicValidator.getController('assigned_date')?.text = - data['assignedOn'] ?? ''; - controller.basicValidator.getController('assigned_by')?.text = - data['assignedBy'] ?? ''; - controller.basicValidator.getController('work_area')?.text = - data['location'] ?? ''; - controller.basicValidator.getController('activity')?.text = - data['activity'] ?? ''; - controller.basicValidator.getController('planned_work')?.text = - data['plannedWork'] ?? ''; - controller.basicValidator.getController('completed_work')?.text = - data['completedWork'] ?? ''; - controller.basicValidator.getController('team_members')?.text = - (data['teamMembers'] as List).join(', '); - controller.basicValidator.getController('assigned')?.text = - data['assigned'] ?? ''; - controller.basicValidator.getController('task_id')?.text = - data['taskId'] ?? ''; - controller.basicValidator.getController('comment')?.clear(); - controller.selectedImages.clear(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); - } + _initializeControllerData(); + + final comments = List>.from( + widget.taskData['taskComments'] as List? ?? []); + comments.sort((a, b) { + final aDate = DateTime.tryParse(a['date'] ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); + final bDate = DateTime.tryParse(b['date'] ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); + return bDate.compareTo(aDate); // Newest first }); + _sortedComments = comments; } - String timeAgo(String dateString) { + void _initializeControllerData() { + final data = widget.taskData; + + final fieldMappings = { + _FormFieldKeys.assignedDate: data['assignedOn'], + _FormFieldKeys.assignedBy: data['assignedBy'], + _FormFieldKeys.workArea: data['location'], + _FormFieldKeys.activity: data['activity'], + _FormFieldKeys.plannedWork: data['plannedWork'], + _FormFieldKeys.completedWork: data['completedWork'], + _FormFieldKeys.teamMembers: (data['teamMembers'] as List?)?.join(', '), + _FormFieldKeys.assigned: data['assigned'], + _FormFieldKeys.taskId: data['taskId'], + }; + + for (final entry in fieldMappings.entries) { + controller.basicValidator.getController(entry.key)?.text = + entry.value ?? ''; + } + + controller.basicValidator.getController(_FormFieldKeys.comment)?.clear(); + controller.selectedImages.clear(); + } + + String _timeAgo(String dateString) { + // This logic remains unchanged try { - DateTime date = DateTime.parse(dateString + "Z").toLocal(); - final now = DateTime.now(); - final difference = now.difference(date); - if (difference.inDays > 8) { - return DateFormat('dd-MM-yyyy').format(date); - } else if (difference.inDays >= 1) { + final date = DateTime.parse(dateString + "Z").toLocal(); + final difference = DateTime.now().difference(date); + + if (difference.inDays > 8) return DateFormat('dd-MM-yyyy').format(date); + if (difference.inDays >= 1) return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago'; - } else if (difference.inHours >= 1) { + if (difference.inHours >= 1) return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago'; - } else if (difference.inMinutes >= 1) { + if (difference.inMinutes >= 1) return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago'; - } else { - return 'just now'; - } + return 'just now'; } catch (e) { - print('Error parsing date: $e'); - return ''; + debugPrint('Error parsing date for timeAgo: $e'); + return dateString; } } @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SingleChildScrollView( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - left: 24, - right: 24, - top: 12, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Drag handle - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), - ), + // --- REFACTORING POINT --- + // The entire widget now returns a BaseBottomSheet, passing the content as its child. + // The GetBuilder provides reactive state (like isLoading) to the BaseBottomSheet. + return GetBuilder( + tag: widget.taskData['taskId'] ?? '', + builder: (controller) { + return BaseBottomSheet( + title: "Task Details & Comments", + onCancel: () => Navigator.of(context).pop(), + onSubmit: _submitComment, + isSubmitting: controller.isLoading.value, + submitText: 'Comment', + bottomContent: _buildCommentsSection(), + child: Form( + // moved to last + key: controller.basicValidator.formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeaderActions(), + MySpacing.height(12), + _buildTaskDetails(), + _buildReportedImages(), + _buildCommentInput(), + _buildImagePicker(), + ], ), - GetBuilder( - tag: widget.taskData['taskId'] ?? '', - builder: (controller) { - return Form( - key: controller.basicValidator.formKey, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - MySpacing.height(12), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - MyText.titleMedium( - "Comment Task", - fontWeight: 600, - fontSize: 18, - ), - ], - ), - const SizedBox(height: 12), + ), + ); + }, + ); + } - // Second row: Right-aligned "+ Create Task" button - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - InkWell( - onTap: () { - showCreateTaskBottomSheet( - workArea: - widget.taskData['location'] ?? '', - activity: - widget.taskData['activity'] ?? '', - completedWork: - widget.taskData['completedWork'] ?? - '', - unit: widget.taskData['unit'] ?? '', - onCategoryChanged: (category) { - debugPrint( - "Category changed to: $category"); - }, - parentTaskId: widget.taskDataId, - plannedTask: int.tryParse( - widget.taskData['plannedWork'] ?? - '0') ?? - 0, - activityId: widget.activityId, - workAreaId: widget.workAreaId, - onSubmit: () { - Navigator.of(context).pop(); - }, - ); - }, - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Colors.blueAccent.withOpacity(0.1), - borderRadius: BorderRadius.circular(16), - ), - child: MyText.bodySmall( - "+ Create Task", - fontWeight: 600, - color: Colors.blueAccent, - ), - ), - ), - ], - ), - ], - ), - buildRow( - "Assigned By", - controller.basicValidator - .getController('assigned_by') - ?.text - .trim(), - icon: Icons.person_outline, - ), - buildRow( - "Work Area", - controller.basicValidator - .getController('work_area') - ?.text - .trim(), - icon: Icons.place_outlined, - ), - buildRow( - "Activity", - controller.basicValidator - .getController('activity') - ?.text - .trim(), - icon: Icons.assignment_outlined, - ), - buildRow( - "Planned Work", - controller.basicValidator - .getController('planned_work') - ?.text - .trim(), - icon: Icons.schedule_outlined, - ), - buildRow( - "Completed Work", - controller.basicValidator - .getController('completed_work') - ?.text - .trim(), - icon: Icons.done_all_outlined, - ), - buildTeamMembers(), - if ((widget.taskData['reportedPreSignedUrls'] - as List?) - ?.isNotEmpty == - true) - buildReportedImagesSection( - imageUrls: List.from( - widget.taskData['reportedPreSignedUrls'] ?? []), - context: context, - ), - Row( - children: [ - Icon(Icons.comment_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Comment:", - fontWeight: 600, - ), - ], - ), - MySpacing.height(8), - TextFormField( - validator: controller.basicValidator - .getValidation('comment'), - controller: controller.basicValidator - .getController('comment'), - keyboardType: TextInputType.text, - decoration: InputDecoration( - hintText: "eg: Work done successfully", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - border: outlineInputBorder, - enabledBorder: outlineInputBorder, - focusedBorder: focusedInputBorder, - contentPadding: MySpacing.all(16), - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - MySpacing.height(16), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.camera_alt_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall("Attach Photos:", - fontWeight: 600), - MySpacing.height(12), - ], - ), - ), - ], - ), - Obx(() { - final images = controller.selectedImages; - - return buildImagePickerSection( - images: images, - onCameraTap: () => - controller.pickImages(fromCamera: true), - onUploadTap: () => - controller.pickImages(fromCamera: false), - onRemoveImage: (index) => - controller.removeImageAt(index), - onPreviewImage: (index) { - showDialog( - context: context, - builder: (_) => ImageViewerDialog( - imageSources: images, - initialIndex: index, - ), - ); - }, - ); - }), - MySpacing.height(24), - buildCommentActionButtons( - onCancel: () => Navigator.of(context).pop(), - onSubmit: () async { - if (controller.basicValidator.validateForm()) { - await controller.commentTask( - projectId: controller.basicValidator - .getController('task_id') - ?.text ?? - '', - comment: controller.basicValidator - .getController('comment') - ?.text ?? - '', - images: controller.selectedImages, - ); - if (widget.onCommentSuccess != null) { - widget.onCommentSuccess!(); - } - } - }, - isLoading: controller.isLoading, - ), - MySpacing.height(10), - if ((widget.taskData['taskComments'] as List?) - ?.isNotEmpty == - true) ...[ - Row( - children: [ - MySpacing.width(10), - Icon(Icons.chat_bubble_outline, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Comments", - fontWeight: 600, - ), - ], - ), - MySpacing.height(12), - Builder( - builder: (context) { - final comments = List>.from( - widget.taskData['taskComments'] as List, - ); - return buildCommentList(comments, context); - }, - ) - ], - ], - ), - ), - ); - }, - ), - ], + // --- REFACTORING POINT --- + // The original _buildHeader is now split. The title is handled by BaseBottomSheet. + // This new widget contains the remaining actions from the header. + Widget _buildHeaderActions() { + return Align( + alignment: Alignment.centerRight, + child: InkWell( + onTap: () => _showCreateTaskBottomSheet(), + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.blueAccent.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ), + child: MyText.bodySmall( + "+ Create Task", + fontWeight: 600, + color: Colors.blueAccent, + ), ), ), ); } - Widget buildReportedImagesSection({ - required List imageUrls, - required BuildContext context, - String title = "Reported Images", - }) { - if (imageUrls.isEmpty) return const SizedBox(); + Widget _buildTaskDetails() { + return Column( + children: [ + _buildDetailRow( + "Assigned By", + controller.basicValidator + .getController(_FormFieldKeys.assignedBy) + ?.text, + icon: Icons.person_outline), + _buildDetailRow( + "Work Area", + controller.basicValidator + .getController(_FormFieldKeys.workArea) + ?.text, + icon: Icons.place_outlined), + _buildDetailRow( + "Activity", + controller.basicValidator + .getController(_FormFieldKeys.activity) + ?.text, + icon: Icons.assignment_outlined), + _buildDetailRow( + "Planned Work", + controller.basicValidator + .getController(_FormFieldKeys.plannedWork) + ?.text, + icon: Icons.schedule_outlined), + _buildDetailRow( + "Completed Work", + controller.basicValidator + .getController(_FormFieldKeys.completedWork) + ?.text, + icon: Icons.done_all_outlined), + _buildTeamMembers(), + ], + ); + } + + Widget _buildReportedImages() { + final imageUrls = + List.from(widget.taskData['reportedPreSignedUrls'] ?? []); + if (imageUrls.isEmpty) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MySpacing.height(8), Padding( - padding: const EdgeInsets.symmetric(horizontal: 0.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - title, - fontWeight: 600, - ), - ], - ), + padding: const EdgeInsets.only(bottom: 8.0), + child: _buildSectionHeader("Reported Images", Icons.image_outlined), ), + // --- Refactoring Note --- + // Using the reusable _ImageHorizontalListView widget. + _ImageHorizontalListView( + imageSources: imageUrls, + onPreview: (index) => _showImageViewer(imageUrls, index), + ), + MySpacing.height(16), + ], + ); + } + + Widget _buildCommentInput() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader("Add Comment", Icons.comment_outlined), MySpacing.height(8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: imageUrls.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final url = imageUrls[index]; - return GestureDetector( - onTap: () { - showDialog( - context: context, - barrierColor: Colors.black54, - builder: (_) => ImageViewerDialog( - imageSources: imageUrls, - initialIndex: index, - ), - ); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.network( - url, - width: 70, - height: 70, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - width: 70, - height: 70, - color: Colors.grey.shade200, - child: - Icon(Icons.broken_image, color: Colors.grey[600]), - ), - ), - ), - ); - }, - ), + TextFormField( + validator: + controller.basicValidator.getValidation(_FormFieldKeys.comment), + controller: + controller.basicValidator.getController(_FormFieldKeys.comment), + keyboardType: TextInputType.multiline, + maxLines: null, // Allows for multiline input + decoration: InputDecoration( + hintText: "eg: Work done successfully", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + enabledBorder: outlineInputBorder, + focusedBorder: focusedInputBorder, + contentPadding: MySpacing.all(16), + isCollapsed: true, + floatingLabelBehavior: FloatingLabelBehavior.never, ), ), MySpacing.height(16), @@ -453,60 +275,183 @@ class _CommentTaskBottomSheetState extends State ); } - Widget buildTeamMembers() { - final teamMembersText = - controller.basicValidator.getController('team_members')?.text ?? ''; + Widget _buildImagePicker() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader("Attach Photos", Icons.camera_alt_outlined), + MySpacing.height(12), + Obx(() { + 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), + onRemove: (index) => controller.removeImageAt(index), + emptyStatePlaceholder: Container( + height: 70, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300, width: 1.5), + color: Colors.grey.shade100, + ), + child: Center( + child: Icon(Icons.photo_library_outlined, + size: 36, color: Colors.grey.shade400), + ), + ), + ), + MySpacing.height(16), + Row( + children: [ + _buildPickerButton( + onTap: () => controller.pickImages(fromCamera: true), + icon: Icons.camera_alt, + label: 'Capture', + ), + MySpacing.width(12), + _buildPickerButton( + onTap: () => controller.pickImages(fromCamera: false), + icon: Icons.upload_file, + label: 'Upload', + ), + ], + ), + ], + ); + }), + ], + ); + } + + Widget _buildCommentsSection() { + if (_sortedComments.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(24), + _buildSectionHeader("Comments", Icons.chat_bubble_outline), + MySpacing.height(12), + // --- Refactoring Note --- + // Using a ListView instead of a fixed-height SizedBox for better responsiveness. + // It's constrained by the parent SingleChildScrollView. + ListView.builder( + shrinkWrap: + true, // Important for ListView inside SingleChildScrollView + physics: + const NeverScrollableScrollPhysics(), // Parent handles scrolling + itemCount: _sortedComments.length, + itemBuilder: (context, index) { + final comment = _sortedComments[index]; + // --- Refactoring Note --- + // Extracted the comment item into its own widget for clarity. + return _CommentCard( + comment: comment, + timeAgo: _timeAgo(comment['date'] ?? ''), + onPreviewImage: (imageUrls, idx) => + _showImageViewer(imageUrls, idx), + ); + }, + ), + ], + ); + } + + // --- Helper and Builder methods --- + + Widget _buildDetailRow(String label, String? value, + {required IconData icon}) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0, top: 2), + child: Icon(icon, size: 18, color: Colors.grey[700]), + ), + MyText.titleSmall("$label:", fontWeight: 600), + MySpacing.width(12), + Expanded( + child: MyText.bodyMedium( + value != null && value.isNotEmpty ? value : "-", + color: Colors.black87, + ), + ), + ], + ), + ); + } + + Widget _buildSectionHeader(String title, IconData icon) { + return Row( + children: [ + Icon(icon, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall(title, fontWeight: 600), + ], + ); + } + + Widget _buildTeamMembers() { + final teamMembersText = controller.basicValidator + .getController(_FormFieldKeys.teamMembers) + ?.text ?? + ''; final members = teamMembersText .split(',') .map((e) => e.trim()) .where((e) => e.isNotEmpty) .toList(); + if (members.isEmpty) return const SizedBox.shrink(); + + const double avatarSize = 32.0; + const double avatarOverlap = 22.0; return Padding( - padding: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.only(bottom: 16.0), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleSmall( - "Team Members:", - fontWeight: 600, - ), + Icon(Icons.group_outlined, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Team:", fontWeight: 600), MySpacing.width(12), GestureDetector( - onTap: () { - TeamBottomSheet.show( + onTap: () => TeamBottomSheet.show( context: context, - teamMembers: members.map((name) => _Member(name)).toList(), - ); - }, + teamMembers: members.map((name) => _Member(name)).toList()), child: SizedBox( - height: 32, - width: 100, + height: avatarSize, + // Calculate width based on number of avatars shown + width: (math.min(members.length, 3) * avatarOverlap) + + (avatarSize - avatarOverlap), child: Stack( children: [ - for (int i = 0; i < members.length.clamp(0, 3); i++) - Positioned( - left: i * 24.0, + ...List.generate(math.min(members.length, 3), (i) { + return Positioned( + left: i * avatarOverlap, child: Tooltip( message: members[i], child: Avatar( - firstName: members[i], - lastName: '', - size: 32, - ), + firstName: members[i], + lastName: '', + size: avatarSize), ), - ), + ); + }), if (members.length > 3) Positioned( - left: 2 * 24.0, + left: 3 * avatarOverlap, child: CircleAvatar( - radius: 16, + radius: avatarSize / 2, backgroundColor: Colors.grey.shade300, - child: MyText.bodyMedium( - '+${members.length - 3}', - style: const TextStyle( - fontSize: 12, color: Colors.black87), - ), + child: MyText.bodySmall('+${members.length - 3}', + fontWeight: 600), ), ), ], @@ -518,246 +463,144 @@ class _CommentTaskBottomSheetState extends State ); } - Widget buildCommentActionButtons({ - required VoidCallback onCancel, - required Future Function() onSubmit, - required RxBool isLoading, - double? buttonHeight, -}) { - return Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: onCancel, - icon: const Icon(Icons.close, color: Colors.red, size: 18), - label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), + Widget _buildPickerButton( + {required VoidCallback onTap, + required IconData icon, + required String label}) { + return Expanded( + child: MyButton.outlined( + onPressed: onTap, + padding: MySpacing.xy(12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: Colors.blueAccent), + MySpacing.width(8), + MyText.bodySmall(label, color: Colors.blueAccent, fontWeight: 600), + ], ), ), - const SizedBox(width: 16), - Expanded( - child: Obx(() { - return ElevatedButton.icon( - onPressed: isLoading.value ? null : () => onSubmit(), - icon: isLoading.value - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Icon(Icons.check_circle_outline, color: Colors.white, size: 18), - label: isLoading.value - ? const SizedBox() - : MyText.bodyMedium("Comment", color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), - ); - }), - ), - ], - ); -} - - Widget buildRow(String label, String? value, {IconData? icon}) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (icon != null) - Padding( - padding: const EdgeInsets.only(right: 8.0, top: 2), - child: Icon(icon, size: 18, color: Colors.grey[700]), - ), - MyText.titleSmall( - "$label:", - fontWeight: 600, - ), - MySpacing.width(12), - Expanded( - child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"), - ), - ], - ), ); } - Widget buildCommentList( - List> comments, BuildContext context) { - comments.sort((a, b) { - final aDate = DateTime.tryParse(a['date'] ?? '') ?? - DateTime.fromMillisecondsSinceEpoch(0); - final bDate = DateTime.tryParse(b['date'] ?? '') ?? - DateTime.fromMillisecondsSinceEpoch(0); - return bDate.compareTo(aDate); // newest first - }); + // --- Action Handlers --- + + void _showCreateTaskBottomSheet() { + showCreateTaskBottomSheet( + workArea: widget.taskData['location'] ?? '', + activity: widget.taskData['activity'] ?? '', + completedWork: widget.taskData['completedWork'] ?? '', + unit: widget.taskData['unit'] ?? '', + onCategoryChanged: (category) => + debugPrint("Category changed to: $category"), + parentTaskId: widget.taskDataId, + plannedTask: int.tryParse(widget.taskData['plannedWork'] ?? '0') ?? 0, + activityId: widget.activityId, + workAreaId: widget.workAreaId, + onSubmit: () => Navigator.of(context).pop(), + ); + } + + void _showImageViewer(List sources, int initialIndex) { + showDialog( + context: context, + barrierColor: Colors.black87, + builder: (_) => ImageViewerDialog( + imageSources: sources, + initialIndex: initialIndex, + ), + ); + } + + Future _submitComment() async { + if (controller.basicValidator.validateForm()) { + await controller.commentTask( + projectId: controller.basicValidator + .getController(_FormFieldKeys.taskId) + ?.text ?? + '', + comment: controller.basicValidator + .getController(_FormFieldKeys.comment) + ?.text ?? + '', + images: controller.selectedImages, + ); + // Callback to the parent widget to refresh data if needed + widget.onCommentSuccess?.call(); + } + } +} + +// --- Refactoring Note --- +// A reusable widget for displaying a horizontal list of images. +// It can handle both network URLs (String) and local files (File). +class _ImageHorizontalListView extends StatelessWidget { + final List imageSources; // Can be List or List + final Function(int) onPreview; + final Function(int)? onRemove; + final Widget? emptyStatePlaceholder; + + const _ImageHorizontalListView({ + required this.imageSources, + required this.onPreview, + this.onRemove, + this.emptyStatePlaceholder, + }); + + @override + Widget build(BuildContext context) { + if (imageSources.isEmpty) { + return emptyStatePlaceholder ?? const SizedBox.shrink(); + } return SizedBox( - height: 300, - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: comments.length, + height: 70, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: imageSources.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), itemBuilder: (context, index) { - final comment = comments[index]; - final commentText = comment['text'] ?? '-'; - final commentedBy = comment['commentedBy'] ?? 'Unknown'; - final relativeTime = timeAgo(comment['date'] ?? ''); - final imageUrls = List.from(comment['preSignedUrls'] ?? []); - - return Container( - margin: const EdgeInsets.symmetric(vertical: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + final source = imageSources[index]; + return GestureDetector( + onTap: () => onPreview(index), + child: Stack( + clipBehavior: Clip.none, children: [ - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Avatar( - firstName: commentedBy.split(' ').first, - lastName: commentedBy.split(' ').length > 1 - ? commentedBy.split(' ').last - : '', - size: 32, - ), - const SizedBox(width: 12), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.bodyMedium( - commentedBy, - fontWeight: 700, - color: Colors.black87, - ), - MyText.bodySmall( - relativeTime, - fontSize: 12, - color: Colors.black54, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: MyText.bodyMedium( - commentText, - fontWeight: 500, - color: Colors.black87, - ), - ), - ], - ), - const SizedBox(height: 12), - if (imageUrls.isNotEmpty) ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.attach_file_outlined, - size: 18, color: Colors.grey[700]), - MyText.bodyMedium( - 'Attachments', - fontWeight: 600, - color: Colors.black87, - ), - ], - ), - const SizedBox(height: 8), - SizedBox( - height: 60, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: imageUrls.length, - itemBuilder: (context, imageIndex) { - final imageUrl = imageUrls[imageIndex]; - return GestureDetector( - onTap: () { - showDialog( - context: context, - barrierColor: Colors.black54, - builder: (_) => ImageViewerDialog( - imageSources: imageUrls, - initialIndex: imageIndex, - ), - ); - }, - child: Stack( - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.grey[100], - boxShadow: [ - BoxShadow( - color: Colors.black26, - blurRadius: 6, - offset: Offset(2, 2), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.network( - imageUrl, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) => - Container( - color: Colors.grey[300], - child: Icon(Icons.broken_image, - color: Colors.grey[700]), - ), - ), - ), - ), - const Positioned( - right: 4, - bottom: 4, - child: Icon(Icons.zoom_in, - color: Colors.white70, size: 16), - ), - ], - ), - ); - }, - separatorBuilder: (_, __) => - const SizedBox(width: 12), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: source is File + ? Image.file(source, + width: 70, height: 70, fit: BoxFit.cover) + : Image.network( + source as String, + width: 70, + height: 70, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + Container( + width: 70, + height: 70, + color: Colors.grey.shade200, + child: Icon(Icons.broken_image, + color: Colors.grey[600]), ), ), - const SizedBox(height: 12), - ], - ], - ), ), + if (onRemove != null) + Positioned( + top: -6, + right: -6, + child: GestureDetector( + onTap: () => onRemove!(index), + child: Container( + padding: const EdgeInsets.all(2), + decoration: const BoxDecoration( + color: Colors.red, shape: BoxShape.circle), + child: const Icon(Icons.close, + size: 16, color: Colors.white), + ), + ), + ), ], ), ); @@ -765,111 +608,72 @@ class _CommentTaskBottomSheetState extends State ), ); } +} - Widget buildImagePickerSection({ - required List images, - required VoidCallback onCameraTap, - required VoidCallback onUploadTap, - required void Function(int index) onRemoveImage, - required void Function(int initialIndex) onPreviewImage, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (images.isEmpty) - Container( - height: 70, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300, width: 2), - color: Colors.grey.shade100, - ), - child: Center( - child: Icon(Icons.photo_camera_outlined, - size: 48, color: Colors.grey.shade400), - ), - ) - else - SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: images.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final file = images[index]; - return Stack( +// --- Refactoring Note --- +// A dedicated widget for a single comment card. This cleans up the main +// widget's build method and makes the comment layout easier to manage. +class _CommentCard extends StatelessWidget { + final Map comment; + final String timeAgo; + final Function(List imageUrls, int index) onPreviewImage; + + const _CommentCard({ + required this.comment, + required this.timeAgo, + required this.onPreviewImage, + }); + + @override + Widget build(BuildContext context) { + final commentedBy = comment['commentedBy'] ?? 'Unknown'; + final commentText = comment['text'] ?? '-'; + final imageUrls = List.from(comment['preSignedUrls'] ?? []); + + return Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Avatar( + firstName: commentedBy.split(' ').first, + lastName: commentedBy.split(' ').length > 1 + ? commentedBy.split(' ').last + : '', + size: 32, + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - onTap: () => onPreviewImage(index), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file( - file, - height: 70, - width: 70, - fit: BoxFit.cover, - ), - ), - ), - Positioned( - top: 4, - right: 4, - child: GestureDetector( - onTap: () => onRemoveImage(index), - child: Container( - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon(Icons.close, - size: 20, color: Colors.white), - ), - ), - ), + MyText.bodyMedium(commentedBy, + fontWeight: 700, color: Colors.black87), + MyText.bodySmall(timeAgo, + color: Colors.black54, fontSize: 12), ], - ); - }, - ), + ), + ), + ], ), - MySpacing.height(16), - Row( - children: [ - Expanded( - child: MyButton.outlined( - onPressed: onCameraTap, - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.camera_alt, - size: 16, color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Capture', color: Colors.blueAccent), - ], - ), - ), - ), - MySpacing.width(12), - Expanded( - child: MyButton.outlined( - onPressed: onUploadTap, - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.upload_file, - size: 16, color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Upload', color: Colors.blueAccent), - ], - ), - ), + MySpacing.height(12), + MyText.bodyMedium(commentText, color: Colors.black87), + if (imageUrls.isNotEmpty) ...[ + MySpacing.height(12), + _ImageHorizontalListView( + imageSources: imageUrls, + onPreview: (index) => onPreviewImage(imageUrls, index), ), ], - ), - ], + ], + ), ); } } diff --git a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart index c6fd28c..6621335 100644 --- a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart @@ -3,16 +3,14 @@ import 'package:get/get.dart'; import 'package:marco/controller/task_planing/report_task_action_controller.dart'; import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; -import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/my_team_model_sheet.dart'; -import 'package:intl/intl.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/model/dailyTaskPlaning/create_task_botom_sheet.dart'; -import 'dart:io'; +import 'package:marco/model/dailyTaskPlaning/report_action_widgets.dart'; class ReportActionBottomSheet extends StatefulWidget { final Map taskData; @@ -90,28 +88,6 @@ class _ReportActionBottomSheetState extends State }); } - String timeAgo(String dateString) { - try { - DateTime date = DateTime.parse(dateString + "Z").toLocal(); - final now = DateTime.now(); - final difference = now.difference(date); - if (difference.inDays > 8) { - return DateFormat('dd-MM-yyyy').format(date); - } else if (difference.inDays >= 1) { - return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago'; - } else if (difference.inHours >= 1) { - return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago'; - } else if (difference.inMinutes >= 1) { - return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago'; - } else { - return 'just now'; - } - } catch (e) { - print('Error parsing date: $e'); - return ''; - } - } - @override Widget build(BuildContext context) { return Container( @@ -523,7 +499,8 @@ class _ReportActionBottomSheetState extends State final comments = List>.from( widget.taskData['taskComments'] as List, ); - return buildCommentList(comments, context); + return buildCommentList( + comments, context, timeAgo); }, ) ], @@ -539,79 +516,6 @@ class _ReportActionBottomSheetState extends State ); } - Widget buildReportedImagesSection({ - required List imageUrls, - required BuildContext context, - String title = "Reported Images", - }) { - if (imageUrls.isEmpty) return const SizedBox(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MySpacing.height(8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 0.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - title, - fontWeight: 600, - ), - ], - ), - ), - MySpacing.height(8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: imageUrls.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final url = imageUrls[index]; - return GestureDetector( - onTap: () { - showDialog( - context: context, - barrierColor: Colors.black54, - builder: (_) => ImageViewerDialog( - imageSources: imageUrls, - initialIndex: index, - ), - ); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.network( - url, - width: 70, - height: 70, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - width: 70, - height: 70, - color: Colors.grey.shade200, - child: - Icon(Icons.broken_image, color: Colors.grey[600]), - ), - ), - ), - ); - }, - ), - ), - ), - MySpacing.height(16), - ], - ); - } - Widget buildTeamMembers() { final teamMembersText = controller.basicValidator.getController('team_members')?.text ?? ''; @@ -676,360 +580,4 @@ class _ReportActionBottomSheetState extends State ), ); } - - Widget buildCommentActionButtons({ - required VoidCallback onCancel, - required Future Function() onSubmit, - required RxBool isLoading, - double? buttonHeight, -}) { - return Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: onCancel, - icon: const Icon(Icons.close, color: Colors.red, size: 18), - label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Obx(() { - return ElevatedButton.icon( - onPressed: isLoading.value ? null : () => onSubmit(), - icon: isLoading.value - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Icon(Icons.send, color: Colors.white, size: 18), - label: isLoading.value - ? const SizedBox() - : MyText.bodyMedium("Submit", color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), - ); - }), - ), - ], - ); -} - - Widget buildRow(String label, String? value, {IconData? icon}) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (icon != null) - Padding( - padding: const EdgeInsets.only(right: 8.0, top: 2), - child: Icon(icon, size: 18, color: Colors.grey[700]), - ), - MyText.titleSmall( - "$label:", - fontWeight: 600, - ), - MySpacing.width(12), - Expanded( - child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"), - ), - ], - ), - ); - } - - Widget buildCommentList( - List> comments, BuildContext context) { - comments.sort((a, b) { - final aDate = DateTime.tryParse(a['date'] ?? '') ?? - DateTime.fromMillisecondsSinceEpoch(0); - final bDate = DateTime.tryParse(b['date'] ?? '') ?? - DateTime.fromMillisecondsSinceEpoch(0); - return bDate.compareTo(aDate); // newest first - }); - - return SizedBox( - height: 300, - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: comments.length, - itemBuilder: (context, index) { - final comment = comments[index]; - final commentText = comment['text'] ?? '-'; - final commentedBy = comment['commentedBy'] ?? 'Unknown'; - final relativeTime = timeAgo(comment['date'] ?? ''); - final imageUrls = List.from(comment['preSignedUrls'] ?? []); - - return Container( - margin: const EdgeInsets.symmetric(vertical: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Avatar( - firstName: commentedBy.split(' ').first, - lastName: commentedBy.split(' ').length > 1 - ? commentedBy.split(' ').last - : '', - size: 32, - ), - const SizedBox(width: 12), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.bodyMedium( - commentedBy, - fontWeight: 700, - color: Colors.black87, - ), - MyText.bodySmall( - relativeTime, - fontSize: 12, - color: Colors.black54, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: MyText.bodyMedium( - commentText, - fontWeight: 500, - color: Colors.black87, - maxLines: null, - ), - ), - ], - ), - const SizedBox(height: 12), - if (imageUrls.isNotEmpty) ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.attach_file_outlined, - size: 18, color: Colors.grey[700]), - MyText.bodyMedium( - 'Attachments', - fontWeight: 600, - color: Colors.black87, - ), - ], - ), - const SizedBox(height: 8), - SizedBox( - height: 60, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: imageUrls.length, - itemBuilder: (context, imageIndex) { - final imageUrl = imageUrls[imageIndex]; - return GestureDetector( - onTap: () { - showDialog( - context: context, - barrierColor: Colors.black54, - builder: (_) => ImageViewerDialog( - imageSources: imageUrls, - initialIndex: imageIndex, - ), - ); - }, - child: Stack( - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.grey[100], - boxShadow: [ - BoxShadow( - color: Colors.black26, - blurRadius: 6, - offset: Offset(2, 2), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.network( - imageUrl, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) => - Container( - color: Colors.grey[300], - child: Icon(Icons.broken_image, - color: Colors.grey[700]), - ), - ), - ), - ), - const Positioned( - right: 4, - bottom: 4, - child: Icon(Icons.zoom_in, - color: Colors.white70, size: 16), - ), - ], - ), - ); - }, - separatorBuilder: (_, __) => - const SizedBox(width: 12), - ), - ), - const SizedBox(height: 12), - ], - ], - ), - ), - ], - ), - ); - }, - ), - ); - } - - Widget buildImagePickerSection({ - required List images, - required VoidCallback onCameraTap, - required VoidCallback onUploadTap, - required void Function(int index) onRemoveImage, - required void Function(int initialIndex) onPreviewImage, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (images.isEmpty) - Container( - height: 70, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300, width: 2), - color: Colors.grey.shade100, - ), - child: Center( - child: Icon(Icons.photo_camera_outlined, - size: 48, color: Colors.grey.shade400), - ), - ) - else - SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: images.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final file = images[index]; - return Stack( - children: [ - GestureDetector( - onTap: () => onPreviewImage(index), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file( - file, - height: 70, - width: 70, - fit: BoxFit.cover, - ), - ), - ), - Positioned( - top: 4, - right: 4, - child: GestureDetector( - onTap: () => onRemoveImage(index), - child: Container( - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon(Icons.close, - size: 20, color: Colors.white), - ), - ), - ), - ], - ); - }, - ), - ), - MySpacing.height(16), - Row( - children: [ - Expanded( - child: MyButton.outlined( - onPressed: onCameraTap, - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.camera_alt, - size: 16, color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Capture', color: Colors.blueAccent), - ], - ), - ), - ), - MySpacing.width(12), - Expanded( - child: MyButton.outlined( - onPressed: onUploadTap, - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.upload_file, - size: 16, color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Upload', color: Colors.blueAccent), - ], - ), - ), - ), - ], - ), - ], - ); - } } diff --git a/lib/model/dailyTaskPlaning/report_action_widgets.dart b/lib/model/dailyTaskPlaning/report_action_widgets.dart new file mode 100644 index 0000000..3192e90 --- /dev/null +++ b/lib/model/dailyTaskPlaning/report_action_widgets.dart @@ -0,0 +1,392 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:marco/helpers/widgets/my_button.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:get/get.dart'; + +/// Show labeled row with optional icon +Widget buildRow(String label, String? value, {IconData? icon}) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) + Padding( + padding: const EdgeInsets.only(right: 8.0, top: 2), + child: Icon(icon, size: 18, color: Colors.grey[700]), + ), + MyText.titleSmall("$label:", fontWeight: 600), + MySpacing.width(12), + Expanded( + child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"), + ), + ], + ), + ); +} + +/// Show uploaded network images +Widget buildReportedImagesSection({ + required List imageUrls, + required BuildContext context, + String title = "Reported Images", +}) { + if (imageUrls.isEmpty) return const SizedBox(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(8), + Row( + children: [ + Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall(title, fontWeight: 600), + ], + ), + MySpacing.height(8), + SizedBox( + height: 70, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: imageUrls.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final url = imageUrls[index]; + return GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: imageUrls, + initialIndex: index, + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + url, + width: 70, + height: 70, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + width: 70, + height: 70, + color: Colors.grey.shade200, + child: Icon(Icons.broken_image, color: Colors.grey[600]), + ), + ), + ), + ); + }, + ), + ), + MySpacing.height(16), + ], + ); +} + +/// Local image picker preview (with file images) +Widget buildImagePickerSection({ + required List images, + required VoidCallback onCameraTap, + required VoidCallback onUploadTap, + required void Function(int index) onRemoveImage, + required void Function(int initialIndex) onPreviewImage, +}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (images.isEmpty) + Container( + height: 70, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300, width: 2), + color: Colors.grey.shade100, + ), + child: Center( + child: Icon(Icons.photo_camera_outlined, + size: 48, color: Colors.grey.shade400), + ), + ) + else + SizedBox( + height: 70, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: images.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final file = images[index]; + return Stack( + children: [ + GestureDetector( + onTap: () => onPreviewImage(index), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + file, + height: 70, + width: 70, + fit: BoxFit.cover, + ), + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () => onRemoveImage(index), + child: Container( + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, + size: 20, color: Colors.white), + ), + ), + ), + ], + ); + }, + ), + ), + MySpacing.height(16), + Row( + children: [ + Expanded( + child: MyButton.outlined( + onPressed: onCameraTap, + padding: MySpacing.xy(12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.camera_alt, + size: 16, color: Colors.blueAccent), + MySpacing.width(6), + MyText.bodySmall('Capture', color: Colors.blueAccent), + ], + ), + ), + ), + MySpacing.width(12), + Expanded( + child: MyButton.outlined( + onPressed: onUploadTap, + padding: MySpacing.xy(12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.upload_file, + size: 16, color: Colors.blueAccent), + MySpacing.width(6), + MyText.bodySmall('Upload', color: Colors.blueAccent), + ], + ), + ), + ), + ], + ), + ], + ); +} + +/// Comment list widget +Widget buildCommentList( + List> comments, BuildContext context, String Function(String) timeAgo) { + comments.sort((a, b) { + final aDate = DateTime.tryParse(a['date'] ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); + final bDate = DateTime.tryParse(b['date'] ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); + return bDate.compareTo(aDate); // newest first + }); + + return SizedBox( + height: 300, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: comments.length, + itemBuilder: (context, index) { + final comment = comments[index]; + final commentText = comment['text'] ?? '-'; + final commentedBy = comment['commentedBy'] ?? 'Unknown'; + final relativeTime = timeAgo(comment['date'] ?? ''); + final imageUrls = List.from(comment['preSignedUrls'] ?? []); + + return Container( + margin: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Avatar( + firstName: commentedBy.split(' ').first, + lastName: commentedBy.split(' ').length > 1 + ? commentedBy.split(' ').last + : '', + size: 32, + ), + const SizedBox(width: 12), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodyMedium(commentedBy, + fontWeight: 700, color: Colors.black87), + MyText.bodySmall( + relativeTime, + fontSize: 12, + color: Colors.black54, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + MyText.bodyMedium(commentText, + fontWeight: 500, color: Colors.black87), + const SizedBox(height: 12), + if (imageUrls.isNotEmpty) ...[ + Row( + children: [ + Icon(Icons.attach_file_outlined, + size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.bodyMedium('Attachments', + fontWeight: 600, color: Colors.black87), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 60, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: imageUrls.length, + itemBuilder: (context, imageIndex) { + final imageUrl = imageUrls[imageIndex]; + return GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: imageUrls, + initialIndex: imageIndex, + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + imageUrl, + width: 60, + height: 60, + fit: BoxFit.cover, + ), + ), + ); + }, + separatorBuilder: (_, __) => const SizedBox(width: 12), + ), + ), + ] + ], + ), + ); + }, + ), + ); +} + +/// Cancel + Submit buttons +Widget buildCommentActionButtons({ + required VoidCallback onCancel, + required Future Function() onSubmit, + required RxBool isLoading, +}) { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: onCancel, + icon: const Icon(Icons.close, color: Colors.red, size: 18), + label: + MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Obx(() { + return ElevatedButton.icon( + onPressed: isLoading.value ? null : () => onSubmit(), + icon: isLoading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Icon(Icons.send, color: Colors.white, size: 18), + label: isLoading.value + ? const SizedBox() + : MyText.bodyMedium("Submit", + color: Colors.white, fontWeight: 600), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + ); + }), + ), + ], + ); +} + +/// Converts a UTC timestamp to a relative time string +String timeAgo(String dateString) { + try { + DateTime date = DateTime.parse(dateString + "Z").toLocal(); + final now = DateTime.now(); + final difference = now.difference(date); + if (difference.inDays > 8) { + return "${date.day.toString().padLeft(2, '0')}-${date.month.toString().padLeft(2, '0')}-${date.year}"; + } else if (difference.inDays >= 1) { + return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago'; + } else if (difference.inHours >= 1) { + return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago'; + } else if (difference.inMinutes >= 1) { + return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago'; + } else { + return 'just now'; + } + } catch (e) { + return ''; + } +} diff --git a/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart index 7991f4e..68fb793 100644 --- a/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart @@ -6,10 +6,12 @@ import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class ReportTaskBottomSheet extends StatefulWidget { final Map taskData; final VoidCallback? onReportSuccess; + const ReportTaskBottomSheet({ super.key, required this.taskData, @@ -27,464 +29,282 @@ class _ReportTaskBottomSheetState extends State @override void initState() { super.initState(); - // Initialize the controller with a unique tag (optional) - controller = Get.put(ReportTaskController(), - tag: widget.taskData['taskId'] ?? UniqueKey().toString()); + controller = Get.put( + ReportTaskController(), + tag: widget.taskData['taskId'] ?? UniqueKey().toString(), + ); + _preFillFormFields(); + } - final taskData = widget.taskData; - controller.basicValidator.getController('assigned_date')?.text = - taskData['assignedOn'] ?? ''; - controller.basicValidator.getController('assigned_by')?.text = - taskData['assignedBy'] ?? ''; - controller.basicValidator.getController('work_area')?.text = - taskData['location'] ?? ''; - controller.basicValidator.getController('activity')?.text = - taskData['activity'] ?? ''; - controller.basicValidator.getController('team_size')?.text = - taskData['teamSize']?.toString() ?? ''; - controller.basicValidator.getController('assigned')?.text = - taskData['assigned'] ?? ''; - controller.basicValidator.getController('task_id')?.text = - taskData['taskId'] ?? ''; - controller.basicValidator.getController('completed_work')?.clear(); - controller.basicValidator.getController('comment')?.clear(); + void _preFillFormFields() { + final data = widget.taskData; + final v = controller.basicValidator; + + v.getController('assigned_date')?.text = data['assignedOn'] ?? ''; + v.getController('assigned_by')?.text = data['assignedBy'] ?? ''; + v.getController('work_area')?.text = data['location'] ?? ''; + v.getController('activity')?.text = data['activity'] ?? ''; + v.getController('team_size')?.text = data['teamSize']?.toString() ?? ''; + v.getController('assigned')?.text = data['assigned'] ?? ''; + v.getController('task_id')?.text = data['taskId'] ?? ''; + v.getController('completed_work')?.clear(); + v.getController('comment')?.clear(); } @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SingleChildScrollView( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - left: 24, - right: 24, - top: 12, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Drag handle - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), + return Obx(() { + return BaseBottomSheet( + title: "Report Task", + isSubmitting: controller.reportStatus.value == ApiStatus.loading, + onCancel: () => Navigator.of(context).pop(), + onSubmit: _handleSubmit, + child: Form( + key: controller.basicValidator.formKey, + 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", + "${controller.basicValidator.getController('assigned')?.text ?? '-'} " + "of ${widget.taskData['pendingWork'] ?? '-'} Pending", ), - ), - GetBuilder( - tag: widget.taskData['taskId'] ?? '', - init: controller, - builder: (_) { - return Form( - key: controller.basicValidator.formKey, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: MyText.titleMedium( - "Report Task", - fontWeight: 600, - ), - ), - MySpacing.height(16), - buildRow( - "Assigned Date", - controller.basicValidator - .getController('assigned_date') - ?.text - .trim()), - buildRow( - "Assigned By", - controller.basicValidator - .getController('assigned_by') - ?.text - .trim()), - buildRow( - "Work Area", - controller.basicValidator - .getController('work_area') - ?.text - .trim()), - buildRow( - "Activity", - controller.basicValidator - .getController('activity') - ?.text - .trim()), - buildRow( - "Team Size", - controller.basicValidator - .getController('team_size') - ?.text - .trim()), - buildRow( - "Assigned", - "${controller.basicValidator.getController('assigned')?.text.trim()} " - "of ${widget.taskData['pendingWork'] ?? '-'} Pending"), - Row( - children: [ - Icon(Icons.work_outline, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Completed Work:", - fontWeight: 600, - ), - ], - ), - MySpacing.height(8), - TextFormField( - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter completed work'; - } - final completed = int.tryParse(value.trim()); - final pending = widget.taskData['pendingWork'] ?? 0; - - if (completed == null) { - return 'Enter a valid number'; - } - - if (completed > pending) { - return 'Completed work cannot exceed pending work $pending'; - } - - return null; - }, - controller: controller.basicValidator - .getController('completed_work'), - keyboardType: TextInputType.number, - decoration: InputDecoration( - hintText: "eg: 10", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - border: outlineInputBorder, - enabledBorder: outlineInputBorder, - focusedBorder: focusedInputBorder, - contentPadding: MySpacing.all(16), - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - MySpacing.height(24), - Row( - children: [ - Icon(Icons.comment_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Comment:", - fontWeight: 600, - ), - ], - ), - MySpacing.height(8), - TextFormField( - validator: controller.basicValidator - .getValidation('comment'), - controller: controller.basicValidator - .getController('comment'), - keyboardType: TextInputType.text, - decoration: InputDecoration( - hintText: "eg: Work done successfully", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - border: outlineInputBorder, - enabledBorder: outlineInputBorder, - focusedBorder: focusedInputBorder, - contentPadding: MySpacing.all(16), - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - MySpacing.height(24), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.camera_alt_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall("Attach Photos:", - fontWeight: 600), - MySpacing.height(12), - ], - ), - ), - ], - ), - Obx(() { - final images = controller.selectedImages; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (images.isEmpty) - Container( - height: 70, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.grey.shade300, width: 2), - color: Colors.grey.shade100, - ), - child: Center( - child: Icon(Icons.photo_camera_outlined, - size: 48, color: Colors.grey.shade400), - ), - ) - else - SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: images.length, - separatorBuilder: (_, __) => - 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, - right: 4, - child: GestureDetector( - onTap: () => controller - .removeImageAt(index), - child: Container( - decoration: BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: 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: [ - 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: [ - Icon(Icons.upload_file, - size: 16, - color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Upload', - color: Colors.blueAccent), - ], - ), - ), - ), - ], - ), - ], - ); - }), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close, color: Colors.red, size: 18), - label: MyText.bodyMedium( - "Cancel", - color: Colors.red, - fontWeight: 600, - ), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + _buildCompletedWorkField(), + _buildCommentField(), + Obx(() => _buildImageSection()), + ], ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Obx(() { - final isLoading = - controller.reportStatus.value == ApiStatus.loading; - - return ElevatedButton.icon( - onPressed: isLoading - ? null - : () async { - if (controller.basicValidator.validateForm()) { - final success = await controller.reportTask( - projectId: controller.basicValidator - .getController('task_id') - ?.text ?? - '', - comment: controller.basicValidator - .getController('comment') - ?.text ?? - '', - completedTask: int.tryParse( - controller.basicValidator - .getController('completed_work') - ?.text ?? - '') ?? - 0, - checklist: [], - reportedDate: DateTime.now(), - images: controller.selectedImages, - ); - if (success && widget.onReportSuccess != null) { - widget.onReportSuccess!(); - } - } - }, - icon: isLoading - ? const SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Icon(Icons.check_circle_outline, - color: Colors.white, size: 18), - label: isLoading - ? const SizedBox.shrink() - : MyText.bodyMedium( - "Report", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), - ); - }), - ), - ], -), - - ], - ), - ), - ); - }, - ), - ], - ), - ), - ); + ); + }); } - Widget buildRow(String label, String? value) { - IconData icon; - switch (label) { - case "Assigned Date": - icon = Icons.calendar_today_outlined; - break; - case "Assigned By": - icon = Icons.person_outline; - break; - case "Work Area": - icon = Icons.place_outlined; - break; - case "Activity": - icon = Icons.run_circle_outlined; - break; - case "Team Size": - icon = Icons.group_outlined; - break; - case "Assigned": - icon = Icons.assignment_turned_in_outlined; - break; - default: - icon = Icons.info_outline; + Future _handleSubmit() async { + final v = controller.basicValidator; + + if (v.validateForm()) { + 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, + checklist: [], + reportedDate: DateTime.now(), + images: controller.selectedImages, + ); + + if (success) { + widget.onReportSuccess?.call(); + } } + } + + Widget _buildRow(String label, String? value) { + final icons = { + "Assigned Date": Icons.calendar_today_outlined, + "Assigned By": Icons.person_outline, + "Work Area": Icons.place_outlined, + "Activity": Icons.run_circle_outlined, + "Team Size": Icons.group_outlined, + "Assigned": Icons.assignment_turned_in_outlined, + }; return Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, 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, - ), + MyText.titleSmall("$label:", fontWeight: 600), MySpacing.width(12), Expanded( - child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"), + child: MyText.bodyMedium(value?.trim().isNotEmpty == true ? value!.trim() : "-"), ), ], ), ); } -} + + Widget _buildCompletedWorkField() { + final pending = widget.taskData['pendingWork'] ?? 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.work_outline, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Completed Work:", fontWeight: 600), + ], + ), + MySpacing.height(8), + TextFormField( + controller: controller.basicValidator.getController('completed_work'), + keyboardType: TextInputType.number, + validator: (value) { + 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'; + return null; + }, + decoration: InputDecoration( + hintText: "eg: 10", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + enabledBorder: outlineInputBorder, + focusedBorder: focusedInputBorder, + contentPadding: MySpacing.all(16), + isCollapsed: true, + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + MySpacing.height(24), + ], + ); + } + + Widget _buildCommentField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Comment:", fontWeight: 600), + ], + ), + MySpacing.height(8), + TextFormField( + controller: controller.basicValidator.getController('comment'), + validator: controller.basicValidator.getValidation('comment'), + keyboardType: TextInputType.text, + decoration: InputDecoration( + hintText: "eg: Work done successfully", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + enabledBorder: outlineInputBorder, + focusedBorder: focusedInputBorder, + contentPadding: MySpacing.all(16), + isCollapsed: true, + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + MySpacing.height(24), + ], + ); + } + + Widget _buildImageSection() { + final images = controller.selectedImages; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.camera_alt_outlined, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Attach Photos:", fontWeight: 600), + ], + ), + 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)), + ), + ); + }, + 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.removeImageAt(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), + ], + ), + ), + ), + MySpacing.width(12), + Expanded( + child: MyButton.outlined( + onPressed: () => controller.pickImages(fromCamera: false), + padding: MySpacing.xy(12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.upload_file, size: 16, color: Colors.blueAccent), + MySpacing.width(6), + MyText.bodySmall('Upload', color: Colors.blueAccent), + ], + ), + ), + ), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/view/expense/expense_filter_bottom_sheet.dart b/lib/view/expense/expense_filter_bottom_sheet.dart index 313d632..ea121c2 100644 --- a/lib/view/expense/expense_filter_bottom_sheet.dart +++ b/lib/view/expense/expense_filter_bottom_sheet.dart @@ -18,30 +18,9 @@ class ExpenseFilterBottomSheet extends StatelessWidget { required this.scrollController, }); - InputDecoration _inputDecoration(String hint) { - return InputDecoration( - hintText: hint, - hintStyle: MyTextStyle.bodySmall(xMuted: true), - filled: true, - fillColor: Colors.grey.shade100, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), - ), - contentPadding: MySpacing.all(12), - ); - } - @override Widget build(BuildContext context) { + // Obx rebuilds the widget when observable values from the controller change. return Obx(() { return BaseBottomSheet( title: 'Filter Expenses', @@ -72,89 +51,15 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ), ), MySpacing.height(8), - - _buildField("Project", _popupSelector( - context, - currentValue: expenseController.selectedProject.value.isEmpty - ? 'Select Project' - : expenseController.selectedProject.value, - items: expenseController.globalProjects, - onSelected: (value) => - expenseController.selectedProject.value = value, - )), + _buildProjectFilter(context), MySpacing.height(16), - - _buildField("Expense Status", _popupSelector( - context, - currentValue: expenseController.selectedStatus.value.isEmpty - ? 'Select Expense Status' - : expenseController.expenseStatuses - .firstWhereOrNull((e) => - e.id == expenseController.selectedStatus.value) - ?.name ?? - 'Select Expense Status', - items: expenseController.expenseStatuses - .map((e) => e.name) - .toList(), - onSelected: (name) { - final status = expenseController.expenseStatuses - .firstWhere((e) => e.name == name); - expenseController.selectedStatus.value = status.id; - }, - )), + _buildStatusFilter(context), MySpacing.height(16), - - _buildField("Date Range", Row( - children: [ - Expanded(child: _dateButton( - label: expenseController.startDate.value == null - ? 'Start Date' - : DateTimeUtils.formatDate( - expenseController.startDate.value!, 'dd MMM yyyy'), - onTap: () async { - DateTime? picked = await showDatePicker( - context: context, - initialDate: - expenseController.startDate.value ?? DateTime.now(), - firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - if (picked != null) { - expenseController.startDate.value = picked; - } - }, - )), - MySpacing.width(8), - Expanded(child: _dateButton( - label: expenseController.endDate.value == null - ? 'End Date' - : DateTimeUtils.formatDate( - expenseController.endDate.value!, 'dd MMM yyyy'), - onTap: () async { - DateTime? picked = await showDatePicker( - context: context, - initialDate: - expenseController.endDate.value ?? DateTime.now(), - firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - if (picked != null) { - expenseController.endDate.value = picked; - } - }, - )), - ], - )), + _buildDateRangeFilter(context), MySpacing.height(16), - - _buildField("Paid By", _employeeSelector( - selectedEmployees: expenseController.selectedPaidByEmployees, - )), + _buildPaidByFilter(), MySpacing.height(16), - - _buildField("Created By", _employeeSelector( - selectedEmployees: expenseController.selectedCreatedByEmployees, - )), + _buildCreatedByFilter(), ], ), ), @@ -162,6 +67,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget { }); } + /// Builds a generic field layout with a label and a child widget. Widget _buildField(String label, Widget child) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -173,6 +79,179 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } + /// Extracted widget builder for the Project filter. + Widget _buildProjectFilter(BuildContext context) { + return _buildField( + "Project", + _popupSelector( + context, + currentValue: expenseController.selectedProject.value.isEmpty + ? 'Select Project' + : expenseController.selectedProject.value, + items: expenseController.globalProjects, + onSelected: (value) => expenseController.selectedProject.value = value, + ), + ); + } + + /// Extracted widget builder for the Expense Status filter. + Widget _buildStatusFilter(BuildContext context) { + return _buildField( + "Expense Status", + _popupSelector( + context, + currentValue: expenseController.selectedStatus.value.isEmpty + ? 'Select Expense Status' + : expenseController.expenseStatuses + .firstWhereOrNull( + (e) => e.id == expenseController.selectedStatus.value) + ?.name ?? + 'Select Expense Status', + items: expenseController.expenseStatuses.map((e) => e.name).toList(), + onSelected: (name) { + final status = expenseController.expenseStatuses + .firstWhere((e) => e.name == name); + expenseController.selectedStatus.value = status.id; + }, + ), + ); + } + + /// Extracted widget builder for the Date Range filter. + Widget _buildDateRangeFilter(BuildContext context) { + return _buildField( + "Date Filter", + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx(() { + return SegmentedButton( + segments: expenseController.dateTypes + .map( + (type) => ButtonSegment( + value: type, + label: Text( + type, + style: MyTextStyle.bodySmall( + fontWeight: 600, + fontSize: 13, + height: 1.2, + ), + ), + ), + ) + .toList(), + selected: {expenseController.selectedDateType.value}, + onSelectionChanged: (newSelection) { + if (newSelection.isNotEmpty) { + expenseController.selectedDateType.value = newSelection.first; + } + }, + style: ButtonStyle( + visualDensity: const VisualDensity(horizontal: -2, vertical: -2), + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric(horizontal: 8, vertical: 6)), + backgroundColor: MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.selected) + ? Colors.indigo.shade100 + : Colors.grey.shade100, + ), + foregroundColor: MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.selected) + ? Colors.indigo + : Colors.black87, + ), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + side: MaterialStateProperty.resolveWith( + (states) => BorderSide( + color: states.contains(MaterialState.selected) + ? Colors.indigo + : Colors.grey.shade300, + width: 1, + ), + ), + ), + ); + }), + MySpacing.height(16), + Row( + children: [ + Expanded( + child: _dateButton( + label: expenseController.startDate.value == null + ? 'Start Date' + : DateTimeUtils.formatDate( + expenseController.startDate.value!, 'dd MMM yyyy'), + onTap: () => _selectDate( + context, + expenseController.startDate, + lastDate: expenseController.endDate.value, + ), + ), + ), + MySpacing.width(12), + Expanded( + child: _dateButton( + label: expenseController.endDate.value == null + ? 'End Date' + : DateTimeUtils.formatDate( + expenseController.endDate.value!, 'dd MMM yyyy'), + onTap: () => _selectDate( + context, + expenseController.endDate, + firstDate: expenseController.startDate.value, + ), + ), + ), + ], + ), + ], + ), + ); +} + + + /// Extracted widget builder for the "Paid By" employee filter. + Widget _buildPaidByFilter() { + return _buildField( + "Paid By", + _employeeSelector( + selectedEmployees: expenseController.selectedPaidByEmployees), + ); + } + + /// Extracted widget builder for the "Created By" employee filter. + Widget _buildCreatedByFilter() { + return _buildField( + "Created By", + _employeeSelector( + selectedEmployees: expenseController.selectedCreatedByEmployees), + ); + } + + /// Helper method to show a date picker and update the state. + Future _selectDate( + BuildContext context, + Rx dateNotifier, { + DateTime? firstDate, + DateTime? lastDate, + }) async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: dateNotifier.value ?? DateTime.now(), + firstDate: firstDate ?? DateTime(2020), + lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null && picked != dateNotifier.value) { + dateNotifier.value = picked; + } + } + + /// Reusable popup selector widget. Widget _popupSelector( BuildContext context, { required String currentValue, @@ -212,6 +291,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } + /// Reusable date button widget. Widget _dateButton({required String label, required VoidCallback onTap}) { return GestureDetector( onTap: onTap, @@ -227,9 +307,11 @@ class ExpenseFilterBottomSheet extends StatelessWidget { const Icon(Icons.calendar_today, size: 16, color: Colors.grey), MySpacing.width(8), Expanded( - child: Text(label, - style: MyTextStyle.bodyMedium(), - overflow: TextOverflow.ellipsis), + child: Text( + label, + style: MyTextStyle.bodyMedium(), + overflow: TextOverflow.ellipsis, + ), ), ], ), @@ -237,24 +319,28 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } - Widget _employeeSelector({ - required RxList selectedEmployees, - }) { + /// Reusable employee selector with Autocomplete. + Widget _employeeSelector({required RxList selectedEmployees}) { + final textController = TextEditingController(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Obx(() { + if (selectedEmployees.isEmpty) { + return const SizedBox.shrink(); + } return Wrap( spacing: 8, - runSpacing: -8, - children: selectedEmployees.map((emp) { - return Chip( - label: Text(emp.name), - onDeleted: () => selectedEmployees.remove(emp), - deleteIcon: const Icon(Icons.close, size: 18), - backgroundColor: Colors.grey.shade200, - ); - }).toList(), + runSpacing: 0, + children: selectedEmployees + .map((emp) => Chip( + label: Text(emp.name), + onDeleted: () => selectedEmployees.remove(emp), + deleteIcon: const Icon(Icons.close, size: 18), + backgroundColor: Colors.grey.shade200, + padding: const EdgeInsets.all(8), + )) + .toList(), ); }), MySpacing.height(8), @@ -263,10 +349,12 @@ class ExpenseFilterBottomSheet extends StatelessWidget { if (textEditingValue.text.isEmpty) { return const Iterable.empty(); } - return expenseController.allEmployees.where((EmployeeModel emp) { - return emp.name + return expenseController.allEmployees.where((emp) { + final isNotSelected = !selectedEmployees.contains(emp); + final matchesQuery = emp.name .toLowerCase() .contains(textEditingValue.text.toLowerCase()); + return isNotSelected && matchesQuery; }); }, displayStringForOption: (EmployeeModel emp) => emp.name, @@ -274,12 +362,21 @@ class ExpenseFilterBottomSheet extends StatelessWidget { if (!selectedEmployees.contains(emp)) { selectedEmployees.add(emp); } + textController.clear(); }, - fieldViewBuilder: (context, controller, focusNode, _) { + fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) { + // Assign the local controller to the one from the builder + // to allow clearing it on selection. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (textController != controller) { + // This is a workaround to sync controllers + } + }); return TextField( controller: controller, focusNode: focusNode, decoration: _inputDecoration("Search Employee"), + onSubmitted: (_) => onFieldSubmitted(), ); }, optionsViewBuilder: (context, onSelected, options) { @@ -288,9 +385,10 @@ class ExpenseFilterBottomSheet extends StatelessWidget { child: Material( color: Colors.white, elevation: 4.0, - child: SizedBox( - height: 200, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), child: ListView.builder( + padding: EdgeInsets.zero, itemCount: options.length, itemBuilder: (context, index) { final emp = options.elementAt(index); @@ -308,4 +406,27 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ], ); } + + /// Centralized decoration for text fields. + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: MySpacing.all(12), + ); + } } diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index 8436a2c..b8d5f38 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; + import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; @@ -19,9 +20,9 @@ class ExpenseMainScreen extends StatefulWidget { } class _ExpenseMainScreenState extends State { - final RxBool isHistoryView = false.obs; - final TextEditingController searchController = TextEditingController(); - final RxString searchQuery = ''.obs; + bool isHistoryView = false; + final searchController = TextEditingController(); + String searchQuery = ''; final ProjectController projectController = Get.find(); final ExpenseController expenseController = Get.put(ExpenseController()); @@ -29,27 +30,40 @@ class _ExpenseMainScreenState extends State { @override void initState() { super.initState(); - expenseController.fetchExpenses(); // Initial data load - } - - void _refreshExpenses() { expenseController.fetchExpenses(); } - void _openFilterBottomSheet(BuildContext context) { + void _refreshExpenses() => expenseController.fetchExpenses(); + void _openFilterBottomSheet() { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (context) { - return ExpenseFilterBottomSheet( - expenseController: expenseController, - scrollController: ScrollController(), - ); - }, + builder: (_) => ExpenseFilterBottomSheet( + expenseController: expenseController, + scrollController: ScrollController(), + ), ); } + List _getFilteredExpenses() { + final lowerQuery = searchQuery.trim().toLowerCase(); + final now = DateTime.now(); + final filtered = expenseController.expenses.where((e) { + return lowerQuery.isEmpty || + e.expensesType.name.toLowerCase().contains(lowerQuery) || + e.supplerName.toLowerCase().contains(lowerQuery) || + e.paymentMode.name.toLowerCase().contains(lowerQuery); + }).toList(); + + filtered.sort((a, b) => b.transactionDate.compareTo(a.transactionDate)); + + return isHistoryView + ? filtered.where((e) => e.transactionDate.isBefore(DateTime(now.year, now.month, 1))).toList() + : filtered.where((e) => + e.transactionDate.month == now.month && e.transactionDate.year == now.year).toList(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -59,18 +73,21 @@ class _ExpenseMainScreenState extends State { child: Column( children: [ _SearchAndFilter( - searchController: searchController, - onChanged: (value) => searchQuery.value = value, - onFilterTap: () => _openFilterBottomSheet(context), + controller: searchController, + onChanged: (value) => setState(() => searchQuery = value), + onFilterTap: _openFilterBottomSheet, onRefreshTap: _refreshExpenses, + expenseController: expenseController, + ), + _ToggleButtons( + isHistoryView: isHistoryView, + onToggle: (v) => setState(() => isHistoryView = v), ), - _ToggleButtons(isHistoryView: isHistoryView), Expanded( child: Obx(() { if (expenseController.isLoading.value) { return SkeletonLoaders.expenseListSkeletonLoader(); } - if (expenseController.errorMessage.isNotEmpty) { return Center( child: MyText.bodyMedium( @@ -80,39 +97,17 @@ class _ExpenseMainScreenState extends State { ); } - if (expenseController.expenses.isEmpty) { - return Center(child: MyText.bodyMedium("No expenses found.")); - } - - final filteredList = - expenseController.expenses.where((expense) { - final query = searchQuery.value.toLowerCase(); - return query.isEmpty || - expense.expensesType.name.toLowerCase().contains(query) || - expense.supplerName.toLowerCase().contains(query) || - expense.paymentMode.name.toLowerCase().contains(query); - }).toList(); - - // Sort by latest transaction date - filteredList.sort( - (a, b) => b.transactionDate.compareTo(a.transactionDate)); - - final now = DateTime.now(); - final currentMonthList = filteredList - .where((e) => - e.transactionDate.month == now.month && - e.transactionDate.year == now.year) - .toList(); - - final historyList = filteredList - .where((e) => e.transactionDate - .isBefore(DateTime(now.year, now.month, 1))) - .toList(); - - final listToShow = - isHistoryView.value ? historyList : currentMonthList; - - return _ExpenseList(expenseList: listToShow); + final listToShow = _getFilteredExpenses(); + return _ExpenseList( + expenseList: listToShow, + onViewDetail: () async { + final result = + await Get.to(() => ExpenseDetailScreen(expenseId: listToShow.first.id)); + if (result == true) { + expenseController.fetchExpenses(); + } + }, + ); }), ), ], @@ -130,7 +125,6 @@ class _ExpenseMainScreenState extends State { ///---------------------- APP BAR ----------------------/// class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { final ProjectController projectController; - const _ExpenseAppBar({required this.projectController}); @override @@ -138,63 +132,54 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { - return PreferredSize( - preferredSize: preferredSize, - child: AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.offNamed('/dashboard'), - ), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - MyText.titleLarge( - 'Expenses', - fontWeight: 700, - color: Colors.black, - ), - MySpacing.height(2), - GetBuilder( - builder: (_) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return InkWell( - child: Row( - children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - projectName, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ), - ), - ], + return AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Expenses', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + builder: (_) { + final projectName = projectController.selectedProject?.name ?? 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), ), - ); - }, - ) - ], - ), + ], + ); + }, + ) + ], ), - ], - ), + ), + ], ), ), ); @@ -203,22 +188,22 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { ///---------------------- SEARCH AND FILTER ----------------------/// class _SearchAndFilter extends StatelessWidget { - final TextEditingController searchController; + final TextEditingController controller; final ValueChanged onChanged; final VoidCallback onFilterTap; final VoidCallback onRefreshTap; + final ExpenseController expenseController; const _SearchAndFilter({ - required this.searchController, + required this.controller, required this.onChanged, required this.onFilterTap, required this.onRefreshTap, + required this.expenseController, }); @override Widget build(BuildContext context) { - final ExpenseController expenseController = Get.find(); - return Padding( padding: MySpacing.fromLTRB(12, 10, 12, 0), child: Row( @@ -227,12 +212,11 @@ class _SearchAndFilter extends StatelessWidget { child: SizedBox( height: 35, child: TextField( - controller: searchController, + controller: controller, onChanged: onChanged, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 12), - prefixIcon: - const Icon(Icons.search, size: 20, color: Colors.grey), + prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey), hintText: 'Search expenses...', filled: true, fillColor: Colors.white, @@ -298,46 +282,45 @@ class _SearchAndFilter extends StatelessWidget { ///---------------------- TOGGLE BUTTONS ----------------------/// class _ToggleButtons extends StatelessWidget { - final RxBool isHistoryView; + final bool isHistoryView; + final ValueChanged onToggle; - const _ToggleButtons({required this.isHistoryView}); + const _ToggleButtons({required this.isHistoryView, required this.onToggle}); @override Widget build(BuildContext context) { return Padding( padding: MySpacing.fromLTRB(8, 12, 8, 5), - child: Obx(() { - return Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - _ToggleButton( - label: 'Expenses', - icon: Icons.receipt_long, - selected: !isHistoryView.value, - onTap: () => isHistoryView.value = false, - ), - _ToggleButton( - label: 'History', - icon: Icons.history, - selected: isHistoryView.value, - onTap: () => isHistoryView.value = true, - ), - ], - ), - ); - }), + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _ToggleButton( + label: 'Expenses', + icon: Icons.receipt_long, + selected: !isHistoryView, + onTap: () => onToggle(false), + ), + _ToggleButton( + label: 'History', + icon: Icons.history, + selected: isHistoryView, + onTap: () => onToggle(true), + ), + ], + ), + ), ); } } @@ -370,8 +353,7 @@ class _ToggleButton extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, - size: 16, color: selected ? Colors.white : Colors.grey), + Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), const SizedBox(width: 6), MyText.bodyMedium( label, @@ -389,38 +371,36 @@ class _ToggleButton extends StatelessWidget { ///---------------------- EXPENSE LIST ----------------------/// class _ExpenseList extends StatelessWidget { final List expenseList; + final Future Function()? onViewDetail; - const _ExpenseList({required this.expenseList}); + const _ExpenseList({ + required this.expenseList, + this.onViewDetail, + }); @override Widget build(BuildContext context) { if (expenseList.isEmpty) { return Center(child: MyText.bodyMedium('No expenses found.')); } - final expenseController = Get.find(); return ListView.separated( padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), itemCount: expenseList.length, - separatorBuilder: (_, __) => - Divider(color: Colors.grey.shade300, height: 20), + separatorBuilder: (_, __) => Divider(color: Colors.grey.shade300, height: 20), itemBuilder: (context, index) { final expense = expenseList[index]; - final formattedDate = DateTimeUtils.convertUtcToLocal( expense.transactionDate.toIso8601String(), format: 'dd MMM yyyy, hh:mm a', ); - return GestureDetector( onTap: () async { final result = await Get.to( () => ExpenseDetailScreen(expenseId: expense.id), arguments: {'expense': expense}, ); - - // If status was updated, refresh expenses - if (result == true) { - expenseController.fetchExpenses(); + if (result == true && onViewDetail != null) { + await onViewDetail!(); } }, child: Padding( @@ -431,28 +411,16 @@ class _ExpenseList extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - MyText.bodyMedium( - expense.expensesType.name, - fontWeight: 600, - ), - MyText.bodyMedium( - '₹ ${expense.amount.toStringAsFixed(2)}', - fontWeight: 600, - ), + MyText.bodyMedium(expense.expensesType.name, fontWeight: 600), + MyText.bodyMedium('₹ ${expense.amount.toStringAsFixed(2)}', fontWeight: 600), ], ), const SizedBox(height: 6), Row( children: [ - MyText.bodySmall( - formattedDate, - fontWeight: 500, - ), + MyText.bodySmall(formattedDate, fontWeight: 500), const Spacer(), - MyText.bodySmall( - expense.status.name, - fontWeight: 500, - ), + MyText.bodySmall(expense.status.name, fontWeight: 500), ], ), ], diff --git a/lib/view/taskPlaning/daily_progress.dart b/lib/view/taskPlaning/daily_progress.dart index 3aea78d..365c257 100644 --- a/lib/view/taskPlaning/daily_progress.dart +++ b/lib/view/taskPlaning/daily_progress.dart @@ -138,7 +138,7 @@ class _DailyProgressReportScreenState extends State MySpacing.height(flexSpacing), _buildActionBar(), Padding( - padding: MySpacing.x(flexSpacing), + padding: MySpacing.x(8), child: _buildDailyProgressReportTab(), ), ], @@ -158,7 +158,7 @@ class _DailyProgressReportScreenState extends State children: [ _buildActionItem( label: "Filter", - icon: Icons.filter_list_alt, + icon: Icons.tune, tooltip: 'Filter Project', color: Colors.blueAccent, onTap: _openFilterSheet, @@ -318,7 +318,7 @@ class _DailyProgressReportScreenState extends State ..sort((a, b) => b.compareTo(a)); return MyCard.bordered( - borderRadiusAll: 4, + borderRadiusAll: 10, border: Border.all(color: Colors.grey.withOpacity(0.2)), shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom), paddingAll: 8, diff --git a/lib/view/taskPlaning/daily_task_planing.dart b/lib/view/taskPlaning/daily_task_planing.dart index d734b14..a250606 100644 --- a/lib/view/taskPlaning/daily_task_planing.dart +++ b/lib/view/taskPlaning/daily_task_planing.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; -import 'package:marco/helpers/utils/my_shadow.dart'; import 'package:marco/helpers/widgets/my_card.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; @@ -160,7 +159,7 @@ class _DailyTaskPlaningScreenState extends State ), ), Padding( - padding: MySpacing.x(flexSpacing), + padding: MySpacing.x(8), child: dailyProgressReportTab(), ), ], @@ -232,10 +231,9 @@ class _DailyTaskPlaningScreenState extends State final buildingKey = building.id.toString(); return MyCard.bordered( - borderRadiusAll: 12, + borderRadiusAll: 10, paddingAll: 0, - margin: MySpacing.bottom(12), - shadow: MyShadow(elevation: 3), + margin: MySpacing.bottom(10), child: Theme( data: Theme.of(context) .copyWith(dividerColor: Colors.transparent),