From f01608e4e789e6b060c01ba13e96a24bfd90b31a Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 30 Oct 2025 10:53:38 +0530 Subject: [PATCH 01/18] feat: Implement pending expenses feature with API integration and UI widget --- .../dashboard/dashboard_controller.dart | 33 +++- lib/helpers/services/api_endpoints.dart | 9 +- lib/helpers/services/api_service.dart | 40 ++++- .../dashbaord/expense_by_status_widget.dart | 163 +++++++++++++++++ .../expense_report_response_model.dart | 74 ++++++++ .../dashboard/expense_type_report_model.dart | 105 +++++++++++ .../dashboard/master_expense_types_model.dart | 74 ++++++++ .../dashboard/pending_expenses_model.dart | 169 ++++++++++++++++++ pubspec.yaml | 1 + 9 files changed, 661 insertions(+), 7 deletions(-) create mode 100644 lib/helpers/widgets/dashbaord/expense_by_status_widget.dart create mode 100644 lib/model/dashboard/expense_report_response_model.dart create mode 100644 lib/model/dashboard/expense_type_report_model.dart create mode 100644 lib/model/dashboard/master_expense_types_model.dart create mode 100644 lib/model/dashboard/pending_expenses_model.dart diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index 72f2331..6eb76f3 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -3,6 +3,7 @@ import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/model/dashboard/project_progress_model.dart'; +import 'package:marco/model/dashboard/pending_expenses_model.dart'; class DashboardController extends GetxController { // ========================= @@ -48,7 +49,11 @@ class DashboardController extends GetxController { // Inject ProjectController final ProjectController projectController = Get.find(); - +// Pending Expenses overview +// ========================= + final RxBool isPendingExpensesLoading = false.obs; + final Rx pendingExpensesData = + Rx(null); @override void onInit() { super.onInit(); @@ -147,9 +152,35 @@ class DashboardController extends GetxController { fetchProjectProgress(), fetchDashboardTasks(projectId: projectId), fetchDashboardTeams(projectId: projectId), + fetchPendingExpenses(), ]); } + Future fetchPendingExpenses() async { + final String projectId = projectController.selectedProjectId.value; + if (projectId.isEmpty) return; + + try { + isPendingExpensesLoading.value = true; + final response = + await ApiService.getPendingExpensesApi(projectId: projectId); + + if (response != null && response.success) { + pendingExpensesData.value = response.data; + logSafe('Pending expenses fetched successfully.', level: LogLevel.info); + } else { + pendingExpensesData.value = null; + logSafe('Failed to fetch pending expenses.', level: LogLevel.error); + } + } catch (e, st) { + pendingExpensesData.value = null; + logSafe('Error fetching pending expenses', + level: LogLevel.error, error: e, stackTrace: st); + } finally { + isPendingExpensesLoading.value = false; + } + } + // ========================= // API Calls // ========================= diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index eea7ae8..5029399 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,5 +1,6 @@ class ApiEndpoints { - static const String baseUrl = "https://mapi.marcoaiot.com/api"; + // static const String baseUrl = "https://mapi.marcoaiot.com/api"; + static const String baseUrl = "https://ofwapi.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api"; @@ -10,12 +11,14 @@ class ApiEndpoints { static const String getDashboardTasks = "/dashboard/tasks"; static const String getDashboardTeams = "/dashboard/teams"; static const String getDashboardProjects = "/dashboard/projects"; + static const String getDashboardMonthlyExpenses = + "/Dashboard/expense/monthly"; + static const String getExpenseTypeReport = "/Dashboard/expense/type"; + static const String getPendingExpenses = "/Dashboard/expense/pendings"; ///// Projects Module API Endpoints static const String createProject = "/project"; - - // Attendance Module API Endpoints static const String getProjects = "/project/list"; static const String getGlobalProjects = "/project/list/basic"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 8fed938..581c0e2 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -19,13 +19,14 @@ import 'package:marco/model/document/master_document_type_model.dart'; import 'package:marco/model/document/document_details_model.dart'; import 'package:marco/model/document/document_version_model.dart'; import 'package:marco/model/attendance/organization_per_project_list_model.dart'; +import 'package:marco/model/dashboard/pending_expenses_model.dart'; class ApiService { static const bool enableLogs = true; static const Duration extendedTimeout = Duration(seconds: 60); static Future _getToken() async { - final token = await LocalStorage.getJwtToken(); + final token = LocalStorage.getJwtToken(); if (token == null) { logSafe("No JWT token found. Logging out..."); @@ -38,7 +39,7 @@ class ApiService { logSafe("Access token is expired. Attempting refresh..."); final refreshed = await AuthService.refreshToken(); if (refreshed) { - return await LocalStorage.getJwtToken(); + return LocalStorage.getJwtToken(); } else { logSafe("Token refresh failed. Logging out immediately..."); await LocalStorage.logout(); @@ -55,7 +56,7 @@ class ApiService { "Access token is about to expire in ${difference.inSeconds}s. Refreshing..."); final refreshed = await AuthService.refreshToken(); if (refreshed) { - return await LocalStorage.getJwtToken(); + return LocalStorage.getJwtToken(); } else { logSafe("Token refresh failed (near expiry). Logging out..."); await LocalStorage.logout(); @@ -288,6 +289,39 @@ class ApiService { } } + /// Get Pending Expenses + static Future getPendingExpensesApi({ + required String projectId, + }) async { + const endpoint = ApiEndpoints.getPendingExpenses; + logSafe("Fetching Pending Expenses for projectId: $projectId"); + + try { + final response = await _getRequest( + endpoint, + queryParams: {'projectId': projectId}, + ); + + if (response == null) { + logSafe("Pending Expenses request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = + _parseResponseForAllData(response, label: "Pending Expenses"); + + if (jsonResponse != null) { + return PendingExpensesResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getPendingExpensesApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } /// Create Project API static Future createProjectApi({ diff --git a/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart b/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart new file mode 100644 index 0000000..f3406ea --- /dev/null +++ b/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/dashboard/dashboard_controller.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +class ExpenseByStatusWidget extends StatelessWidget { + final DashboardController controller; + + const ExpenseByStatusWidget({super.key, required this.controller}); + + Widget _buildStatusTile({ + required IconData icon, + required Color color, + required String title, + required String amount, + required String count, + }) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + CircleAvatar( + backgroundColor: Colors.white, + radius: 20, + child: Icon(icon, color: color, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium( + title, + fontWeight: 600, + ), + const SizedBox(height: 2), + MyText.bodyMedium( + amount, + color: Colors.blue, + ), + ], + ), + ), + MyText.titleMedium( + count, + color: Colors.blue, + fontWeight: 600, + ), + const Icon(Icons.chevron_right, color: Colors.blue, size: 22), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + final data = controller.pendingExpensesData.value; + + if (controller.isPendingExpensesLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (data == null) { + return Center( + child: MyText.bodyMedium("No expense status data available"), + ); + } + + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge( + "Expense - By Status", + fontWeight: 700, + ), + const SizedBox(height: 4), + MyText.bodyMedium( + controller.projectController.selectedProjectName.value, + color: Colors.grey.shade600, + ), + const SizedBox(height: 16), + + // Pending Payment + _buildStatusTile( + icon: Icons.currency_rupee, + color: Colors.blue, + title: "Pending Payment", + amount: "₹${data.processPending.amount.toStringAsFixed(1)}K", + count: data.processPending.count.toString(), + ), + + // Pending Approve + _buildStatusTile( + icon: Icons.check_circle_outline, + color: Colors.orange, + title: "Pending Approve", + amount: "₹${data.approvePending.amount.toStringAsFixed(1)}K", + count: data.approvePending.count.toString(), + ), + + // Pending Review + _buildStatusTile( + icon: Icons.search, + color: Colors.grey.shade700, + title: "Pending Review", + amount: "₹${data.reviewPending.amount.toStringAsFixed(1)}K", + count: data.reviewPending.count.toString(), + ), + + // Draft + _buildStatusTile( + icon: Icons.insert_drive_file_outlined, + color: Colors.cyan, + title: "Draft", + amount: "₹${data.draft.amount.toStringAsFixed(1)}K", + count: data.draft.count.toString(), + ), + + const SizedBox(height: 8), + Divider(color: Colors.grey.shade300), + const SizedBox(height: 8), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium( + "Project Spendings:", + fontWeight: 600, + ), + MyText.bodySmall( + "(All Processed Payments)", + color: Colors.grey.shade600, + ), + ], + ), + MyText.titleLarge( + "₹${(data.totalAmount / 1000).toStringAsFixed(2)}K >", + color: Colors.blue, + fontWeight: 700, + ), + ], + ), + ], + ), + ), + ); + }); + } +} diff --git a/lib/model/dashboard/expense_report_response_model.dart b/lib/model/dashboard/expense_report_response_model.dart new file mode 100644 index 0000000..4887ac4 --- /dev/null +++ b/lib/model/dashboard/expense_report_response_model.dart @@ -0,0 +1,74 @@ +class ExpenseReportResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + ExpenseReportResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory ExpenseReportResponse.fromJson(Map json) { + return ExpenseReportResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: json['data'] != null + ? List.from( + json['data'].map((x) => ExpenseReportData.fromJson(x))) + : [], + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map toJson() => { + 'success': success, + 'message': message, + 'data': data.map((x) => x.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; +} + +class ExpenseReportData { + final String monthName; + final int year; + final double total; + final int count; + + ExpenseReportData({ + required this.monthName, + required this.year, + required this.total, + required this.count, + }); + + factory ExpenseReportData.fromJson(Map json) { + return ExpenseReportData( + monthName: json['monthName'] ?? '', + year: json['year'] ?? 0, + total: json['total'] != null + ? (json['total'] is int + ? (json['total'] as int).toDouble() + : json['total'] as double) + : 0.0, + count: json['count'] ?? 0, + ); + } + + Map toJson() => { + 'monthName': monthName, + 'year': year, + 'total': total, + 'count': count, + }; +} diff --git a/lib/model/dashboard/expense_type_report_model.dart b/lib/model/dashboard/expense_type_report_model.dart new file mode 100644 index 0000000..7620ee0 --- /dev/null +++ b/lib/model/dashboard/expense_type_report_model.dart @@ -0,0 +1,105 @@ +class ExpenseTypeReportResponse { + final bool success; + final String message; + final ExpenseTypeReportData data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + ExpenseTypeReportResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory ExpenseTypeReportResponse.fromJson(Map json) { + return ExpenseTypeReportResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: ExpenseTypeReportData.fromJson(json['data'] ?? {}), + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map toJson() => { + 'success': success, + 'message': message, + 'data': data.toJson(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; +} + +class ExpenseTypeReportData { + final List report; + final double totalAmount; + + ExpenseTypeReportData({ + required this.report, + required this.totalAmount, + }); + + factory ExpenseTypeReportData.fromJson(Map json) { + return ExpenseTypeReportData( + report: json['report'] != null + ? List.from( + json['report'].map((x) => ExpenseTypeReportItem.fromJson(x))) + : [], + totalAmount: json['totalAmount'] != null + ? (json['totalAmount'] is int + ? (json['totalAmount'] as int).toDouble() + : json['totalAmount'] as double) + : 0.0, + ); + } + + Map toJson() => { + 'report': report.map((x) => x.toJson()).toList(), + 'totalAmount': totalAmount, + }; +} + +class ExpenseTypeReportItem { + final String projectName; + final double totalApprovedAmount; + final double totalPendingAmount; + final double totalRejectedAmount; + final double totalProcessedAmount; + + ExpenseTypeReportItem({ + required this.projectName, + required this.totalApprovedAmount, + required this.totalPendingAmount, + required this.totalRejectedAmount, + required this.totalProcessedAmount, + }); + + factory ExpenseTypeReportItem.fromJson(Map json) { + double parseAmount(dynamic value) { + if (value == null) return 0.0; + return value is int ? value.toDouble() : value as double; + } + + return ExpenseTypeReportItem( + projectName: json['projectName'] ?? '', + totalApprovedAmount: parseAmount(json['totalApprovedAmount']), + totalPendingAmount: parseAmount(json['totalPendingAmount']), + totalRejectedAmount: parseAmount(json['totalRejectedAmount']), + totalProcessedAmount: parseAmount(json['totalProcessedAmount']), + ); + } + + Map toJson() => { + 'projectName': projectName, + 'totalApprovedAmount': totalApprovedAmount, + 'totalPendingAmount': totalPendingAmount, + 'totalRejectedAmount': totalRejectedAmount, + 'totalProcessedAmount': totalProcessedAmount, + }; +} diff --git a/lib/model/dashboard/master_expense_types_model.dart b/lib/model/dashboard/master_expense_types_model.dart new file mode 100644 index 0000000..cd569ab --- /dev/null +++ b/lib/model/dashboard/master_expense_types_model.dart @@ -0,0 +1,74 @@ +class ExpenseTypeResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + ExpenseTypeResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory ExpenseTypeResponse.fromJson(Map json) { + return ExpenseTypeResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: json['data'] != null + ? List.from( + json['data'].map((x) => ExpenseTypeData.fromJson(x))) + : [], + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map toJson() => { + 'success': success, + 'message': message, + 'data': data.map((x) => x.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; +} + +class ExpenseTypeData { + final String id; + final String name; + final bool noOfPersonsRequired; + final bool isAttachmentRequried; + final String description; + + ExpenseTypeData({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.isAttachmentRequried, + required this.description, + }); + + factory ExpenseTypeData.fromJson(Map json) { + return ExpenseTypeData( + id: json['id'] ?? '', + name: json['name'] ?? '', + noOfPersonsRequired: json['noOfPersonsRequired'] ?? false, + isAttachmentRequried: json['isAttachmentRequried'] ?? false, + description: json['description'] ?? '', + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'noOfPersonsRequired': noOfPersonsRequired, + 'isAttachmentRequried': isAttachmentRequried, + 'description': description, + }; +} diff --git a/lib/model/dashboard/pending_expenses_model.dart b/lib/model/dashboard/pending_expenses_model.dart new file mode 100644 index 0000000..0f59826 --- /dev/null +++ b/lib/model/dashboard/pending_expenses_model.dart @@ -0,0 +1,169 @@ +import 'package:equatable/equatable.dart'; + +class PendingExpensesResponse extends Equatable { + final bool success; + final String message; + final PendingExpensesData? data; + final dynamic errors; + final int statusCode; + final String timestamp; + + const PendingExpensesResponse({ + required this.success, + required this.message, + this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory PendingExpensesResponse.fromJson(Map json) { + return PendingExpensesResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: json['data'] != null + ? PendingExpensesData.fromJson(json['data']) + : null, + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: json['timestamp'] ?? '', + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data?.toJson(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp, + }; + } + + PendingExpensesResponse copyWith({ + bool? success, + String? message, + PendingExpensesData? data, + dynamic errors, + int? statusCode, + String? timestamp, + }) { + return PendingExpensesResponse( + success: success ?? this.success, + message: message ?? this.message, + data: data ?? this.data, + errors: errors ?? this.errors, + statusCode: statusCode ?? this.statusCode, + timestamp: timestamp ?? this.timestamp, + ); + } + + @override + List get props => [success, message, data, errors, statusCode, timestamp]; +} + +class PendingExpensesData extends Equatable { + final ExpenseStatus draft; + final ExpenseStatus reviewPending; + final ExpenseStatus approvePending; + final ExpenseStatus processPending; + final ExpenseStatus submited; + final double totalAmount; + + const PendingExpensesData({ + required this.draft, + required this.reviewPending, + required this.approvePending, + required this.processPending, + required this.submited, + required this.totalAmount, + }); + + factory PendingExpensesData.fromJson(Map json) { + return PendingExpensesData( + draft: ExpenseStatus.fromJson(json['draft']), + reviewPending: ExpenseStatus.fromJson(json['reviewPending']), + approvePending: ExpenseStatus.fromJson(json['approvePending']), + processPending: ExpenseStatus.fromJson(json['processPending']), + submited: ExpenseStatus.fromJson(json['submited']), + totalAmount: (json['totalAmount'] ?? 0).toDouble(), + ); + } + + Map toJson() { + return { + 'draft': draft.toJson(), + 'reviewPending': reviewPending.toJson(), + 'approvePending': approvePending.toJson(), + 'processPending': processPending.toJson(), + 'submited': submited.toJson(), + 'totalAmount': totalAmount, + }; + } + + PendingExpensesData copyWith({ + ExpenseStatus? draft, + ExpenseStatus? reviewPending, + ExpenseStatus? approvePending, + ExpenseStatus? processPending, + ExpenseStatus? submited, + double? totalAmount, + }) { + return PendingExpensesData( + draft: draft ?? this.draft, + reviewPending: reviewPending ?? this.reviewPending, + approvePending: approvePending ?? this.approvePending, + processPending: processPending ?? this.processPending, + submited: submited ?? this.submited, + totalAmount: totalAmount ?? this.totalAmount, + ); + } + + @override + List get props => [ + draft, + reviewPending, + approvePending, + processPending, + submited, + totalAmount, + ]; +} + +class ExpenseStatus extends Equatable { + final int count; + final double totalAmount; + + const ExpenseStatus({ + required this.count, + required this.totalAmount, + }); + + factory ExpenseStatus.fromJson(Map json) { + return ExpenseStatus( + count: json['count'] ?? 0, + totalAmount: (json['totalAmount'] ?? 0).toDouble(), + ); + } + + Map toJson() { + return { + 'count': count, + 'totalAmount': totalAmount, + }; + } + + ExpenseStatus copyWith({ + int? count, + double? totalAmount, + }) { + return ExpenseStatus( + count: count ?? this.count, + totalAmount: totalAmount ?? this.totalAmount, + ); + } + + @override + List get props => [count, totalAmount]; +} diff --git a/pubspec.yaml b/pubspec.yaml index f7e01f2..9e05a09 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -80,6 +80,7 @@ dependencies: googleapis_auth: ^2.0.0 device_info_plus: ^11.3.0 flutter_local_notifications: 19.4.0 + equatable: ^2.0.7 timeline_tile: ^2.0.0 dev_dependencies: From 6d5137b103fefc4bfb85ca126b9f374e8e1d086c Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 30 Oct 2025 15:50:55 +0530 Subject: [PATCH 02/18] feat: Add Expense Type Report feature with chart visualization and API integration --- .../dashboard/dashboard_controller.dart | 56 ++++- lib/helpers/services/api_service.dart | 47 +++- lib/helpers/utils/utils.dart | 9 + .../dashbaord/expense_breakdown_chart.dart | 229 ++++++++++++++++++ .../dashbaord/expense_by_status_widget.dart | 187 ++++++-------- lib/view/dashboard/dashboard_screen.dart | 18 +- 6 files changed, 426 insertions(+), 120 deletions(-) create mode 100644 lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index 6eb76f3..3fe4e65 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -4,6 +4,7 @@ import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/model/dashboard/project_progress_model.dart'; import 'package:marco/model/dashboard/pending_expenses_model.dart'; +import 'package:marco/model/dashboard/expense_type_report_model.dart'; class DashboardController extends GetxController { // ========================= @@ -54,6 +55,15 @@ class DashboardController extends GetxController { final RxBool isPendingExpensesLoading = false.obs; final Rx pendingExpensesData = Rx(null); + // ========================= +// Expense Type Report +// ========================= + final RxBool isExpenseTypeReportLoading = false.obs; + final Rx expenseTypeReportData = + Rx(null); + final Rx expenseReportStartDate = DateTime.now().obs; + final Rx expenseReportEndDate = DateTime.now().obs; + @override void onInit() { super.onInit(); @@ -69,7 +79,12 @@ class DashboardController extends GetxController { ever(projectController.selectedProjectId, (id) { fetchAllDashboardData(); }); - + everAll([expenseReportStartDate, expenseReportEndDate], (_) { + fetchExpenseTypeReport( + startDate: expenseReportStartDate.value, + endDate: expenseReportEndDate.value, + ); + }); // React to range changes ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance()); ever(projectSelectedRange, (_) => fetchProjectProgress()); @@ -152,7 +167,11 @@ class DashboardController extends GetxController { fetchProjectProgress(), fetchDashboardTasks(projectId: projectId), fetchDashboardTeams(projectId: projectId), - fetchPendingExpenses(), + fetchPendingExpenses(), + fetchExpenseTypeReport( + startDate: expenseReportStartDate.value, + endDate: expenseReportEndDate.value, + ) ]); } @@ -213,6 +232,39 @@ class DashboardController extends GetxController { } } + Future fetchExpenseTypeReport({ + required DateTime startDate, + required DateTime endDate, + }) async { + final String projectId = projectController.selectedProjectId.value; + if (projectId.isEmpty) return; + + try { + isExpenseTypeReportLoading.value = true; + + final response = await ApiService.getExpenseTypeReportApi( + projectId: projectId, + startDate: startDate, + endDate: endDate, + ); + + if (response != null && response.success) { + expenseTypeReportData.value = response.data; + logSafe('Expense Type Report fetched successfully.', + level: LogLevel.info); + } else { + expenseTypeReportData.value = null; + logSafe('Failed to fetch Expense Type Report.', level: LogLevel.error); + } + } catch (e, st) { + expenseTypeReportData.value = null; + logSafe('Error fetching Expense Type Report', + level: LogLevel.error, error: e, stackTrace: st); + } finally { + isExpenseTypeReportLoading.value = false; + } + } + Future fetchProjectProgress() async { final String projectId = projectController.selectedProjectId.value; if (projectId.isEmpty) return; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 581c0e2..6dbfd33 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -20,13 +20,14 @@ import 'package:marco/model/document/document_details_model.dart'; import 'package:marco/model/document/document_version_model.dart'; import 'package:marco/model/attendance/organization_per_project_list_model.dart'; import 'package:marco/model/dashboard/pending_expenses_model.dart'; +import 'package:marco/model/dashboard/expense_type_report_model.dart'; class ApiService { static const bool enableLogs = true; static const Duration extendedTimeout = Duration(seconds: 60); static Future _getToken() async { - final token = LocalStorage.getJwtToken(); + final token = LocalStorage.getJwtToken(); if (token == null) { logSafe("No JWT token found. Logging out..."); @@ -39,7 +40,7 @@ class ApiService { logSafe("Access token is expired. Attempting refresh..."); final refreshed = await AuthService.refreshToken(); if (refreshed) { - return LocalStorage.getJwtToken(); + return LocalStorage.getJwtToken(); } else { logSafe("Token refresh failed. Logging out immediately..."); await LocalStorage.logout(); @@ -56,7 +57,7 @@ class ApiService { "Access token is about to expire in ${difference.inSeconds}s. Refreshing..."); final refreshed = await AuthService.refreshToken(); if (refreshed) { - return LocalStorage.getJwtToken(); + return LocalStorage.getJwtToken(); } else { logSafe("Token refresh failed (near expiry). Logging out..."); await LocalStorage.logout(); @@ -289,6 +290,46 @@ class ApiService { } } + /// Get Expense Type Report + static Future getExpenseTypeReportApi({ + required String projectId, + required DateTime startDate, + required DateTime endDate, + }) async { + const endpoint = ApiEndpoints.getExpenseTypeReport; + logSafe("Fetching Expense Type Report for projectId: $projectId"); + + try { + final response = await _getRequest( + endpoint, + queryParams: { + 'projectId': projectId, + 'startDate': startDate.toIso8601String(), + 'endDate': endDate.toIso8601String(), + }, + ); + + if (response == null) { + logSafe("Expense Type Report request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = + _parseResponseForAllData(response, label: "Expense Type Report"); + + if (jsonResponse != null) { + return ExpenseTypeReportResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getExpenseTypeReportApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + /// Get Pending Expenses static Future getPendingExpensesApi({ required String projectId, diff --git a/lib/helpers/utils/utils.dart b/lib/helpers/utils/utils.dart index a020a48..10ae5c5 100644 --- a/lib/helpers/utils/utils.dart +++ b/lib/helpers/utils/utils.dart @@ -1,3 +1,4 @@ +import 'package:intl/intl.dart'; import 'package:marco/helpers/extensions/date_time_extension.dart'; class Utils { @@ -76,4 +77,12 @@ class Utils { return "${b.toStringAsFixed(2)} Bytes"; } } + + static String formatCurrency(num amount, + {String currency = "INR", String locale = "en_US"}) { + // Use en_US for standard K, M, B formatting + final symbol = NumberFormat.simpleCurrency(name: currency).currencySymbol; + final formatter = NumberFormat.compact(locale: 'en_US'); + return "$symbol${formatter.format(amount)}"; + } } diff --git a/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart new file mode 100644 index 0000000..17a79b3 --- /dev/null +++ b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; +import 'package:marco/controller/dashboard/dashboard_controller.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/dashboard/expense_type_report_model.dart'; + +import 'package:marco/helpers/utils/utils.dart'; + +class ExpenseTypeReportChart extends StatelessWidget { + ExpenseTypeReportChart({Key? key}) : super(key: key); + + final DashboardController _controller = Get.find(); + + static const List _flatColors = [ + Color(0xFFE57373), // Red 300 + Color(0xFF64B5F6), // Blue 300 + Color(0xFF81C784), // Green 300 + Color(0xFFFFB74D), // Orange 300 + Color(0xFFBA68C8), // Purple 300 + Color(0xFFFF8A65), // Deep Orange 300 + Color(0xFF4DB6AC), // Teal 300 + Color(0xFFA1887F), // Brown 400 + Color(0xFFDCE775), // Lime 300 + Color(0xFF9575CD), // Deep Purple 300 + Color(0xFF7986CB), // Indigo 300 + Color(0xFFAED581), // Light Green 300 + Color(0xFFFF7043), // Deep Orange 400 + Color(0xFF4FC3F7), // Light Blue 300 + Color(0xFFFFD54F), // Amber 300 + Color(0xFF90A4AE), // Blue Grey 300 + Color(0xFFE573BB), // Pink 300 + Color(0xFF81D4FA), // Light Blue 200 + Color(0xFFBCAAA4), // Brown 300 + Color(0xFFA5D6A7), // Green 300 + Color(0xFFCE93D8), // Purple 200 + Color(0xFFFF8A65), // Deep Orange 300 (repeat to fill) + Color(0xFF80CBC4), // Teal 200 + Color(0xFFFFF176), // Yellow 300 + Color(0xFF90CAF9), // Blue 200 + Color(0xFFE0E0E0), // Grey 300 + Color(0xFFF48FB1), // Pink 200 + Color(0xFFA1887F), // Brown 400 (repeat) + Color(0xFFB0BEC5), // Blue Grey 200 + Color(0xFF81C784), // Green 300 (repeat) + Color(0xFFFFB74D), // Orange 300 (repeat) + Color(0xFF64B5F6), // Blue 300 (repeat) + ]; + + Color _getSeriesColor(int index) => _flatColors[index % _flatColors.length]; + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + + return Obx(() { + final isLoading = _controller.isExpenseTypeReportLoading.value; + final data = _controller.expenseTypeReportData.value; + + return Container( + decoration: _containerDecoration, + padding: EdgeInsets.symmetric( + vertical: 16, + horizontal: screenWidth < 600 ? 8 : 20, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Header(), + const SizedBox(height: 12), + // 👇 replace Expanded with fixed height + SizedBox( + height: 350, // choose based on your design + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : data == null || data.report.isEmpty + ? const _NoDataMessage() + : _ExpenseChart( + data: data, + getSeriesColor: _getSeriesColor, + ), + ), + ], + ), + ); + }); + } + + BoxDecoration get _containerDecoration => BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.05), + blurRadius: 6, + spreadRadius: 1, + offset: const Offset(0, 2), + ), + ], + ); +} + +class _Header extends StatelessWidget { + const _Header({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium('Expense Type Overview', fontWeight: 700), + const SizedBox(height: 2), + MyText.bodySmall( + 'Project-wise approved, pending, rejected & processed expenses', + color: Colors.grey), + ], + ); + } +} + +// No data +class _NoDataMessage extends StatelessWidget { + const _NoDataMessage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 200, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.info_outline, color: Colors.grey.shade400, size: 48), + const SizedBox(height: 10), + MyText.bodyMedium( + 'No expense data available.', + textAlign: TextAlign.center, + color: Colors.grey.shade500, + ), + ], + ), + ), + ); + } +} + +// Chart +class _ExpenseChart extends StatelessWidget { + const _ExpenseChart({ + Key? key, + required this.data, + required this.getSeriesColor, + }) : super(key: key); + + final ExpenseTypeReportData data; + final Color Function(int index) getSeriesColor; + + @override + Widget build(BuildContext context) { + final List> chartSeries = [ + { + 'name': 'Approved', + 'color': getSeriesColor(0), + 'yValue': (ExpenseTypeReportItem e) => e.totalApprovedAmount, + }, + { + 'name': 'Pending', + 'color': getSeriesColor(1), + 'yValue': (ExpenseTypeReportItem e) => e.totalPendingAmount, + }, + { + 'name': 'Rejected', + 'color': getSeriesColor(2), + 'yValue': (ExpenseTypeReportItem e) => e.totalRejectedAmount, + }, + { + 'name': 'Processed', + 'color': getSeriesColor(3), + 'yValue': (ExpenseTypeReportItem e) => e.totalProcessedAmount, + }, + ]; + + return SfCartesianChart( + tooltipBehavior: TooltipBehavior( + enable: true, + shared: true, + builder: (data, point, series, pointIndex, seriesIndex) { + final ExpenseTypeReportItem item = data; + final value = chartSeries[seriesIndex]['yValue'](item); + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${chartSeries[seriesIndex]['name']}: ${Utils.formatCurrency(value)}', + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ); + }, + ), + legend: Legend(isVisible: true, position: LegendPosition.bottom), + primaryXAxis: CategoryAxis(labelRotation: 45), + primaryYAxis: NumericAxis( + // ✅ Format axis labels with Utils + axisLabelFormatter: (AxisLabelRenderDetails details) { + final num value = details.value; + return ChartAxisLabel( + Utils.formatCurrency(value), const TextStyle(fontSize: 10)); + }, + axisLine: const AxisLine(width: 0), + majorGridLines: const MajorGridLines(width: 0.5), + ), + series: chartSeries.map((seriesInfo) { + return ColumnSeries( + dataSource: data.report, + xValueMapper: (item, _) => item.projectName, + yValueMapper: (item, _) => seriesInfo['yValue'](item), + name: seriesInfo['name'], + color: seriesInfo['color'], + dataLabelSettings: const DataLabelSettings(isVisible: true), + // ✅ Format data labels as well + dataLabelMapper: (item, _) => + Utils.formatCurrency(seriesInfo['yValue'](item)), + ); + }).toList(), + ); + } +} diff --git a/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart b/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart index f3406ea..264ab34 100644 --- a/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart +++ b/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/dashboard/dashboard_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/utils/utils.dart'; class ExpenseByStatusWidget extends StatelessWidget { final DashboardController controller; @@ -15,43 +16,28 @@ class ExpenseByStatusWidget extends StatelessWidget { required String amount, required String count, }) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - ), + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ CircleAvatar( - backgroundColor: Colors.white, - radius: 20, - child: Icon(icon, color: color, size: 22), + backgroundColor: color.withOpacity(0.15), + radius: 22, + child: Icon(icon, color: color, size: 24), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.titleMedium( - title, - fontWeight: 600, - ), + MyText.bodyMedium(title, fontWeight: 600), const SizedBox(height: 2), - MyText.bodyMedium( - amount, - color: Colors.blue, - ), + MyText.titleMedium(amount, color: Colors.blue, fontWeight: 700), ], ), ), - MyText.titleMedium( - count, - color: Colors.blue, - fontWeight: 600, - ), - const Icon(Icons.chevron_right, color: Colors.blue, size: 22), + MyText.titleMedium(count, color: Colors.blue, fontWeight: 700), + const Icon(Icons.chevron_right, color: Colors.blue, size: 24), ], ), ); @@ -72,90 +58,75 @@ class ExpenseByStatusWidget extends StatelessWidget { ); } - return Card( - elevation: 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleLarge( - "Expense - By Status", - fontWeight: 700, - ), - const SizedBox(height: 4), - MyText.bodyMedium( - controller.projectController.selectedProjectName.value, - color: Colors.grey.shade600, - ), - const SizedBox(height: 16), - - // Pending Payment - _buildStatusTile( - icon: Icons.currency_rupee, - color: Colors.blue, - title: "Pending Payment", - amount: "₹${data.processPending.amount.toStringAsFixed(1)}K", - count: data.processPending.count.toString(), - ), - - // Pending Approve - _buildStatusTile( - icon: Icons.check_circle_outline, - color: Colors.orange, - title: "Pending Approve", - amount: "₹${data.approvePending.amount.toStringAsFixed(1)}K", - count: data.approvePending.count.toString(), - ), - - // Pending Review - _buildStatusTile( - icon: Icons.search, - color: Colors.grey.shade700, - title: "Pending Review", - amount: "₹${data.reviewPending.amount.toStringAsFixed(1)}K", - count: data.reviewPending.count.toString(), - ), - - // Draft - _buildStatusTile( - icon: Icons.insert_drive_file_outlined, - color: Colors.cyan, - title: "Draft", - amount: "₹${data.draft.amount.toStringAsFixed(1)}K", - count: data.draft.count.toString(), - ), - - const SizedBox(height: 8), - Divider(color: Colors.grey.shade300), - const SizedBox(height: 8), - - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodyMedium( - "Project Spendings:", - fontWeight: 600, - ), - MyText.bodySmall( - "(All Processed Payments)", - color: Colors.grey.shade600, - ), - ], - ), - MyText.titleLarge( - "₹${(data.totalAmount / 1000).toStringAsFixed(2)}K >", - color: Colors.blue, - fontWeight: 700, - ), - ], - ), - ], - ), + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.05), + blurRadius: 6, + spreadRadius: 1, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium("Expense - By Status", fontWeight: 700), + const SizedBox(height: 16), + _buildStatusTile( + icon: Icons.currency_rupee, + color: Colors.blue, + title: "Pending Payment", + amount: Utils.formatCurrency(data.processPending.totalAmount), + count: data.processPending.count.toString(), + ), + _buildStatusTile( + icon: Icons.check_circle_outline, + color: Colors.orange, + title: "Pending Approve", + amount: Utils.formatCurrency(data.approvePending.totalAmount), + count: data.approvePending.count.toString(), + ), + _buildStatusTile( + icon: Icons.search, + color: Colors.grey.shade700, + title: "Pending Review", + amount: Utils.formatCurrency(data.reviewPending.totalAmount), + count: data.reviewPending.count.toString(), + ), + _buildStatusTile( + icon: Icons.insert_drive_file_outlined, + color: Colors.cyan, + title: "Draft", + amount: Utils.formatCurrency(data.draft.totalAmount), + count: data.draft.count.toString(), + ), + const SizedBox(height: 16), + Divider(color: Colors.grey.shade300), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium("Project Spendings:", fontWeight: 600), + MyText.bodySmall("(All Processed Payments)", + color: Colors.grey.shade600), + ], + ), + MyText.titleLarge( + "${Utils.formatCurrency(data.totalAmount)} >", + color: Colors.blue, + fontWeight: 700, + ) + ], + ), + ], ), ); }); diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index ff91892..cf5103f 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -10,7 +10,9 @@ import 'package:marco/helpers/widgets/my_container.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/dashbaord/attendance_overview_chart.dart'; +import 'package:marco/helpers/widgets/dashbaord/expense_by_status_widget.dart'; import 'package:marco/view/layouts/layout.dart'; +import 'package:marco/helpers/widgets/dashbaord/expense_breakdown_chart.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -51,14 +53,22 @@ class _DashboardScreenState extends State with UIMixin { children: [ _buildDashboardStats(context), MySpacing.height(24), + + // 📊 Attendance Section _buildAttendanceChartSection(), + MySpacing.height(24), + + ExpenseByStatusWidget(controller: dashboardController), + MySpacing.height(24), + + // Expense Type Report Chart + ExpenseTypeReportChart(), ], ), ), ); } - /// Attendance Chart Section Widget _buildAttendanceChartSection() { return GetBuilder( id: 'dashboard_controller', @@ -81,7 +91,6 @@ class _DashboardScreenState extends State with UIMixin { ); } - /// No Project Assigned Message Widget _buildNoProjectMessage() { return Padding( padding: const EdgeInsets.symmetric(vertical: 12), @@ -106,8 +115,6 @@ class _DashboardScreenState extends State with UIMixin { ); } - - /// Dashboard Statistics Section Widget _buildDashboardStats(BuildContext context) { final stats = [ _StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success, @@ -150,7 +157,6 @@ class _DashboardScreenState extends State with UIMixin { ); } - /// Stat Card (Compact + Small) Widget _buildStatCard( _StatItem statItem, bool isProjectSelected, double width) { const double cardHeight = 60; @@ -195,7 +201,6 @@ class _DashboardScreenState extends State with UIMixin { ); } - /// Compact Icon (smaller) Widget _buildStatCardIconCompact(_StatItem statItem, {double size = 12}) { return MyContainer.rounded( paddingAll: 4, @@ -208,7 +213,6 @@ class _DashboardScreenState extends State with UIMixin { ); } - /// Handle Tap void _handleStatCardTap(_StatItem statItem, bool isEnabled) { if (!isEnabled) { Get.defaultDialog( From 62eb7b1d97ceeb8cb95d947a06944925f78c92b7 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 31 Oct 2025 10:55:12 +0530 Subject: [PATCH 03/18] feat: Enhance theme customization with color theme persistence and toggle functionality --- lib/helpers/theme/theme_customizer.dart | 46 ++++++++++++++++++++-- lib/helpers/theme/theme_editor_widget.dart | 16 +++++++- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/lib/helpers/theme/theme_customizer.dart b/lib/helpers/theme/theme_customizer.dart index cf6f16a..7c2eeaf 100644 --- a/lib/helpers/theme/theme_customizer.dart +++ b/lib/helpers/theme/theme_customizer.dart @@ -1,5 +1,5 @@ import 'dart:convert'; - +import 'package:flutter/material.dart'; import 'package:marco/helpers/services/json_decoder.dart'; import 'package:marco/helpers/services/localizations/language.dart'; import 'package:marco/helpers/services/localizations/translator.dart'; @@ -7,8 +7,8 @@ import 'package:marco/helpers/services/navigation_services.dart'; import 'package:marco/helpers/theme/admin_theme.dart'; import 'package:marco/helpers/theme/app_notifier.dart'; import 'package:marco/helpers/theme/app_theme.dart'; -import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; typedef ThemeChangeCallback = void Function( ThemeCustomizer oldVal, ThemeCustomizer newVal); @@ -33,6 +33,8 @@ class ThemeCustomizer { static Future init() async { await initLanguage(); + await _loadColorTheme(); + _notify(); } static initLanguage() async { @@ -40,7 +42,7 @@ class ThemeCustomizer { } String toJSON() { - return jsonEncode({'theme': theme.name}); + return jsonEncode({'theme': theme.name, 'colorTheme': colorTheme.name}); } static ThemeCustomizer fromJSON(String? json) { @@ -49,6 +51,8 @@ class ThemeCustomizer { JSONDecoder decoder = JSONDecoder(json); instance.theme = decoder.getEnum('theme', ThemeMode.values, ThemeMode.light); + instance.colorTheme = decoder.getEnum( + 'colorTheme', ColorThemeType.values, ColorThemeType.red); } return instance; } @@ -117,12 +121,46 @@ class ThemeCustomizer { tc.topBarTheme = topBarTheme; tc.rightBarOpen = rightBarOpen; tc.leftBarCondensed = leftBarCondensed; + tc.colorTheme = colorTheme; tc.currentLanguage = currentLanguage.clone(); return tc; } @override String toString() { - return 'ThemeCustomizer{theme: $theme}'; + return 'ThemeCustomizer{theme: $theme, colorTheme: $colorTheme}'; + } + + // --------------------------------------------------------------------------- + // 🟢 Color Theme Persistence + // --------------------------------------------------------------------------- + + static const _colorThemeKey = 'color_theme_type'; + + /// Save selected color theme + static Future saveColorTheme(ColorThemeType type) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_colorThemeKey, type.name); + instance.colorTheme = type; + _notify(); + } + + /// Load saved color theme (called at startup) + static Future _loadColorTheme() async { + final prefs = await SharedPreferences.getInstance(); + final savedType = prefs.getString(_colorThemeKey); + if (savedType != null) { + instance.colorTheme = ColorThemeType.values.firstWhere( + (e) => e.name == savedType, + orElse: () => ColorThemeType.red, + ); + } + } + + /// Change color theme & persist + static Future changeColorTheme(ColorThemeType type) async { + oldInstance = instance.clone(); + instance.colorTheme = type; + await saveColorTheme(type); } } diff --git a/lib/helpers/theme/theme_editor_widget.dart b/lib/helpers/theme/theme_editor_widget.dart index 8e6b2b3..31466da 100644 --- a/lib/helpers/theme/theme_editor_widget.dart +++ b/lib/helpers/theme/theme_editor_widget.dart @@ -4,6 +4,7 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/wave_background.dart'; import 'package:marco/helpers/theme/admin_theme.dart'; import 'package:marco/helpers/theme/theme_customizer.dart'; +import 'package:flutter_lucide/flutter_lucide.dart'; class ThemeOption { final String label; @@ -105,7 +106,20 @@ class _ThemeEditorWidgetState extends State { ], ), const SizedBox(height: 12), - + InkWell( + onTap: () { + ThemeCustomizer.setTheme( + ThemeCustomizer.instance.theme == ThemeMode.dark + ? ThemeMode.light + : ThemeMode.dark); + }, + child: Icon( + ThemeCustomizer.instance.theme == ThemeMode.dark + ? LucideIcons.sun + : LucideIcons.moon, + size: 18, + ), + ), // Theme cards wrapped in reactive Obx widget Center( child: Obx( From bc9fc4d6f1f6891cc3c4167badf516df7bfab874 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 31 Oct 2025 14:58:15 +0530 Subject: [PATCH 04/18] Refactor UserDocumentsPage: Enhance UI with search bar, filter chips, and document cards; implement infinite scrolling and FAB for document upload; improve state management and permission checks. --- .../attendance_screen_controller.dart | 11 +- .../document/user_document_controller.dart | 170 +- .../expense/add_expense_controller.dart | 17 +- lib/helpers/theme/theme_editor_widget.dart | 15 - .../widgets/time_stamp_image_helper.dart | 97 ++ .../expense/add_expense_bottom_sheet.dart | 748 ++++----- lib/view/document/user_document_screen.dart | 1452 +++++++++++------ 7 files changed, 1473 insertions(+), 1037 deletions(-) create mode 100644 lib/helpers/widgets/time_stamp_image_helper.dart diff --git a/lib/controller/attendance/attendance_screen_controller.dart b/lib/controller/attendance/attendance_screen_controller.dart index 501a94f..8e45d48 100644 --- a/lib/controller/attendance/attendance_screen_controller.dart +++ b/lib/controller/attendance/attendance_screen_controller.dart @@ -8,6 +8,7 @@ import 'package:intl/intl.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/widgets/my_image_compressor.dart'; +import 'package:marco/helpers/widgets/time_stamp_image_helper.dart'; import 'package:marco/model/attendance/attendance_model.dart'; import 'package:marco/model/project_model.dart'; @@ -32,7 +33,7 @@ class AttendanceController extends GetxController { final isLoadingOrganizations = false.obs; // States -String selectedTab = 'todaysAttendance'; + String selectedTab = 'todaysAttendance'; DateTime? startDateAttendance; DateTime? endDateAttendance; @@ -104,7 +105,7 @@ String selectedTab = 'todaysAttendance'; .toList(); } -// Computed filtered regularization logs + // Computed filtered regularization logs List get filteredRegularizationLogs { if (searchQuery.value.isEmpty) return regularizationLogs; return regularizationLogs @@ -174,8 +175,12 @@ String selectedTab = 'todaysAttendance'; return false; } + // 🔹 Add timestamp to the image + final timestampedFile = await TimestampImageHelper.addTimestamp( + imageFile: File(image.path)); + final compressedBytes = - await compressImageToUnder100KB(File(image.path)); + await compressImageToUnder100KB(timestampedFile); if (compressedBytes == null) { logSafe("Image compression failed.", level: LogLevel.error); return false; diff --git a/lib/controller/document/user_document_controller.dart b/lib/controller/document/user_document_controller.dart index f5f070e..fb3f734 100644 --- a/lib/controller/document/user_document_controller.dart +++ b/lib/controller/document/user_document_controller.dart @@ -5,54 +5,63 @@ import 'package:marco/model/document/document_filter_model.dart'; import 'package:marco/model/document/documents_list_model.dart'; class DocumentController extends GetxController { - // ------------------ Observables --------------------- - var isLoading = false.obs; - var documents = [].obs; - var filters = Rxn(); + // ==================== Observables ==================== + final isLoading = false.obs; + final documents = [].obs; + final filters = Rxn(); - // ✅ Selected filters (multi-select support) - var selectedUploadedBy = [].obs; - var selectedCategory = [].obs; - var selectedType = [].obs; - var selectedTag = [].obs; + // Selected filters (multi-select) + final selectedUploadedBy = [].obs; + final selectedCategory = [].obs; + final selectedType = [].obs; + final selectedTag = [].obs; - // Pagination state - var pageNumber = 1.obs; - final int pageSize = 20; - var hasMore = true.obs; + // Pagination + final pageNumber = 1.obs; + final pageSize = 20; + final hasMore = true.obs; - // Error message - var errorMessage = "".obs; + // Error handling + final errorMessage = ''.obs; - // NEW: show inactive toggle - var showInactive = false.obs; + // Preferences + final showInactive = false.obs; - // NEW: search - var searchQuery = ''.obs; - var searchController = TextEditingController(); -// New filter fields - var isUploadedAt = true.obs; - var isVerified = RxnBool(); - var startDate = Rxn(); - var endDate = Rxn(); + // Search + final searchQuery = ''.obs; + final searchController = TextEditingController(); - // ------------------ API Calls ----------------------- + // Additional filters + final isUploadedAt = true.obs; + final isVerified = RxnBool(); + final startDate = Rxn(); + final endDate = Rxn(); - /// Fetch Document Filters for an Entity + // ==================== Lifecycle ==================== + + @override + void onClose() { + // Don't dispose searchController here - it's managed by the page + super.onClose(); + } + + // ==================== API Methods ==================== + + /// Fetch document filters for entity Future fetchFilters(String entityTypeId) async { try { - isLoading.value = true; final response = await ApiService.getDocumentFilters(entityTypeId); if (response != null && response.success) { filters.value = response.data; } else { - errorMessage.value = response?.message ?? "Failed to fetch filters"; + errorMessage.value = response?.message ?? 'Failed to fetch filters'; + _showError('Failed to load filters'); } } catch (e) { - errorMessage.value = "Error fetching filters: $e"; - } finally { - isLoading.value = false; + errorMessage.value = 'Error fetching filters: $e'; + _showError('Error loading filters'); + debugPrint('❌ Error fetching filters: $e'); } } @@ -65,11 +74,14 @@ class DocumentController extends GetxController { }) async { try { isLoading.value = true; - final success = - await ApiService.deleteDocumentApi(id: id, isActive: isActive); + + final success = await ApiService.deleteDocumentApi( + id: id, + isActive: isActive, + ); if (success) { - // 🔥 Always fetch fresh list after toggle + // Refresh list after state change await fetchDocuments( entityTypeId: entityTypeId, entityId: entityId, @@ -77,41 +89,19 @@ class DocumentController extends GetxController { ); return true; } else { - errorMessage.value = "Failed to update document state"; + errorMessage.value = 'Failed to update document state'; return false; } } catch (e) { - errorMessage.value = "Error updating document: $e"; + errorMessage.value = 'Error updating document: $e'; + debugPrint('❌ Error toggling document state: $e'); return false; } finally { isLoading.value = false; } } - /// Permanently delete a document (or deactivate depending on API) - Future deleteDocument(String id, {bool isActive = false}) async { - try { - isLoading.value = true; - final success = - await ApiService.deleteDocumentApi(id: id, isActive: isActive); - - if (success) { - // remove from local list immediately for better UX - documents.removeWhere((doc) => doc.id == id); - return true; - } else { - errorMessage.value = "Failed to delete document"; - return false; - } - } catch (e) { - errorMessage.value = "Error deleting document: $e"; - return false; - } finally { - isLoading.value = false; - } - } - - /// Fetch Documents for an entity + /// Fetch documents for entity with pagination Future fetchDocuments({ required String entityTypeId, required String entityId, @@ -120,20 +110,25 @@ class DocumentController extends GetxController { bool reset = false, }) async { try { + // Reset pagination if needed if (reset) { pageNumber.value = 1; documents.clear(); hasMore.value = true; } - if (!hasMore.value) return; + // Don't fetch if no more data + if (!hasMore.value && !reset) return; + + // Prevent duplicate requests + if (isLoading.value) return; isLoading.value = true; final response = await ApiService.getDocumentListApi( entityTypeId: entityTypeId, entityId: entityId, - filter: filter ?? "", + filter: filter ?? '', searchString: searchString ?? searchQuery.value, pageNumber: pageNumber.value, pageSize: pageSize, @@ -147,19 +142,27 @@ class DocumentController extends GetxController { } else { hasMore.value = false; } + errorMessage.value = ''; } else { - errorMessage.value = response?.message ?? "Failed to fetch documents"; + errorMessage.value = response?.message ?? 'Failed to fetch documents'; + if (documents.isEmpty) { + _showError('Failed to load documents'); + } } } catch (e) { - errorMessage.value = "Error fetching documents: $e"; + errorMessage.value = 'Error fetching documents: $e'; + if (documents.isEmpty) { + _showError('Error loading documents'); + } + debugPrint('❌ Error fetching documents: $e'); } finally { isLoading.value = false; } } - // ------------------ Helpers ----------------------- + // ==================== Helper Methods ==================== - /// Clear selected filters + /// Clear all selected filters void clearFilters() { selectedUploadedBy.clear(); selectedCategory.clear(); @@ -171,11 +174,40 @@ class DocumentController extends GetxController { endDate.value = null; } - /// Check if any filters are active (for red dot indicator) + /// Check if any filters are active bool hasActiveFilters() { return selectedUploadedBy.isNotEmpty || selectedCategory.isNotEmpty || selectedType.isNotEmpty || - selectedTag.isNotEmpty; + selectedTag.isNotEmpty || + startDate.value != null || + endDate.value != null || + isVerified.value != null; + } + + /// Show error message + void _showError(String message) { + Get.snackbar( + 'Error', + message, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red.shade100, + colorText: Colors.red.shade900, + margin: const EdgeInsets.all(16), + borderRadius: 8, + duration: const Duration(seconds: 3), + ); + } + + /// Reset controller state + void reset() { + documents.clear(); + clearFilters(); + searchController.clear(); + searchQuery.value = ''; + pageNumber.value = 1; + hasMore.value = true; + showInactive.value = false; + errorMessage.value = ''; } } diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 139c2d9..75e0500 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -17,6 +17,7 @@ import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/helpers/widgets/time_stamp_image_helper.dart'; class AddExpenseController extends GetxController { // --- Text Controllers --- @@ -65,6 +66,7 @@ class AddExpenseController extends GetxController { final paymentModes = [].obs; final allEmployees = [].obs; final employeeSearchResults = [].obs; + final isProcessingAttachment = false.obs; String? editingExpenseId; @@ -252,9 +254,22 @@ class AddExpenseController extends GetxController { Future pickFromCamera() async { try { final pickedFile = await _picker.pickImage(source: ImageSource.camera); - if (pickedFile != null) attachments.add(File(pickedFile.path)); + if (pickedFile != null) { + isProcessingAttachment.value = true; // start loading + File imageFile = File(pickedFile.path); + + // Add timestamp to the captured image + File timestampedFile = await TimestampImageHelper.addTimestamp( + imageFile: imageFile, + ); + + attachments.add(timestampedFile); + attachments.refresh(); // refresh UI + } } catch (e) { _errorSnackbar("Camera error: $e"); + } finally { + isProcessingAttachment.value = false; // stop loading } } diff --git a/lib/helpers/theme/theme_editor_widget.dart b/lib/helpers/theme/theme_editor_widget.dart index 31466da..05b2561 100644 --- a/lib/helpers/theme/theme_editor_widget.dart +++ b/lib/helpers/theme/theme_editor_widget.dart @@ -4,7 +4,6 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/wave_background.dart'; import 'package:marco/helpers/theme/admin_theme.dart'; import 'package:marco/helpers/theme/theme_customizer.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; class ThemeOption { final String label; @@ -106,20 +105,6 @@ class _ThemeEditorWidgetState extends State { ], ), const SizedBox(height: 12), - InkWell( - onTap: () { - ThemeCustomizer.setTheme( - ThemeCustomizer.instance.theme == ThemeMode.dark - ? ThemeMode.light - : ThemeMode.dark); - }, - child: Icon( - ThemeCustomizer.instance.theme == ThemeMode.dark - ? LucideIcons.sun - : LucideIcons.moon, - size: 18, - ), - ), // Theme cards wrapped in reactive Obx widget Center( child: Obx( diff --git a/lib/helpers/widgets/time_stamp_image_helper.dart b/lib/helpers/widgets/time_stamp_image_helper.dart new file mode 100644 index 0000000..954a205 --- /dev/null +++ b/lib/helpers/widgets/time_stamp_image_helper.dart @@ -0,0 +1,97 @@ +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/helpers/services/app_logger.dart'; + +class TimestampImageHelper { + /// Adds a timestamp to an image file and returns a new File + static Future addTimestamp({ + required File imageFile, + Color textColor = Colors.white, + double fontSize = 60, + Color backgroundColor = Colors.black54, + double padding = 40, + double bottomPadding = 60, + }) async { + try { + // Read the image file + final bytes = await imageFile.readAsBytes(); + final originalImage = await decodeImageFromList(bytes); + + // Create a canvas + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + // Draw original image + final paint = Paint(); + canvas.drawImage(originalImage, Offset.zero, paint); + + // Timestamp text + final now = DateTime.now(); + final timestamp = DateFormat('dd MMM yyyy hh:mm:ss a').format(now); + + final textStyle = ui.TextStyle( + color: textColor, + fontSize: fontSize, + fontWeight: FontWeight.bold, + shadows: [ + const ui.Shadow( + color: Colors.black, + offset: Offset(3, 3), + blurRadius: 6, + ), + ], + ); + + final paragraphStyle = ui.ParagraphStyle(textAlign: TextAlign.left); + final paragraphBuilder = ui.ParagraphBuilder(paragraphStyle) + ..pushStyle(textStyle) + ..addText(timestamp); + + final paragraph = paragraphBuilder.build(); + paragraph.layout(const ui.ParagraphConstraints(width: double.infinity)); + + final textWidth = paragraph.maxIntrinsicWidth; + final yPosition = originalImage.height - paragraph.height - bottomPadding; + final xPosition = (originalImage.width - textWidth) / 2; + + // Draw background + final backgroundPaint = Paint() + ..color = backgroundColor + ..style = PaintingStyle.fill; + + final backgroundRect = Rect.fromLTWH( + xPosition - padding, + yPosition - 15, + textWidth + padding * 2, + paragraph.height + 30, + ); + + canvas.drawRRect( + RRect.fromRectAndRadius(backgroundRect, const Radius.circular(8)), + backgroundPaint, + ); + + // Draw timestamp text + canvas.drawParagraph(paragraph, Offset(xPosition, yPosition)); + + // Convert canvas to image + final picture = recorder.endRecording(); + final img = await picture.toImage(originalImage.width, originalImage.height); + + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + final buffer = byteData!.buffer.asUint8List(); + + // Save to temporary file + final tempDir = await Directory.systemTemp.createTemp(); + final timestampedFile = File('${tempDir.path}/timestamped_${DateTime.now().millisecondsSinceEpoch}.png'); + await timestampedFile.writeAsBytes(buffer); + + return timestampedFile; + } catch (e, stacktrace) { + logSafe("Error adding timestamp to image", level: LogLevel.error, error: e, stackTrace: stacktrace); + return imageFile; // fallback + } + } +} diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 8592fe4..7d0ef4b 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:intl/intl.dart'; + import 'package:marco/controller/expense/add_expense_controller.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/employee_selector_bottom_sheet.dart'; @@ -11,7 +12,6 @@ import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart'; -import 'package:marco/view/project/create_project_bottom_sheet.dart'; /// Show bottom sheet wrapper Future showAddExpenseBottomSheet({ @@ -19,7 +19,10 @@ Future showAddExpenseBottomSheet({ Map? existingExpense, }) { return Get.bottomSheet( - _AddExpenseBottomSheet(isEdit: isEdit, existingExpense: existingExpense), + _AddExpenseBottomSheet( + isEdit: isEdit, + existingExpense: existingExpense, + ), isScrollControlled: true, ); } @@ -38,7 +41,8 @@ class _AddExpenseBottomSheet extends StatefulWidget { State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState(); } -class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { +class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> + with UIMixin { final AddExpenseController controller = Get.put(AddExpenseController()); final _formKey = GlobalKey(); @@ -46,6 +50,95 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { final GlobalKey _expenseTypeDropdownKey = GlobalKey(); final GlobalKey _paymentModeDropdownKey = GlobalKey(); + /// Show employee list + Future _showEmployeeList() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => ReusableEmployeeSelectorBottomSheet( + searchController: controller.employeeSearchController, + searchResults: controller.employeeSearchResults, + isSearching: controller.isSearchingEmployees, + onSearch: controller.searchEmployees, + onSelect: (emp) => controller.selectedPaidBy.value = emp, + ), + ); + + controller.employeeSearchController.clear(); + controller.employeeSearchResults.clear(); + } + + /// Generic option list + Future _showOptionList( + List options, + String Function(T) getLabel, + ValueChanged onSelected, + GlobalKey triggerKey, + ) async { + final RenderBox button = + triggerKey.currentContext!.findRenderObject() as RenderBox; + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + final position = button.localToGlobal(Offset.zero, ancestor: overlay); + + final selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + position.dx, + position.dy + button.size.height, + overlay.size.width - position.dx - button.size.width, + 0, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + items: options + .map((opt) => PopupMenuItem( + value: opt, + child: Text(getLabel(opt)), + )) + .toList(), + ); + + if (selected != null) onSelected(selected); + } + + /// Validate required selections + bool _validateSelections() { + if (controller.selectedProject.value.isEmpty) { + _showError("Please select a project"); + return false; + } + if (controller.selectedExpenseType.value == null) { + _showError("Please select an expense type"); + return false; + } + if (controller.selectedPaymentMode.value == null) { + _showError("Please select a payment mode"); + return false; + } + if (controller.selectedPaidBy.value == null) { + _showError("Please select a person who paid"); + return false; + } + if (controller.attachments.isEmpty && + controller.existingAttachments.isEmpty) { + _showError("Please attach at least one document"); + return false; + } + return true; + } + + void _showError(String msg) { + showAppSnackbar( + title: "Error", + message: msg, + type: SnackbarType.error, + ); + } + @override Widget build(BuildContext context) { return Obx( @@ -55,44 +148,140 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { title: widget.isEdit ? "Edit Expense" : "Add Expense", isSubmitting: controller.isSubmitting.value, onCancel: Get.back, - onSubmit: _handleSubmit, + onSubmit: () { + if (_formKey.currentState!.validate() && _validateSelections()) { + controller.submitOrUpdateExpense(); + } else { + _showError("Please fill all required fields correctly"); + } + }, child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCreateProjectButton(), - _buildProjectDropdown(), + _buildDropdownField( + icon: Icons.work_outline, + title: "Project", + requiredField: true, + value: controller.selectedProject.value.isEmpty + ? "Select Project" + : controller.selectedProject.value, + onTap: () => _showOptionList( + controller.globalProjects.toList(), + (p) => p, + (val) => controller.selectedProject.value = val, + _projectDropdownKey, + ), + dropdownKey: _projectDropdownKey, + ), _gap(), - _buildExpenseTypeDropdown(), + + _buildDropdownField( + icon: Icons.category_outlined, + title: "Expense Type", + requiredField: true, + value: controller.selectedExpenseType.value?.name ?? + "Select Expense Type", + onTap: () => _showOptionList( + controller.expenseTypes.toList(), + (e) => e.name, + (val) => controller.selectedExpenseType.value = val, + _expenseTypeDropdownKey, + ), + dropdownKey: _expenseTypeDropdownKey, + ), + + // Persons if required if (controller.selectedExpenseType.value?.noOfPersonsRequired == true) ...[ _gap(), - _buildNumberField( + _buildTextFieldSection( icon: Icons.people_outline, title: "No. of Persons", controller: controller.noOfPersonsController, hint: "Enter No. of Persons", + keyboardType: TextInputType.number, validator: Validators.requiredField, ), ], _gap(), - _buildPaymentModeDropdown(), + + _buildTextFieldSection( + icon: Icons.confirmation_number_outlined, + title: "GST No.", + controller: controller.gstController, + hint: "Enter GST No.", + ), _gap(), + + _buildDropdownField( + icon: Icons.payment, + title: "Payment Mode", + requiredField: true, + value: controller.selectedPaymentMode.value?.name ?? + "Select Payment Mode", + onTap: () => _showOptionList( + controller.paymentModes.toList(), + (p) => p.name, + (val) => controller.selectedPaymentMode.value = val, + _paymentModeDropdownKey, + ), + dropdownKey: _paymentModeDropdownKey, + ), + _gap(), + _buildPaidBySection(), _gap(), - _buildAmountField(), + + _buildTextFieldSection( + icon: Icons.currency_rupee, + title: "Amount", + controller: controller.amountController, + hint: "Enter Amount", + keyboardType: TextInputType.number, + validator: (v) => Validators.isNumeric(v ?? "") + ? null + : "Enter valid amount", + ), _gap(), - _buildSupplierField(), + + _buildTextFieldSection( + icon: Icons.store_mall_directory_outlined, + title: "Supplier Name/Transporter Name/Other", + controller: controller.supplierController, + hint: "Enter Supplier Name/Transporter Name or Other", + validator: Validators.nameValidator, + ), _gap(), + + _buildTextFieldSection( + icon: Icons.confirmation_number_outlined, + title: "Transaction ID", + controller: controller.transactionIdController, + hint: "Enter Transaction ID", + validator: (v) => (v != null && v.isNotEmpty) + ? Validators.transactionIdValidator(v) + : null, + ), + _gap(), + _buildTransactionDateField(), _gap(), - _buildTransactionIdField(), - _gap(), + _buildLocationField(), _gap(), + _buildAttachmentsSection(), _gap(), - _buildDescriptionField(), + + _buildTextFieldSection( + icon: Icons.description_outlined, + title: "Description", + controller: controller.descriptionController, + hint: "Enter Description", + maxLines: 3, + validator: Validators.requiredField, + ), ], ), ), @@ -101,137 +290,101 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ); } - /// 🟦 UI SECTION BUILDERS + Widget _gap([double h = 16]) => MySpacing.height(h); - Widget _buildCreateProjectButton() { - return Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - onPressed: () async { - await Get.bottomSheet(const CreateProjectBottomSheet(), - isScrollControlled: true); - await controller.fetchGlobalProjects(); - }, - icon: const Icon(Icons.add, color: Colors.blue), - label: const Text( - "Create Project", - style: TextStyle(color: Colors.blue, fontWeight: FontWeight.w600), + Widget _buildDropdownField({ + required IconData icon, + required String title, + required bool requiredField, + required String value, + required VoidCallback onTap, + required GlobalKey dropdownKey, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle(icon: icon, title: title, requiredField: requiredField), + MySpacing.height(6), + DropdownTile(key: dropdownKey, title: value, onTap: onTap), + ], + ); + } + + Widget _buildTextFieldSection({ + required IconData icon, + required String title, + required TextEditingController controller, + String? hint, + TextInputType? keyboardType, + FormFieldValidator? validator, + int maxLines = 1, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle( + icon: icon, title: title, requiredField: validator != null), + MySpacing.height(6), + CustomTextField( + controller: controller, + hint: hint ?? "", + keyboardType: keyboardType ?? TextInputType.text, + validator: validator, + maxLines: maxLines, ), - ), - ); - } - - Widget _buildProjectDropdown() { - return _buildDropdownField( - icon: Icons.work_outline, - title: "Project", - requiredField: true, - value: controller.selectedProject.value.isEmpty - ? "Select Project" - : controller.selectedProject.value, - onTap: _showProjectSelector, - dropdownKey: _projectDropdownKey, - ); - } - - Widget _buildExpenseTypeDropdown() { - return _buildDropdownField( - icon: Icons.category_outlined, - title: "Expense Type", - requiredField: true, - value: - controller.selectedExpenseType.value?.name ?? "Select Expense Type", - onTap: () => _showOptionList( - controller.expenseTypes.toList(), - (e) => e.name, - (val) => controller.selectedExpenseType.value = val, - _expenseTypeDropdownKey, - ), - dropdownKey: _expenseTypeDropdownKey, - ); - } - - Widget _buildPaymentModeDropdown() { - return _buildDropdownField( - icon: Icons.payment, - title: "Payment Mode", - requiredField: true, - value: - controller.selectedPaymentMode.value?.name ?? "Select Payment Mode", - onTap: () => _showOptionList( - controller.paymentModes.toList(), - (p) => p.name, - (val) => controller.selectedPaymentMode.value = val, - _paymentModeDropdownKey, - ), - dropdownKey: _paymentModeDropdownKey, + ], ); } Widget _buildPaidBySection() { - return _buildTileSelector( - icon: Icons.person_outline, - title: "Paid By", - required: true, - displayText: controller.selectedPaidBy.value == null - ? "Select Paid By" - : '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', - onTap: _showEmployeeList, - ); - } - - Widget _buildAmountField() => _buildNumberField( - icon: Icons.currency_rupee, - title: "Amount", - controller: controller.amountController, - hint: "Enter Amount", - validator: (v) => - Validators.isNumeric(v ?? "") ? null : "Enter valid amount", - ); - - Widget _buildSupplierField() => _buildTextField( - icon: Icons.store_mall_directory_outlined, - title: "Supplier Name/Transporter Name/Other", - controller: controller.supplierController, - hint: "Enter Supplier Name/Transporter Name or Other", - validator: Validators.nameValidator, - ); - - Widget _buildTransactionIdField() { - final paymentMode = - controller.selectedPaymentMode.value?.name.toLowerCase() ?? ''; - final isRequired = paymentMode.isNotEmpty && - paymentMode != 'cash' && - paymentMode != 'cheque'; - - return _buildTextField( - icon: Icons.confirmation_number_outlined, - title: "Transaction ID", - controller: controller.transactionIdController, - hint: "Enter Transaction ID", - validator: (v) { - if (isRequired) { - if (v == null || v.isEmpty) - return "Transaction ID is required for this payment mode"; - return Validators.transactionIdValidator(v); - } - return null; - }, - requiredField: isRequired, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionTitle( + icon: Icons.person_outline, title: "Paid By", requiredField: true), + MySpacing.height(6), + GestureDetector( + onTap: _showEmployeeList, + child: TileContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + controller.selectedPaidBy.value == null + ? "Select Paid By" + : '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', + style: const TextStyle(fontSize: 14), + ), + const Icon(Icons.arrow_drop_down, size: 22), + ], + ), + ), + ), + ], ); } Widget _buildTransactionDateField() { - return Obx(() => _buildTileSelector( - icon: Icons.calendar_today, - title: "Transaction Date", - required: true, - displayText: controller.selectedTransactionDate.value == null - ? "Select Transaction Date" - : DateFormat('dd MMM yyyy') - .format(controller.selectedTransactionDate.value!), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionTitle( + icon: Icons.calendar_today, + title: "Transaction Date", + requiredField: true), + MySpacing.height(6), + GestureDetector( onTap: () => controller.pickTransactionDate(context), - )); + child: AbsorbPointer( + child: CustomTextField( + controller: controller.transactionDateController, + hint: "Select Transaction Date", + validator: Validators.requiredField, + ), + ), + ), + ], + ); } Widget _buildLocationField() { @@ -278,281 +431,62 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { title: "Attachments", requiredField: true, ), - MySpacing.height(6), - AttachmentsSection( - attachments: controller.attachments, - existingAttachments: controller.existingAttachments, - onRemoveNew: controller.removeAttachment, - onRemoveExisting: _confirmRemoveAttachment, - onAdd: controller.pickAttachments, - ), - ], - ); - } - - Widget _buildDescriptionField() => _buildTextField( - icon: Icons.description_outlined, - title: "Description", - controller: controller.descriptionController, - hint: "Enter Description", - maxLines: 3, - validator: Validators.requiredField, - ); - - /// 🟩 COMMON HELPERS - - Widget _gap([double h = 16]) => MySpacing.height(h); - - Widget _buildDropdownField({ - required IconData icon, - required String title, - required bool requiredField, - required String value, - required VoidCallback onTap, - required GlobalKey dropdownKey, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionTitle(icon: icon, title: title, requiredField: requiredField), - MySpacing.height(6), - DropdownTile(key: dropdownKey, title: value, onTap: onTap), - ], - ); - } - - Widget _buildTextField({ - required IconData icon, - required String title, - required TextEditingController controller, - String? hint, - FormFieldValidator? validator, - bool requiredField = true, - int maxLines = 1, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionTitle(icon: icon, title: title, requiredField: requiredField), - MySpacing.height(6), - CustomTextField( - controller: controller, - hint: hint ?? "", - validator: validator, - maxLines: maxLines, - ), - ], - ); - } - - Widget _buildNumberField({ - required IconData icon, - required String title, - required TextEditingController controller, - String? hint, - FormFieldValidator? validator, - }) { - return _buildTextField( - icon: icon, - title: title, - controller: controller, - hint: hint, - validator: validator, - ); - } - - Widget _buildTileSelector({ - required IconData icon, - required String title, - required String displayText, - required VoidCallback onTap, - bool required = false, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionTitle(icon: icon, title: title, requiredField: required), - MySpacing.height(6), - GestureDetector( - onTap: onTap, - child: TileContainer( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(displayText, style: const TextStyle(fontSize: 14)), - const Icon(Icons.arrow_drop_down, size: 22), - ], - ), - ), - ), - ], - ); - } - - /// 🧰 LOGIC HELPERS - - Future _showProjectSelector() async { - final sortedProjects = controller.globalProjects.toList() - ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); - - const specialOption = 'Create New Project'; - final displayList = [...sortedProjects, specialOption]; - - final selected = await showMenu( - context: context, - position: _getPopupMenuPosition(_projectDropdownKey), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - items: displayList.map((opt) { - final isSpecial = opt == specialOption; - return PopupMenuItem( - value: opt, - child: isSpecial - ? Row( - children: const [ - Icon(Icons.add, color: Colors.blue), - SizedBox(width: 8), - Text( - specialOption, - style: TextStyle( - fontWeight: FontWeight.w600, - color: Colors.blue, - ), - ), - ], - ) - : Text( - opt, - style: const TextStyle( - fontWeight: FontWeight.normal, - color: Colors.black, + MySpacing.height(10), + Obx(() { + if (controller.isProcessingAttachment.value) { + return Center( + child: Column( + children: [ + CircularProgressIndicator( + color: contentTheme.primary, ), - ), - ); - }).toList(), - ); - - if (selected == null) return; - if (selected == specialOption) { - controller.selectedProject.value = specialOption; - await Get.bottomSheet(const CreateProjectBottomSheet(), - isScrollControlled: true); - await controller.fetchGlobalProjects(); - controller.selectedProject.value = ""; - } else { - controller.selectedProject.value = selected; - } - } - - Future _showEmployeeList() async { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (_) => ReusableEmployeeSelectorBottomSheet( - searchController: controller.employeeSearchController, - searchResults: controller.employeeSearchResults, - isSearching: controller.isSearchingEmployees, - onSearch: controller.searchEmployees, - onSelect: (emp) => controller.selectedPaidBy.value = emp, - ), - ); - controller.employeeSearchController.clear(); - controller.employeeSearchResults.clear(); - } - - Future _confirmRemoveAttachment(item) async { - await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => ConfirmDialog( - title: "Remove Attachment", - message: "Are you sure you want to remove this attachment?", - confirmText: "Remove", - icon: Icons.delete, - confirmColor: Colors.redAccent, - onConfirm: () async { - final index = controller.existingAttachments.indexOf(item); - if (index != -1) { - controller.existingAttachments[index]['isActive'] = false; - controller.existingAttachments.refresh(); + const SizedBox(height: 8), + Text( + "Processing image, please wait...", + style: TextStyle( + fontSize: 14, + color: contentTheme.primary, + ), + ), + ], + ), + ); } - showAppSnackbar( - title: 'Removed', - message: 'Attachment has been removed.', - type: SnackbarType.success, + + return AttachmentsSection( + attachments: controller.attachments, + existingAttachments: controller.existingAttachments, + onRemoveNew: controller.removeAttachment, + onRemoveExisting: (item) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => ConfirmDialog( + title: "Remove Attachment", + message: "Are you sure you want to remove this attachment?", + confirmText: "Remove", + icon: Icons.delete, + confirmColor: Colors.redAccent, + onConfirm: () async { + final index = controller.existingAttachments.indexOf(item); + if (index != -1) { + controller.existingAttachments[index]['isActive'] = false; + controller.existingAttachments.refresh(); + } + showAppSnackbar( + title: 'Removed', + message: 'Attachment has been removed.', + type: SnackbarType.success, + ); + Navigator.pop(context); + }, + ), + ); + }, + onAdd: controller.pickAttachments, ); - }, - ), + }), + ], ); } - - Future _showOptionList( - List options, - String Function(T) getLabel, - ValueChanged onSelected, - GlobalKey triggerKey, - ) async { - final selected = await showMenu( - context: context, - position: _getPopupMenuPosition(triggerKey), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - items: options - .map((opt) => PopupMenuItem( - value: opt, - child: Text(getLabel(opt)), - )) - .toList(), - ); - if (selected != null) onSelected(selected); - } - - RelativeRect _getPopupMenuPosition(GlobalKey key) { - final RenderBox button = - key.currentContext!.findRenderObject() as RenderBox; - final RenderBox overlay = - Overlay.of(context).context.findRenderObject() as RenderBox; - final position = button.localToGlobal(Offset.zero, ancestor: overlay); - return RelativeRect.fromLTRB( - position.dx, - position.dy + button.size.height, - overlay.size.width - position.dx - button.size.width, - 0, - ); - } - - bool _validateSelections() { - if (controller.selectedProject.value.isEmpty) { - return _error("Please select a project"); - } - if (controller.selectedExpenseType.value == null) { - return _error("Please select an expense type"); - } - if (controller.selectedPaymentMode.value == null) { - return _error("Please select a payment mode"); - } - if (controller.selectedPaidBy.value == null) { - return _error("Please select a person who paid"); - } - if (controller.attachments.isEmpty && - controller.existingAttachments.isEmpty) { - return _error("Please attach at least one document"); - } - return true; - } - - bool _error(String msg) { - showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error); - return false; - } - - void _handleSubmit() { - if (_formKey.currentState!.validate() && _validateSelections()) { - controller.submitOrUpdateExpense(); - } else { - _error("Please fill all required fields correctly"); - } - } } diff --git a/lib/view/document/user_document_screen.dart b/lib/view/document/user_document_screen.dart index 70fbce3..ced6530 100644 --- a/lib/view/document/user_document_screen.dart +++ b/lib/view/document/user_document_screen.dart @@ -1,24 +1,24 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:get/get.dart'; -import 'package:marco/controller/document/user_document_controller.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_refresh_indicator.dart'; -import 'package:marco/helpers/utils/permission_constants.dart'; -import 'package:marco/model/document/user_document_filter_bottom_sheet.dart'; -import 'package:marco/model/document/documents_list_model.dart'; import 'package:intl/intl.dart'; -import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; -import 'package:marco/model/document/document_upload_bottom_sheet.dart'; +import 'package:marco/controller/document/document_details_controller.dart'; import 'package:marco/controller/document/document_upload_controller.dart'; -import 'package:marco/view/document/document_details_page.dart'; +import 'package:marco/controller/document/user_document_controller.dart'; +import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/widgets/custom_app_bar.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; -import 'package:marco/controller/permission_controller.dart'; -import 'package:marco/controller/document/document_details_controller.dart'; -import 'dart:convert'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/model/document/document_upload_bottom_sheet.dart'; +import 'package:marco/model/document/documents_list_model.dart'; +import 'package:marco/model/document/user_document_filter_bottom_sheet.dart'; +import 'package:marco/view/document/document_details_page.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; class UserDocumentsPage extends StatefulWidget { @@ -35,12 +35,18 @@ class UserDocumentsPage extends StatefulWidget { State createState() => _UserDocumentsPageState(); } -class _UserDocumentsPageState extends State with UIMixin { - final DocumentController docController = Get.put(DocumentController()); - final PermissionController permissionController = +class _UserDocumentsPageState extends State + with UIMixin, SingleTickerProviderStateMixin { + late ScrollController _scrollController; + late AnimationController _fabAnimationController; + late Animation _fabScaleAnimation; + + DocumentController get docController => Get.find(); + PermissionController get permissionController => Get.find(); - final DocumentDetailsController controller = - Get.put(DocumentDetailsController()); + DocumentDetailsController get detailsController => + Get.find(); + String get entityTypeId => widget.isEmployee ? Permissions.employeeEntity : Permissions.projectEntity; @@ -52,188 +58,488 @@ class _UserDocumentsPageState extends State with UIMixin { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - docController.fetchFilters(entityTypeId); - docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - reset: true, - ); - }); + + if (!Get.isRegistered()) Get.put(DocumentController()); + if (!Get.isRegistered()) + Get.put(PermissionController()); + if (!Get.isRegistered()) + Get.put(DocumentDetailsController()); + + _scrollController = ScrollController()..addListener(_onScroll); + + _fabAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + _fabScaleAnimation = CurvedAnimation( + parent: _fabAnimationController, + curve: Curves.easeInOut, + ); + _fabAnimationController.forward(); + + WidgetsBinding.instance.addPostFrameCallback((_) => _initializeData()); + } + + void _initializeData() { + docController.fetchFilters(entityTypeId); + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent * 0.8) { + if (!docController.isLoading.value && docController.hasMore.value) { + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + ); + } + } + + if (_scrollController.position.userScrollDirection == + ScrollDirection.reverse) { + if (_fabAnimationController.isCompleted) + _fabAnimationController.reverse(); + } else if (_scrollController.position.userScrollDirection == + ScrollDirection.forward) { + if (_fabAnimationController.isDismissed) + _fabAnimationController.forward(); + } } @override void dispose() { + _scrollController.dispose(); + _fabAnimationController.dispose(); + docController.searchController.dispose(); docController.documents.clear(); super.dispose(); } - Widget _buildDocumentTile(DocumentItem doc, bool showDateHeader) { + // ==================== UI BUILDERS ==================== + + Widget _buildSearchBar() { + return Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: docController.searchController, + onChanged: (value) { + docController.searchQuery.value = value; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + }, + style: const TextStyle(fontSize: 15), + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + prefixIcon: + const Icon(Icons.search_rounded, size: 22, color: Colors.grey), + suffixIcon: ValueListenableBuilder( + valueListenable: docController.searchController, + builder: (context, value, _) { + if (value.text.isEmpty) return const SizedBox.shrink(); + return IconButton( + icon: const Icon(Icons.clear_rounded, size: 20), + color: Colors.grey.shade600, + onPressed: () { + docController.searchController.clear(); + docController.searchQuery.value = ''; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + }, + ); + }, + ), + hintText: 'Search by document name or type...', + hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 15), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: BorderSide(color: contentTheme.primary, width: 2), + ), + ), + ), + ); + } + + Widget _buildFilterChips() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Obx(() { + final hasFilters = docController.hasActiveFilters(); + return Row( + children: [ + if (hasFilters) ...[ + _buildChip( + 'Clear Filters', + icon: Icons.close_rounded, + isSelected: false, + onTap: () { + docController.clearFilters(); + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + }, + backgroundColor: Colors.red.shade50, + textColor: Colors.red.shade700, + ), + const SizedBox(width: 8), + ], + _buildFilterButton(), + const SizedBox(width: 8), + _buildMoreOptionsButton(), + ], + ); + }), + ), + ), + ], + ), + ); + } + + Widget _buildChip( + String label, { + IconData? icon, + bool isSelected = false, + VoidCallback? onTap, + Color? backgroundColor, + Color? textColor, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(5), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: backgroundColor ?? + (isSelected + ? contentTheme.primary.withOpacity(0.1) + : Colors.white), + borderRadius: BorderRadius.circular(5), + border: Border.all( + color: isSelected ? contentTheme.primary : Colors.grey.shade300, + width: 1.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 16, + color: textColor ?? + (isSelected ? contentTheme.primary : Colors.grey.shade700), + ), + const SizedBox(width: 6), + ], + MyText.labelSmall( + label, + fontWeight: 600, + color: textColor ?? + (isSelected ? contentTheme.primary : Colors.grey.shade700), + ), + ], + ), + ), + ); + } + + Widget _buildFilterButton() { + return Obx(() { + final isFilterActive = docController.hasActiveFilters(); + return Stack( + clipBehavior: Clip.none, + children: [ + _buildChip( + 'Filters', + icon: Icons.tune_rounded, + isSelected: isFilterActive, + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => UserDocumentFilterBottomSheet( + entityId: resolvedEntityId, + entityTypeId: entityTypeId, + ), + ); + }, + ), + if (isFilterActive) + Positioned( + top: -4, + right: -4, + child: Container( + height: 10, + width: 10, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFFF1F1F1), width: 2), + ), + ), + ), + ], + ); + }); + } + + Widget _buildMoreOptionsButton() { + return PopupMenuButton( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + offset: const Offset(0, 40), + child: _buildChip( + 'Options', + icon: Icons.more_horiz_rounded, + ), + itemBuilder: (context) => [ + PopupMenuItem( + enabled: false, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: MyText.bodySmall( + 'Preferences', + fontWeight: 700, + color: Colors.grey.shade600, + ), + ), + const PopupMenuDivider(height: 1), + PopupMenuItem( + value: 'show_deleted', + enabled: false, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Obx(() => Row( + children: [ + Icon(Icons.visibility_off_outlined, + size: 20, color: Colors.grey.shade700), + const SizedBox(width: 12), + Expanded( + child: MyText.bodyMedium('Show Deleted', fontSize: 14), + ), + Switch.adaptive( + value: docController.showInactive.value, + activeColor: contentTheme.primary, + onChanged: (val) { + docController.showInactive.value = val; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + Navigator.pop(context); + }, + ), + ], + )), + ), + ], + ); + } + + Widget _buildStatusBanner() { + return Obx(() { + if (!docController.showInactive.value) return const SizedBox.shrink(); + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + decoration: BoxDecoration( + color: Colors.red.shade50, + border: + Border(bottom: BorderSide(color: Colors.red.shade100, width: 1)), + ), + child: Row( + children: [ + Icon(Icons.info_outline_rounded, + color: Colors.red.shade700, size: 18), + const SizedBox(width: 10), + Expanded( + child: MyText.bodySmall( + 'Showing deleted documents', + fontWeight: 600, + color: Colors.red.shade700, + ), + ), + TextButton( + onPressed: () { + docController.showInactive.value = false; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + }, + style: TextButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: MyText.labelMedium( + 'Hide', + fontWeight: 700, + color: Colors.red.shade700, + ), + ), + ], + ), + ); + }); + } + + Widget _buildDocumentCard(DocumentItem doc, bool showDateHeader) { final uploadDate = DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal()); - + final uploadTime = DateFormat("hh:mm a").format(doc.uploadedAt.toLocal()); final uploader = doc.uploadedBy.firstName.isNotEmpty - ? "Added by ${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}" - .trim() - : "Added by you"; + ? "${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}".trim() + : "You"; + + final iconColor = _getDocumentTypeColor(doc.documentType.name); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (showDateHeader) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: MyText.bodySmall( - uploadDate, - fontSize: 13, - fontWeight: 500, - color: Colors.grey, - ), - ), - InkWell( - onTap: () { - // 👉 Navigate to details page - Get.to(() => DocumentDetailsPage(documentId: doc.id)); - }, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: Colors.white, + if (showDateHeader) _buildDateHeader(uploadDate), + Hero( + tag: 'document_${doc.id}', + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Get.to( + () => DocumentDetailsPage(documentId: doc.id), + transition: Transition.rightToLeft, + duration: const Duration(milliseconds: 300), + ); + }, borderRadius: BorderRadius.circular(5), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.blue.shade50, - borderRadius: BorderRadius.circular(5), - ), - child: const Icon(Icons.description, color: Colors.blue), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodySmall( - doc.documentType.name, - fontSize: 13, - fontWeight: 600, - color: Colors.grey, - ), - MySpacing.height(2), - MyText.bodyMedium( - doc.name, - fontSize: 15, - fontWeight: 600, - color: Colors.black, - ), - MySpacing.height(2), - MyText.bodySmall( - uploader, - fontSize: 13, - color: Colors.grey, - ), - ], - ), - ), - PopupMenuButton( - icon: const Icon(Icons.more_vert, color: Colors.black54), - onSelected: (value) async { - if (value == "delete") { - // existing delete flow (unchanged) - final result = await showDialog( - context: context, - builder: (_) => ConfirmDialog( - title: "Delete Document", - message: - "Are you sure you want to delete \"${doc.name}\"?\nThis action cannot be undone.", - confirmText: "Delete", - cancelText: "Cancel", - icon: Icons.delete_forever, - confirmColor: Colors.redAccent, - onConfirm: () async { - final success = - await docController.toggleDocumentActive( - doc.id, - isActive: false, - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - ); - - if (success) { - showAppSnackbar( - title: "Deleted", - message: "Document deleted successfully", - type: SnackbarType.success, - ); - } else { - showAppSnackbar( - title: "Error", - message: "Failed to delete document", - type: SnackbarType.error, - ); - throw Exception( - "Failed to delete"); // keep dialog open - } - }, - ), - ); - if (result == true) { - debugPrint("✅ Document deleted and removed from list"); - } - } else if (value == "restore") { - // existing activate flow (unchanged) - final success = await docController.toggleDocumentActive( - doc.id, - isActive: true, - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - ); - - if (success) { - showAppSnackbar( - title: "Restored", - message: "Document reastored successfully", - type: SnackbarType.success, - ); - } else { - showAppSnackbar( - title: "Error", - message: "Failed to restore document", - type: SnackbarType.error, - ); - } - } - }, - itemBuilder: (context) => [ - if (doc.isActive && - permissionController - .hasPermission(Permissions.deleteDocument)) - const PopupMenuItem( - value: "delete", - child: Text("Delete"), - ) - else if (!doc.isActive && - permissionController - .hasPermission(Permissions.modifyDocument)) - const PopupMenuItem( - value: "restore", - child: Text("Restore"), - ), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + border: Border.all(color: Colors.grey.shade200, width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), ], ), - ], + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: iconColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + _getDocumentIcon(doc.documentType.name), + color: iconColor, + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: iconColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: MyText.labelSmall( + doc.documentType.name, + fontWeight: 600, + color: iconColor, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 8), + MyText.bodyMedium( + doc.name, + fontWeight: 600, + color: Colors.black87, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Row( + children: [ + Icon(Icons.person_outline_rounded, + size: 14, color: Colors.grey.shade600), + const SizedBox(width: 4), + Expanded( + child: MyText.bodySmall( + 'Added by $uploader', + color: Colors.grey.shade600, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + MyText.bodySmall( + uploadTime, + color: Colors.grey.shade500, + fontWeight: 500, + fontSize: 11, + ), + ], + ), + ], + ), + ), + _buildDocumentMenu(doc), + ], + ), + ), ), ), ), @@ -241,263 +547,269 @@ class _UserDocumentsPageState extends State with UIMixin { ); } - Widget _buildEmptyState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.inbox_outlined, size: 60, color: Colors.grey), - MySpacing.height(18), - MyText.titleMedium( - 'No documents found.', - fontWeight: 600, - color: Colors.grey, - ), - MySpacing.height(10), - MyText.bodySmall( - 'Try adjusting your filters or refresh to reload.', - color: Colors.grey, - ), - ], - ), - ); - } - - Widget _buildFilterRow(BuildContext context) { + Widget _buildDateHeader(String date) { return Padding( - padding: MySpacing.xy(8, 8), - child: Row( - children: [ - // 🔍 Search Bar - Expanded( - child: SizedBox( - height: 35, - child: TextField( - controller: docController.searchController, - onChanged: (value) { - docController.searchQuery.value = value; - docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - reset: true, - ); - }, - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - prefixIcon: - const Icon(Icons.search, size: 20, color: Colors.grey), - suffixIcon: ValueListenableBuilder( - valueListenable: docController.searchController, - builder: (context, value, _) { - if (value.text.isEmpty) return const SizedBox.shrink(); - return IconButton( - icon: const Icon(Icons.clear, - size: 20, color: Colors.grey), - onPressed: () { - docController.searchController.clear(); - docController.searchQuery.value = ''; - docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - reset: true, - ); - }, - ); - }, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(5), - borderSide: - BorderSide(color: contentTheme.primary, width: 1.5), - ), - hintText: 'Search documents...', - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(5), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(5), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - ), - ), - ), - ), - MySpacing.width(8), - - // 🛠️ Filter Icon with indicator - Obx(() { - final isFilterActive = docController.hasActiveFilters(); - return Stack( - children: [ - Container( - height: 35, - width: 35, - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(5), - ), - child: IconButton( - padding: EdgeInsets.zero, - constraints: BoxConstraints(), - icon: Icon( - Icons.tune, - size: 20, - color: Colors.black87, - ), - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: - BorderRadius.vertical(top: Radius.circular(5)), - ), - builder: (_) => UserDocumentFilterBottomSheet( - entityId: resolvedEntityId, - entityTypeId: entityTypeId, - ), - ); - }, - ), - ), - if (isFilterActive) - Positioned( - top: 6, - right: 6, - child: Container( - height: 8, - width: 8, - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - ), - ), - ], - ); - }), - MySpacing.width(10), - - // ⋮ Menu (Show Inactive toggle) - Container( - height: 35, - width: 35, - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(5), - ), - child: PopupMenuButton( - padding: EdgeInsets.zero, - icon: - const Icon(Icons.more_vert, size: 20, color: Colors.black87), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), - itemBuilder: (context) => [ - const PopupMenuItem( - enabled: false, - height: 30, - child: Text( - "Preferences", - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.grey, - ), - ), - ), - PopupMenuItem( - value: 0, - enabled: false, - child: Obx(() => Row( - children: [ - const Icon(Icons.visibility_off_outlined, - size: 20, color: Colors.black87), - const SizedBox(width: 10), - const Expanded(child: Text('Show Deleted Documents')), - Switch.adaptive( - value: docController.showInactive.value, - activeColor: contentTheme.primary, - onChanged: (val) { - docController.showInactive.value = val; - docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - reset: true, - ); - Navigator.pop(context); - }, - ), - ], - )), - ), - ], - ), - ), - ], + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: MyText.bodySmall( + date, + fontWeight: 700, + color: Colors.grey.shade700, + letterSpacing: 0.5, ), ); } - Widget _buildStatusHeader() { + Widget _buildDocumentMenu(DocumentItem doc) { return Obx(() { - final isInactive = docController.showInactive.value; - if (!isInactive) return const SizedBox.shrink(); // hide when active + final canDelete = + permissionController.hasPermission(Permissions.deleteDocument); + final canModify = + permissionController.hasPermission(Permissions.modifyDocument); - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - color: Colors.red.shade50, - child: Row( - children: [ - Icon( - Icons.visibility_off, - color: Colors.red, - size: 18, + // Build menu items list + final List> menuItems = []; + + if (doc.isActive && canDelete) { + menuItems.add( + PopupMenuItem( + value: "delete", + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.delete_outline_rounded, + size: 20, color: Colors.red.shade700), + const SizedBox(width: 12), + MyText.bodyMedium( + 'Delete', + color: Colors.red.shade700, + ) + ], ), - const SizedBox(width: 8), - Text( - "Showing Deleted Documents", - style: TextStyle( - color: Colors.red, - fontWeight: FontWeight.w600, - ), + ), + ); + } else if (!doc.isActive && canModify) { + menuItems.add( + PopupMenuItem( + value: "restore", + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.restore_rounded, + size: 20, color: contentTheme.primary), + const SizedBox(width: 12), + MyText.bodyMedium( + 'Restore', + color: contentTheme.primary, + ) + ], ), - ], - ), + ), + ); + } + + // If no menu items, return empty widget + if (menuItems.isEmpty) { + return const SizedBox.shrink(); + } + + return PopupMenuButton( + icon: Icon(Icons.more_vert_rounded, + color: Colors.grey.shade600, size: 20), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + offset: const Offset(-10, 30), + onSelected: (value) => _handleMenuAction(value, doc), + itemBuilder: (context) => menuItems, ); }); } - Widget _buildBody(BuildContext context) { - // 🔒 Check for viewDocument permission - if (!permissionController.hasPermission(Permissions.viewDocument)) { - return Center( + Future _handleMenuAction(String action, DocumentItem doc) async { + if (action == "delete") { + await showDialog( + context: context, + builder: (_) => ConfirmDialog( + title: "Delete Document", + message: + "Are you sure you want to delete \"${doc.name}\"?\n\nThis action cannot be undone.", + confirmText: "Delete", + cancelText: "Cancel", + icon: Icons.delete_forever_rounded, + confirmColor: Colors.redAccent, + onConfirm: () async { + final success = await docController.toggleDocumentActive( + doc.id, + isActive: false, + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + ); + + if (success) { + showAppSnackbar( + title: "Deleted", + message: "Document deleted successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to delete document", + type: SnackbarType.error, + ); + throw Exception("Failed to delete"); + } + }, + ), + ); + } else if (action == "restore") { + final success = await docController.toggleDocumentActive( + doc.id, + isActive: true, + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + ); + + if (success) { + showAppSnackbar( + title: "Restored", + message: "Document restored successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to restore document", + type: SnackbarType.error, + ); + } + } + } + + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.lock_outline, size: 60, color: Colors.grey), - MySpacing.height(18), - MyText.titleMedium( - 'Access Denied', - fontWeight: 600, - color: Colors.grey, + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.grey.shade100, + shape: BoxShape.circle, + ), + child: Icon( + Icons.folder_open_rounded, + size: 64, + color: Colors.grey.shade400, + ), ), - MySpacing.height(10), + const SizedBox(height: 24), + MyText.bodyLarge( + 'No documents found', + fontWeight: 600, + color: Colors.grey.shade700, + ), + const SizedBox(height: 8), MyText.bodySmall( - 'You do not have permission to view documents.', - color: Colors.grey, + 'Try adjusting your filters or\nadd a new document to get started', + color: Colors.grey.shade600, + height: 1.5, + textAlign: TextAlign.center, ), ], ), - ); - } + ), + ); + } + Widget _buildLoadingIndicator() { + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator(), + ), + ); + } + + Widget _buildNoMoreIndicator() { + return Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + height: 1, + width: 40, + color: Colors.grey.shade300, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: MyText.bodySmall( + 'No more documents', + fontWeight: 500, + )), + Container( + height: 1, + width: 40, + color: Colors.grey.shade300, + ), + ], + ), + ), + ); + } + + Widget _buildPermissionDenied() { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.red.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.lock_outline_rounded, + size: 64, + color: Colors.red.shade300, + ), + ), + const SizedBox(height: 24), + MyText.bodyLarge( + 'Access Denied', + fontWeight: 600, + color: Colors.grey.shade700, + ), + const SizedBox(height: 8), + MyText.bodySmall( + 'You don\'t have permission\nto view documents', + color: Colors.grey.shade600, + height: 1.5, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildBody() { return Obx(() { + // Check permissions + if (permissionController.permissions.isEmpty) { + return _buildLoadingIndicator(); + } + + if (!permissionController.hasPermission(Permissions.viewDocument)) { + return _buildPermissionDenied(); + } + + // Show skeleton loader if (docController.isLoading.value && docController.documents.isEmpty) { return SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), @@ -506,149 +818,205 @@ class _UserDocumentsPageState extends State with UIMixin { } final docs = docController.documents; - return SafeArea( - child: Column( - children: [ - _buildFilterRow(context), - _buildStatusHeader(), - Expanded( - child: MyRefreshIndicator( - onRefresh: () async { - final combinedFilter = { - 'uploadedByIds': docController.selectedUploadedBy.toList(), - 'documentCategoryIds': - docController.selectedCategory.toList(), - 'documentTypeIds': docController.selectedType.toList(), - 'documentTagIds': docController.selectedTag.toList(), - }; - await docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - filter: jsonEncode(combinedFilter), - reset: true, - ); - }, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: docs.isEmpty - ? null - : const EdgeInsets.fromLTRB(0, 0, 0, 80), - children: docs.isEmpty - ? [ - SizedBox( - height: MediaQuery.of(context).size.height * 0.6, - child: _buildEmptyState(), - ), - ] - : [ - ...docs.asMap().entries.map((entry) { - final index = entry.key; - final doc = entry.value; + return Column( + children: [ + _buildSearchBar(), + _buildFilterChips(), + _buildStatusBanner(), + Expanded( + child: MyRefreshIndicator( + onRefresh: () async { + final combinedFilter = { + 'uploadedByIds': docController.selectedUploadedBy.toList(), + 'documentCategoryIds': + docController.selectedCategory.toList(), + 'documentTypeIds': docController.selectedType.toList(), + 'documentTagIds': docController.selectedTag.toList(), + }; - final currentDate = DateFormat("dd MMM yyyy") - .format(doc.uploadedAt.toLocal()); - final prevDate = index > 0 - ? DateFormat("dd MMM yyyy").format( - docs[index - 1].uploadedAt.toLocal()) - : null; + await docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + filter: jsonEncode(combinedFilter), + reset: true, + ); + }, + child: docs.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: _buildEmptyState(), + ), + ], + ) + : ListView.builder( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 100, top: 8), + itemCount: docs.length + 1, + itemBuilder: (context, index) { + if (index == docs.length) { + return Obx(() { + if (docController.isLoading.value) { + return _buildLoadingIndicator(); + } + if (!docController.hasMore.value && + docs.isNotEmpty) { + return _buildNoMoreIndicator(); + } + return const SizedBox.shrink(); + }); + } - final showDateHeader = currentDate != prevDate; + final doc = docs[index]; + final currentDate = DateFormat("dd MMM yyyy") + .format(doc.uploadedAt.toLocal()); + final prevDate = index > 0 + ? DateFormat("dd MMM yyyy") + .format(docs[index - 1].uploadedAt.toLocal()) + : null; + final showDateHeader = currentDate != prevDate; - return _buildDocumentTile(doc, showDateHeader); - }), - if (docController.isLoading.value) - const Padding( - padding: EdgeInsets.all(12), - child: Center(child: CircularProgressIndicator()), - ), - if (!docController.hasMore.value) - Padding( - padding: const EdgeInsets.all(12), - child: Center( - child: MyText.bodySmall( - "No more documents", - color: Colors.grey, - ), - ), - ), - ], - ), - ), + return _buildDocumentCard(doc, showDateHeader); + }, + ), ), - ], + ), + ], + ); + }); + } + + Widget _buildFAB() { + return Obx(() { + if (permissionController.permissions.isEmpty) { + return const SizedBox.shrink(); + } + + if (!permissionController.hasPermission(Permissions.uploadDocument)) { + return const SizedBox.shrink(); + } + + return ScaleTransition( + scale: _fabScaleAnimation, + child: FloatingActionButton.extended( + onPressed: _showUploadBottomSheet, + elevation: 4, + highlightElevation: 8, + backgroundColor: contentTheme.primary, + foregroundColor: Colors.white, + icon: const Icon(Icons.add_rounded, size: 24), + label: const Text( + 'Add Document', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), ), ); }); } + void _showUploadBottomSheet() { + final uploadController = Get.put(DocumentUploadController()); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => DocumentUploadBottomSheet( + isEmployee: widget.isEmployee, + onSubmit: (data) async { + final success = await uploadController.uploadDocument( + name: data["name"], + description: data["description"], + documentId: data["documentId"], + entityId: resolvedEntityId, + documentTypeId: data["documentTypeId"], + fileName: data["attachment"]["fileName"], + base64Data: data["attachment"]["base64Data"], + contentType: data["attachment"]["contentType"], + fileSize: data["attachment"]["fileSize"], + ); + + if (success) { + Navigator.pop(context); + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + showAppSnackbar( + title: "Success", + message: "Document uploaded successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Upload failed, please try again", + type: SnackbarType.error, + ); + } + }, + ), + ); + } + + // Helper methods for document type styling + Color _getDocumentTypeColor(String type) { + final lowerType = type.toLowerCase(); + if (lowerType.contains('contract') || lowerType.contains('agreement')) { + return Colors.purple; + } else if (lowerType.contains('invoice') || lowerType.contains('receipt')) { + return Colors.green; + } else if (lowerType.contains('report')) { + return Colors.orange; + } else if (lowerType.contains('certificate')) { + return Colors.blue; + } else if (lowerType.contains('id') || lowerType.contains('identity')) { + return Colors.red; + } else { + return Colors.blueGrey; + } + } + + IconData _getDocumentIcon(String type) { + final lowerType = type.toLowerCase(); + if (lowerType.contains('contract') || lowerType.contains('agreement')) { + return Icons.article_rounded; + } else if (lowerType.contains('invoice') || lowerType.contains('receipt')) { + return Icons.receipt_long_rounded; + } else if (lowerType.contains('report')) { + return Icons.assessment_rounded; + } else if (lowerType.contains('certificate')) { + return Icons.workspace_premium_rounded; + } else if (lowerType.contains('id') || lowerType.contains('identity')) { + return Icons.badge_rounded; + } else { + return Icons.description_rounded; + } + } + @override Widget build(BuildContext context) { - final bool showAppBar = !widget.isEmployee; - return Scaffold( backgroundColor: const Color(0xFFF1F1F1), - appBar: showAppBar + appBar: !widget.isEmployee ? CustomAppBar( title: 'Documents', - onBackPressed: () { - Get.back(); - }, - ) - : null, - body: _buildBody(context), - floatingActionButton: permissionController - .hasPermission(Permissions.uploadDocument) - ? FloatingActionButton.extended( - onPressed: () { - final uploadController = Get.put(DocumentUploadController()); - - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => DocumentUploadBottomSheet( - isEmployee: widget.isEmployee, - onSubmit: (data) async { - final success = await uploadController.uploadDocument( - name: data["name"], - description: data["description"], - documentId: data["documentId"], - entityId: resolvedEntityId, - documentTypeId: data["documentTypeId"], - fileName: data["attachment"]["fileName"], - base64Data: data["attachment"]["base64Data"], - contentType: data["attachment"]["contentType"], - fileSize: data["attachment"]["fileSize"], - ); - - if (success) { - Navigator.pop(context); - docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - reset: true, - ); - } else { - showAppSnackbar( - title: "Error", - message: "Upload failed, please try again", - type: SnackbarType.error, - ); - } - }, - ), - ); - }, - icon: const Icon(Icons.add, color: Colors.white), - label: MyText.bodyMedium( - "Add Document", - color: Colors.white, - fontWeight: 600, - ), - backgroundColor: contentTheme.primary, + onBackPressed: () => Get.back(), ) : null, + body: SafeArea( + child: _buildBody(), + ), + floatingActionButton: _buildFAB(), floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ); } From 6568dc70c8b313a59585f3da5549c4b0eb090d01 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 31 Oct 2025 15:22:05 +0530 Subject: [PATCH 05/18] refactor: Update package and bundle identifiers to reflect new naming convention --- android/app/build.gradle | 2 +- .../com/example/maxdash/MainActivity.kt | 2 +- ios/Runner.xcodeproj/project.pbxproj | 12 +- lib/view/dashboard/dashboard_screen.dart | 6 +- .../employees/employee_detail_screen.dart | 442 ++++++++++++------ lib/view/layouts/user_profile_right_bar.dart | 14 + lib/view/support/support_screen.dart | 4 +- linux/CMakeLists.txt | 2 +- macos/Runner.xcodeproj/project.pbxproj | 6 +- macos/Runner/Configs/AppInfo.xcconfig | 2 +- 10 files changed, 323 insertions(+), 169 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 7cb551f..23b8b74 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -15,7 +15,7 @@ if (keystorePropertiesFile.exists()) { android { // Define the namespace for your Android application - namespace = "com.onfieldwork.marcoaiot" + namespace = "com.marcoonfieldwork.aiot" // Set the compile SDK version based on Flutter's configuration compileSdk = flutter.compileSdkVersion // Set the NDK version based on Flutter's configuration diff --git a/android/app/src/main/kotlin/com/example/maxdash/MainActivity.kt b/android/app/src/main/kotlin/com/example/maxdash/MainActivity.kt index fcacfad..072f2a5 100644 --- a/android/app/src/main/kotlin/com/example/maxdash/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/maxdash/MainActivity.kt @@ -1,4 +1,4 @@ -package com.onfieldwork.marcoaiot +package com.marcoonfieldwork.aiot import io.flutter.embedding.android.FlutterActivity diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 065543e..aec584f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -368,7 +368,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot; + PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -384,7 +384,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -401,7 +401,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -416,7 +416,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -547,7 +547,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot; + PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -569,7 +569,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot; + PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index cf5103f..5cb2e71 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -12,7 +12,7 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/dashbaord/attendance_overview_chart.dart'; import 'package:marco/helpers/widgets/dashbaord/expense_by_status_widget.dart'; import 'package:marco/view/layouts/layout.dart'; -import 'package:marco/helpers/widgets/dashbaord/expense_breakdown_chart.dart'; +// import 'package:marco/helpers/widgets/dashbaord/expense_breakdown_chart.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -61,8 +61,8 @@ class _DashboardScreenState extends State with UIMixin { ExpenseByStatusWidget(controller: dashboardController), MySpacing.height(24), - // Expense Type Report Chart - ExpenseTypeReportChart(), + // // Expense Type Report Chart + // ExpenseTypeReportChart(), ], ), ), diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index ef05c7f..d7d3217 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -11,7 +11,6 @@ import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/model/employees/add_employee_bottom_sheet.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; - class EmployeeDetailPage extends StatefulWidget { final String employeeId; final bool fromProfile; @@ -30,7 +29,6 @@ class _EmployeeDetailPageState extends State with UIMixin { final EmployeesScreenController controller = Get.put(EmployeesScreenController()); - @override void initState() { super.initState(); @@ -61,77 +59,58 @@ class _EmployeeDetailPageState extends State with UIMixin { } } - Widget _buildLabelValueRow(String label, String value, - {bool isMultiLine = false}) { - final lowerLabel = label.toLowerCase(); - final isEmail = lowerLabel == 'email'; - final isPhone = - lowerLabel == 'phone number' || lowerLabel == 'emergency phone number'; - - void handleTap() { - if (value == 'NA') return; - if (isEmail) { - LauncherUtils.launchEmail(value); - } else if (isPhone) { - LauncherUtils.launchPhone(value); - } - } - - void handleLongPress() { - if (value == 'NA') return; - LauncherUtils.copyToClipboard(value, typeLabel: label); - } - - final valueWidget = GestureDetector( - onTap: (isEmail || isPhone) ? handleTap : null, - onLongPress: (isEmail || isPhone) ? handleLongPress : null, - child: Text( - value, - style: TextStyle( - fontWeight: FontWeight.normal, - color: (isEmail || isPhone) ? contentTheme.primary : Colors.black54, - fontSize: 14, - decoration: (isEmail || isPhone) - ? TextDecoration.underline - : TextDecoration.none, - ), - ), - ); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (isMultiLine) ...[ - Text( - label, - style: const TextStyle( - fontWeight: FontWeight.bold, - color: Colors.black87, - fontSize: 14, + Widget _buildDetailRow({ + required IconData icon, + required String label, + required String value, + VoidCallback? onTap, + VoidCallback? onLongPress, + bool isActionable = false, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: InkWell( + onTap: isActionable && value != 'NA' ? onTap : null, + onLongPress: isActionable && value != 'NA' ? onLongPress : null, + borderRadius: BorderRadius.circular(5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: contentTheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(5), + ), + child: Icon( + icon, + size: 20, + color: contentTheme.primary, + ), ), - ), - MySpacing.height(4), - valueWidget, - ] else - GestureDetector( - onTap: (isEmail || isPhone) ? handleTap : null, - onLongPress: (isEmail || isPhone) ? handleLongPress : null, - child: RichText( - text: TextSpan( - text: "$label: ", - style: const TextStyle( - fontWeight: FontWeight.bold, - color: Colors.black87, - fontSize: 14, - ), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextSpan( - text: value, + Text( + label, style: TextStyle( - fontWeight: FontWeight.normal, - color: - (isEmail || isPhone) ? Colors.indigo : Colors.black54, - decoration: (isEmail || isPhone) + fontSize: 12, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + MySpacing.height(4), + Text( + value, + style: TextStyle( + fontSize: 15, + color: isActionable && value != 'NA' + ? contentTheme.primary + : Colors.black87, + fontWeight: FontWeight.w500, + decoration: isActionable && value != 'NA' ? TextDecoration.underline : TextDecoration.none, ), @@ -139,46 +118,53 @@ class _EmployeeDetailPageState extends State with UIMixin { ], ), ), - ), - MySpacing.height(10), - Divider(color: Colors.grey[300], height: 1), - MySpacing.height(10), - ], + if (isActionable && value != 'NA') + Icon( + Icons.chevron_right, + color: Colors.grey[400], + size: 20, + ), + ], + ), + ), ); } - Widget _buildInfoCard(employee) { + Widget _buildSectionCard({ + required String title, + required IconData titleIcon, + required List children, + }) { return Card( - elevation: 3, + elevation: 2, shadowColor: Colors.black12, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), child: Padding( - padding: const EdgeInsets.fromLTRB(12, 16, 12, 16), + padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MySpacing.height(12), - _buildLabelValueRow('Email', _getDisplayValue(employee.email)), - _buildLabelValueRow( - 'Phone Number', _getDisplayValue(employee.phoneNumber)), - _buildLabelValueRow('Emergency Contact Person', - _getDisplayValue(employee.emergencyContactPerson)), - _buildLabelValueRow('Emergency Phone Number', - _getDisplayValue(employee.emergencyPhoneNumber)), - _buildLabelValueRow('Gender', _getDisplayValue(employee.gender)), - _buildLabelValueRow('Birth Date', _formatDate(employee.birthDate)), - _buildLabelValueRow( - 'Joining Date', _formatDate(employee.joiningDate)), - _buildLabelValueRow( - 'Current Address', - _getDisplayValue(employee.currentAddress), - isMultiLine: true, - ), - _buildLabelValueRow( - 'Permanent Address', - _getDisplayValue(employee.permanentAddress), - isMultiLine: true, + Row( + children: [ + Icon( + titleIcon, + size: 20, + color: contentTheme.primary, + ), + MySpacing.width(8), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], ), + MySpacing.height(8), + const Divider(), + ...children, ], ), ), @@ -224,65 +210,219 @@ class _EmployeeDetailPageState extends State with UIMixin { child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Row( - children: [ - Avatar( - firstName: employee.firstName, - lastName: employee.lastName, - size: 45, - ), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleMedium( - '${employee.firstName} ${employee.lastName}', - fontWeight: 700, + // Header Section + Card( + elevation: 2, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Avatar( + firstName: employee.firstName, + lastName: employee.lastName, + size: 45, + ), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium( + '${employee.firstName} ${employee.lastName}', + fontWeight: 700, + ), + MySpacing.height(6), + MyText.bodySmall( + _getDisplayValue(employee.jobRole), + fontWeight: 500, + ), + ], ), - MySpacing.height(6), - MyText.bodySmall( - _getDisplayValue(employee.jobRole), - fontWeight: 500, - ), - ], - ), - ), - IconButton( - icon: - Icon(Icons.edit, size: 24, color: contentTheme.primary), - onPressed: () async { - final result = - await showModalBottomSheet>( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => AddEmployeeBottomSheet( - employeeData: { - 'id': employee.id, - 'first_name': employee.firstName, - 'last_name': employee.lastName, - 'phone_number': employee.phoneNumber, - 'email': employee.email, - 'hasApplicationAccess': - employee.hasApplicationAccess, - 'gender': employee.gender.toLowerCase(), - 'job_role_id': employee.jobRoleId, - 'joining_date': - employee.joiningDate?.toIso8601String(), - }, - ), - ); + ), + IconButton( + icon: Icon(Icons.edit, + size: 24, color: contentTheme.primary), + onPressed: () async { + final result = await showModalBottomSheet< + Map>( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => AddEmployeeBottomSheet( + employeeData: { + 'id': employee.id, + 'first_name': employee.firstName, + 'last_name': employee.lastName, + 'phone_number': employee.phoneNumber, + 'email': employee.email, + 'hasApplicationAccess': + employee.hasApplicationAccess, + 'gender': employee.gender.toLowerCase(), + 'job_role_id': employee.jobRoleId, + 'joining_date': + employee.joiningDate?.toIso8601String(), + }, + ), + ); - if (result != null) { - controller.fetchEmployeeDetails(widget.employeeId); + if (result != null) { + controller + .fetchEmployeeDetails(widget.employeeId); + } + }, + ), + ], + ), + ), + ), + MySpacing.height(16), + + // Contact Information Section + _buildSectionCard( + title: 'Contact Information', + titleIcon: Icons.contact_phone, + children: [ + _buildDetailRow( + icon: Icons.email_outlined, + label: 'Email', + value: _getDisplayValue(employee.email), + isActionable: true, + onTap: () { + if (employee.email != null && + employee.email.toString().trim().isNotEmpty) { + LauncherUtils.launchEmail(employee.email!); + } + }, + onLongPress: () { + if (employee.email != null && + employee.email.toString().trim().isNotEmpty) { + LauncherUtils.copyToClipboard(employee.email!, + typeLabel: 'Email'); + } + }, + ), + _buildDetailRow( + icon: Icons.phone_outlined, + label: 'Phone Number', + value: _getDisplayValue(employee.phoneNumber), + isActionable: true, + onTap: () { + if (employee.phoneNumber != null && + employee.phoneNumber + .toString() + .trim() + .isNotEmpty) { + LauncherUtils.launchPhone(employee.phoneNumber!); + } + }, + onLongPress: () { + if (employee.phoneNumber != null && + employee.phoneNumber + .toString() + .trim() + .isNotEmpty) { + LauncherUtils.copyToClipboard(employee.phoneNumber!, + typeLabel: 'Phone Number'); } }, ), ], ), - MySpacing.height(14), - _buildInfoCard(employee), + MySpacing.height(16), + + // Emergency Contact Section + _buildSectionCard( + title: 'Emergency Contact', + titleIcon: Icons.emergency, + children: [ + _buildDetailRow( + icon: Icons.person_outline, + label: 'Contact Person', + value: + _getDisplayValue(employee.emergencyContactPerson), + isActionable: false, + ), + _buildDetailRow( + icon: Icons.phone_in_talk_outlined, + label: 'Emergency Phone', + value: _getDisplayValue(employee.emergencyPhoneNumber), + isActionable: true, + onTap: () { + if (employee.emergencyPhoneNumber != null && + employee.emergencyPhoneNumber + .toString() + .trim() + .isNotEmpty) { + LauncherUtils.launchPhone( + employee.emergencyPhoneNumber!); + } + }, + onLongPress: () { + if (employee.emergencyPhoneNumber != null && + employee.emergencyPhoneNumber + .toString() + .trim() + .isNotEmpty) { + LauncherUtils.copyToClipboard( + employee.emergencyPhoneNumber!, + typeLabel: 'Emergency Phone'); + } + }, + ), + ], + ), + MySpacing.height(16), + + // Personal Information Section + _buildSectionCard( + title: 'Personal Information', + titleIcon: Icons.person, + children: [ + _buildDetailRow( + icon: Icons.wc_outlined, + label: 'Gender', + value: _getDisplayValue(employee.gender), + isActionable: false, + ), + _buildDetailRow( + icon: Icons.cake_outlined, + label: 'Birth Date', + value: _formatDate(employee.birthDate), + isActionable: false, + ), + _buildDetailRow( + icon: Icons.work_outline, + label: 'Joining Date', + value: _formatDate(employee.joiningDate), + isActionable: false, + ), + ], + ), + MySpacing.height(16), + + // Address Information Section + _buildSectionCard( + title: 'Address Information', + titleIcon: Icons.location_on, + children: [ + _buildDetailRow( + icon: Icons.home_outlined, + label: 'Current Address', + value: _getDisplayValue(employee.currentAddress), + isActionable: false, + ), + _buildDetailRow( + icon: Icons.home_work_outlined, + label: 'Permanent Address', + value: _getDisplayValue(employee.permanentAddress), + isActionable: false, + ), + ], + ), ], ), ), diff --git a/lib/view/layouts/user_profile_right_bar.dart b/lib/view/layouts/user_profile_right_bar.dart index 63bc6b2..b55a2ef 100644 --- a/lib/view/layouts/user_profile_right_bar.dart +++ b/lib/view/layouts/user_profile_right_bar.dart @@ -11,6 +11,8 @@ import 'package:marco/model/employees/employee_info.dart'; import 'package:marco/controller/auth/mpin_controller.dart'; import 'package:marco/view/employees/employee_profile_screen.dart'; import 'package:marco/helpers/theme/theme_editor_widget.dart'; +import 'package:marco/view/faq/faq_screen.dart'; +import 'package:marco/view/support/support_screen.dart'; class UserProfileBar extends StatefulWidget { @@ -122,6 +124,7 @@ class _UserProfileBarState extends State ), ); } + Widget _userProfileSection(bool condensed) { final padding = MySpacing.fromLTRB( condensed ? 16 : 26, @@ -206,6 +209,17 @@ class _UserProfileBarState extends State _menuItemRow( icon: LucideIcons.badge_alert, label: 'Support', + onTap: () { + Get.to(() => SupportScreen()); + }, + ), + SizedBox(height: spacingHeight), + _menuItemRow( + icon: LucideIcons.badge_help, + label: 'FAQ', + onTap: () { + Get.to(() => FAQScreen()); + }, ), SizedBox(height: spacingHeight), _menuItemRow( diff --git a/lib/view/support/support_screen.dart b/lib/view/support/support_screen.dart index b5606bd..7eef909 100644 --- a/lib/view/support/support_screen.dart +++ b/lib/view/support/support_screen.dart @@ -16,10 +16,10 @@ class _SupportScreenState extends State with UIMixin { final List> contacts = [ { "type": "email", - "label": "info@marcoaiot.com", + "label": "support@onfieldwork.com", "subLabel": "Email us your queries", "icon": LucideIcons.mail, - "action": "mailto:info@marcoaiot.com?subject=Support Request" + "action": "mailto:support@onfieldwork.com?subject=Support Request" }, { "type": "phone", diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index cb7c5a2..fa2be66 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "marco") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.onfieldwork.marcoaiot") +set(APPLICATION_ID "com.marcoonfieldwork.aiot") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index cbcf812..a89772a 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -385,7 +385,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; @@ -399,7 +399,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; @@ -413,7 +413,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index e5e5002..52293a5 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = marco // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot +PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot // The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved. From 8dbd21df8b8686289ebcb5c0990be2fafe40a8a6 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 31 Oct 2025 15:35:53 +0530 Subject: [PATCH 06/18] refactor: Update API base URL and change default color theme to green --- lib/helpers/services/api_endpoints.dart | 3 ++- lib/helpers/theme/admin_theme.dart | 2 +- lib/helpers/theme/theme_customizer.dart | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 5029399..b9c4d9c 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,6 +1,7 @@ class ApiEndpoints { // static const String baseUrl = "https://mapi.marcoaiot.com/api"; - static const String baseUrl = "https://ofwapi.marcoaiot.com/api"; + // static const String baseUrl = "https://ofwapi.marcoaiot.com/api"; + static const String baseUrl = "https://api.onfieldwork.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api"; diff --git a/lib/helpers/theme/admin_theme.dart b/lib/helpers/theme/admin_theme.dart index 55d5680..3304113 100644 --- a/lib/helpers/theme/admin_theme.dart +++ b/lib/helpers/theme/admin_theme.dart @@ -266,7 +266,7 @@ class AdminTheme { leftBarTheme: LeftBarTheme.lightLeftBarTheme, topBarTheme: TopBarTheme.lightTopBarTheme, rightBarTheme: RightBarTheme.lightRightBarTheme, - contentTheme: ContentTheme.withColorTheme(ColorThemeType.purple, mode: ThemeMode.light), + contentTheme: ContentTheme.withColorTheme(ColorThemeType.green, mode: ThemeMode.light), ); static void setTheme() { diff --git a/lib/helpers/theme/theme_customizer.dart b/lib/helpers/theme/theme_customizer.dart index 7c2eeaf..1baee33 100644 --- a/lib/helpers/theme/theme_customizer.dart +++ b/lib/helpers/theme/theme_customizer.dart @@ -24,7 +24,7 @@ class ThemeCustomizer { ThemeMode leftBarTheme = ThemeMode.light; ThemeMode rightBarTheme = ThemeMode.light; ThemeMode topBarTheme = ThemeMode.light; - ColorThemeType colorTheme = ColorThemeType.red; + ColorThemeType colorTheme = ColorThemeType.green; bool rightBarOpen = false; bool leftBarCondensed = false; @@ -34,7 +34,7 @@ class ThemeCustomizer { static Future init() async { await initLanguage(); await _loadColorTheme(); - _notify(); + _notify(); } static initLanguage() async { From 1e39210a29f78c9b5717b6fb433867aad996e565 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 31 Oct 2025 16:05:43 +0530 Subject: [PATCH 07/18] feat: Implement expense by status skeleton loader for improved loading experience --- .../dashbaord/expense_by_status_widget.dart | 3 +- lib/helpers/widgets/my_custom_skeleton.dart | 135 ++++++++++++++++++ lib/view/dashboard/dashboard_screen.dart | 2 +- 3 files changed, 138 insertions(+), 2 deletions(-) diff --git a/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart b/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart index 264ab34..7c67c7f 100644 --- a/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart +++ b/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:marco/controller/dashboard/dashboard_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/utils/utils.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; class ExpenseByStatusWidget extends StatelessWidget { final DashboardController controller; @@ -49,7 +50,7 @@ class ExpenseByStatusWidget extends StatelessWidget { final data = controller.pendingExpensesData.value; if (controller.isPendingExpensesLoading.value) { - return const Center(child: CircularProgressIndicator()); + return SkeletonLoaders.expenseByStatusSkeletonLoader(); } if (data == null) { diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index 1d48e89..7578b54 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -45,6 +45,141 @@ class SkeletonLoaders { ); } +// Expense By Status Skeleton Loader + static Widget expenseByStatusSkeletonLoader() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.05), + blurRadius: 6, + spreadRadius: 1, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Container( + height: 16, + width: 160, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(height: 16), + + // 4 Status Rows + ...List.generate(4, (index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + // Icon placeholder + Container( + height: 44, + width: 44, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + + // Title + Amount + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 12, + width: 100, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 6), + Container( + height: 12, + width: 60, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + + // Count + arrow placeholder + Container( + height: 12, + width: 30, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 6), + Icon(Icons.chevron_right, + color: Colors.grey.shade300, size: 24), + ], + ), + ); + }), + + const SizedBox(height: 16), + Divider(color: Colors.grey.shade300), + const SizedBox(height: 12), + + // Bottom Row (Project Spendings) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 12, + width: 120, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 4), + Container( + height: 10, + width: 140, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + Container( + height: 16, + width: 80, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ], + ), + ); + } + // Chart Skeleton Loader static Widget chartSkeletonLoader() { return MyCard.bordered( diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 5cb2e71..89f8d5d 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -16,7 +16,7 @@ import 'package:marco/view/layouts/layout.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); - + static const String employeesRoute = "/dashboard/employees"; static const String attendanceRoute = "/dashboard/attendance"; static const String directoryMainPageRoute = "/dashboard/directory-main-page"; From d62f0d2c6087e0448e388f4c6540ca70e12109d9 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 31 Oct 2025 17:13:43 +0530 Subject: [PATCH 08/18] feat: Enhance ExpenseByStatusWidget with navigation and filter functionality; update ExpenseMainScreen to fetch expenses after UI initialization --- .../dashbaord/expense_by_status_widget.dart | 179 ++++++++++++++---- lib/view/expense/expense_screen.dart | 15 +- 2 files changed, 153 insertions(+), 41 deletions(-) diff --git a/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart b/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart index 7c67c7f..1e66fe6 100644 --- a/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart +++ b/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart @@ -4,6 +4,9 @@ import 'package:marco/controller/dashboard/dashboard_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/utils/utils.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/view/expense/expense_screen.dart'; +import 'package:collection/collection.dart'; class ExpenseByStatusWidget extends StatelessWidget { final DashboardController controller; @@ -16,34 +19,106 @@ class ExpenseByStatusWidget extends StatelessWidget { required String title, required String amount, required String count, + required VoidCallback onTap, }) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - CircleAvatar( - backgroundColor: color.withOpacity(0.15), - radius: 22, - child: Icon(icon, color: color, size: 24), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodyMedium(title, fontWeight: 600), - const SizedBox(height: 2), - MyText.titleMedium(amount, color: Colors.blue, fontWeight: 700), - ], + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + CircleAvatar( + backgroundColor: color.withOpacity(0.15), + radius: 22, + child: Icon(icon, color: color, size: 24), ), - ), - MyText.titleMedium(count, color: Colors.blue, fontWeight: 700), - const Icon(Icons.chevron_right, color: Colors.blue, size: 24), - ], + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium(title, fontWeight: 600), + const SizedBox(height: 2), + MyText.titleMedium(amount, + color: Colors.blue, fontWeight: 700), + ], + ), + ), + MyText.titleMedium(count, color: Colors.blue, fontWeight: 700), + const Icon(Icons.chevron_right, color: Colors.blue, size: 24), + ], + ), ), ); } +// Navigate with status filter + Future _navigateToExpenseWithFilter( + BuildContext context, String statusName) async { + final expenseController = Get.put(ExpenseController()); + + // 1️⃣ Ensure global projects and master data are loaded + if (expenseController.projectsMap.isEmpty) { + await expenseController.fetchGlobalProjects(); + } + + if (expenseController.expenseStatuses.isEmpty) { + await expenseController.fetchMasterData(); + } + + // 2️⃣ Auto-select current project from DashboardController + final dashboardController = Get.find(); + final currentProjectId = + dashboardController.projectController.selectedProjectId.value; + + final projectName = expenseController.projectsMap.entries + .firstWhereOrNull((entry) => entry.value == currentProjectId) + ?.key; + + expenseController.selectedProject.value = projectName ?? ''; + + // 3️⃣ Select status filter + final matchedStatus = expenseController.expenseStatuses.firstWhereOrNull( + (e) => e.name.toLowerCase() == statusName.toLowerCase(), + ); + expenseController.selectedStatus.value = matchedStatus?.id ?? ''; + + // 4️⃣ Fetch expenses immediately with applied filters + await expenseController.fetchExpenses(); + + // 5️⃣ Navigate to Expense screen + Get.to(() => const ExpenseMainScreen()); + } + +// Navigate without status filter + Future _navigateToExpenseWithoutFilter() async { + final expenseController = Get.put(ExpenseController()); + + // Ensure global projects loaded + if (expenseController.projectsMap.isEmpty) { + await expenseController.fetchGlobalProjects(); + } + + // Auto-select current project + final dashboardController = Get.find(); + final currentProjectId = + dashboardController.projectController.selectedProjectId.value; + + final projectName = expenseController.projectsMap.entries + .firstWhereOrNull((entry) => entry.value == currentProjectId) + ?.key; + + expenseController.selectedProject.value = projectName ?? ''; + expenseController.selectedStatus.value = ''; + + // Fetch expenses with project filter (no status) + await expenseController.fetchExpenses(); + + // Navigate to Expense screen + Get.to(() => const ExpenseMainScreen()); + } + @override Widget build(BuildContext context) { return Obx(() { @@ -78,12 +153,17 @@ class ExpenseByStatusWidget extends StatelessWidget { children: [ MyText.titleMedium("Expense - By Status", fontWeight: 700), const SizedBox(height: 16), + + // ✅ Status tiles _buildStatusTile( icon: Icons.currency_rupee, color: Colors.blue, title: "Pending Payment", amount: Utils.formatCurrency(data.processPending.totalAmount), count: data.processPending.count.toString(), + onTap: () { + _navigateToExpenseWithFilter(context, 'Payment Pending'); + }, ), _buildStatusTile( icon: Icons.check_circle_outline, @@ -91,6 +171,9 @@ class ExpenseByStatusWidget extends StatelessWidget { title: "Pending Approve", amount: Utils.formatCurrency(data.approvePending.totalAmount), count: data.approvePending.count.toString(), + onTap: () { + _navigateToExpenseWithFilter(context, 'Approval Pending'); + }, ), _buildStatusTile( icon: Icons.search, @@ -98,6 +181,9 @@ class ExpenseByStatusWidget extends StatelessWidget { title: "Pending Review", amount: Utils.formatCurrency(data.reviewPending.totalAmount), count: data.reviewPending.count.toString(), + onTap: () { + _navigateToExpenseWithFilter(context, 'Review Pending'); + }, ), _buildStatusTile( icon: Icons.insert_drive_file_outlined, @@ -105,27 +191,48 @@ class ExpenseByStatusWidget extends StatelessWidget { title: "Draft", amount: Utils.formatCurrency(data.draft.totalAmount), count: data.draft.count.toString(), + onTap: () { + _navigateToExpenseWithFilter(context, 'Draft'); + }, ), + const SizedBox(height: 16), Divider(color: Colors.grey.shade300), const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, + + // ✅ Total row tap navigation (no filter) + InkWell( + onTap: _navigateToExpenseWithoutFilter, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - MyText.bodyMedium("Project Spendings:", fontWeight: 600), - MyText.bodySmall("(All Processed Payments)", - color: Colors.grey.shade600), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium("Project Spendings:", + fontWeight: 600), + MyText.bodySmall("(All Processed Payments)", + color: Colors.grey.shade600), + ], + ), + Row( + children: [ + MyText.titleLarge( + Utils.formatCurrency(data.totalAmount), + color: Colors.blue, + fontWeight: 700, + ), + const SizedBox(width: 6), + const Icon(Icons.chevron_right, + color: Colors.blue, size: 22), + ], + ) ], ), - MyText.titleLarge( - "${Utils.formatCurrency(data.totalAmount)} >", - color: Colors.blue, - fontWeight: 700, - ) - ], + ), ), ], ), diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index 39d14af..fb2fffa 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -28,12 +28,17 @@ class _ExpenseMainScreenState extends State final projectController = Get.find(); final permissionController = Get.find(); - @override - void initState() { - super.initState(); - _tabController = TabController(length: 2, vsync: this); +@override +void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + + // ✅ Delay fetch until after UI & controller are ready + WidgetsBinding.instance.addPostFrameCallback((_) { + final expenseController = Get.find(); expenseController.fetchExpenses(); - } + }); +} @override void dispose() { From 2f283765c16cdf64a00ba7b7b3173103b452f3cf Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 31 Oct 2025 17:20:44 +0530 Subject: [PATCH 09/18] refactor: Update FAQ and Support screens to use contentTheme for colors and improve UI consistency --- lib/view/faq/faq_screen.dart | 6 +++--- lib/view/support/support_screen.dart | 17 +++++++---------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/lib/view/faq/faq_screen.dart b/lib/view/faq/faq_screen.dart index 452a3a8..1f33a67 100644 --- a/lib/view/faq/faq_screen.dart +++ b/lib/view/faq/faq_screen.dart @@ -112,11 +112,11 @@ class _FAQScreenState extends State with UIMixin { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.1), + color: contentTheme.primary.withOpacity(0.1), shape: BoxShape.circle, ), - child: const Icon(LucideIcons.badge_help, - color: Colors.blue, size: 24), + child: Icon(LucideIcons.badge_help, + color: contentTheme.primary, size: 24), ), const SizedBox(width: 16), Expanded( diff --git a/lib/view/support/support_screen.dart b/lib/view/support/support_screen.dart index 7eef909..2138f38 100644 --- a/lib/view/support/support_screen.dart +++ b/lib/view/support/support_screen.dart @@ -34,13 +34,11 @@ class _SupportScreenState extends State with UIMixin { final Uri uri = Uri.parse(action); if (await canLaunchUrl(uri)) { - // Use LaunchMode.externalApplication for mailto/tel await launchUrl( uri, mode: LaunchMode.externalApplication, ); } else { - // Fallback if no app found ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No app found to open this link.')), ); @@ -95,10 +93,11 @@ class _SupportScreenState extends State with UIMixin { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.red.withOpacity(0.1), + color: contentTheme.primary.withOpacity(0.1), shape: BoxShape.circle, ), - child: Icon(contact["icon"], color: Colors.red, size: 24), + child: + Icon(contact["icon"], color: contentTheme.primary, size: 24), ), const SizedBox(width: 16), Expanded( @@ -145,10 +144,10 @@ class _SupportScreenState extends State with UIMixin { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.red.withOpacity(0.1), + color: contentTheme.primary.withOpacity(0.1), shape: BoxShape.circle, ), - child: Icon(icon, color: Colors.red, size: 28), + child: Icon(icon, color: contentTheme.primary, size: 28), ), const SizedBox(width: 16), Expanded( @@ -176,9 +175,7 @@ class _SupportScreenState extends State with UIMixin { ), body: SafeArea( child: RefreshIndicator( - onRefresh: () async { - // Optional: Implement refresh logic - }, + onRefresh: () async {}, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), child: Column( @@ -190,7 +187,7 @@ class _SupportScreenState extends State with UIMixin { child: MyText.titleLarge( "Need Help?", fontWeight: 700, - color: Colors.red, + color: contentTheme.primary, ), ), const SizedBox(height: 8), From 68cac95908e99c59144c3cfb3d3dc5eca9ebb195 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 1 Nov 2025 11:34:50 +0530 Subject: [PATCH 10/18] chore: Update version number to 1.0.0+12 in pubspec.yaml --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 9e05a09..9d426c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+11 +version: 1.0.0+12 environment: sdk: ^3.5.3 From 4a5fd1c7cc8f8d92d5e0f87ebb0ca11cd77304fc Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 1 Nov 2025 15:38:58 +0530 Subject: [PATCH 11/18] feat: Update Dashboard with Expense Type Report Chart and enhance loading experience with skeleton loaders --- .../dashboard/dashboard_controller.dart | 6 +- lib/helpers/utils/utils.dart | 4 + .../dashbaord/expense_breakdown_chart.dart | 632 ++++++++++++++---- lib/helpers/widgets/my_custom_skeleton.dart | 127 ++-- lib/view/dashboard/dashboard_screen.dart | 6 +- 5 files changed, 585 insertions(+), 190 deletions(-) diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index 3fe4e65..03e4d56 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -61,8 +61,10 @@ class DashboardController extends GetxController { final RxBool isExpenseTypeReportLoading = false.obs; final Rx expenseTypeReportData = Rx(null); - final Rx expenseReportStartDate = DateTime.now().obs; - final Rx expenseReportEndDate = DateTime.now().obs; + final Rx expenseReportStartDate = + DateTime.now().subtract(const Duration(days: 15)).obs; +final Rx expenseReportEndDate = DateTime.now().obs; + @override void onInit() { diff --git a/lib/helpers/utils/utils.dart b/lib/helpers/utils/utils.dart index 10ae5c5..8a72760 100644 --- a/lib/helpers/utils/utils.dart +++ b/lib/helpers/utils/utils.dart @@ -45,6 +45,10 @@ class Utils { return "$hour:$minute${showSecond ? ":" : ""}$second$meridian"; } + static String formatDate(DateTime date) { + return DateFormat('d MMM yyyy').format(date); + } + static String getDateTimeStringFromDateTime(DateTime dateTime, {bool showSecond = true, bool showDate = true, diff --git a/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart index 17a79b3..02ae458 100644 --- a/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart +++ b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart @@ -4,14 +4,16 @@ import 'package:syncfusion_flutter_charts/charts.dart'; import 'package:marco/controller/dashboard/dashboard_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/dashboard/expense_type_report_model.dart'; - import 'package:marco/helpers/utils/utils.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; + class ExpenseTypeReportChart extends StatelessWidget { ExpenseTypeReportChart({Key? key}) : super(key: key); final DashboardController _controller = Get.find(); + // Extended color palette for multiple projects static const List _flatColors = [ Color(0xFFE57373), // Red 300 Color(0xFF64B5F6), // Blue 300 @@ -48,35 +50,67 @@ class ExpenseTypeReportChart extends StatelessWidget { ]; Color _getSeriesColor(int index) => _flatColors[index % _flatColors.length]; + @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; + final isMobile = screenWidth < 600; return Obx(() { final isLoading = _controller.isExpenseTypeReportLoading.value; final data = _controller.expenseTypeReportData.value; return Container( - decoration: _containerDecoration, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.05), + blurRadius: 6, + spreadRadius: 1, + offset: const Offset(0, 2), + ), + ], + ), padding: EdgeInsets.symmetric( - vertical: 16, - horizontal: screenWidth < 600 ? 8 : 20, + vertical: isMobile ? 16 : 20, + horizontal: isMobile ? 12 : 20, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _Header(), + // Chart Header + isLoading + ? SkeletonLoaders.dateSkeletonLoader() + : _ChartHeader(controller: _controller), + const SizedBox(height: 12), - // 👇 replace Expanded with fixed height + + // Date Range Picker + isLoading + ? Row( + children: [ + Expanded(child: SkeletonLoaders.dateSkeletonLoader()), + const SizedBox(width: 8), + Expanded(child: SkeletonLoaders.dateSkeletonLoader()), + ], + ) + : _DateRangePicker(controller: _controller), + + const SizedBox(height: 16), + + // Chart Area SizedBox( - height: 350, // choose based on your design + height: isMobile ? 350 : 400, child: isLoading - ? const Center(child: CircularProgressIndicator()) - : data == null || data.report.isEmpty + ? SkeletonLoaders.chartSkeletonLoader() + : (data == null || data.report.isEmpty) ? const _NoDataMessage() - : _ExpenseChart( + : _ExpenseDonutChart( data: data, getSeriesColor: _getSeriesColor, + isMobile: isMobile, ), ), ], @@ -84,146 +118,510 @@ class ExpenseTypeReportChart extends StatelessWidget { ); }); } - - BoxDecoration get _containerDecoration => BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.05), - blurRadius: 6, - spreadRadius: 1, - offset: const Offset(0, 2), - ), - ], - ); } -class _Header extends StatelessWidget { - const _Header({Key? key}) : super(key: key); +// ----------------------------------------------------------------------------- +// Chart Header +// ----------------------------------------------------------------------------- +class _ChartHeader extends StatelessWidget { + const _ChartHeader({Key? key, required this.controller}) : super(key: key); + + final DashboardController controller; @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodyMedium('Expense Type Overview', fontWeight: 700), - const SizedBox(height: 2), - MyText.bodySmall( - 'Project-wise approved, pending, rejected & processed expenses', - color: Colors.grey), - ], - ); + return Obx(() { + final data = controller.expenseTypeReportData.value; + // Calculate total from totalApprovedAmount only + final total = data?.report.fold( + 0, + (sum, e) => sum + e.totalApprovedAmount, + ) ?? + 0; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium('Project Expense Analytics', fontWeight: 700), + const SizedBox(height: 2), + MyText.bodySmall('Approved expenses by project', + color: Colors.grey), + ], + ), + ), + if (total > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.blueAccent.withOpacity(0.15), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: Colors.blueAccent, width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + MyText.bodySmall( + 'Total Approved', + color: Colors.blueAccent, + fontSize: 10, + ), + MyText.bodyMedium( + Utils.formatCurrency(total), + color: Colors.blueAccent, + fontWeight: 700, + fontSize: 14, + ), + ], + ), + ), + ], + ); + }); } } -// No data -class _NoDataMessage extends StatelessWidget { - const _NoDataMessage({Key? key}) : super(key: key); +// ----------------------------------------------------------------------------- +// Date Range Picker +// ----------------------------------------------------------------------------- +class _DateRangePicker extends StatelessWidget { + const _DateRangePicker({Key? key, required this.controller}) + : super(key: key); + + final DashboardController controller; + + Future _selectDate( + BuildContext context, bool isStartDate, DateTime currentDate) async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: currentDate, + firstDate: DateTime(2020), + lastDate: DateTime.now(), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: Colors.blueAccent, + onPrimary: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + + if (picked != null) { + if (isStartDate) { + controller.expenseReportStartDate.value = picked; + } else { + controller.expenseReportEndDate.value = picked; + } + } + } @override Widget build(BuildContext context) { - return SizedBox( - height: 200, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.info_outline, color: Colors.grey.shade400, size: 48), - const SizedBox(height: 10), - MyText.bodyMedium( - 'No expense data available.', - textAlign: TextAlign.center, - color: Colors.grey.shade500, + return Obx(() { + final startDate = controller.expenseReportStartDate.value; + final endDate = controller.expenseReportEndDate.value; + + return Row( + children: [ + _DateBox( + label: 'Start Date', + date: startDate, + onTap: () => _selectDate(context, true, startDate), + icon: Icons.calendar_today_outlined, + ), + const SizedBox(width: 8), + _DateBox( + label: 'End Date', + date: endDate, + onTap: () => _selectDate(context, false, endDate), + icon: Icons.event_outlined, + ), + ], + ); + }); + } +} + +class _DateBox extends StatelessWidget { + final String label; + final DateTime date; + final VoidCallback onTap; + final IconData icon; + + const _DateBox({ + Key? key, + required this.label, + required this.date, + required this.onTap, + required this.icon, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(5), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Colors.blueAccent.withOpacity(0.08), + border: Border.all(color: Colors.blueAccent.withOpacity(0.3)), + borderRadius: BorderRadius.circular(5), ), - ], + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.blueAccent.withOpacity(0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Icon( + icon, + size: 14, + color: Colors.blueAccent, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 10, + color: Colors.grey.shade600, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + Utils.formatDate(date), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.blueAccent, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), ), ), ); } } -// Chart -class _ExpenseChart extends StatelessWidget { - const _ExpenseChart({ +// ----------------------------------------------------------------------------- +// No Data Message +// ----------------------------------------------------------------------------- +class _NoDataMessage extends StatelessWidget { + const _NoDataMessage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.donut_large_outlined, + color: Colors.grey.shade400, size: 48), + const SizedBox(height: 10), + MyText.bodyMedium( + 'No expense data available for this range.', + textAlign: TextAlign.center, + color: Colors.grey.shade500, + ), + ], + ), + ); + } +} + +// ----------------------------------------------------------------------------- +// Donut Chart +// ----------------------------------------------------------------------------- +class _ExpenseDonutChart extends StatefulWidget { + const _ExpenseDonutChart({ Key? key, required this.data, required this.getSeriesColor, + required this.isMobile, }) : super(key: key); final ExpenseTypeReportData data; final Color Function(int index) getSeriesColor; + final bool isMobile; + + @override + State<_ExpenseDonutChart> createState() => _ExpenseDonutChartState(); +} + +class _ExpenseDonutChartState extends State<_ExpenseDonutChart> { + late TooltipBehavior _tooltipBehavior; + late SelectionBehavior _selectionBehavior; + + @override + void initState() { + super.initState(); + _tooltipBehavior = TooltipBehavior( + enable: true, + format: 'point.x: point.y', + color: Colors.blueAccent, + textStyle: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + elevation: 4, + animationDuration: 300, + ); + + _selectionBehavior = SelectionBehavior( + enable: true, + selectedColor: Colors.white, + selectedBorderColor: Colors.blueAccent, + selectedBorderWidth: 3, + unselectedOpacity: 0.5, + ); + } @override Widget build(BuildContext context) { - final List> chartSeries = [ - { - 'name': 'Approved', - 'color': getSeriesColor(0), - 'yValue': (ExpenseTypeReportItem e) => e.totalApprovedAmount, - }, - { - 'name': 'Pending', - 'color': getSeriesColor(1), - 'yValue': (ExpenseTypeReportItem e) => e.totalPendingAmount, - }, - { - 'name': 'Rejected', - 'color': getSeriesColor(2), - 'yValue': (ExpenseTypeReportItem e) => e.totalRejectedAmount, - }, - { - 'name': 'Processed', - 'color': getSeriesColor(3), - 'yValue': (ExpenseTypeReportItem e) => e.totalProcessedAmount, - }, - ]; + // Create donut data from project items using totalApprovedAmount + final List<_DonutData> donutData = widget.data.report + .asMap() + .entries + .map((entry) => _DonutData( + entry.value.projectName.isEmpty + ? 'Project ${entry.key + 1}' + : entry.value.projectName, + entry.value.totalApprovedAmount, + widget.getSeriesColor(entry.key), + Icons.folder_outlined, + )) + .toList(); - return SfCartesianChart( - tooltipBehavior: TooltipBehavior( - enable: true, - shared: true, - builder: (data, point, series, pointIndex, seriesIndex) { - final ExpenseTypeReportItem item = data; - final value = chartSeries[seriesIndex]['yValue'](item); - return Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black87, - borderRadius: BorderRadius.circular(6), + // Filter out zero values for cleaner visualization + final filteredData = donutData.where((data) => data.value > 0).toList(); + + if (filteredData.isEmpty) { + return const Center( + child: Text( + 'No approved expense data for the selected range.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + ); + } + + // Calculate total for center display + final total = filteredData.fold(0, (sum, item) => sum + item.value); + + return Column( + children: [ + Expanded( + child: SfCircularChart( + margin: EdgeInsets.zero, + legend: Legend( + isVisible: true, + position: LegendPosition.bottom, + overflowMode: LegendItemOverflowMode.wrap, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + iconHeight: 10, + iconWidth: 10, + itemPadding: widget.isMobile ? 6 : 10, + padding: widget.isMobile ? 10 : 14, ), - child: Text( - '${chartSeries[seriesIndex]['name']}: ${Utils.formatCurrency(value)}', - style: const TextStyle(color: Colors.white, fontSize: 12), + tooltipBehavior: _tooltipBehavior, + // Center annotation showing total approved amount + annotations: [ + CircularChartAnnotation( + widget: Container( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle_outline, + color: Colors.green.shade600, + size: widget.isMobile ? 28 : 32, + ), + const SizedBox(height: 6), + Text( + 'Total Approved', + style: TextStyle( + fontSize: widget.isMobile ? 11 : 12, + color: Colors.grey.shade600, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + Utils.formatCurrency(total), + style: TextStyle( + fontSize: widget.isMobile ? 16 : 18, + color: Colors.green.shade700, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + '${filteredData.length} ${filteredData.length == 1 ? 'Project' : 'Projects'}', + style: TextStyle( + fontSize: widget.isMobile ? 9 : 10, + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + series: >[ + DoughnutSeries<_DonutData, String>( + dataSource: filteredData, + xValueMapper: (datum, _) => datum.label, + yValueMapper: (datum, _) => datum.value, + pointColorMapper: (datum, _) => datum.color, + dataLabelMapper: (datum, _) { + final percentage = + (datum.value / total * 100).toStringAsFixed(1); + return widget.isMobile + ? '$percentage%' + : '${datum.label}\n$percentage%'; + }, + dataLabelSettings: DataLabelSettings( + isVisible: true, + labelPosition: ChartDataLabelPosition.outside, + connectorLineSettings: ConnectorLineSettings( + type: ConnectorType.curve, + length: widget.isMobile ? '15%' : '18%', + width: 1.5, + color: Colors.grey.shade400, + ), + textStyle: TextStyle( + fontSize: widget.isMobile ? 10 : 11, + fontWeight: FontWeight.w700, + color: Colors.black87, + ), + labelIntersectAction: LabelIntersectAction.shift, + ), + // Donut chart specific properties + innerRadius: widget.isMobile ? '60%' : '65%', + radius: widget.isMobile ? '75%' : '80%', + + // Reduced explode for cleaner donut look + explode: true, + explodeAll: false, + explodeIndex: 0, + explodeOffset: '5%', + explodeGesture: ActivationMode.singleTap, + + startAngle: 90, + endAngle: 450, + strokeColor: Colors.white, + strokeWidth: 2.5, + enableTooltip: true, + animationDuration: 1000, + selectionBehavior: _selectionBehavior, + opacity: 0.95, + ), + ], + ), + ), + if (!widget.isMobile) ...[ + const SizedBox(height: 12), + _ProjectSummary(donutData: filteredData), + ], + ], + ); + } +} + +// ----------------------------------------------------------------------------- +// Project Summary (Desktop only) +// ----------------------------------------------------------------------------- +class _ProjectSummary extends StatelessWidget { + const _ProjectSummary({Key? key, required this.donutData}) : super(key: key); + + final List<_DonutData> donutData; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: donutData.map((data) { + return Container( + constraints: const BoxConstraints(minWidth: 120), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: data.color.withOpacity(0.15), + borderRadius: BorderRadius.circular(5), + border: Border.all( + color: data.color.withOpacity(0.4), + width: 1, ), - ); - }, - ), - legend: Legend(isVisible: true, position: LegendPosition.bottom), - primaryXAxis: CategoryAxis(labelRotation: 45), - primaryYAxis: NumericAxis( - // ✅ Format axis labels with Utils - axisLabelFormatter: (AxisLabelRenderDetails details) { - final num value = details.value; - return ChartAxisLabel( - Utils.formatCurrency(value), const TextStyle(fontSize: 10)); - }, - axisLine: const AxisLine(width: 0), - majorGridLines: const MajorGridLines(width: 0.5), - ), - series: chartSeries.map((seriesInfo) { - return ColumnSeries( - dataSource: data.report, - xValueMapper: (item, _) => item.projectName, - yValueMapper: (item, _) => seriesInfo['yValue'](item), - name: seriesInfo['name'], - color: seriesInfo['color'], - dataLabelSettings: const DataLabelSettings(isVisible: true), - // ✅ Format data labels as well - dataLabelMapper: (item, _) => - Utils.formatCurrency(seriesInfo['yValue'](item)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(data.icon, color: data.color, size: 18), + const SizedBox(height: 4), + Text( + data.label, + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade700, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + Utils.formatCurrency(data.value), + style: TextStyle( + fontSize: 12, + color: data.color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), ); }).toList(), ); } } + +class _DonutData { + final String label; + final double value; + final Color color; + final IconData icon; + + _DonutData(this.label, this.value, this.color, this.icon); +} diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index 7578b54..4537143 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -33,6 +33,65 @@ class SkeletonLoaders { ); } + // Chart Skeleton Loader (Donut Chart) + static Widget chartSkeletonLoader() { + return MyCard.bordered( + paddingAll: 16, + borderRadiusAll: 12, + shadow: MyShadow( + elevation: 1.5, + position: MyShadowPosition.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Chart Header Placeholder + Container( + height: 16, + width: 180, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(height: 16), + + // Donut Skeleton Placeholder + Expanded( + child: Center( + child: Container( + width: 180, + height: 180, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey.shade300.withOpacity(0.5), + ), + ), + ), + ), + + const SizedBox(height: 16), + + // Legend placeholders + Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate(5, (index) { + return Container( + width: 100, + height: 14, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ); + }), + ), + ], + ), + ); + } + // Date Skeleton Loader static Widget dateSkeletonLoader() { return Container( @@ -180,74 +239,6 @@ class SkeletonLoaders { ); } -// Chart Skeleton Loader - static Widget chartSkeletonLoader() { - return MyCard.bordered( - margin: MySpacing.only(bottom: 12), - paddingAll: 16, - borderRadiusAll: 16, - shadow: MyShadow( - elevation: 1.5, - position: MyShadowPosition.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Chart Title Placeholder - Container( - height: 14, - width: 120, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(6), - ), - ), - MySpacing.height(20), - - // Chart Bars (variable height for realism) - SizedBox( - height: 180, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: List.generate(6, (index) { - return Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Container( - height: - (60 + (index * 20)).toDouble(), // fake chart shape - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(6), - ), - ), - ), - ); - }), - ), - ), - - MySpacing.height(16), - - // X-Axis Labels - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: List.generate(6, (index) { - return Container( - height: 10, - width: 30, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(4), - ), - ); - }), - ), - ], - ), - ); - } - // Document List Skeleton Loader static Widget documentSkeletonLoader() { return Column( diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 89f8d5d..a8f2b5d 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -12,7 +12,7 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/dashbaord/attendance_overview_chart.dart'; import 'package:marco/helpers/widgets/dashbaord/expense_by_status_widget.dart'; import 'package:marco/view/layouts/layout.dart'; -// import 'package:marco/helpers/widgets/dashbaord/expense_breakdown_chart.dart'; +import 'package:marco/helpers/widgets/dashbaord/expense_breakdown_chart.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -61,8 +61,8 @@ class _DashboardScreenState extends State with UIMixin { ExpenseByStatusWidget(controller: dashboardController), MySpacing.height(24), - // // Expense Type Report Chart - // ExpenseTypeReportChart(), + // Expense Type Report Chart + ExpenseTypeReportChart(), ], ), ), From 9890fbaffe282eaf26c130c8c6db173754808d99 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 1 Nov 2025 15:41:05 +0530 Subject: [PATCH 12/18] refactor: Update ExpenseDonutChart data labels to display formatted currency instead of percentage --- .../widgets/dashbaord/expense_breakdown_chart.dart | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart index 02ae458..a1f9145 100644 --- a/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart +++ b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart @@ -7,7 +7,6 @@ import 'package:marco/model/dashboard/expense_type_report_model.dart'; import 'package:marco/helpers/utils/utils.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; - class ExpenseTypeReportChart extends StatelessWidget { ExpenseTypeReportChart({Key? key}) : super(key: key); @@ -504,11 +503,10 @@ class _ExpenseDonutChartState extends State<_ExpenseDonutChart> { yValueMapper: (datum, _) => datum.value, pointColorMapper: (datum, _) => datum.color, dataLabelMapper: (datum, _) { - final percentage = - (datum.value / total * 100).toStringAsFixed(1); + final amount = Utils.formatCurrency(datum.value); return widget.isMobile - ? '$percentage%' - : '${datum.label}\n$percentage%'; + ? '$amount' + : '${datum.label}\n$amount'; }, dataLabelSettings: DataLabelSettings( isVisible: true, @@ -526,17 +524,13 @@ class _ExpenseDonutChartState extends State<_ExpenseDonutChart> { ), labelIntersectAction: LabelIntersectAction.shift, ), - // Donut chart specific properties innerRadius: widget.isMobile ? '60%' : '65%', radius: widget.isMobile ? '75%' : '80%', - - // Reduced explode for cleaner donut look explode: true, explodeAll: false, explodeIndex: 0, explodeOffset: '5%', explodeGesture: ActivationMode.singleTap, - startAngle: 90, endAngle: 450, strokeColor: Colors.white, From 99f6c594b9c432ec24dfb909dbc216f40e4a1f64 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 1 Nov 2025 15:52:59 +0530 Subject: [PATCH 13/18] feat: Enhance tooltip behavior in ExpenseDonutChart to display formatted currency and percentage --- .../dashbaord/expense_breakdown_chart.dart | 46 ++++++++++++++++--- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart index a1f9145..384ed66 100644 --- a/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart +++ b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart @@ -381,13 +381,45 @@ class _ExpenseDonutChartState extends State<_ExpenseDonutChart> { super.initState(); _tooltipBehavior = TooltipBehavior( enable: true, - format: 'point.x: point.y', - color: Colors.blueAccent, - textStyle: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600, - ), + builder: (dynamic data, dynamic point, dynamic series, int pointIndex, + int seriesIndex) { + final total = widget.data.report + .fold(0, (sum, e) => sum + e.totalApprovedAmount); + final value = data.value as double; + final percentage = total > 0 ? (value / total * 100) : 0; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: Colors.blueAccent, + borderRadius: BorderRadius.circular(4), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.label, + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + Text( + Utils.formatCurrency(value), + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.w600), + ), + Text( + '${percentage.toStringAsFixed(1)}%', + style: const TextStyle( + color: Colors.white70, + fontWeight: FontWeight.w500, + fontSize: 10), + ), + ], + ), + ); + }, elevation: 4, animationDuration: 300, ); From b4be463da698b0b6c58ab7a6362515763d7493fd Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 1 Nov 2025 17:14:44 +0530 Subject: [PATCH 14/18] refactor: Adjust inner radius of ExpenseDonutChart for improved visual clarity --- lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart index 384ed66..8204c16 100644 --- a/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart +++ b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart @@ -556,7 +556,7 @@ class _ExpenseDonutChartState extends State<_ExpenseDonutChart> { ), labelIntersectAction: LabelIntersectAction.shift, ), - innerRadius: widget.isMobile ? '60%' : '65%', + innerRadius: widget.isMobile ? '40%' : '45%', radius: widget.isMobile ? '75%' : '80%', explode: true, explodeAll: false, From 4f0261bf0b784d301326e08ba679395136c31cee Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 3 Nov 2025 16:43:29 +0530 Subject: [PATCH 15/18] feat: Add Monthly Expense Dashboard Chart and related data models --- .../attendance_screen_controller.dart | 6 - .../dashboard/dashboard_controller.dart | 121 +++- lib/helpers/services/api_service.dart | 43 ++ .../services/notification_action_handler.dart | 56 +- .../dashbaord/expense_breakdown_chart.dart | 6 +- .../monthly_expense_dashboard_chart.dart | 520 ++++++++++++++++++ .../dashboard/monthly_expence_model.dart | 70 +++ lib/view/dashboard/dashboard_screen.dart | 6 +- 8 files changed, 797 insertions(+), 31 deletions(-) create mode 100644 lib/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart create mode 100644 lib/model/dashboard/monthly_expence_model.dart diff --git a/lib/controller/attendance/attendance_screen_controller.dart b/lib/controller/attendance/attendance_screen_controller.dart index 8e45d48..e0751e8 100644 --- a/lib/controller/attendance/attendance_screen_controller.dart +++ b/lib/controller/attendance/attendance_screen_controller.dart @@ -50,12 +50,6 @@ class AttendanceController extends GetxController { void onInit() { super.onInit(); _initializeDefaults(); - - // 🔹 Fetch organizations for the selected project - final projectId = Get.find().selectedProject?.id; - if (projectId != null) { - fetchOrganizations(projectId); - } } void _initializeDefaults() { diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index 03e4d56..50e801a 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -5,6 +5,8 @@ import 'package:marco/controller/project_controller.dart'; import 'package:marco/model/dashboard/project_progress_model.dart'; import 'package:marco/model/dashboard/pending_expenses_model.dart'; import 'package:marco/model/dashboard/expense_type_report_model.dart'; +import 'package:marco/model/dashboard/monthly_expence_model.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; class DashboardController extends GetxController { // ========================= @@ -49,7 +51,7 @@ class DashboardController extends GetxController { final List ranges = ['7D', '15D', '30D']; // Inject ProjectController - final ProjectController projectController = Get.find(); + final ProjectController projectController = Get.put(ProjectController()); // Pending Expenses overview // ========================= final RxBool isPendingExpensesLoading = false.obs; @@ -61,10 +63,37 @@ class DashboardController extends GetxController { final RxBool isExpenseTypeReportLoading = false.obs; final Rx expenseTypeReportData = Rx(null); - final Rx expenseReportStartDate = - DateTime.now().subtract(const Duration(days: 15)).obs; -final Rx expenseReportEndDate = DateTime.now().obs; + final Rx expenseReportStartDate = + DateTime.now().subtract(const Duration(days: 15)).obs; + final Rx expenseReportEndDate = DateTime.now().obs; + // ========================= + // Monthly Expense Report + // ========================= + final RxBool isMonthlyExpenseLoading = false.obs; + final RxList monthlyExpenseList = + [].obs; + // ========================= + // Monthly Expense Report Filters + // ========================= + final Rx selectedMonthlyExpenseDuration = + MonthlyExpenseDuration.twelveMonths.obs; + final RxInt selectedMonthsCount = 12.obs; + final RxList expenseTypes = [].obs; + final Rx selectedExpenseType = Rx(null); + + void updateSelectedExpenseType(ExpenseTypeModel? type) { + selectedExpenseType.value = type; + + // Debug print to verify + print('Selected: ${type?.name ?? "All Types"}'); + + if (type == null) { + fetchMonthlyExpenses(); + } else { + fetchMonthlyExpenses(categoryId: type.id); + } + } @override void onInit() { @@ -173,10 +202,84 @@ final Rx expenseReportEndDate = DateTime.now().obs; fetchExpenseTypeReport( startDate: expenseReportStartDate.value, endDate: expenseReportEndDate.value, - ) + ), + fetchMonthlyExpenses(), + fetchMasterData() ]); } + void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) { + selectedMonthlyExpenseDuration.value = duration; + + // Set months count based on selection + switch (duration) { + case MonthlyExpenseDuration.oneMonth: + selectedMonthsCount.value = 1; + break; + case MonthlyExpenseDuration.threeMonths: + selectedMonthsCount.value = 3; + break; + case MonthlyExpenseDuration.sixMonths: + selectedMonthsCount.value = 6; + break; + case MonthlyExpenseDuration.twelveMonths: + selectedMonthsCount.value = 12; + break; + case MonthlyExpenseDuration.all: + selectedMonthsCount.value = 0; // 0 = All months in your API + break; + } + + // Re-fetch updated data + fetchMonthlyExpenses(); + } + + Future fetchMasterData() async { + try { + final expenseTypesData = await ApiService.getMasterExpenseTypes(); + if (expenseTypesData is List) { + expenseTypes.value = + expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + } + } catch (e) { + logSafe('Error fetching master data', level: LogLevel.error, error: e); + } + } + + Future fetchMonthlyExpenses({String? categoryId}) async { + try { + isMonthlyExpenseLoading.value = true; + + int months = selectedMonthsCount.value; + logSafe( + 'Fetching Monthly Expense Report for last $months months' + '${categoryId != null ? ' (categoryId: $categoryId)' : ''}', + level: LogLevel.info, + ); + + final response = await ApiService.getDashboardMonthlyExpensesApi( + categoryId: categoryId, + months: months, + ); + + if (response != null && response.success) { + monthlyExpenseList.value = response.data; + logSafe('Monthly Expense Report fetched successfully.', + level: LogLevel.info); + } else { + monthlyExpenseList.clear(); + logSafe('Failed to fetch Monthly Expense Report.', + level: LogLevel.error); + } + } catch (e, st) { + monthlyExpenseList.clear(); + logSafe('Error fetching Monthly Expense Report', + level: LogLevel.error, error: e, stackTrace: st); + } finally { + isMonthlyExpenseLoading.value = false; + } + } + Future fetchPendingExpenses() async { final String projectId = projectController.selectedProjectId.value; if (projectId.isEmpty) return; @@ -345,3 +448,11 @@ final Rx expenseReportEndDate = DateTime.now().obs; } } } + +enum MonthlyExpenseDuration { + oneMonth, + threeMonths, + sixMonths, + twelveMonths, + all, +} diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 6dbfd33..13aecfa 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -21,6 +21,7 @@ import 'package:marco/model/document/document_version_model.dart'; import 'package:marco/model/attendance/organization_per_project_list_model.dart'; import 'package:marco/model/dashboard/pending_expenses_model.dart'; import 'package:marco/model/dashboard/expense_type_report_model.dart'; +import 'package:marco/model/dashboard/monthly_expence_model.dart'; class ApiService { static const bool enableLogs = true; @@ -290,6 +291,48 @@ class ApiService { } } + /// Get Monthly Expense Report (categoryId is optional) + static Future + getDashboardMonthlyExpensesApi({ + String? categoryId, + int months = 12, + }) async { + const endpoint = ApiEndpoints.getDashboardMonthlyExpenses; + logSafe("Fetching Dashboard Monthly Expenses for last $months months"); + + try { + final queryParams = { + 'months': months.toString(), + if (categoryId != null && categoryId.isNotEmpty) + 'categoryId': categoryId, + }; + + final response = await _getRequest( + endpoint, + queryParams: queryParams, + ); + + if (response == null) { + logSafe("Monthly Expense request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData(response, + label: "Dashboard Monthly Expenses"); + + if (jsonResponse != null) { + return DashboardMonthlyExpenseResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getDashboardMonthlyExpensesApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + /// Get Expense Type Report static Future getExpenseTypeReportApi({ required String projectId, diff --git a/lib/helpers/services/notification_action_handler.dart b/lib/helpers/services/notification_action_handler.dart index 3f8a54a..0293db9 100644 --- a/lib/helpers/services/notification_action_handler.dart +++ b/lib/helpers/services/notification_action_handler.dart @@ -66,7 +66,6 @@ class NotificationActionHandler { } break; case 'Team_Modified': - // Call method to handle team modifications and dashboard update _handleDashboardUpdate(data); break; /// 🔹 Expenses @@ -106,7 +105,6 @@ class NotificationActionHandler { /// ---------------------- HANDLERS ---------------------- - static bool _isAttendanceAction(String? action) { const validActions = { 'CHECK_IN', @@ -120,13 +118,17 @@ class NotificationActionHandler { } static void _handleExpenseUpdated(Map data) { + if (!_isCurrentProject(data)) { + _logger.i("ℹ️ Ignored expense update from another project."); + return; + } + final expenseId = data['ExpenseId']; if (expenseId == null) { _logger.w("⚠️ Expense update received without ExpenseId: $data"); return; } - // Update Expense List _safeControllerUpdate( onFound: (controller) async { await controller.fetchExpenses(); @@ -136,7 +138,6 @@ class NotificationActionHandler { '✅ ExpenseController refreshed from expense notification.', ); - // Update Expense Detail (if open and matches this expenseId) _safeControllerUpdate( onFound: (controller) async { if (controller.expense.value?.id == expenseId) { @@ -151,6 +152,11 @@ class NotificationActionHandler { } static void _handleAttendanceUpdated(Map data) { + if (!_isCurrentProject(data)) { + _logger.i("ℹ️ Ignored attendance update from another project."); + return; + } + _safeControllerUpdate( onFound: (controller) => controller.refreshDataFromNotification( projectId: data['ProjectId'], @@ -160,13 +166,18 @@ class NotificationActionHandler { ); } + /// ---------------------- DOCUMENT HANDLER ---------------------- static void _handleDocumentModified(Map data) { + if (!_isCurrentProject(data)) { + _logger.i("ℹ️ Ignored document update from another project."); + return; + } + String entityTypeId; String entityId; String? documentId = data['DocumentId']; - // Determine entity type and ID if (data['Keyword'] == 'Employee_Document_Modified') { entityTypeId = Permissions.employeeEntity; entityId = data['EmployeeId'] ?? ''; @@ -186,7 +197,6 @@ class NotificationActionHandler { _logger.i( "🔔 Document notification received: keyword=${data['Keyword']}, entityTypeId=$entityTypeId, entityId=$entityId, documentId=$documentId"); - // Refresh Document List if (Get.isRegistered()) { _safeControllerUpdate( onFound: (controller) async { @@ -204,11 +214,9 @@ class NotificationActionHandler { _logger.w('⚠️ DocumentController not registered, skipping list refresh.'); } - // Refresh Document Details (if open) if (documentId != null && Get.isRegistered()) { _safeControllerUpdate( onFound: (controller) async { - // Refresh details regardless of current document await controller.fetchDocumentDetails(documentId); _logger.i( "✅ DocumentDetailsController refreshed for Document $documentId"); @@ -225,13 +233,10 @@ class NotificationActionHandler { /// ---------------------- DIRECTORY HANDLERS ---------------------- static void _handleContactModified(Map data) { - final contactId = data['ContactId']; - - // Always refresh the contact list _safeControllerUpdate( onFound: (controller) { controller.fetchContacts(); - // If a specific contact is provided, refresh its notes as well + final contactId = data['ContactId']; if (contactId != null) { controller.fetchCommentsForContact(contactId); } @@ -242,7 +247,6 @@ class NotificationActionHandler { '✅ Directory contacts (and notes if applicable) refreshed from notification.', ); - // Refresh notes globally as well _safeControllerUpdate( onFound: (controller) => controller.fetchNotes(), notFoundMessage: '⚠️ NotesController not found, cannot refresh notes.', @@ -251,7 +255,6 @@ class NotificationActionHandler { } static void _handleContactNoteModified(Map data) { - // Refresh both contacts and notes when a note is modified _handleContactModified(data); } @@ -273,6 +276,11 @@ class NotificationActionHandler { /// ---------------------- DASHBOARD HANDLER ---------------------- static void _handleDashboardUpdate(Map data) { + if (!_isCurrentProject(data)) { + _logger.i("ℹ️ Ignored dashboard update from another project."); + return; + } + _safeControllerUpdate( onFound: (controller) async { final type = data['type'] ?? ''; @@ -296,11 +304,9 @@ class NotificationActionHandler { controller.projectController.selectedProjectId.value; final projectIdsString = data['ProjectIds'] ?? ''; - // Convert comma-separated string to List final notificationProjectIds = projectIdsString.split(',').map((e) => e.trim()).toList(); - // Refresh only if current project ID is in the list if (notificationProjectIds.contains(currentProjectId)) { await controller.fetchDashboardTeams(projectId: currentProjectId); } @@ -324,6 +330,24 @@ class NotificationActionHandler { /// ---------------------- UTILITY ---------------------- + static bool _isCurrentProject(Map data) { + try { + final dashboard = Get.find(); + final currentProjectId = + dashboard.projectController.selectedProjectId.value; + final notificationProjectId = data['ProjectId']?.toString(); + + if (notificationProjectId == null || notificationProjectId.isEmpty) { + return true; // No project info → allow global refresh + } + + return notificationProjectId == currentProjectId; + } catch (e) { + _logger.w("⚠️ Could not verify project context: $e"); + return true; + } + } + static void _safeControllerUpdate({ required void Function(T controller) onFound, required String notFoundMessage, diff --git a/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart index 8204c16..fda9928 100644 --- a/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart +++ b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart @@ -480,8 +480,8 @@ class _ExpenseDonutChartState extends State<_ExpenseDonutChart> { ), iconHeight: 10, iconWidth: 10, - itemPadding: widget.isMobile ? 6 : 10, - padding: widget.isMobile ? 10 : 14, + itemPadding: widget.isMobile ? 12 : 20, + padding: widget.isMobile ? 20 : 28, ), tooltipBehavior: _tooltipBehavior, // Center annotation showing total approved amount @@ -556,7 +556,7 @@ class _ExpenseDonutChartState extends State<_ExpenseDonutChart> { ), labelIntersectAction: LabelIntersectAction.shift, ), - innerRadius: widget.isMobile ? '40%' : '45%', + innerRadius: widget.isMobile ? '65%' : '70%', radius: widget.isMobile ? '75%' : '80%', explode: true, explodeAll: false, diff --git a/lib/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart b/lib/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart new file mode 100644 index 0000000..d3e27cf --- /dev/null +++ b/lib/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart @@ -0,0 +1,520 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; +import 'package:marco/controller/dashboard/dashboard_controller.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/utils/utils.dart'; +import 'package:intl/intl.dart'; + +// ========================= +// CONSTANTS +// ========================= +class _ChartConstants { + static const List flatColors = [ + Color(0xFFE57373), + Color(0xFF64B5F6), + Color(0xFF81C784), + Color(0xFFFFB74D), + Color(0xFFBA68C8), + Color(0xFFFF8A65), + Color(0xFF4DB6AC), + Color(0xFFA1887F), + Color(0xFFDCE775), + Color(0xFF9575CD), + Color(0xFF7986CB), + Color(0xFFAED581), + Color(0xFFFF7043), + Color(0xFF4FC3F7), + Color(0xFFFFD54F), + Color(0xFF90A4AE), + Color(0xFFE573BB), + Color(0xFF81D4FA), + Color(0xFFBCAAA4), + Color(0xFFA5D6A7), + Color(0xFFCE93D8), + Color(0xFFFF8A65), + Color(0xFF80CBC4), + Color(0xFFFFF176), + Color(0xFF90CAF9), + Color(0xFFE0E0E0), + Color(0xFFF48FB1), + Color(0xFFA1887F), + Color(0xFFB0BEC5), + Color(0xFF81C784), + Color(0xFFFFB74D), + Color(0xFF64B5F6), + ]; + + static const Map durationLabels = { + MonthlyExpenseDuration.oneMonth: "1M", + MonthlyExpenseDuration.threeMonths: "3M", + MonthlyExpenseDuration.sixMonths: "6M", + MonthlyExpenseDuration.twelveMonths: "12M", + MonthlyExpenseDuration.all: "All", + }; + + static const double mobileBreakpoint = 600; + static const double mobileChartHeight = 350; + static const double desktopChartHeight = 400; + static const double mobilePadding = 12; + static const double desktopPadding = 20; + static const double mobileVerticalPadding = 16; + static const double desktopVerticalPadding = 20; + static const double noDataIconSize = 48; + static const double noDataContainerHeight = 220; + static const double labelRotation = 45; + static const int tooltipAnimationDuration = 300; +} + +// ========================= +// MAIN CHART WIDGET +// ========================= +class MonthlyExpenseDashboardChart extends StatelessWidget { + MonthlyExpenseDashboardChart({Key? key}) : super(key: key); + + final DashboardController _controller = Get.find(); + + Color _getColorForIndex(int index) => + _ChartConstants.flatColors[index % _ChartConstants.flatColors.length]; + + BoxDecoration get _containerDecoration => BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.05), + blurRadius: 6, + spreadRadius: 1, + offset: const Offset(0, 2), + ), + ], + ); + + bool _isMobileLayout(double screenWidth) => + screenWidth < _ChartConstants.mobileBreakpoint; + + double _calculateTotalExpense(List data) => + data.fold(0, (sum, item) => sum + item.total); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final isMobile = _isMobileLayout(screenWidth); + + return Obx(() { + final isLoading = _controller.isMonthlyExpenseLoading.value; + final expenseData = _controller.monthlyExpenseList; + final selectedDuration = _controller.selectedMonthlyExpenseDuration.value; + final totalExpense = _calculateTotalExpense(expenseData); + + return Container( + decoration: _containerDecoration, + padding: EdgeInsets.symmetric( + vertical: isMobile + ? _ChartConstants.mobileVerticalPadding + : _ChartConstants.desktopVerticalPadding, + horizontal: isMobile + ? _ChartConstants.mobilePadding + : _ChartConstants.desktopPadding, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ChartHeader( + controller: _controller, // pass controller explicitly + selectedDuration: selectedDuration, + onDurationChanged: _controller.updateMonthlyExpenseDuration, + totalExpense: totalExpense, + ), + const SizedBox(height: 12), + SizedBox( + height: isMobile + ? _ChartConstants.mobileChartHeight + : _ChartConstants.desktopChartHeight, + child: _buildChartContent( + isLoading: isLoading, + data: expenseData, + isMobile: isMobile, + totalExpense: totalExpense, + ), + ), + ], + ), + ); + }); + } + + Widget _buildChartContent({ + required bool isLoading, + required List data, + required bool isMobile, + required double totalExpense, + }) { + if (isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (data.isEmpty) { + return const _EmptyDataWidget(); + } + + return _MonthlyExpenseChart( + data: data, + getColor: _getColorForIndex, + isMobile: isMobile, + totalExpense: totalExpense, + ); + } +} + +// ========================= +// HEADER WIDGET +// ========================= +class _ChartHeader extends StatelessWidget { + const _ChartHeader({ + Key? key, + required this.controller, // added + required this.selectedDuration, + required this.onDurationChanged, + required this.totalExpense, + }) : super(key: key); + + final DashboardController controller; // added + final MonthlyExpenseDuration selectedDuration; + final ValueChanged onDurationChanged; + final double totalExpense; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(), + const SizedBox(height: 2), + _buildSubtitle(), + const SizedBox(height: 8), + // ========================== + // Row with popup menu on the right + // ========================== + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Obx(() { + final selectedType = controller.selectedExpenseType.value; + + return PopupMenuButton( + tooltip: 'Filter by Expense Type', + onSelected: (String value) { + if (value == 'all') { + controller.updateSelectedExpenseType(null); + } else { + final type = controller.expenseTypes + .firstWhere((t) => t.id == value); + controller.updateSelectedExpenseType(type); + } + }, + itemBuilder: (context) { + final types = controller.expenseTypes; + return [ + PopupMenuItem( + value: 'all', + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('All Types'), + if (selectedType == null) + const Icon(Icons.check, + size: 16, color: Colors.blueAccent), + ], + ), + ), + ...types.map((type) => PopupMenuItem( + value: type.id, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(type.name), + if (selectedType?.id == type.id) + const Icon(Icons.check, + size: 16, color: Colors.blueAccent), + ], + ), + )), + ]; + }, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + selectedType?.name ?? 'All Types', + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14), + ), + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 20), + ], + ), + ), + ); + }), + ], + ), + + const SizedBox(height: 8), + _buildDurationSelector(), + ], + ); + } + + Widget _buildTitle() => + MyText.bodyMedium('Monthly Expense Overview', fontWeight: 700); + + Widget _buildSubtitle() => + MyText.bodySmall('Month-wise total expense', color: Colors.grey); + + Widget _buildDurationSelector() { + return Row( + children: _ChartConstants.durationLabels.entries + .map((entry) => _DurationChip( + label: entry.value, + duration: entry.key, + isSelected: selectedDuration == entry.key, + onSelected: onDurationChanged, + )) + .toList(), + ); + } +} + +// ========================= +// DURATION CHIP WIDGET +// ========================= +class _DurationChip extends StatelessWidget { + const _DurationChip({ + Key? key, + required this.label, + required this.duration, + required this.isSelected, + required this.onSelected, + }) : super(key: key); + + final String label; + final MonthlyExpenseDuration duration; + final bool isSelected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 4), + child: ChoiceChip( + label: Text(label, style: const TextStyle(fontSize: 12)), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 0), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + selected: isSelected, + onSelected: (_) => onSelected(duration), + selectedColor: Colors.blueAccent.withOpacity(0.15), + backgroundColor: Colors.grey.shade200, + labelStyle: TextStyle( + color: isSelected ? Colors.blueAccent : Colors.black87, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + side: BorderSide( + color: isSelected ? Colors.blueAccent : Colors.grey.shade300, + ), + ), + ), + ); + } +} + +// ========================= +// EMPTY DATA WIDGET +// ========================= +class _EmptyDataWidget extends StatelessWidget { + const _EmptyDataWidget({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _ChartConstants.noDataContainerHeight, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.info_outline, + color: Colors.grey.shade400, + size: _ChartConstants.noDataIconSize, + ), + const SizedBox(height: 10), + MyText.bodyMedium( + 'No monthly expense data available.', + textAlign: TextAlign.center, + color: Colors.grey.shade500, + ), + ], + ), + ), + ); + } +} + +// ========================= +// CHART WIDGET +// ========================= +class _MonthlyExpenseChart extends StatelessWidget { + const _MonthlyExpenseChart({ + Key? key, + required this.data, + required this.getColor, + required this.isMobile, + required this.totalExpense, + }) : super(key: key); + + final List data; + final Color Function(int index) getColor; + final bool isMobile; + final double totalExpense; + + @override + Widget build(BuildContext context) { + return SfCartesianChart( + tooltipBehavior: _buildTooltipBehavior(), + primaryXAxis: _buildXAxis(), + primaryYAxis: _buildYAxis(), + series: [_buildColumnSeries()], + ); + } + + TooltipBehavior _buildTooltipBehavior() { + return TooltipBehavior( + enable: true, + builder: _tooltipBuilder, + animationDuration: _ChartConstants.tooltipAnimationDuration, + ); + } + + Widget _tooltipBuilder( + dynamic data, + dynamic point, + dynamic series, + int pointIndex, + int seriesIndex, + ) { + final value = data.total as double; + final percentage = totalExpense > 0 ? (value / totalExpense * 100) : 0; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: Colors.blueAccent, + borderRadius: BorderRadius.circular(4), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${data.monthName} ${data.year}', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + Utils.formatCurrency(value), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + Text( + '${percentage.toStringAsFixed(1)}%', + style: const TextStyle( + color: Colors.white70, + fontWeight: FontWeight.w500, + fontSize: 10, + ), + ), + ], + ), + ); + } + + CategoryAxis _buildXAxis() { + return CategoryAxis( + labelRotation: _ChartConstants.labelRotation.toInt(), + majorGridLines: + const MajorGridLines(width: 0), // removes X-axis grid lines + ); + } + + NumericAxis _buildYAxis() { + return NumericAxis( + numberFormat: NumberFormat.simpleCurrency( + locale: 'en_IN', + name: '₹', + decimalDigits: 0, + ), + axisLabelFormatter: (AxisLabelRenderDetails args) { + return ChartAxisLabel(Utils.formatCurrency(args.value), null); + }, + majorGridLines: + const MajorGridLines(width: 0), // removes Y-axis grid lines + ); + } + + ColumnSeries _buildColumnSeries() { + return ColumnSeries( + dataSource: data, + xValueMapper: (d, _) => _ChartFormatter.formatMonthYear(d), + yValueMapper: (d, _) => d.total, + pointColorMapper: (_, index) => getColor(index), + name: 'Monthly Expense', + borderRadius: BorderRadius.circular(4), + dataLabelSettings: _buildDataLabelSettings(), + ); + } + + DataLabelSettings _buildDataLabelSettings() { + return DataLabelSettings( + isVisible: true, + builder: (data, _, __, ___, ____) => Text( + Utils.formatCurrency(data.total), + style: const TextStyle(fontSize: 11), + ), + ); + } +} + +// ========================= +// FORMATTER HELPER +// ========================= +class _ChartFormatter { + static String formatMonthYear(dynamic data) { + try { + final month = data.month ?? 1; + final year = data.year ?? DateTime.now().year; + final date = DateTime(year, month, 1); + final monthName = DateFormat('MMM').format(date); + final shortYear = year % 100; + return '$shortYear $monthName'; + } catch (e) { + return '${data.monthName} ${data.year}'; + } + } +} diff --git a/lib/model/dashboard/monthly_expence_model.dart b/lib/model/dashboard/monthly_expence_model.dart new file mode 100644 index 0000000..f28082b --- /dev/null +++ b/lib/model/dashboard/monthly_expence_model.dart @@ -0,0 +1,70 @@ +class DashboardMonthlyExpenseResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final String timestamp; + + DashboardMonthlyExpenseResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory DashboardMonthlyExpenseResponse.fromJson(Map json) { + return DashboardMonthlyExpenseResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: (json['data'] as List?) + ?.map((e) => MonthlyExpenseData.fromJson(e)) + .toList() ?? + [], + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: json['timestamp'] ?? '', + ); + } + + Map toJson() => { + 'success': success, + 'message': message, + 'data': data.map((e) => e.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp, + }; +} + +class MonthlyExpenseData { + final String monthName; + final int year; + final double total; + final int count; + + MonthlyExpenseData({ + required this.monthName, + required this.year, + required this.total, + required this.count, + }); + + factory MonthlyExpenseData.fromJson(Map json) { + return MonthlyExpenseData( + monthName: json['monthName'] ?? '', + year: json['year'] ?? 0, + total: (json['total'] ?? 0).toDouble(), + count: json['count'] ?? 0, + ); + } + + Map toJson() => { + 'monthName': monthName, + 'year': year, + 'total': total, + 'count': count, + }; +} diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index a8f2b5d..1211068 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -13,10 +13,12 @@ import 'package:marco/helpers/widgets/dashbaord/attendance_overview_chart.dart'; import 'package:marco/helpers/widgets/dashbaord/expense_by_status_widget.dart'; import 'package:marco/view/layouts/layout.dart'; import 'package:marco/helpers/widgets/dashbaord/expense_breakdown_chart.dart'; +import 'package:marco/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart'; + class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); - + static const String employeesRoute = "/dashboard/employees"; static const String attendanceRoute = "/dashboard/attendance"; static const String directoryMainPageRoute = "/dashboard/directory-main-page"; @@ -63,6 +65,8 @@ class _DashboardScreenState extends State with UIMixin { // Expense Type Report Chart ExpenseTypeReportChart(), + MySpacing.height(24), + MonthlyExpenseDashboardChart(), ], ), ), From 817672c8b2000b5a778d7c9f29d1f7adc2da84eb Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 3 Nov 2025 16:51:51 +0530 Subject: [PATCH 16/18] refactor: Improve Attendance and Project Progress Charts with enhanced styling and tooltip formatting --- .../dashbaord/attendance_overview_chart.dart | 100 ++++++------ .../dashbaord/project_progress_chart.dart | 149 ++++++++++-------- 2 files changed, 136 insertions(+), 113 deletions(-) diff --git a/lib/helpers/widgets/dashbaord/attendance_overview_chart.dart b/lib/helpers/widgets/dashbaord/attendance_overview_chart.dart index 3de75cd..3435b20 100644 --- a/lib/helpers/widgets/dashbaord/attendance_overview_chart.dart +++ b/lib/helpers/widgets/dashbaord/attendance_overview_chart.dart @@ -60,7 +60,6 @@ class AttendanceDashboardChart extends StatelessWidget { final filteredData = _getFilteredData(); - return Container( decoration: _containerDecoration, padding: EdgeInsets.symmetric( @@ -254,7 +253,7 @@ class _AttendanceChart extends StatelessWidget { @override Widget build(BuildContext context) { - final dateFormat = DateFormat('d MMMM'); + final dateFormat = DateFormat('d MMM'); final uniqueDates = data .map((e) => DateTime.parse(e['date'] as String)) .toSet() @@ -273,10 +272,6 @@ class _AttendanceChart extends StatelessWidget { if (allZero) { return Container( height: 600, - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular(5), - ), child: const Center( child: Text( 'No attendance data for the selected range.', @@ -302,14 +297,22 @@ class _AttendanceChart extends StatelessWidget { height: 600, padding: const EdgeInsets.all(6), decoration: BoxDecoration( - color: Colors.transparent, borderRadius: BorderRadius.circular(5), ), child: SfCartesianChart( tooltipBehavior: TooltipBehavior(enable: true, shared: true), legend: Legend(isVisible: true, position: LegendPosition.bottom), - primaryXAxis: CategoryAxis(labelRotation: 45), - primaryYAxis: NumericAxis(minimum: 0, interval: 1), + primaryXAxis: CategoryAxis( + labelRotation: 45, + majorGridLines: + const MajorGridLines(width: 0), // removes vertical grid lines + ), + primaryYAxis: NumericAxis( + minimum: 0, + interval: 1, + majorGridLines: + const MajorGridLines(width: 0), // removes horizontal grid lines + ), series: rolesWithData.map((role) { final seriesData = filteredDates .map((date) { @@ -317,7 +320,7 @@ class _AttendanceChart extends StatelessWidget { return {'date': date, 'present': formattedMap[key] ?? 0}; }) .where((d) => (d['present'] ?? 0) > 0) - .toList(); // ✅ remove 0 bars + .toList(); return StackedColumnSeries, String>( dataSource: seriesData, @@ -358,7 +361,7 @@ class _AttendanceTable extends StatelessWidget { @override Widget build(BuildContext context) { - final dateFormat = DateFormat('d MMMM'); + final dateFormat = DateFormat('d MMM'); final uniqueDates = data .map((e) => DateTime.parse(e['date'] as String)) .toSet() @@ -377,10 +380,6 @@ class _AttendanceTable extends StatelessWidget { if (allZero) { return Container( height: 300, - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular(5), - ), child: const Center( child: Text( 'No attendance data for the selected range.', @@ -402,38 +401,49 @@ class _AttendanceTable extends StatelessWidget { decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(5), - color: Colors.grey.shade50, ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - columnSpacing: screenWidth < 600 ? 20 : 36, - headingRowHeight: 44, - headingRowColor: - MaterialStateProperty.all(Colors.blueAccent.withOpacity(0.08)), - headingTextStyle: const TextStyle( - fontWeight: FontWeight.bold, color: Colors.black87), - columns: [ - const DataColumn(label: Text('Role')), - ...filteredDates.map((d) => DataColumn(label: Text(d))), - ], - rows: filteredRoles.map((role) { - return DataRow( - cells: [ - DataCell(_RolePill(role: role, color: getRoleColor(role))), - ...filteredDates.map((date) { - final key = '${role}_$date'; - return DataCell( - Text( - NumberFormat.decimalPattern() - .format(formattedMap[key] ?? 0), - style: const TextStyle(fontSize: 13), - ), + child: Scrollbar( + thumbVisibility: true, + trackVisibility: true, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: + BoxConstraints(minWidth: MediaQuery.of(context).size.width), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: DataTable( + columnSpacing: 20, + headingRowHeight: 44, + headingRowColor: MaterialStateProperty.all( + Colors.blueAccent.withOpacity(0.08)), + headingTextStyle: const TextStyle( + fontWeight: FontWeight.bold, color: Colors.black87), + columns: [ + const DataColumn(label: Text('Role')), + ...filteredDates.map((d) => DataColumn(label: Text(d))), + ], + rows: filteredRoles.map((role) { + return DataRow( + cells: [ + DataCell( + _RolePill(role: role, color: getRoleColor(role))), + ...filteredDates.map((date) { + final key = '${role}_$date'; + return DataCell( + Text( + NumberFormat.decimalPattern() + .format(formattedMap[key] ?? 0), + style: const TextStyle(fontSize: 13), + ), + ); + }), + ], ); - }), - ], - ); - }).toList(), + }).toList(), + ), + ), + ), ), ), ); diff --git a/lib/helpers/widgets/dashbaord/project_progress_chart.dart b/lib/helpers/widgets/dashbaord/project_progress_chart.dart index 648fc75..9ada6ef 100644 --- a/lib/helpers/widgets/dashbaord/project_progress_chart.dart +++ b/lib/helpers/widgets/dashbaord/project_progress_chart.dart @@ -5,6 +5,7 @@ import 'package:syncfusion_flutter_charts/charts.dart'; import 'package:marco/model/dashboard/project_progress_model.dart'; import 'package:marco/controller/dashboard/dashboard_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/utils/utils.dart'; class ProjectProgressChart extends StatelessWidget { final List data; @@ -47,7 +48,6 @@ class ProjectProgressChart extends StatelessWidget { Color(0xFFFFB74D), Color(0xFF64B5F6), ]; - static final NumberFormat _commaFormatter = NumberFormat.decimalPattern(); Color _getTaskColor(String taskName) { final index = taskName.hashCode % _flatColors.length; @@ -71,7 +71,7 @@ class ProjectProgressChart extends StatelessWidget { color: Colors.grey.withOpacity(0.04), blurRadius: 6, spreadRadius: 1, - offset: Offset(0, 2), + offset: const Offset(0, 2), ), ], ), @@ -102,6 +102,7 @@ class ProjectProgressChart extends StatelessWidget { }); } + // ================= HEADER ================= Widget _buildHeader( String selectedRange, bool isChartView, double screenWidth) { return Column( @@ -129,7 +130,7 @@ class ProjectProgressChart extends StatelessWidget { color: Colors.grey, constraints: BoxConstraints( minHeight: 30, - minWidth: (screenWidth < 400 ? 28 : 36), + minWidth: screenWidth < 400 ? 28 : 36, ), isSelected: [isChartView, !isChartView], onPressed: (index) { @@ -185,50 +186,64 @@ class ProjectProgressChart extends StatelessWidget { ); } + // ================= CHART ================= Widget _buildChart(double height) { final nonZeroData = data.where((d) => d.planned != 0 || d.completed != 0).toList(); - if (nonZeroData.isEmpty) { - return _buildNoDataContainer(height); - } + if (nonZeroData.isEmpty) return _buildNoDataContainer(height); return Container( height: height > 280 ? 280 : height, padding: const EdgeInsets.all(6), decoration: BoxDecoration( - color: Colors.blueGrey.shade50, + color: Colors.transparent, borderRadius: BorderRadius.circular(5), ), child: SfCartesianChart( - tooltipBehavior: TooltipBehavior(enable: true), + tooltipBehavior: TooltipBehavior( + enable: true, + builder: (data, point, series, pointIndex, seriesIndex) { + final task = data as ChartTaskData; + final value = seriesIndex == 0 ? task.planned : task.completed; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: Colors.blueAccent, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + Utils.formatCurrency(value), + style: const TextStyle(color: Colors.white), + ), + ); + }, + ), legend: Legend(isVisible: true, position: LegendPosition.bottom), - // ✅ Use CategoryAxis so only nonZeroData dates show up primaryXAxis: CategoryAxis( majorGridLines: const MajorGridLines(width: 0), axisLine: const AxisLine(width: 0), - labelRotation: 0, + labelRotation: 45, ), primaryYAxis: NumericAxis( - labelFormat: '{value}', axisLine: const AxisLine(width: 0), - majorTickLines: const MajorTickLines(size: 0), + majorGridLines: const MajorGridLines(width: 0), + labelFormat: '{value}', + numberFormat: NumberFormat.compact(), ), - series: [ + series: >[ ColumnSeries( name: 'Planned', dataSource: nonZeroData, - xValueMapper: (d, _) => DateFormat('MMM d').format(d.date), + xValueMapper: (d, _) => DateFormat('d MMM').format(d.date), yValueMapper: (d, _) => d.planned, color: _getTaskColor('Planned'), dataLabelSettings: DataLabelSettings( isVisible: true, - builder: (data, point, series, pointIndex, seriesIndex) { - final value = seriesIndex == 0 - ? (data as ChartTaskData).planned - : (data as ChartTaskData).completed; + builder: (data, _, __, ___, ____) { + final value = (data as ChartTaskData).planned; return Text( - _commaFormatter.format(value), + Utils.formatCurrency(value), style: const TextStyle(fontSize: 11), ); }, @@ -237,17 +252,15 @@ class ProjectProgressChart extends StatelessWidget { ColumnSeries( name: 'Completed', dataSource: nonZeroData, - xValueMapper: (d, _) => DateFormat('MMM d').format(d.date), + xValueMapper: (d, _) => DateFormat('d MMM').format(d.date), yValueMapper: (d, _) => d.completed, color: _getTaskColor('Completed'), dataLabelSettings: DataLabelSettings( isVisible: true, - builder: (data, point, series, pointIndex, seriesIndex) { - final value = seriesIndex == 0 - ? (data as ChartTaskData).planned - : (data as ChartTaskData).completed; + builder: (data, _, __, ___, ____) { + final value = (data as ChartTaskData).completed; return Text( - _commaFormatter.format(value), + Utils.formatCurrency(value), style: const TextStyle(fontSize: 11), ); }, @@ -258,14 +271,13 @@ class ProjectProgressChart extends StatelessWidget { ); } + // ================= TABLE ================= Widget _buildTable(double maxHeight, double screenWidth) { final containerHeight = maxHeight > 300 ? 300.0 : maxHeight; final nonZeroData = data.where((d) => d.planned != 0 || d.completed != 0).toList(); - if (nonZeroData.isEmpty) { - return _buildNoDataContainer(containerHeight); - } + if (nonZeroData.isEmpty) return _buildNoDataContainer(containerHeight); return Container( height: containerHeight, @@ -273,57 +285,58 @@ class ProjectProgressChart extends StatelessWidget { decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(5), - color: Colors.grey.shade50, + color: Colors.transparent, ), - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: DataTable( - columnSpacing: screenWidth < 600 ? 16 : 36, - headingRowHeight: 44, - headingRowColor: MaterialStateProperty.all( - Colors.blueAccent.withOpacity(0.08)), - headingTextStyle: const TextStyle( - fontWeight: FontWeight.bold, color: Colors.black87), - columns: const [ - DataColumn(label: Text('Date')), - DataColumn(label: Text('Planned')), - DataColumn(label: Text('Completed')), - ], - rows: nonZeroData.map((task) { - return DataRow( - cells: [ - DataCell(Text(DateFormat('d MMM').format(task.date))), - DataCell(Text( - '${task.planned}', - style: TextStyle(color: _getTaskColor('Planned')), - )), - DataCell(Text( - '${task.completed}', - style: TextStyle(color: _getTaskColor('Completed')), - )), - ], - ); - }).toList(), - ), + child: Scrollbar( + thumbVisibility: true, + trackVisibility: true, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: screenWidth), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: DataTable( + columnSpacing: screenWidth < 600 ? 16 : 36, + headingRowHeight: 44, + headingRowColor: MaterialStateProperty.all( + Colors.blueAccent.withOpacity(0.08)), + headingTextStyle: const TextStyle( + fontWeight: FontWeight.bold, color: Colors.black87), + columns: const [ + DataColumn(label: Text('Date')), + DataColumn(label: Text('Planned')), + DataColumn(label: Text('Completed')), + ], + rows: nonZeroData.map((task) { + return DataRow( + cells: [ + DataCell(Text(DateFormat('d MMM').format(task.date))), + DataCell(Text( + Utils.formatCurrency(task.planned), + style: TextStyle(color: _getTaskColor('Planned')), + )), + DataCell(Text( + Utils.formatCurrency(task.completed), + style: TextStyle(color: _getTaskColor('Completed')), + )), + ], + ); + }).toList(), ), ), - ); - }, + ), + ), ), ); } + // ================= NO DATA WIDGETS ================= Widget _buildNoDataContainer(double height) { return Container( height: height > 280 ? 280 : height, decoration: BoxDecoration( - color: Colors.blueGrey.shade50, + color: Colors.transparent, borderRadius: BorderRadius.circular(5), ), child: const Center( From b33b3da6c0ce5f083b705c6f4bf21ad98b3a81d3 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 3 Nov 2025 17:40:18 +0530 Subject: [PATCH 17/18] refactor: Rename ProjectProgressChart to AttendanceDashboardChart and update data handling for attendance overview --- .../dashbaord/project_progress_chart.dart | 562 +++++++++++------- 1 file changed, 333 insertions(+), 229 deletions(-) diff --git a/lib/helpers/widgets/dashbaord/project_progress_chart.dart b/lib/helpers/widgets/dashbaord/project_progress_chart.dart index 9ada6ef..e0f1fad 100644 --- a/lib/helpers/widgets/dashbaord/project_progress_chart.dart +++ b/lib/helpers/widgets/dashbaord/project_progress_chart.dart @@ -2,55 +2,51 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:syncfusion_flutter_charts/charts.dart'; -import 'package:marco/model/dashboard/project_progress_model.dart'; import 'package:marco/controller/dashboard/dashboard_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/helpers/utils/utils.dart'; -class ProjectProgressChart extends StatelessWidget { - final List data; - final DashboardController controller = Get.find(); +class AttendanceDashboardChart extends StatelessWidget { + AttendanceDashboardChart({Key? key}) : super(key: key); - ProjectProgressChart({super.key, required this.data}); + final DashboardController _controller = Get.find(); - // ================= Flat Colors ================= static const List _flatColors = [ - Color(0xFFE57373), - Color(0xFF64B5F6), - Color(0xFF81C784), - Color(0xFFFFB74D), - Color(0xFFBA68C8), - Color(0xFFFF8A65), - Color(0xFF4DB6AC), - Color(0xFFA1887F), - Color(0xFFDCE775), - Color(0xFF9575CD), - Color(0xFF7986CB), - Color(0xFFAED581), - Color(0xFFFF7043), - Color(0xFF4FC3F7), - Color(0xFFFFD54F), - Color(0xFF90A4AE), - Color(0xFFE573BB), - Color(0xFF81D4FA), - Color(0xFFBCAAA4), - Color(0xFFA5D6A7), - Color(0xFFCE93D8), - Color(0xFFFF8A65), - Color(0xFF80CBC4), - Color(0xFFFFF176), - Color(0xFF90CAF9), - Color(0xFFE0E0E0), - Color(0xFFF48FB1), - Color(0xFFA1887F), - Color(0xFFB0BEC5), - Color(0xFF81C784), - Color(0xFFFFB74D), - Color(0xFF64B5F6), + Color(0xFFE57373), // Red 300 + Color(0xFF64B5F6), // Blue 300 + Color(0xFF81C784), // Green 300 + Color(0xFFFFB74D), // Orange 300 + Color(0xFFBA68C8), // Purple 300 + Color(0xFFFF8A65), // Deep Orange 300 + Color(0xFF4DB6AC), // Teal 300 + Color(0xFFA1887F), // Brown 400 + Color(0xFFDCE775), // Lime 300 + Color(0xFF9575CD), // Deep Purple 300 + Color(0xFF7986CB), // Indigo 300 + Color(0xFFAED581), // Light Green 300 + Color(0xFFFF7043), // Deep Orange 400 + Color(0xFF4FC3F7), // Light Blue 300 + Color(0xFFFFD54F), // Amber 300 + Color(0xFF90A4AE), // Blue Grey 300 + Color(0xFFE573BB), // Pink 300 + Color(0xFF81D4FA), // Light Blue 200 + Color(0xFFBCAAA4), // Brown 300 + Color(0xFFA5D6A7), // Green 300 + Color(0xFFCE93D8), // Purple 200 + Color(0xFFFF8A65), // Deep Orange 300 (repeat to fill) + Color(0xFF80CBC4), // Teal 200 + Color(0xFFFFF176), // Yellow 300 + Color(0xFF90CAF9), // Blue 200 + Color(0xFFE0E0E0), // Grey 300 + Color(0xFFF48FB1), // Pink 200 + Color(0xFFA1887F), // Brown 400 (repeat) + Color(0xFFB0BEC5), // Blue Grey 200 + Color(0xFF81C784), // Green 300 (repeat) + Color(0xFFFFB74D), // Orange 300 (repeat) + Color(0xFF64B5F6), // Blue 300 (repeat) ]; - Color _getTaskColor(String taskName) { - final index = taskName.hashCode % _flatColors.length; + Color _getRoleColor(String role) { + final index = role.hashCode.abs() % _flatColors.length; return _flatColors[index]; } @@ -59,42 +55,39 @@ class ProjectProgressChart extends StatelessWidget { final screenWidth = MediaQuery.of(context).size.width; return Obx(() { - final isChartView = controller.projectIsChartView.value; - final selectedRange = controller.projectSelectedRange.value; + final isChartView = _controller.attendanceIsChartView.value; + final selectedRange = _controller.attendanceSelectedRange.value; + + final filteredData = _getFilteredData(); return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.04), - blurRadius: 6, - spreadRadius: 1, - offset: const Offset(0, 2), - ), - ], - ), + decoration: _containerDecoration, padding: EdgeInsets.symmetric( vertical: 16, - horizontal: screenWidth < 600 ? 8 : 24, + horizontal: screenWidth < 600 ? 8 : 20, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildHeader(selectedRange, isChartView, screenWidth), - const SizedBox(height: 14), + _Header( + selectedRange: selectedRange, + isChartView: isChartView, + screenWidth: screenWidth, + onToggleChanged: (isChart) => + _controller.attendanceIsChartView.value = isChart, + onRangeChanged: _controller.updateAttendanceRange, + ), + const SizedBox(height: 12), Expanded( - child: LayoutBuilder( - builder: (context, constraints) => AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: data.isEmpty - ? _buildNoDataMessage() - : isChartView - ? _buildChart(constraints.maxHeight) - : _buildTable(constraints.maxHeight, screenWidth), - ), - ), + child: filteredData.isEmpty + ? _NoDataMessage() + : isChartView + ? _AttendanceChart( + data: filteredData, getRoleColor: _getRoleColor) + : _AttendanceTable( + data: filteredData, + getRoleColor: _getRoleColor, + screenWidth: screenWidth), ), ], ), @@ -102,22 +95,62 @@ class ProjectProgressChart extends StatelessWidget { }); } - // ================= HEADER ================= - Widget _buildHeader( - String selectedRange, bool isChartView, double screenWidth) { + BoxDecoration get _containerDecoration => BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.05), + blurRadius: 6, + spreadRadius: 1, + offset: const Offset(0, 2), + ), + ], + ); + + List> _getFilteredData() { + final now = DateTime.now(); + final daysBack = _controller.getAttendanceDays(); + return _controller.roleWiseData.where((entry) { + final date = DateTime.parse(entry['date'] as String); + return date.isAfter(now.subtract(Duration(days: daysBack))) && + !date.isAfter(now); + }).toList(); + } +} + +// Header +class _Header extends StatelessWidget { + const _Header({ + Key? key, + required this.selectedRange, + required this.isChartView, + required this.screenWidth, + required this.onToggleChanged, + required this.onRangeChanged, + }) : super(key: key); + + final String selectedRange; + final bool isChartView; + final double screenWidth; + final ValueChanged onToggleChanged; + final ValueChanged onRangeChanged; + + @override + Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.bodyMedium('Project Progress', fontWeight: 700), - MyText.bodySmall('Planned vs Completed', - color: Colors.grey.shade700), + MyText.bodyMedium('Attendance Overview', fontWeight: 700), + const SizedBox(height: 2), + MyText.bodySmall('Role-wise present count', + color: Colors.grey), ], ), ), @@ -133,9 +166,7 @@ class ProjectProgressChart extends StatelessWidget { minWidth: screenWidth < 400 ? 28 : 36, ), isSelected: [isChartView, !isChartView], - onPressed: (index) { - controller.projectIsChartView.value = index == 0; - }, + onPressed: (index) => onToggleChanged(index == 0), children: const [ Icon(Icons.bar_chart_rounded, size: 15), Icon(Icons.table_chart, size: 15), @@ -143,149 +174,233 @@ class ProjectProgressChart extends StatelessWidget { ), ], ), - const SizedBox(height: 6), + const SizedBox(height: 8), Row( - children: [ - _buildRangeButton("7D", selectedRange), - _buildRangeButton("15D", selectedRange), - _buildRangeButton("30D", selectedRange), - _buildRangeButton("3M", selectedRange), - _buildRangeButton("6M", selectedRange), - ], + children: ["7D", "15D", "30D"] + .map( + (label) => Padding( + padding: const EdgeInsets.only(right: 4), + child: ChoiceChip( + label: Text(label, style: const TextStyle(fontSize: 12)), + padding: + const EdgeInsets.symmetric(horizontal: 5, vertical: 0), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + selected: selectedRange == label, + onSelected: (_) => onRangeChanged(label), + selectedColor: Colors.blueAccent.withOpacity(0.15), + backgroundColor: Colors.grey.shade200, + labelStyle: TextStyle( + color: selectedRange == label + ? Colors.blueAccent + : Colors.black87, + fontWeight: selectedRange == label + ? FontWeight.w600 + : FontWeight.normal, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + side: BorderSide( + color: selectedRange == label + ? Colors.blueAccent + : Colors.grey.shade300, + ), + ), + ), + ), + ) + .toList(), ), ], ); } +} - Widget _buildRangeButton(String label, String selectedRange) { - return Padding( - padding: const EdgeInsets.only(right: 4.0), - child: ChoiceChip( - label: Text(label, style: const TextStyle(fontSize: 12)), - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 0), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - selected: selectedRange == label, - onSelected: (_) => controller.updateProjectRange(label), - selectedColor: Colors.blueAccent.withOpacity(0.15), - backgroundColor: Colors.grey.shade200, - labelStyle: TextStyle( - color: selectedRange == label ? Colors.blueAccent : Colors.black87, - fontWeight: - selectedRange == label ? FontWeight.w600 : FontWeight.normal, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - side: BorderSide( - color: selectedRange == label - ? Colors.blueAccent - : Colors.grey.shade300, - ), +// No Data +class _NoDataMessage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return SizedBox( + height: 180, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.info_outline, color: Colors.grey.shade400, size: 48), + const SizedBox(height: 10), + MyText.bodyMedium( + 'No attendance data available for this range.', + textAlign: TextAlign.center, + color: Colors.grey.shade500, + ), + ], ), ), ); } +} - // ================= CHART ================= - Widget _buildChart(double height) { - final nonZeroData = - data.where((d) => d.planned != 0 || d.completed != 0).toList(); +// Chart +class _AttendanceChart extends StatelessWidget { + const _AttendanceChart({ + Key? key, + required this.data, + required this.getRoleColor, + }) : super(key: key); - if (nonZeroData.isEmpty) return _buildNoDataContainer(height); + final List> data; + final Color Function(String role) getRoleColor; + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat('d MMM'); + final uniqueDates = data + .map((e) => DateTime.parse(e['date'] as String)) + .toSet() + .toList() + ..sort(); + final filteredDates = uniqueDates.map(dateFormat.format).toList(); + + final filteredRoles = data.map((e) => e['role'] as String).toSet().toList(); + + final allZero = filteredRoles.every((role) { + return data + .where((entry) => entry['role'] == role) + .every((entry) => (entry['present'] ?? 0) == 0); + }); + + if (allZero) { + return Container( + height: 600, + child: const Center( + child: Text( + 'No attendance data for the selected range.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + ), + ); + } + + final formattedMap = { + for (var e in data) + '${e['role']}_${dateFormat.format(DateTime.parse(e['date'] as String))}': + e['present'], + }; + + final rolesWithData = filteredRoles.where((role) { + return data + .any((entry) => entry['role'] == role && (entry['present'] ?? 0) > 0); + }).toList(); return Container( - height: height > 280 ? 280 : height, + height: 600, padding: const EdgeInsets.all(6), decoration: BoxDecoration( - color: Colors.transparent, borderRadius: BorderRadius.circular(5), ), child: SfCartesianChart( - tooltipBehavior: TooltipBehavior( - enable: true, - builder: (data, point, series, pointIndex, seriesIndex) { - final task = data as ChartTaskData; - final value = seriesIndex == 0 ? task.planned : task.completed; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - decoration: BoxDecoration( - color: Colors.blueAccent, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - Utils.formatCurrency(value), - style: const TextStyle(color: Colors.white), - ), - ); - }, - ), + tooltipBehavior: TooltipBehavior(enable: true, shared: true), legend: Legend(isVisible: true, position: LegendPosition.bottom), primaryXAxis: CategoryAxis( - majorGridLines: const MajorGridLines(width: 0), - axisLine: const AxisLine(width: 0), labelRotation: 45, + majorGridLines: + const MajorGridLines(width: 0), // removes vertical grid lines ), primaryYAxis: NumericAxis( - axisLine: const AxisLine(width: 0), - majorGridLines: const MajorGridLines(width: 0), - labelFormat: '{value}', - numberFormat: NumberFormat.compact(), + minimum: 0, + interval: 1, + majorGridLines: + const MajorGridLines(width: 0), // removes horizontal grid lines ), - series: >[ - ColumnSeries( - name: 'Planned', - dataSource: nonZeroData, - xValueMapper: (d, _) => DateFormat('d MMM').format(d.date), - yValueMapper: (d, _) => d.planned, - color: _getTaskColor('Planned'), + series: rolesWithData.map((role) { + final seriesData = filteredDates + .map((date) { + final key = '${role}_$date'; + return {'date': date, 'present': formattedMap[key] ?? 0}; + }) + .where((d) => (d['present'] ?? 0) > 0) + .toList(); + + return StackedColumnSeries, String>( + dataSource: seriesData, + xValueMapper: (d, _) => d['date'], + yValueMapper: (d, _) => d['present'], + name: role, + color: getRoleColor(role), dataLabelSettings: DataLabelSettings( isVisible: true, - builder: (data, _, __, ___, ____) { - final value = (data as ChartTaskData).planned; - return Text( - Utils.formatCurrency(value), - style: const TextStyle(fontSize: 11), - ); + builder: (dynamic data, _, __, ___, ____) { + return (data['present'] ?? 0) > 0 + ? Text( + NumberFormat.decimalPattern().format(data['present']), + style: const TextStyle(fontSize: 11), + ) + : const SizedBox.shrink(); }, ), - ), - ColumnSeries( - name: 'Completed', - dataSource: nonZeroData, - xValueMapper: (d, _) => DateFormat('d MMM').format(d.date), - yValueMapper: (d, _) => d.completed, - color: _getTaskColor('Completed'), - dataLabelSettings: DataLabelSettings( - isVisible: true, - builder: (data, _, __, ___, ____) { - final value = (data as ChartTaskData).completed; - return Text( - Utils.formatCurrency(value), - style: const TextStyle(fontSize: 11), - ); - }, - ), - ), - ], + ); + }).toList(), ), ); } +} - // ================= TABLE ================= - Widget _buildTable(double maxHeight, double screenWidth) { - final containerHeight = maxHeight > 300 ? 300.0 : maxHeight; - final nonZeroData = - data.where((d) => d.planned != 0 || d.completed != 0).toList(); +// Table +class _AttendanceTable extends StatelessWidget { + const _AttendanceTable({ + Key? key, + required this.data, + required this.getRoleColor, + required this.screenWidth, + }) : super(key: key); - if (nonZeroData.isEmpty) return _buildNoDataContainer(containerHeight); + final List> data; + final Color Function(String role) getRoleColor; + final double screenWidth; + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat('d MMM'); + final uniqueDates = data + .map((e) => DateTime.parse(e['date'] as String)) + .toSet() + .toList() + ..sort(); + final filteredDates = uniqueDates.map(dateFormat.format).toList(); + + final filteredRoles = data.map((e) => e['role'] as String).toSet().toList(); + + final allZero = filteredRoles.every((role) { + return data + .where((entry) => entry['role'] == role) + .every((entry) => (entry['present'] ?? 0) == 0); + }); + + if (allZero) { + return Container( + height: 300, + child: const Center( + child: Text( + 'No attendance data for the selected range.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + ), + ); + } + + final formattedMap = { + for (var e in data) + '${e['role']}_${dateFormat.format(DateTime.parse(e['date'] as String))}': + e['present'], + }; return Container( - height: containerHeight, - padding: const EdgeInsets.symmetric(vertical: 8), + height: 300, decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(5), - color: Colors.transparent, ), child: Scrollbar( thumbVisibility: true, @@ -293,33 +408,40 @@ class ProjectProgressChart extends StatelessWidget { child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: ConstrainedBox( - constraints: BoxConstraints(minWidth: screenWidth), + constraints: + BoxConstraints(minWidth: MediaQuery.of(context).size.width), child: SingleChildScrollView( scrollDirection: Axis.vertical, child: DataTable( - columnSpacing: screenWidth < 600 ? 16 : 36, + columnSpacing: 20, headingRowHeight: 44, headingRowColor: MaterialStateProperty.all( Colors.blueAccent.withOpacity(0.08)), headingTextStyle: const TextStyle( fontWeight: FontWeight.bold, color: Colors.black87), - columns: const [ - DataColumn(label: Text('Date')), - DataColumn(label: Text('Planned')), - DataColumn(label: Text('Completed')), + columns: [ + const DataColumn(label: Text('Role')), + ...filteredDates + .map((d) => DataColumn(label: Center(child: Text(d)))), ], - rows: nonZeroData.map((task) { + rows: filteredRoles.map((role) { return DataRow( cells: [ - DataCell(Text(DateFormat('d MMM').format(task.date))), - DataCell(Text( - Utils.formatCurrency(task.planned), - style: TextStyle(color: _getTaskColor('Planned')), - )), - DataCell(Text( - Utils.formatCurrency(task.completed), - style: TextStyle(color: _getTaskColor('Completed')), - )), + DataCell( + _RolePill(role: role, color: getRoleColor(role))), + ...filteredDates.map((date) { + final key = '${role}_$date'; + return DataCell( + Center( + child: Text( + NumberFormat.decimalPattern() + .format(formattedMap[key] ?? 0), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13), + ), + ), + ); + }), ], ); }).toList(), @@ -330,42 +452,24 @@ class ProjectProgressChart extends StatelessWidget { ), ); } +} - // ================= NO DATA WIDGETS ================= - Widget _buildNoDataContainer(double height) { +class _RolePill extends StatelessWidget { + const _RolePill({Key? key, required this.role, required this.color}) + : super(key: key); + + final String role; + final Color color; + + @override + Widget build(BuildContext context) { return Container( - height: height > 280 ? 280 : height, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: Colors.transparent, + color: color.withOpacity(0.15), borderRadius: BorderRadius.circular(5), ), - child: const Center( - child: Text( - 'No project progress data for the selected range.', - style: TextStyle(fontSize: 14, color: Colors.grey), - textAlign: TextAlign.center, - ), - ), - ); - } - - Widget _buildNoDataMessage() { - return SizedBox( - height: 180, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.info_outline, color: Colors.grey.shade400, size: 54), - const SizedBox(height: 10), - MyText.bodyMedium( - 'No project progress data available for the selected range.', - textAlign: TextAlign.center, - color: Colors.grey.shade500, - ), - ], - ), - ), + child: MyText.labelSmall(role, fontWeight: 500), ); } } From 03b82764ed8ff816d70dca389e22e0bd906511b2 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 4 Nov 2025 14:15:45 +0530 Subject: [PATCH 18/18] Refactor attendance and document controllers to use reactive date ranges, implement reusable DateRangePickerWidget, and enhance filter functionality in attendance and expense screens. - Updated AttendanceController to use Rx for date ranges. - Introduced DateRangePickerWidget for selecting date ranges in attendance and expense filters. - Refactored attendance filter bottom sheet to utilize the new DateRangePickerWidget. - Enhanced user document filter bottom sheet with date range selection. - Improved expense filter bottom sheet to include date range selection and refactored UI components for better readability. - Cleaned up unused code and improved overall code structure for maintainability. --- .../attendance_screen_controller.dart | 118 +++--- .../document/user_document_controller.dart | 8 +- lib/helpers/widgets/date_range_picker.dart | 147 ++++++++ .../attendance/attendence_filter_sheet.dart | 56 +-- .../user_document_filter_bottom_sheet.dart | 226 ++++-------- lib/view/Attendence/attendance_logs_tab.dart | 12 +- lib/view/Attendence/attendance_screen.dart | 19 +- .../expense/expense_filter_bottom_sheet.dart | 338 +++++++----------- 8 files changed, 430 insertions(+), 494 deletions(-) create mode 100644 lib/helpers/widgets/date_range_picker.dart diff --git a/lib/controller/attendance/attendance_screen_controller.dart b/lib/controller/attendance/attendance_screen_controller.dart index e0751e8..cab2ffd 100644 --- a/lib/controller/attendance/attendance_screen_controller.dart +++ b/lib/controller/attendance/attendance_screen_controller.dart @@ -20,22 +20,27 @@ import 'package:marco/model/attendance/organization_per_project_list_model.dart' import 'package:marco/controller/project_controller.dart'; class AttendanceController extends GetxController { - // Data models + // ------------------ Data Models ------------------ List attendances = []; List projects = []; List employees = []; List attendanceLogs = []; List regularizationLogs = []; List attendenceLogsView = []; + // ------------------ Organizations ------------------ List organizations = []; Organization? selectedOrganization; final isLoadingOrganizations = false.obs; - // States + // ------------------ States ------------------ String selectedTab = 'todaysAttendance'; - DateTime? startDateAttendance; - DateTime? endDateAttendance; + + // ✅ Reactive date range + final Rx startDateAttendance = + DateTime.now().subtract(const Duration(days: 7)).obs; + final Rx endDateAttendance = + DateTime.now().subtract(const Duration(days: 1)).obs; final isLoading = true.obs; final isLoadingProjects = true.obs; @@ -46,6 +51,8 @@ class AttendanceController extends GetxController { final uploadingStates = {}.obs; var showPendingOnly = false.obs; + final searchQuery = ''.obs; + @override void onInit() { super.onInit(); @@ -58,14 +65,38 @@ class AttendanceController extends GetxController { void _setDefaultDateRange() { final today = DateTime.now(); - startDateAttendance = today.subtract(const Duration(days: 7)); - endDateAttendance = today.subtract(const Duration(days: 1)); + startDateAttendance.value = today.subtract(const Duration(days: 7)); + endDateAttendance.value = today.subtract(const Duration(days: 1)); logSafe( - "Default date range set: $startDateAttendance to $endDateAttendance"); + "Default date range set: ${startDateAttendance.value} to ${endDateAttendance.value}"); } - // ------------------ Project & Employee ------------------ - /// Called when a notification says attendance has been updated + // ------------------ Computed Filters ------------------ + List get filteredEmployees { + if (searchQuery.value.isEmpty) return employees; + return employees + .where((e) => + e.name.toLowerCase().contains(searchQuery.value.toLowerCase())) + .toList(); + } + + List get filteredLogs { + if (searchQuery.value.isEmpty) return attendanceLogs; + return attendanceLogs + .where((log) => + log.name.toLowerCase().contains(searchQuery.value.toLowerCase())) + .toList(); + } + + List get filteredRegularizationLogs { + if (searchQuery.value.isEmpty) return regularizationLogs; + return regularizationLogs + .where((log) => + log.name.toLowerCase().contains(searchQuery.value.toLowerCase())) + .toList(); + } + + // ------------------ Project & Employee APIs ------------------ Future refreshDataFromNotification({String? projectId}) async { projectId ??= Get.find().selectedProject?.id; if (projectId == null) { @@ -78,36 +109,6 @@ class AttendanceController extends GetxController { "Attendance data refreshed from notification for project $projectId"); } - // 🔍 Search query - final searchQuery = ''.obs; - - // Computed filtered employees - List get filteredEmployees { - if (searchQuery.value.isEmpty) return employees; - return employees - .where((e) => - e.name.toLowerCase().contains(searchQuery.value.toLowerCase())) - .toList(); - } - - // Computed filtered logs - List get filteredLogs { - if (searchQuery.value.isEmpty) return attendanceLogs; - return attendanceLogs - .where((log) => - (log.name).toLowerCase().contains(searchQuery.value.toLowerCase())) - .toList(); - } - - // Computed filtered regularization logs - List get filteredRegularizationLogs { - if (searchQuery.value.isEmpty) return regularizationLogs; - return regularizationLogs - .where((log) => - log.name.toLowerCase().contains(searchQuery.value.toLowerCase())) - .toList(); - } - Future fetchTodaysAttendance(String? projectId) async { if (projectId == null) return; @@ -127,6 +128,7 @@ class AttendanceController extends GetxController { logSafe("Failed to fetch employees for project $projectId", level: LogLevel.error); } + isLoadingEmployees.value = false; update(); } @@ -146,7 +148,6 @@ class AttendanceController extends GetxController { } // ------------------ Attendance Capture ------------------ - Future captureAndUploadAttendance( String id, String employeeId, @@ -154,8 +155,8 @@ class AttendanceController extends GetxController { String comment = "Marked via mobile app", required int action, bool imageCapture = true, - String? markTime, // still optional in controller - String? date, // new optional param + String? markTime, + String? date, }) async { try { uploadingStates[employeeId]?.value = true; @@ -169,7 +170,6 @@ class AttendanceController extends GetxController { return false; } - // 🔹 Add timestamp to the image final timestampedFile = await TimestampImageHelper.addTimestamp( imageFile: File(image.path)); @@ -192,29 +192,20 @@ class AttendanceController extends GetxController { ? ApiService.generateImageName(employeeId, employees.length + 1) : ""; - // ---------------- DATE / TIME LOGIC ---------------- final now = DateTime.now(); - - // Default effectiveDate = now DateTime effectiveDate = now; if (action == 1) { - // Checkout - // Try to find today's open log for this employee final log = attendanceLogs.firstWhereOrNull( (log) => log.employeeId == employeeId && log.checkOut == null, ); - if (log?.checkIn != null) { - effectiveDate = log!.checkIn!; // use check-in date - } + if (log?.checkIn != null) effectiveDate = log!.checkIn!; } final formattedMarkTime = markTime ?? DateFormat('hh:mm a').format(now); - final formattedDate = date ?? DateFormat('yyyy-MM-dd').format(effectiveDate); - // ---------------- API CALL ---------------- final result = await ApiService.uploadAttendanceImage( id, employeeId, @@ -263,7 +254,6 @@ class AttendanceController extends GetxController { } // ------------------ Attendance Logs ------------------ - Future fetchAttendanceLogs(String? projectId, {DateTime? dateFrom, DateTime? dateTo}) async { if (projectId == null) return; @@ -312,7 +302,6 @@ class AttendanceController extends GetxController { } // ------------------ Regularization Logs ------------------ - Future fetchRegularizationLogs(String? projectId) async { if (projectId == null) return; @@ -336,7 +325,6 @@ class AttendanceController extends GetxController { } // ------------------ Attendance Log View ------------------ - Future fetchLogsView(String? id) async { if (id == null) return; @@ -359,7 +347,6 @@ class AttendanceController extends GetxController { } // ------------------ Combined Load ------------------ - Future loadAttendanceData(String projectId) async { isLoading.value = true; await fetchProjectData(projectId); @@ -371,7 +358,6 @@ class AttendanceController extends GetxController { await fetchOrganizations(projectId); - // Call APIs depending on the selected tab only switch (selectedTab) { case 'todaysAttendance': await fetchTodaysAttendance(projectId); @@ -379,8 +365,8 @@ class AttendanceController extends GetxController { case 'attendanceLogs': await fetchAttendanceLogs( projectId, - dateFrom: startDateAttendance, - dateTo: endDateAttendance, + dateFrom: startDateAttendance.value, + dateTo: endDateAttendance.value, ); break; case 'regularizationRequests': @@ -394,7 +380,6 @@ class AttendanceController extends GetxController { } // ------------------ UI Interaction ------------------ - Future selectDateRangeForAttendance( BuildContext context, AttendanceController controller) async { final today = DateTime.now(); @@ -404,16 +389,17 @@ class AttendanceController extends GetxController { firstDate: DateTime(2022), lastDate: today.subtract(const Duration(days: 1)), initialDateRange: DateTimeRange( - start: startDateAttendance ?? today.subtract(const Duration(days: 7)), - end: endDateAttendance ?? today.subtract(const Duration(days: 1)), + start: startDateAttendance.value, + end: endDateAttendance.value, ), ); if (picked != null) { - startDateAttendance = picked.start; - endDateAttendance = picked.end; + startDateAttendance.value = picked.start; + endDateAttendance.value = picked.end; + logSafe( - "Date range selected: $startDateAttendance to $endDateAttendance"); + "Date range selected: ${startDateAttendance.value} to ${endDateAttendance.value}"); await controller.fetchAttendanceLogs( Get.find().selectedProject?.id, diff --git a/lib/controller/document/user_document_controller.dart b/lib/controller/document/user_document_controller.dart index fb3f734..5667003 100644 --- a/lib/controller/document/user_document_controller.dart +++ b/lib/controller/document/user_document_controller.dart @@ -34,11 +34,11 @@ class DocumentController extends GetxController { // Additional filters final isUploadedAt = true.obs; final isVerified = RxnBool(); - final startDate = Rxn(); - final endDate = Rxn(); + final startDate = Rxn(); + final endDate = Rxn(); // ==================== Lifecycle ==================== - + @override void onClose() { // Don't dispose searchController here - it's managed by the page @@ -74,7 +74,7 @@ class DocumentController extends GetxController { }) async { try { isLoading.value = true; - + final success = await ApiService.deleteDocumentApi( id: id, isActive: isActive, diff --git a/lib/helpers/widgets/date_range_picker.dart b/lib/helpers/widgets/date_range_picker.dart new file mode 100644 index 0000000..6afab60 --- /dev/null +++ b/lib/helpers/widgets/date_range_picker.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/utils/utils.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +typedef OnDateRangeSelected = void Function(DateTime? start, DateTime? end); + +class DateRangePickerWidget extends StatefulWidget { + final Rx startDate; + final Rx endDate; + final OnDateRangeSelected? onDateRangeSelected; + final String? startLabel; + final String? endLabel; + + const DateRangePickerWidget({ + Key? key, + required this.startDate, + required this.endDate, + this.onDateRangeSelected, + this.startLabel, + this.endLabel, + }); + + @override + State createState() => _DateRangePickerWidgetState(); +} + +class _DateRangePickerWidgetState extends State + with UIMixin { + Future _selectDate(BuildContext context, bool isStartDate) async { + final current = isStartDate + ? widget.startDate.value ?? DateTime.now() + : widget.endDate.value ?? DateTime.now(); + + final DateTime? picked = await showDatePicker( + context: context, + initialDate: current, + firstDate: DateTime(2000), + lastDate: DateTime.now(), + builder: (context, child) => Theme( + data: Theme.of(context).copyWith( + colorScheme: ColorScheme.light( + primary: contentTheme.primary, + onPrimary: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ), + ); + + if (picked != null) { + if (isStartDate) { + widget.startDate.value = picked; + } else { + widget.endDate.value = picked; + } + + if (widget.onDateRangeSelected != null) { + widget.onDateRangeSelected!( + widget.startDate.value, widget.endDate.value); + } + } + } + + Widget _dateBox({ + required BuildContext context, + required String label, + required Rx date, + required bool isStart, + }) { + return Expanded( + child: Obx(() { + return InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => _selectDate(context, isStart), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: contentTheme.primary.withOpacity(0.08), + border: Border.all(color: contentTheme.primary.withOpacity(0.3)), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: contentTheme.primary.withOpacity(0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Icon( + isStart + ? Icons.calendar_today_outlined + : Icons.event_outlined, + size: 14, + color: contentTheme.primary, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText(label, fontSize: 10, fontWeight: 500), + const SizedBox(height: 2), + MyText( + date.value != null + ? Utils.formatDate(date.value!) + : 'Not selected', + fontWeight: 600, + color: contentTheme.primary, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ); + }), + ); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + _dateBox( + context: context, + label: widget.startLabel ?? 'Start Date', + date: widget.startDate, + isStart: true, + ), + const SizedBox(width: 8), + _dateBox( + context: context, + label: widget.endLabel ?? 'End Date', + date: widget.endDate, + isStart: false, + ), + ], + ); + } +} diff --git a/lib/model/attendance/attendence_filter_sheet.dart b/lib/model/attendance/attendence_filter_sheet.dart index 7a9eec8..8d0d10d 100644 --- a/lib/model/attendance/attendence_filter_sheet.dart +++ b/lib/model/attendance/attendence_filter_sheet.dart @@ -5,6 +5,7 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/helpers/widgets/date_range_picker.dart'; class AttendanceFilterBottomSheet extends StatefulWidget { final AttendanceController controller; @@ -34,15 +35,11 @@ class _AttendanceFilterBottomSheetState } String getLabelText() { - final startDate = widget.controller.startDateAttendance; - final endDate = widget.controller.endDateAttendance; - - if (startDate != null && endDate != null) { - final start = DateTimeUtils.formatDate(startDate, 'dd MMM yyyy'); - final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy'); - return "$start - $end"; - } - return "Date Range"; + final start = DateTimeUtils.formatDate( + widget.controller.startDateAttendance.value, 'dd MMM yyyy'); + final end = DateTimeUtils.formatDate( + widget.controller.endDateAttendance.value, 'dd MMM yyyy'); + return "$start - $end"; } List buildMainFilters() { @@ -61,6 +58,7 @@ class _AttendanceFilterBottomSheetState }).toList(); final List widgets = [ + // 🔹 View Section Padding( padding: const EdgeInsets.only(bottom: 4), child: Align( @@ -82,8 +80,7 @@ class _AttendanceFilterBottomSheetState ); }), ]; - - // 🔹 Date Range only for attendanceLogs + // 🔹 Date Range (only for Attendance Logs) if (tempSelectedTab == 'attendanceLogs') { widgets.addAll([ const Divider(), @@ -94,37 +91,16 @@ class _AttendanceFilterBottomSheetState child: MyText.titleSmall("Date Range", fontWeight: 600), ), ), - InkWell( - borderRadius: BorderRadius.circular(10), - onTap: () async { - await widget.controller.selectDateRangeForAttendance( - context, - widget.controller, - ); + // ✅ Reusable DateRangePickerWidget + DateRangePickerWidget( + startDate: widget.controller.startDateAttendance, + endDate: widget.controller.endDateAttendance, + startLabel: "Start Date", + endLabel: "End Date", + onDateRangeSelected: (start, end) { + // Optional: trigger UI updates if needed setState(() {}); }, - child: Ink( - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey.shade400), - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - child: Row( - children: [ - const Icon(Icons.date_range, color: Colors.black87), - const SizedBox(width: 12), - Expanded( - child: MyText.bodyMedium( - getLabelText(), - fontWeight: 500, - color: Colors.black87, - ), - ), - const Icon(Icons.arrow_drop_down, color: Colors.black87), - ], - ), - ), ), ]); } diff --git a/lib/model/document/user_document_filter_bottom_sheet.dart b/lib/model/document/user_document_filter_bottom_sheet.dart index fa0546a..c50fb1c 100644 --- a/lib/model/document/user_document_filter_bottom_sheet.dart +++ b/lib/model/document/user_document_filter_bottom_sheet.dart @@ -2,24 +2,33 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/document/user_document_controller.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; -import 'package:marco/helpers/utils/date_time_utils.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/model/document/document_filter_model.dart'; import 'dart:convert'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/date_range_picker.dart'; -class UserDocumentFilterBottomSheet extends StatelessWidget { +class UserDocumentFilterBottomSheet extends StatefulWidget { final String entityId; final String entityTypeId; - final DocumentController docController = Get.find(); - UserDocumentFilterBottomSheet({ + const UserDocumentFilterBottomSheet({ super.key, required this.entityId, required this.entityTypeId, }); + @override + State createState() => + _UserDocumentFilterBottomSheetState(); +} + +class _UserDocumentFilterBottomSheetState + extends State with UIMixin { + final DocumentController docController = Get.find(); + @override Widget build(BuildContext context) { final filterData = docController.filters.value; @@ -51,8 +60,8 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { }; docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: entityId, + entityTypeId: widget.entityTypeId, + entityId: widget.entityId, filter: jsonEncode(combinedFilter), reset: true, ); @@ -76,144 +85,64 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { ), ), ), - // --- Date Filter (Uploaded On / Updated On) --- + // --- Date Range using Radio Buttons on Same Row --- _buildField( "Choose Date", Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Segmented Buttons Obx(() { - return Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(24), - ), - child: Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () => - docController.isUploadedAt.value = true, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 10), - decoration: BoxDecoration( - color: docController.isUploadedAt.value - ? Colors.indigo.shade400 - : Colors.transparent, - borderRadius: - const BorderRadius.horizontal( - left: Radius.circular(24), - ), - ), - child: Center( - child: MyText( - "Upload Date", - style: MyTextStyle.bodyMedium( - color: - docController.isUploadedAt.value - ? Colors.white - : Colors.black87, - fontWeight: 600, - ), - ), - ), + return Row( + children: [ + // --- Upload Date --- + Expanded( + child: Row( + children: [ + Radio( + value: true, + groupValue: + docController.isUploadedAt.value, + onChanged: (val) => docController + .isUploadedAt.value = val!, + activeColor: contentTheme.primary, ), - ), + MyText("Upload Date"), + ], ), - Expanded( - child: GestureDetector( - onTap: () => docController - .isUploadedAt.value = false, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 10), - decoration: BoxDecoration( - color: !docController.isUploadedAt.value - ? Colors.indigo.shade400 - : Colors.transparent, - borderRadius: - const BorderRadius.horizontal( - right: Radius.circular(24), - ), - ), - child: Center( - child: MyText( - "Update Date", - style: MyTextStyle.bodyMedium( - color: !docController - .isUploadedAt.value - ? Colors.white - : Colors.black87, - fontWeight: 600, - ), - ), - ), + ), + // --- Update Date --- + Expanded( + child: Row( + children: [ + Radio( + value: false, + groupValue: + docController.isUploadedAt.value, + onChanged: (val) => docController + .isUploadedAt.value = val!, + activeColor: contentTheme.primary, ), - ), + MyText("Update Date"), + ], ), - ], - ), + ), + ], ); }), MySpacing.height(12), - // Date Range - Row( - children: [ - Expanded( - child: Obx(() { - return _dateButton( - label: docController.startDate.value == null - ? 'From Date' - : DateTimeUtils.formatDate( - DateTime.parse( - docController.startDate.value!), - 'dd MMM yyyy', - ), - onTap: () async { - final picked = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime(2000), - lastDate: DateTime.now(), - ); - if (picked != null) { - docController.startDate.value = - picked.toIso8601String(); - } - }, - ); - }), - ), - MySpacing.width(12), - Expanded( - child: Obx(() { - return _dateButton( - label: docController.endDate.value == null - ? 'To Date' - : DateTimeUtils.formatDate( - DateTime.parse( - docController.endDate.value!), - 'dd MMM yyyy', - ), - onTap: () async { - final picked = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime(2000), - lastDate: DateTime.now(), - ); - if (picked != null) { - docController.endDate.value = - picked.toIso8601String(); - } - }, - ); - }), - ), - ], + // --- Date Range Picker --- + DateRangePickerWidget( + startDate: docController.startDate, + endDate: docController.endDate, + startLabel: "From Date", + endLabel: "To Date", + onDateRangeSelected: (start, end) { + if (start != null && end != null) { + docController.startDate.value = start; + docController.endDate.value = end; + } + }, ), ], ), @@ -251,7 +180,6 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { Obx(() { return Container( padding: MySpacing.all(12), - child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -263,8 +191,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { groupValue: docController.isVerified.value, onChanged: (val) => docController.isVerified.value = val, - activeColor: - Colors.indigo, + activeColor: contentTheme.primary, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), @@ -279,7 +206,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { groupValue: docController.isVerified.value, onChanged: (val) => docController.isVerified.value = val, - activeColor: Colors.indigo, + activeColor: contentTheme.primary, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), @@ -294,7 +221,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { groupValue: docController.isVerified.value, onChanged: (val) => docController.isVerified.value = val, - activeColor: Colors.indigo, + activeColor: contentTheme.primary, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), @@ -391,7 +318,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { (states) { if (states .contains(MaterialState.selected)) { - return Colors.indigo; // checked → Indigo + return contentTheme.primary; } return Colors.white; // unchecked → White }, @@ -454,31 +381,4 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { ], ); } - - Widget _dateButton({required String label, required VoidCallback onTap}) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: MySpacing.xy(16, 12), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300), - ), - child: Row( - children: [ - const Icon(Icons.calendar_today, size: 16, color: Colors.grey), - MySpacing.width(8), - Expanded( - child: MyText( - label, - style: MyTextStyle.bodyMedium(), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ); - } } diff --git a/lib/view/Attendence/attendance_logs_tab.dart b/lib/view/Attendence/attendance_logs_tab.dart index fb9e5d0..61b9021 100644 --- a/lib/view/Attendence/attendance_logs_tab.dart +++ b/lib/view/Attendence/attendance_logs_tab.dart @@ -104,9 +104,7 @@ class _AttendanceLogsTabState extends State { // Filter logs if "pending only" final showPendingOnly = widget.controller.showPendingOnly.value; final filteredLogs = showPendingOnly - ? allLogs - .where((emp) => emp.activity == 1 ) - .toList() + ? allLogs.where((emp) => emp.activity == 1).toList() : allLogs; // Group logs by date string @@ -126,11 +124,9 @@ class _AttendanceLogsTabState extends State { return db.compareTo(da); }); - final dateRangeText = widget.controller.startDateAttendance != null && - widget.controller.endDateAttendance != null - ? '${DateTimeUtils.formatDate(widget.controller.startDateAttendance!, 'dd MMM yyyy')} - ' - '${DateTimeUtils.formatDate(widget.controller.endDateAttendance!, 'dd MMM yyyy')}' - : 'Select date range'; + final dateRangeText = + '${DateTimeUtils.formatDate(widget.controller.startDateAttendance.value, 'dd MMM yyyy')} - ' + '${DateTimeUtils.formatDate(widget.controller.endDateAttendance.value, 'dd MMM yyyy')}'; return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/view/Attendence/attendance_screen.dart b/lib/view/Attendence/attendance_screen.dart index 32d3d8d..2a5614a 100644 --- a/lib/view/Attendence/attendance_screen.dart +++ b/lib/view/Attendence/attendance_screen.dart @@ -34,12 +34,12 @@ class _AttendanceScreenState extends State with UIMixin { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - // Listen for future project selection changes + // 🔁 Listen for project changes ever(projectController.selectedProjectId, (projectId) async { if (projectId.isNotEmpty) await _loadData(projectId); }); - // Load initial data + // 🚀 Load initial data only once the screen is shown final projectId = projectController.selectedProjectId.value; if (projectId.isNotEmpty) _loadData(projectId); }); @@ -47,7 +47,7 @@ class _AttendanceScreenState extends State with UIMixin { Future _loadData(String projectId) async { try { - attendanceController.selectedTab = 'todaysAttendance'; + attendanceController.selectedTab = 'todaysAttendance'; await attendanceController.loadAttendanceData(projectId); attendanceController.update(['attendance_dashboard_controller']); } catch (e) { @@ -67,8 +67,8 @@ class _AttendanceScreenState extends State with UIMixin { case 'attendanceLogs': await attendanceController.fetchAttendanceLogs( projectId, - dateFrom: attendanceController.startDateAttendance, - dateTo: attendanceController.endDateAttendance, + dateFrom: attendanceController.startDateAttendance.value, + dateTo: attendanceController.endDateAttendance.value, ); break; case 'regularizationRequests': @@ -402,4 +402,13 @@ class _AttendanceScreenState extends State with UIMixin { ), ); } + + @override + void dispose() { + // 🧹 Clean up the controller when user leaves this screen + if (Get.isRegistered()) { + Get.delete(); + } + super.dispose(); + } } diff --git a/lib/view/expense/expense_filter_bottom_sheet.dart b/lib/view/expense/expense_filter_bottom_sheet.dart index 210242b..0c6d565 100644 --- a/lib/view/expense/expense_filter_bottom_sheet.dart +++ b/lib/view/expense/expense_filter_bottom_sheet.dart @@ -1,15 +1,18 @@ +// ignore_for_file: must_be_immutable + import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; -import 'package:marco/helpers/utils/date_time_utils.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/model/employees/employee_model.dart'; import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/date_range_picker.dart'; -class ExpenseFilterBottomSheet extends StatelessWidget { +class ExpenseFilterBottomSheet extends StatefulWidget { final ExpenseController expenseController; final ScrollController scrollController; @@ -19,12 +22,18 @@ class ExpenseFilterBottomSheet extends StatelessWidget { required this.scrollController, }); - // FIX: create search adapter + @override + State createState() => + _ExpenseFilterBottomSheetState(); +} + +class _ExpenseFilterBottomSheetState extends State + with UIMixin { + /// Search employees for Paid By / Created By filters Future> searchEmployeesForBottomSheet( String query) async { - await expenseController - .searchEmployees(query); // async method, returns void - return expenseController.employeeSearchResults.toList(); + await widget.expenseController.searchEmployees(query); + return widget.expenseController.employeeSearchResults.toList(); } @override @@ -34,20 +43,21 @@ class ExpenseFilterBottomSheet extends StatelessWidget { title: 'Filter Expenses', onCancel: () => Get.back(), onSubmit: () { - expenseController.fetchExpenses(); + widget.expenseController.fetchExpenses(); Get.back(); }, submitText: 'Submit', + submitColor: contentTheme.primary, submitIcon: Icons.check_circle_outline, child: SingleChildScrollView( - controller: scrollController, + controller: widget.scrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Align( alignment: Alignment.centerRight, child: TextButton( - onPressed: () => expenseController.clearFilters(), + onPressed: () => widget.expenseController.clearFilters(), child: MyText( "Reset Filter", style: MyTextStyle.labelMedium( @@ -58,15 +68,15 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ), ), MySpacing.height(8), - _buildProjectFilter(context), + _buildProjectFilter(), MySpacing.height(16), - _buildStatusFilter(context), + _buildStatusFilter(), MySpacing.height(16), - _buildDateRangeFilter(context), + _buildDateRangeFilter(), MySpacing.height(16), - _buildPaidByFilter(context), + _buildPaidByFilter(), MySpacing.height(16), - _buildCreatedByFilter(context), + _buildCreatedByFilter(), ], ), ), @@ -85,190 +95,145 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } - Widget _buildProjectFilter(BuildContext context) { + Widget _buildProjectFilter() { return _buildField( "Project", _popupSelector( - context, - currentValue: expenseController.selectedProject.value.isEmpty + currentValue: widget.expenseController.selectedProject.value.isEmpty ? 'Select Project' - : expenseController.selectedProject.value, - items: expenseController.globalProjects, - onSelected: (value) => expenseController.selectedProject.value = value, + : widget.expenseController.selectedProject.value, + items: widget.expenseController.globalProjects, + onSelected: (value) => + widget.expenseController.selectedProject.value = value, ), ); } - Widget _buildStatusFilter(BuildContext context) { + Widget _buildStatusFilter() { return _buildField( "Expense Status", _popupSelector( - context, - currentValue: expenseController.selectedStatus.value.isEmpty + currentValue: widget.expenseController.selectedStatus.value.isEmpty ? 'Select Expense Status' - : expenseController.expenseStatuses - .firstWhereOrNull( - (e) => e.id == expenseController.selectedStatus.value) + : widget.expenseController.expenseStatuses + .firstWhereOrNull((e) => + e.id == widget.expenseController.selectedStatus.value) ?.name ?? 'Select Expense Status', - items: expenseController.expenseStatuses.map((e) => e.name).toList(), + items: widget.expenseController.expenseStatuses + .map((e) => e.name) + .toList(), onSelected: (name) { - final status = expenseController.expenseStatuses + final status = widget.expenseController.expenseStatuses .firstWhere((e) => e.name == name); - expenseController.selectedStatus.value = status.id; + widget.expenseController.selectedStatus.value = status.id; }, ), ); } - Widget _buildDateRangeFilter(BuildContext context) { + Widget _buildDateRangeFilter() { return _buildField( "Date Filter", Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // --- Radio Buttons for Transaction Date / Created At --- Obx(() { - return SizedBox( - width: double.infinity, // Make it full width - child: SegmentedButton( - segments: expenseController.dateTypes - .map( - (type) => ButtonSegment( - value: type, - label: Center( - // Center label text - child: MyText( - type, - style: MyTextStyle.bodySmall( - fontWeight: 600, - fontSize: 13, - height: 1.2, - ), - ), + return Row( + children: [ + // --- Transaction Date --- + Expanded( + child: Row( + children: [ + Radio( + value: "Transaction Date", + groupValue: + widget.expenseController.selectedDateType.value, + onChanged: (val) { + if (val != null) { + widget.expenseController.selectedDateType.value = + val; + } + }, + activeColor: contentTheme.primary, + ), + Flexible( + child: MyText( + "Transaction Date", ), ), - ) - .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, - ), + ], ), ), - ), + // --- Created At --- + Expanded( + child: Row( + children: [ + Radio( + value: "Created At", + groupValue: + widget.expenseController.selectedDateType.value, + onChanged: (val) { + if (val != null) { + widget.expenseController.selectedDateType.value = + val; + } + }, + activeColor: contentTheme.primary, + ), + Flexible( + child: MyText( + "Created At", + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], ); }), 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, - ), - ), - ), - ], + // --- Reusable Date Range Picker --- + DateRangePickerWidget( + startDate: widget.expenseController.startDate, + endDate: widget.expenseController.endDate, + startLabel: "Start Date", + endLabel: "End Date", + onDateRangeSelected: (start, end) { + widget.expenseController.startDate.value = start; + widget.expenseController.endDate.value = end; + }, ), ], ), ); } - Widget _buildPaidByFilter(BuildContext context) { + Widget _buildPaidByFilter() { return _buildField( "Paid By", _employeeSelector( - context: context, - selectedEmployees: expenseController.selectedPaidByEmployees, - searchEmployees: searchEmployeesForBottomSheet, // FIXED + selectedEmployees: widget.expenseController.selectedPaidByEmployees, + searchEmployees: searchEmployeesForBottomSheet, title: 'Search Paid By', ), ); } - Widget _buildCreatedByFilter(BuildContext context) { + Widget _buildCreatedByFilter() { return _buildField( "Created By", _employeeSelector( - context: context, - selectedEmployees: expenseController.selectedCreatedByEmployees, - searchEmployees: searchEmployeesForBottomSheet, // FIXED + selectedEmployees: widget.expenseController.selectedCreatedByEmployees, + searchEmployees: searchEmployeesForBottomSheet, title: 'Search Created By', ), ); } - 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; - } - } - - Widget _popupSelector( - BuildContext context, { + Widget _popupSelector({ required String currentValue, required List items, required ValueChanged onSelected, @@ -306,59 +271,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } - Widget _dateButton({required String label, required VoidCallback onTap}) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: MySpacing.xy(16, 12), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300), - ), - child: Row( - children: [ - const Icon(Icons.calendar_today, size: 16, color: Colors.grey), - MySpacing.width(8), - Expanded( - child: MyText( - label, - style: MyTextStyle.bodyMedium(), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ); - } - - Future _showEmployeeSelectorBottomSheet({ - required BuildContext context, - required RxList selectedEmployees, - required Future> Function(String) searchEmployees, - String title = 'Select Employee', - }) async { - final List? result = - await showModalBottomSheet>( - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => EmployeeSelectorBottomSheet( - selectedEmployees: selectedEmployees, - searchEmployees: searchEmployees, - title: title, - ), - ); - if (result != null) { - selectedEmployees.assignAll(result); - } - } - Widget _employeeSelector({ - required BuildContext context, required RxList selectedEmployees, required Future> Function(String) searchEmployees, String title = 'Search Employee', @@ -367,27 +280,37 @@ class ExpenseFilterBottomSheet extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Obx(() { - if (selectedEmployees.isEmpty) { - return const SizedBox.shrink(); - } + if (selectedEmployees.isEmpty) return const SizedBox.shrink(); return Wrap( spacing: 8, children: selectedEmployees - .map((emp) => Chip( - label: MyText(emp.name), - onDeleted: () => selectedEmployees.remove(emp), - )) + .map( + (emp) => Chip( + label: MyText(emp.name), + onDeleted: () => selectedEmployees.remove(emp), + ), + ) .toList(), ); }), MySpacing.height(8), GestureDetector( - onTap: () => _showEmployeeSelectorBottomSheet( - context: context, - selectedEmployees: selectedEmployees, - searchEmployees: searchEmployees, - title: title, - ), + onTap: () async { + final List? result = + await showModalBottomSheet>( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => EmployeeSelectorBottomSheet( + selectedEmployees: selectedEmployees, + searchEmployees: searchEmployees, + title: title, + ), + ); + if (result != null) selectedEmployees.assignAll(result); + }, child: Container( padding: MySpacing.all(12), decoration: BoxDecoration( @@ -407,5 +330,4 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ], ); } - }