Merge pull request 'Dashboard_Expense_Widgets_OFW' (#78) from Dashboard_Expense_Widgets_OFW into OnFieldWork

Reviewed-on: #78
This commit is contained in:
vaibhav.surve 2025-11-04 08:46:32 +00:00
commit 5d99f3fdfd
44 changed files with 5193 additions and 2095 deletions

View File

@ -15,7 +15,7 @@ if (keystorePropertiesFile.exists()) {
android { android {
// Define the namespace for your Android application // 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 // Set the compile SDK version based on Flutter's configuration
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
// Set the NDK version based on Flutter's configuration // Set the NDK version based on Flutter's configuration

View File

@ -1,4 +1,4 @@
package com.onfieldwork.marcoaiot package com.marcoonfieldwork.aiot
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity

View File

@ -368,7 +368,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -384,7 +384,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -401,7 +401,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -416,7 +416,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -547,7 +547,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -569,7 +569,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

View File

@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_image_compressor.dart'; import 'package:marco/helpers/widgets/my_image_compressor.dart';
import 'package:marco/helpers/widgets/time_stamp_image_helper.dart';
import 'package:marco/model/attendance/attendance_model.dart'; import 'package:marco/model/attendance/attendance_model.dart';
import 'package:marco/model/project_model.dart'; import 'package:marco/model/project_model.dart';
@ -19,22 +20,27 @@ import 'package:marco/model/attendance/organization_per_project_list_model.dart'
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
class AttendanceController extends GetxController { class AttendanceController extends GetxController {
// Data models // ------------------ Data Models ------------------
List<AttendanceModel> attendances = []; List<AttendanceModel> attendances = [];
List<ProjectModel> projects = []; List<ProjectModel> projects = [];
List<EmployeeModel> employees = []; List<EmployeeModel> employees = [];
List<AttendanceLogModel> attendanceLogs = []; List<AttendanceLogModel> attendanceLogs = [];
List<RegularizationLogModel> regularizationLogs = []; List<RegularizationLogModel> regularizationLogs = [];
List<AttendanceLogViewModel> attendenceLogsView = []; List<AttendanceLogViewModel> attendenceLogsView = [];
// ------------------ Organizations ------------------ // ------------------ Organizations ------------------
List<Organization> organizations = []; List<Organization> organizations = [];
Organization? selectedOrganization; Organization? selectedOrganization;
final isLoadingOrganizations = false.obs; final isLoadingOrganizations = false.obs;
// States // ------------------ States ------------------
String selectedTab = 'todaysAttendance'; String selectedTab = 'todaysAttendance';
DateTime? startDateAttendance;
DateTime? endDateAttendance; // Reactive date range
final Rx<DateTime> startDateAttendance =
DateTime.now().subtract(const Duration(days: 7)).obs;
final Rx<DateTime> endDateAttendance =
DateTime.now().subtract(const Duration(days: 1)).obs;
final isLoading = true.obs; final isLoading = true.obs;
final isLoadingProjects = true.obs; final isLoadingProjects = true.obs;
@ -45,16 +51,12 @@ String selectedTab = 'todaysAttendance';
final uploadingStates = <String, RxBool>{}.obs; final uploadingStates = <String, RxBool>{}.obs;
var showPendingOnly = false.obs; var showPendingOnly = false.obs;
final searchQuery = ''.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
_initializeDefaults(); _initializeDefaults();
// 🔹 Fetch organizations for the selected project
final projectId = Get.find<ProjectController>().selectedProject?.id;
if (projectId != null) {
fetchOrganizations(projectId);
}
} }
void _initializeDefaults() { void _initializeDefaults() {
@ -63,14 +65,38 @@ String selectedTab = 'todaysAttendance';
void _setDefaultDateRange() { void _setDefaultDateRange() {
final today = DateTime.now(); final today = DateTime.now();
startDateAttendance = today.subtract(const Duration(days: 7)); startDateAttendance.value = today.subtract(const Duration(days: 7));
endDateAttendance = today.subtract(const Duration(days: 1)); endDateAttendance.value = today.subtract(const Duration(days: 1));
logSafe( logSafe(
"Default date range set: $startDateAttendance to $endDateAttendance"); "Default date range set: ${startDateAttendance.value} to ${endDateAttendance.value}");
} }
// ------------------ Project & Employee ------------------ // ------------------ Computed Filters ------------------
/// Called when a notification says attendance has been updated List<EmployeeModel> get filteredEmployees {
if (searchQuery.value.isEmpty) return employees;
return employees
.where((e) =>
e.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
List<AttendanceLogModel> get filteredLogs {
if (searchQuery.value.isEmpty) return attendanceLogs;
return attendanceLogs
.where((log) =>
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
List<RegularizationLogModel> get filteredRegularizationLogs {
if (searchQuery.value.isEmpty) return regularizationLogs;
return regularizationLogs
.where((log) =>
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
// ------------------ Project & Employee APIs ------------------
Future<void> refreshDataFromNotification({String? projectId}) async { Future<void> refreshDataFromNotification({String? projectId}) async {
projectId ??= Get.find<ProjectController>().selectedProject?.id; projectId ??= Get.find<ProjectController>().selectedProject?.id;
if (projectId == null) { if (projectId == null) {
@ -83,36 +109,6 @@ String selectedTab = 'todaysAttendance';
"Attendance data refreshed from notification for project $projectId"); "Attendance data refreshed from notification for project $projectId");
} }
// 🔍 Search query
final searchQuery = ''.obs;
// Computed filtered employees
List<EmployeeModel> get filteredEmployees {
if (searchQuery.value.isEmpty) return employees;
return employees
.where((e) =>
e.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
// Computed filtered logs
List<AttendanceLogModel> 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<RegularizationLogModel> get filteredRegularizationLogs {
if (searchQuery.value.isEmpty) return regularizationLogs;
return regularizationLogs
.where((log) =>
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
Future<void> fetchTodaysAttendance(String? projectId) async { Future<void> fetchTodaysAttendance(String? projectId) async {
if (projectId == null) return; if (projectId == null) return;
@ -132,6 +128,7 @@ String selectedTab = 'todaysAttendance';
logSafe("Failed to fetch employees for project $projectId", logSafe("Failed to fetch employees for project $projectId",
level: LogLevel.error); level: LogLevel.error);
} }
isLoadingEmployees.value = false; isLoadingEmployees.value = false;
update(); update();
} }
@ -151,7 +148,6 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ Attendance Capture ------------------ // ------------------ Attendance Capture ------------------
Future<bool> captureAndUploadAttendance( Future<bool> captureAndUploadAttendance(
String id, String id,
String employeeId, String employeeId,
@ -159,8 +155,8 @@ String selectedTab = 'todaysAttendance';
String comment = "Marked via mobile app", String comment = "Marked via mobile app",
required int action, required int action,
bool imageCapture = true, bool imageCapture = true,
String? markTime, // still optional in controller String? markTime,
String? date, // new optional param String? date,
}) async { }) async {
try { try {
uploadingStates[employeeId]?.value = true; uploadingStates[employeeId]?.value = true;
@ -174,8 +170,11 @@ String selectedTab = 'todaysAttendance';
return false; return false;
} }
final timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: File(image.path));
final compressedBytes = final compressedBytes =
await compressImageToUnder100KB(File(image.path)); await compressImageToUnder100KB(timestampedFile);
if (compressedBytes == null) { if (compressedBytes == null) {
logSafe("Image compression failed.", level: LogLevel.error); logSafe("Image compression failed.", level: LogLevel.error);
return false; return false;
@ -193,29 +192,20 @@ String selectedTab = 'todaysAttendance';
? ApiService.generateImageName(employeeId, employees.length + 1) ? ApiService.generateImageName(employeeId, employees.length + 1)
: ""; : "";
// ---------------- DATE / TIME LOGIC ----------------
final now = DateTime.now(); final now = DateTime.now();
// Default effectiveDate = now
DateTime effectiveDate = now; DateTime effectiveDate = now;
if (action == 1) { if (action == 1) {
// Checkout
// Try to find today's open log for this employee
final log = attendanceLogs.firstWhereOrNull( final log = attendanceLogs.firstWhereOrNull(
(log) => log.employeeId == employeeId && log.checkOut == null, (log) => log.employeeId == employeeId && log.checkOut == null,
); );
if (log?.checkIn != null) { if (log?.checkIn != null) effectiveDate = log!.checkIn!;
effectiveDate = log!.checkIn!; // use check-in date
}
} }
final formattedMarkTime = markTime ?? DateFormat('hh:mm a').format(now); final formattedMarkTime = markTime ?? DateFormat('hh:mm a').format(now);
final formattedDate = final formattedDate =
date ?? DateFormat('yyyy-MM-dd').format(effectiveDate); date ?? DateFormat('yyyy-MM-dd').format(effectiveDate);
// ---------------- API CALL ----------------
final result = await ApiService.uploadAttendanceImage( final result = await ApiService.uploadAttendanceImage(
id, id,
employeeId, employeeId,
@ -264,7 +254,6 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ Attendance Logs ------------------ // ------------------ Attendance Logs ------------------
Future<void> fetchAttendanceLogs(String? projectId, Future<void> fetchAttendanceLogs(String? projectId,
{DateTime? dateFrom, DateTime? dateTo}) async { {DateTime? dateFrom, DateTime? dateTo}) async {
if (projectId == null) return; if (projectId == null) return;
@ -313,7 +302,6 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ Regularization Logs ------------------ // ------------------ Regularization Logs ------------------
Future<void> fetchRegularizationLogs(String? projectId) async { Future<void> fetchRegularizationLogs(String? projectId) async {
if (projectId == null) return; if (projectId == null) return;
@ -337,7 +325,6 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ Attendance Log View ------------------ // ------------------ Attendance Log View ------------------
Future<void> fetchLogsView(String? id) async { Future<void> fetchLogsView(String? id) async {
if (id == null) return; if (id == null) return;
@ -360,7 +347,6 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ Combined Load ------------------ // ------------------ Combined Load ------------------
Future<void> loadAttendanceData(String projectId) async { Future<void> loadAttendanceData(String projectId) async {
isLoading.value = true; isLoading.value = true;
await fetchProjectData(projectId); await fetchProjectData(projectId);
@ -372,7 +358,6 @@ String selectedTab = 'todaysAttendance';
await fetchOrganizations(projectId); await fetchOrganizations(projectId);
// Call APIs depending on the selected tab only
switch (selectedTab) { switch (selectedTab) {
case 'todaysAttendance': case 'todaysAttendance':
await fetchTodaysAttendance(projectId); await fetchTodaysAttendance(projectId);
@ -380,8 +365,8 @@ String selectedTab = 'todaysAttendance';
case 'attendanceLogs': case 'attendanceLogs':
await fetchAttendanceLogs( await fetchAttendanceLogs(
projectId, projectId,
dateFrom: startDateAttendance, dateFrom: startDateAttendance.value,
dateTo: endDateAttendance, dateTo: endDateAttendance.value,
); );
break; break;
case 'regularizationRequests': case 'regularizationRequests':
@ -395,7 +380,6 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ UI Interaction ------------------ // ------------------ UI Interaction ------------------
Future<void> selectDateRangeForAttendance( Future<void> selectDateRangeForAttendance(
BuildContext context, AttendanceController controller) async { BuildContext context, AttendanceController controller) async {
final today = DateTime.now(); final today = DateTime.now();
@ -405,16 +389,17 @@ String selectedTab = 'todaysAttendance';
firstDate: DateTime(2022), firstDate: DateTime(2022),
lastDate: today.subtract(const Duration(days: 1)), lastDate: today.subtract(const Duration(days: 1)),
initialDateRange: DateTimeRange( initialDateRange: DateTimeRange(
start: startDateAttendance ?? today.subtract(const Duration(days: 7)), start: startDateAttendance.value,
end: endDateAttendance ?? today.subtract(const Duration(days: 1)), end: endDateAttendance.value,
), ),
); );
if (picked != null) { if (picked != null) {
startDateAttendance = picked.start; startDateAttendance.value = picked.start;
endDateAttendance = picked.end; endDateAttendance.value = picked.end;
logSafe( logSafe(
"Date range selected: $startDateAttendance to $endDateAttendance"); "Date range selected: ${startDateAttendance.value} to ${endDateAttendance.value}");
await controller.fetchAttendanceLogs( await controller.fetchAttendanceLogs(
Get.find<ProjectController>().selectedProject?.id, Get.find<ProjectController>().selectedProject?.id,

View File

@ -3,6 +3,10 @@ import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/model/dashboard/project_progress_model.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 { class DashboardController extends GetxController {
// ========================= // =========================
@ -47,7 +51,49 @@ class DashboardController extends GetxController {
final List<String> ranges = ['7D', '15D', '30D']; final List<String> ranges = ['7D', '15D', '30D'];
// Inject ProjectController // Inject ProjectController
final ProjectController projectController = Get.find<ProjectController>(); final ProjectController projectController = Get.put(ProjectController());
// Pending Expenses overview
// =========================
final RxBool isPendingExpensesLoading = false.obs;
final Rx<PendingExpensesData?> pendingExpensesData =
Rx<PendingExpensesData?>(null);
// =========================
// Expense Type Report
// =========================
final RxBool isExpenseTypeReportLoading = false.obs;
final Rx<ExpenseTypeReportData?> expenseTypeReportData =
Rx<ExpenseTypeReportData?>(null);
final Rx<DateTime> expenseReportStartDate =
DateTime.now().subtract(const Duration(days: 15)).obs;
final Rx<DateTime> expenseReportEndDate = DateTime.now().obs;
// =========================
// Monthly Expense Report
// =========================
final RxBool isMonthlyExpenseLoading = false.obs;
final RxList<MonthlyExpenseData> monthlyExpenseList =
<MonthlyExpenseData>[].obs;
// =========================
// Monthly Expense Report Filters
// =========================
final Rx<MonthlyExpenseDuration> selectedMonthlyExpenseDuration =
MonthlyExpenseDuration.twelveMonths.obs;
final RxInt selectedMonthsCount = 12.obs;
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
final Rx<ExpenseTypeModel?> selectedExpenseType = Rx<ExpenseTypeModel?>(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 @override
void onInit() { void onInit() {
@ -64,7 +110,12 @@ class DashboardController extends GetxController {
ever<String>(projectController.selectedProjectId, (id) { ever<String>(projectController.selectedProjectId, (id) {
fetchAllDashboardData(); fetchAllDashboardData();
}); });
everAll([expenseReportStartDate, expenseReportEndDate], (_) {
fetchExpenseTypeReport(
startDate: expenseReportStartDate.value,
endDate: expenseReportEndDate.value,
);
});
// React to range changes // React to range changes
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance()); ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
ever(projectSelectedRange, (_) => fetchProjectProgress()); ever(projectSelectedRange, (_) => fetchProjectProgress());
@ -147,9 +198,113 @@ class DashboardController extends GetxController {
fetchProjectProgress(), fetchProjectProgress(),
fetchDashboardTasks(projectId: projectId), fetchDashboardTasks(projectId: projectId),
fetchDashboardTeams(projectId: projectId), fetchDashboardTeams(projectId: projectId),
fetchPendingExpenses(),
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<void> 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<void> 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<void> 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 // API Calls
// ========================= // =========================
@ -182,6 +337,39 @@ class DashboardController extends GetxController {
} }
} }
Future<void> 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<void> fetchProjectProgress() async { Future<void> fetchProjectProgress() async {
final String projectId = projectController.selectedProjectId.value; final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return; if (projectId.isEmpty) return;
@ -260,3 +448,11 @@ class DashboardController extends GetxController {
} }
} }
} }
enum MonthlyExpenseDuration {
oneMonth,
threeMonths,
sixMonths,
twelveMonths,
all,
}

View File

@ -5,54 +5,63 @@ import 'package:marco/model/document/document_filter_model.dart';
import 'package:marco/model/document/documents_list_model.dart'; import 'package:marco/model/document/documents_list_model.dart';
class DocumentController extends GetxController { class DocumentController extends GetxController {
// ------------------ Observables --------------------- // ==================== Observables ====================
var isLoading = false.obs; final isLoading = false.obs;
var documents = <DocumentItem>[].obs; final documents = <DocumentItem>[].obs;
var filters = Rxn<DocumentFiltersData>(); final filters = Rxn<DocumentFiltersData>();
// Selected filters (multi-select support) // Selected filters (multi-select)
var selectedUploadedBy = <String>[].obs; final selectedUploadedBy = <String>[].obs;
var selectedCategory = <String>[].obs; final selectedCategory = <String>[].obs;
var selectedType = <String>[].obs; final selectedType = <String>[].obs;
var selectedTag = <String>[].obs; final selectedTag = <String>[].obs;
// Pagination state // Pagination
var pageNumber = 1.obs; final pageNumber = 1.obs;
final int pageSize = 20; final pageSize = 20;
var hasMore = true.obs; final hasMore = true.obs;
// Error message // Error handling
var errorMessage = "".obs; final errorMessage = ''.obs;
// NEW: show inactive toggle // Preferences
var showInactive = false.obs; final showInactive = false.obs;
// NEW: search // Search
var searchQuery = ''.obs; final searchQuery = ''.obs;
var searchController = TextEditingController(); final searchController = TextEditingController();
// New filter fields
var isUploadedAt = true.obs;
var isVerified = RxnBool();
var startDate = Rxn<String>();
var endDate = Rxn<String>();
// ------------------ API Calls ----------------------- // Additional filters
final isUploadedAt = true.obs;
final isVerified = RxnBool();
final startDate = Rxn<DateTime>();
final endDate = Rxn<DateTime>();
/// 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<void> fetchFilters(String entityTypeId) async { Future<void> fetchFilters(String entityTypeId) async {
try { try {
isLoading.value = true;
final response = await ApiService.getDocumentFilters(entityTypeId); final response = await ApiService.getDocumentFilters(entityTypeId);
if (response != null && response.success) { if (response != null && response.success) {
filters.value = response.data; filters.value = response.data;
} else { } else {
errorMessage.value = response?.message ?? "Failed to fetch filters"; errorMessage.value = response?.message ?? 'Failed to fetch filters';
_showError('Failed to load filters');
} }
} catch (e) { } catch (e) {
errorMessage.value = "Error fetching filters: $e"; errorMessage.value = 'Error fetching filters: $e';
} finally { _showError('Error loading filters');
isLoading.value = false; debugPrint('❌ Error fetching filters: $e');
} }
} }
@ -65,11 +74,14 @@ class DocumentController extends GetxController {
}) async { }) async {
try { try {
isLoading.value = true; isLoading.value = true;
final success =
await ApiService.deleteDocumentApi(id: id, isActive: isActive); final success = await ApiService.deleteDocumentApi(
id: id,
isActive: isActive,
);
if (success) { if (success) {
// 🔥 Always fetch fresh list after toggle // Refresh list after state change
await fetchDocuments( await fetchDocuments(
entityTypeId: entityTypeId, entityTypeId: entityTypeId,
entityId: entityId, entityId: entityId,
@ -77,41 +89,19 @@ class DocumentController extends GetxController {
); );
return true; return true;
} else { } else {
errorMessage.value = "Failed to update document state"; errorMessage.value = 'Failed to update document state';
return false; return false;
} }
} catch (e) { } catch (e) {
errorMessage.value = "Error updating document: $e"; errorMessage.value = 'Error updating document: $e';
debugPrint('❌ Error toggling document state: $e');
return false; return false;
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
} }
/// Permanently delete a document (or deactivate depending on API) /// Fetch documents for entity with pagination
Future<bool> 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
Future<void> fetchDocuments({ Future<void> fetchDocuments({
required String entityTypeId, required String entityTypeId,
required String entityId, required String entityId,
@ -120,20 +110,25 @@ class DocumentController extends GetxController {
bool reset = false, bool reset = false,
}) async { }) async {
try { try {
// Reset pagination if needed
if (reset) { if (reset) {
pageNumber.value = 1; pageNumber.value = 1;
documents.clear(); documents.clear();
hasMore.value = true; 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; isLoading.value = true;
final response = await ApiService.getDocumentListApi( final response = await ApiService.getDocumentListApi(
entityTypeId: entityTypeId, entityTypeId: entityTypeId,
entityId: entityId, entityId: entityId,
filter: filter ?? "", filter: filter ?? '',
searchString: searchString ?? searchQuery.value, searchString: searchString ?? searchQuery.value,
pageNumber: pageNumber.value, pageNumber: pageNumber.value,
pageSize: pageSize, pageSize: pageSize,
@ -147,19 +142,27 @@ class DocumentController extends GetxController {
} else { } else {
hasMore.value = false; hasMore.value = false;
} }
errorMessage.value = '';
} else { } 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) { } 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 { } finally {
isLoading.value = false; isLoading.value = false;
} }
} }
// ------------------ Helpers ----------------------- // ==================== Helper Methods ====================
/// Clear selected filters /// Clear all selected filters
void clearFilters() { void clearFilters() {
selectedUploadedBy.clear(); selectedUploadedBy.clear();
selectedCategory.clear(); selectedCategory.clear();
@ -171,11 +174,40 @@ class DocumentController extends GetxController {
endDate.value = null; endDate.value = null;
} }
/// Check if any filters are active (for red dot indicator) /// Check if any filters are active
bool hasActiveFilters() { bool hasActiveFilters() {
return selectedUploadedBy.isNotEmpty || return selectedUploadedBy.isNotEmpty ||
selectedCategory.isNotEmpty || selectedCategory.isNotEmpty ||
selectedType.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 = '';
} }
} }

View File

@ -17,6 +17,7 @@ import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/payment_types_model.dart';
import 'package:marco/helpers/widgets/time_stamp_image_helper.dart';
class AddExpenseController extends GetxController { class AddExpenseController extends GetxController {
// --- Text Controllers --- // --- Text Controllers ---
@ -65,6 +66,7 @@ class AddExpenseController extends GetxController {
final paymentModes = <PaymentModeModel>[].obs; final paymentModes = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs; final allEmployees = <EmployeeModel>[].obs;
final employeeSearchResults = <EmployeeModel>[].obs; final employeeSearchResults = <EmployeeModel>[].obs;
final isProcessingAttachment = false.obs;
String? editingExpenseId; String? editingExpenseId;
@ -252,9 +254,22 @@ class AddExpenseController extends GetxController {
Future<void> pickFromCamera() async { Future<void> pickFromCamera() async {
try { try {
final pickedFile = await _picker.pickImage(source: ImageSource.camera); final pickedFile = await _picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) attachments.add(File(pickedFile.path)); if (pickedFile != null) {
isProcessingAttachment.value = true; // start loading
File imageFile = File(pickedFile.path);
// Add timestamp to the captured image
File timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: imageFile,
);
attachments.add(timestampedFile);
attachments.refresh(); // refresh UI
}
} catch (e) { } catch (e) {
_errorSnackbar("Camera error: $e"); _errorSnackbar("Camera error: $e");
} finally {
isProcessingAttachment.value = false; // stop loading
} }
} }

View File

@ -1,5 +1,7 @@
class ApiEndpoints { 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.onfieldwork.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api";
@ -10,12 +12,14 @@ class ApiEndpoints {
static const String getDashboardTasks = "/dashboard/tasks"; static const String getDashboardTasks = "/dashboard/tasks";
static const String getDashboardTeams = "/dashboard/teams"; static const String getDashboardTeams = "/dashboard/teams";
static const String getDashboardProjects = "/dashboard/projects"; 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 ///// Projects Module API Endpoints
static const String createProject = "/project"; static const String createProject = "/project";
// Attendance Module API Endpoints // Attendance Module API Endpoints
static const String getProjects = "/project/list"; static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic"; static const String getGlobalProjects = "/project/list/basic";

View File

@ -19,13 +19,16 @@ 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_details_model.dart';
import 'package:marco/model/document/document_version_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/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 { class ApiService {
static const bool enableLogs = true; static const bool enableLogs = true;
static const Duration extendedTimeout = Duration(seconds: 60); static const Duration extendedTimeout = Duration(seconds: 60);
static Future<String?> _getToken() async { static Future<String?> _getToken() async {
final token = await LocalStorage.getJwtToken(); final token = LocalStorage.getJwtToken();
if (token == null) { if (token == null) {
logSafe("No JWT token found. Logging out..."); logSafe("No JWT token found. Logging out...");
@ -38,7 +41,7 @@ class ApiService {
logSafe("Access token is expired. Attempting refresh..."); logSafe("Access token is expired. Attempting refresh...");
final refreshed = await AuthService.refreshToken(); final refreshed = await AuthService.refreshToken();
if (refreshed) { if (refreshed) {
return await LocalStorage.getJwtToken(); return LocalStorage.getJwtToken();
} else { } else {
logSafe("Token refresh failed. Logging out immediately..."); logSafe("Token refresh failed. Logging out immediately...");
await LocalStorage.logout(); await LocalStorage.logout();
@ -55,7 +58,7 @@ class ApiService {
"Access token is about to expire in ${difference.inSeconds}s. Refreshing..."); "Access token is about to expire in ${difference.inSeconds}s. Refreshing...");
final refreshed = await AuthService.refreshToken(); final refreshed = await AuthService.refreshToken();
if (refreshed) { if (refreshed) {
return await LocalStorage.getJwtToken(); return LocalStorage.getJwtToken();
} else { } else {
logSafe("Token refresh failed (near expiry). Logging out..."); logSafe("Token refresh failed (near expiry). Logging out...");
await LocalStorage.logout(); await LocalStorage.logout();
@ -288,6 +291,121 @@ class ApiService {
} }
} }
/// Get Monthly Expense Report (categoryId is optional)
static Future<DashboardMonthlyExpenseResponse?>
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<ExpenseTypeReportResponse?> 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<PendingExpensesResponse?> 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 /// Create Project API
static Future<bool> createProjectApi({ static Future<bool> createProjectApi({

View File

@ -66,7 +66,6 @@ class NotificationActionHandler {
} }
break; break;
case 'Team_Modified': case 'Team_Modified':
// Call method to handle team modifications and dashboard update
_handleDashboardUpdate(data); _handleDashboardUpdate(data);
break; break;
/// 🔹 Expenses /// 🔹 Expenses
@ -106,7 +105,6 @@ class NotificationActionHandler {
/// ---------------------- HANDLERS ---------------------- /// ---------------------- HANDLERS ----------------------
static bool _isAttendanceAction(String? action) { static bool _isAttendanceAction(String? action) {
const validActions = { const validActions = {
'CHECK_IN', 'CHECK_IN',
@ -120,13 +118,17 @@ class NotificationActionHandler {
} }
static void _handleExpenseUpdated(Map<String, dynamic> data) { static void _handleExpenseUpdated(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored expense update from another project.");
return;
}
final expenseId = data['ExpenseId']; final expenseId = data['ExpenseId'];
if (expenseId == null) { if (expenseId == null) {
_logger.w("⚠️ Expense update received without ExpenseId: $data"); _logger.w("⚠️ Expense update received without ExpenseId: $data");
return; return;
} }
// Update Expense List
_safeControllerUpdate<ExpenseController>( _safeControllerUpdate<ExpenseController>(
onFound: (controller) async { onFound: (controller) async {
await controller.fetchExpenses(); await controller.fetchExpenses();
@ -136,7 +138,6 @@ class NotificationActionHandler {
'✅ ExpenseController refreshed from expense notification.', '✅ ExpenseController refreshed from expense notification.',
); );
// Update Expense Detail (if open and matches this expenseId)
_safeControllerUpdate<ExpenseDetailController>( _safeControllerUpdate<ExpenseDetailController>(
onFound: (controller) async { onFound: (controller) async {
if (controller.expense.value?.id == expenseId) { if (controller.expense.value?.id == expenseId) {
@ -151,6 +152,11 @@ class NotificationActionHandler {
} }
static void _handleAttendanceUpdated(Map<String, dynamic> data) { static void _handleAttendanceUpdated(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored attendance update from another project.");
return;
}
_safeControllerUpdate<AttendanceController>( _safeControllerUpdate<AttendanceController>(
onFound: (controller) => controller.refreshDataFromNotification( onFound: (controller) => controller.refreshDataFromNotification(
projectId: data['ProjectId'], projectId: data['ProjectId'],
@ -160,13 +166,18 @@ class NotificationActionHandler {
); );
} }
/// ---------------------- DOCUMENT HANDLER ---------------------- /// ---------------------- DOCUMENT HANDLER ----------------------
static void _handleDocumentModified(Map<String, dynamic> data) { static void _handleDocumentModified(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored document update from another project.");
return;
}
String entityTypeId; String entityTypeId;
String entityId; String entityId;
String? documentId = data['DocumentId']; String? documentId = data['DocumentId'];
// Determine entity type and ID
if (data['Keyword'] == 'Employee_Document_Modified') { if (data['Keyword'] == 'Employee_Document_Modified') {
entityTypeId = Permissions.employeeEntity; entityTypeId = Permissions.employeeEntity;
entityId = data['EmployeeId'] ?? ''; entityId = data['EmployeeId'] ?? '';
@ -186,7 +197,6 @@ class NotificationActionHandler {
_logger.i( _logger.i(
"🔔 Document notification received: keyword=${data['Keyword']}, entityTypeId=$entityTypeId, entityId=$entityId, documentId=$documentId"); "🔔 Document notification received: keyword=${data['Keyword']}, entityTypeId=$entityTypeId, entityId=$entityId, documentId=$documentId");
// Refresh Document List
if (Get.isRegistered<DocumentController>()) { if (Get.isRegistered<DocumentController>()) {
_safeControllerUpdate<DocumentController>( _safeControllerUpdate<DocumentController>(
onFound: (controller) async { onFound: (controller) async {
@ -204,11 +214,9 @@ class NotificationActionHandler {
_logger.w('⚠️ DocumentController not registered, skipping list refresh.'); _logger.w('⚠️ DocumentController not registered, skipping list refresh.');
} }
// Refresh Document Details (if open)
if (documentId != null && Get.isRegistered<DocumentDetailsController>()) { if (documentId != null && Get.isRegistered<DocumentDetailsController>()) {
_safeControllerUpdate<DocumentDetailsController>( _safeControllerUpdate<DocumentDetailsController>(
onFound: (controller) async { onFound: (controller) async {
// Refresh details regardless of current document
await controller.fetchDocumentDetails(documentId); await controller.fetchDocumentDetails(documentId);
_logger.i( _logger.i(
"✅ DocumentDetailsController refreshed for Document $documentId"); "✅ DocumentDetailsController refreshed for Document $documentId");
@ -225,13 +233,10 @@ class NotificationActionHandler {
/// ---------------------- DIRECTORY HANDLERS ---------------------- /// ---------------------- DIRECTORY HANDLERS ----------------------
static void _handleContactModified(Map<String, dynamic> data) { static void _handleContactModified(Map<String, dynamic> data) {
final contactId = data['ContactId'];
// Always refresh the contact list
_safeControllerUpdate<DirectoryController>( _safeControllerUpdate<DirectoryController>(
onFound: (controller) { onFound: (controller) {
controller.fetchContacts(); controller.fetchContacts();
// If a specific contact is provided, refresh its notes as well final contactId = data['ContactId'];
if (contactId != null) { if (contactId != null) {
controller.fetchCommentsForContact(contactId); controller.fetchCommentsForContact(contactId);
} }
@ -242,7 +247,6 @@ class NotificationActionHandler {
'✅ Directory contacts (and notes if applicable) refreshed from notification.', '✅ Directory contacts (and notes if applicable) refreshed from notification.',
); );
// Refresh notes globally as well
_safeControllerUpdate<NotesController>( _safeControllerUpdate<NotesController>(
onFound: (controller) => controller.fetchNotes(), onFound: (controller) => controller.fetchNotes(),
notFoundMessage: '⚠️ NotesController not found, cannot refresh notes.', notFoundMessage: '⚠️ NotesController not found, cannot refresh notes.',
@ -251,7 +255,6 @@ class NotificationActionHandler {
} }
static void _handleContactNoteModified(Map<String, dynamic> data) { static void _handleContactNoteModified(Map<String, dynamic> data) {
// Refresh both contacts and notes when a note is modified
_handleContactModified(data); _handleContactModified(data);
} }
@ -273,6 +276,11 @@ class NotificationActionHandler {
/// ---------------------- DASHBOARD HANDLER ---------------------- /// ---------------------- DASHBOARD HANDLER ----------------------
static void _handleDashboardUpdate(Map<String, dynamic> data) { static void _handleDashboardUpdate(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored dashboard update from another project.");
return;
}
_safeControllerUpdate<DashboardController>( _safeControllerUpdate<DashboardController>(
onFound: (controller) async { onFound: (controller) async {
final type = data['type'] ?? ''; final type = data['type'] ?? '';
@ -296,11 +304,9 @@ class NotificationActionHandler {
controller.projectController.selectedProjectId.value; controller.projectController.selectedProjectId.value;
final projectIdsString = data['ProjectIds'] ?? ''; final projectIdsString = data['ProjectIds'] ?? '';
// Convert comma-separated string to List<String>
final notificationProjectIds = final notificationProjectIds =
projectIdsString.split(',').map((e) => e.trim()).toList(); projectIdsString.split(',').map((e) => e.trim()).toList();
// Refresh only if current project ID is in the list
if (notificationProjectIds.contains(currentProjectId)) { if (notificationProjectIds.contains(currentProjectId)) {
await controller.fetchDashboardTeams(projectId: currentProjectId); await controller.fetchDashboardTeams(projectId: currentProjectId);
} }
@ -324,6 +330,24 @@ class NotificationActionHandler {
/// ---------------------- UTILITY ---------------------- /// ---------------------- UTILITY ----------------------
static bool _isCurrentProject(Map<String, dynamic> data) {
try {
final dashboard = Get.find<DashboardController>();
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<T>({ static void _safeControllerUpdate<T>({
required void Function(T controller) onFound, required void Function(T controller) onFound,
required String notFoundMessage, required String notFoundMessage,

View File

@ -266,7 +266,7 @@ class AdminTheme {
leftBarTheme: LeftBarTheme.lightLeftBarTheme, leftBarTheme: LeftBarTheme.lightLeftBarTheme,
topBarTheme: TopBarTheme.lightTopBarTheme, topBarTheme: TopBarTheme.lightTopBarTheme,
rightBarTheme: RightBarTheme.lightRightBarTheme, rightBarTheme: RightBarTheme.lightRightBarTheme,
contentTheme: ContentTheme.withColorTheme(ColorThemeType.purple, mode: ThemeMode.light), contentTheme: ContentTheme.withColorTheme(ColorThemeType.green, mode: ThemeMode.light),
); );
static void setTheme() { static void setTheme() {

View File

@ -1,5 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:marco/helpers/services/json_decoder.dart'; import 'package:marco/helpers/services/json_decoder.dart';
import 'package:marco/helpers/services/localizations/language.dart'; import 'package:marco/helpers/services/localizations/language.dart';
import 'package:marco/helpers/services/localizations/translator.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/admin_theme.dart';
import 'package:marco/helpers/theme/app_notifier.dart'; import 'package:marco/helpers/theme/app_notifier.dart';
import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
typedef ThemeChangeCallback = void Function( typedef ThemeChangeCallback = void Function(
ThemeCustomizer oldVal, ThemeCustomizer newVal); ThemeCustomizer oldVal, ThemeCustomizer newVal);
@ -24,7 +24,7 @@ class ThemeCustomizer {
ThemeMode leftBarTheme = ThemeMode.light; ThemeMode leftBarTheme = ThemeMode.light;
ThemeMode rightBarTheme = ThemeMode.light; ThemeMode rightBarTheme = ThemeMode.light;
ThemeMode topBarTheme = ThemeMode.light; ThemeMode topBarTheme = ThemeMode.light;
ColorThemeType colorTheme = ColorThemeType.red; ColorThemeType colorTheme = ColorThemeType.green;
bool rightBarOpen = false; bool rightBarOpen = false;
bool leftBarCondensed = false; bool leftBarCondensed = false;
@ -33,6 +33,8 @@ class ThemeCustomizer {
static Future<void> init() async { static Future<void> init() async {
await initLanguage(); await initLanguage();
await _loadColorTheme();
_notify();
} }
static initLanguage() async { static initLanguage() async {
@ -40,7 +42,7 @@ class ThemeCustomizer {
} }
String toJSON() { String toJSON() {
return jsonEncode({'theme': theme.name}); return jsonEncode({'theme': theme.name, 'colorTheme': colorTheme.name});
} }
static ThemeCustomizer fromJSON(String? json) { static ThemeCustomizer fromJSON(String? json) {
@ -49,6 +51,8 @@ class ThemeCustomizer {
JSONDecoder decoder = JSONDecoder(json); JSONDecoder decoder = JSONDecoder(json);
instance.theme = instance.theme =
decoder.getEnum('theme', ThemeMode.values, ThemeMode.light); decoder.getEnum('theme', ThemeMode.values, ThemeMode.light);
instance.colorTheme = decoder.getEnum(
'colorTheme', ColorThemeType.values, ColorThemeType.red);
} }
return instance; return instance;
} }
@ -117,12 +121,46 @@ class ThemeCustomizer {
tc.topBarTheme = topBarTheme; tc.topBarTheme = topBarTheme;
tc.rightBarOpen = rightBarOpen; tc.rightBarOpen = rightBarOpen;
tc.leftBarCondensed = leftBarCondensed; tc.leftBarCondensed = leftBarCondensed;
tc.colorTheme = colorTheme;
tc.currentLanguage = currentLanguage.clone(); tc.currentLanguage = currentLanguage.clone();
return tc; return tc;
} }
@override @override
String toString() { 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<void> 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<void> _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<void> changeColorTheme(ColorThemeType type) async {
oldInstance = instance.clone();
instance.colorTheme = type;
await saveColorTheme(type);
} }
} }

View File

@ -105,7 +105,6 @@ class _ThemeEditorWidgetState extends State<ThemeEditorWidget> {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Theme cards wrapped in reactive Obx widget // Theme cards wrapped in reactive Obx widget
Center( Center(
child: Obx( child: Obx(

View File

@ -1,3 +1,4 @@
import 'package:intl/intl.dart';
import 'package:marco/helpers/extensions/date_time_extension.dart'; import 'package:marco/helpers/extensions/date_time_extension.dart';
class Utils { class Utils {
@ -44,6 +45,10 @@ class Utils {
return "$hour:$minute${showSecond ? ":" : ""}$second$meridian"; return "$hour:$minute${showSecond ? ":" : ""}$second$meridian";
} }
static String formatDate(DateTime date) {
return DateFormat('d MMM yyyy').format(date);
}
static String getDateTimeStringFromDateTime(DateTime dateTime, static String getDateTimeStringFromDateTime(DateTime dateTime,
{bool showSecond = true, {bool showSecond = true,
bool showDate = true, bool showDate = true,
@ -76,4 +81,12 @@ class Utils {
return "${b.toStringAsFixed(2)} Bytes"; 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)}";
}
} }

View File

@ -60,7 +60,6 @@ class AttendanceDashboardChart extends StatelessWidget {
final filteredData = _getFilteredData(); final filteredData = _getFilteredData();
return Container( return Container(
decoration: _containerDecoration, decoration: _containerDecoration,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
@ -254,7 +253,7 @@ class _AttendanceChart extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dateFormat = DateFormat('d MMMM'); final dateFormat = DateFormat('d MMM');
final uniqueDates = data final uniqueDates = data
.map((e) => DateTime.parse(e['date'] as String)) .map((e) => DateTime.parse(e['date'] as String))
.toSet() .toSet()
@ -273,10 +272,6 @@ class _AttendanceChart extends StatelessWidget {
if (allZero) { if (allZero) {
return Container( return Container(
height: 600, height: 600,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(5),
),
child: const Center( child: const Center(
child: Text( child: Text(
'No attendance data for the selected range.', 'No attendance data for the selected range.',
@ -302,14 +297,22 @@ class _AttendanceChart extends StatelessWidget {
height: 600, height: 600,
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
child: SfCartesianChart( child: SfCartesianChart(
tooltipBehavior: TooltipBehavior(enable: true, shared: true), tooltipBehavior: TooltipBehavior(enable: true, shared: true),
legend: Legend(isVisible: true, position: LegendPosition.bottom), legend: Legend(isVisible: true, position: LegendPosition.bottom),
primaryXAxis: CategoryAxis(labelRotation: 45), primaryXAxis: CategoryAxis(
primaryYAxis: NumericAxis(minimum: 0, interval: 1), 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) { series: rolesWithData.map((role) {
final seriesData = filteredDates final seriesData = filteredDates
.map((date) { .map((date) {
@ -317,7 +320,7 @@ class _AttendanceChart extends StatelessWidget {
return {'date': date, 'present': formattedMap[key] ?? 0}; return {'date': date, 'present': formattedMap[key] ?? 0};
}) })
.where((d) => (d['present'] ?? 0) > 0) .where((d) => (d['present'] ?? 0) > 0)
.toList(); // remove 0 bars .toList();
return StackedColumnSeries<Map<String, dynamic>, String>( return StackedColumnSeries<Map<String, dynamic>, String>(
dataSource: seriesData, dataSource: seriesData,
@ -358,7 +361,7 @@ class _AttendanceTable extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dateFormat = DateFormat('d MMMM'); final dateFormat = DateFormat('d MMM');
final uniqueDates = data final uniqueDates = data
.map((e) => DateTime.parse(e['date'] as String)) .map((e) => DateTime.parse(e['date'] as String))
.toSet() .toSet()
@ -377,10 +380,6 @@ class _AttendanceTable extends StatelessWidget {
if (allZero) { if (allZero) {
return Container( return Container(
height: 300, height: 300,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(5),
),
child: const Center( child: const Center(
child: Text( child: Text(
'No attendance data for the selected range.', 'No attendance data for the selected range.',
@ -402,15 +401,22 @@ class _AttendanceTable extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
color: Colors.grey.shade50,
), ),
child: Scrollbar(
thumbVisibility: true,
trackVisibility: true,
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints:
BoxConstraints(minWidth: MediaQuery.of(context).size.width),
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: DataTable( child: DataTable(
columnSpacing: screenWidth < 600 ? 20 : 36, columnSpacing: 20,
headingRowHeight: 44, headingRowHeight: 44,
headingRowColor: headingRowColor: MaterialStateProperty.all(
MaterialStateProperty.all(Colors.blueAccent.withOpacity(0.08)), Colors.blueAccent.withOpacity(0.08)),
headingTextStyle: const TextStyle( headingTextStyle: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.black87), fontWeight: FontWeight.bold, color: Colors.black87),
columns: [ columns: [
@ -420,7 +426,8 @@ class _AttendanceTable extends StatelessWidget {
rows: filteredRoles.map((role) { rows: filteredRoles.map((role) {
return DataRow( return DataRow(
cells: [ cells: [
DataCell(_RolePill(role: role, color: getRoleColor(role))), DataCell(
_RolePill(role: role, color: getRoleColor(role))),
...filteredDates.map((date) { ...filteredDates.map((date) {
final key = '${role}_$date'; final key = '${role}_$date';
return DataCell( return DataCell(
@ -436,6 +443,9 @@ class _AttendanceTable extends StatelessWidget {
}).toList(), }).toList(),
), ),
), ),
),
),
),
); );
} }
} }

View File

@ -0,0 +1,653 @@
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';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
class ExpenseTypeReportChart extends StatelessWidget {
ExpenseTypeReportChart({Key? key}) : super(key: key);
final DashboardController _controller = Get.find<DashboardController>();
// Extended color palette for multiple projects
static const List<Color> _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;
final isMobile = screenWidth < 600;
return Obx(() {
final isLoading = _controller.isExpenseTypeReportLoading.value;
final data = _controller.expenseTypeReportData.value;
return Container(
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: isMobile ? 16 : 20,
horizontal: isMobile ? 12 : 20,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Chart Header
isLoading
? SkeletonLoaders.dateSkeletonLoader()
: _ChartHeader(controller: _controller),
const SizedBox(height: 12),
// 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: isMobile ? 350 : 400,
child: isLoading
? SkeletonLoaders.chartSkeletonLoader()
: (data == null || data.report.isEmpty)
? const _NoDataMessage()
: _ExpenseDonutChart(
data: data,
getSeriesColor: _getSeriesColor,
isMobile: isMobile,
),
),
],
),
);
});
}
}
// -----------------------------------------------------------------------------
// 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 Obx(() {
final data = controller.expenseTypeReportData.value;
// Calculate total from totalApprovedAmount only
final total = data?.report.fold<double>(
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,
),
],
),
),
],
);
});
}
}
// -----------------------------------------------------------------------------
// Date Range Picker
// -----------------------------------------------------------------------------
class _DateRangePicker extends StatelessWidget {
const _DateRangePicker({Key? key, required this.controller})
: super(key: key);
final DashboardController controller;
Future<void> _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 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,
),
],
),
),
],
),
),
),
),
);
}
}
// -----------------------------------------------------------------------------
// 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,
builder: (dynamic data, dynamic point, dynamic series, int pointIndex,
int seriesIndex) {
final total = widget.data.report
.fold<double>(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,
);
_selectionBehavior = SelectionBehavior(
enable: true,
selectedColor: Colors.white,
selectedBorderColor: Colors.blueAccent,
selectedBorderWidth: 3,
unselectedOpacity: 0.5,
);
}
@override
Widget build(BuildContext context) {
// 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();
// 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<double>(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 ? 12 : 20,
padding: widget.isMobile ? 20 : 28,
),
tooltipBehavior: _tooltipBehavior,
// Center annotation showing total approved amount
annotations: <CircularChartAnnotation>[
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>>[
DoughnutSeries<_DonutData, String>(
dataSource: filteredData,
xValueMapper: (datum, _) => datum.label,
yValueMapper: (datum, _) => datum.value,
pointColorMapper: (datum, _) => datum.color,
dataLabelMapper: (datum, _) {
final amount = Utils.formatCurrency(datum.value);
return widget.isMobile
? '$amount'
: '${datum.label}\n$amount';
},
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,
),
innerRadius: widget.isMobile ? '65%' : '70%',
radius: widget.isMobile ? '75%' : '80%',
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,
),
),
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);
}

View File

@ -0,0 +1,242 @@
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';
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;
const ExpenseByStatusWidget({super.key, required this.controller});
Widget _buildStatusTile({
required IconData icon,
required Color color,
required String title,
required String amount,
required String count,
required VoidCallback onTap,
}) {
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),
),
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<void> _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<DashboardController>();
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<void> _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<DashboardController>();
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(() {
final data = controller.pendingExpensesData.value;
if (controller.isPendingExpensesLoading.value) {
return SkeletonLoaders.expenseByStatusSkeletonLoader();
}
if (data == null) {
return Center(
child: MyText.bodyMedium("No expense status data available"),
);
}
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),
// 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,
color: Colors.orange,
title: "Pending Approve",
amount: Utils.formatCurrency(data.approvePending.totalAmount),
count: data.approvePending.count.toString(),
onTap: () {
_navigateToExpenseWithFilter(context, 'Approval Pending');
},
),
_buildStatusTile(
icon: Icons.search,
color: Colors.grey.shade700,
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,
color: Colors.cyan,
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),
// 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: [
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),
],
)
],
),
),
),
],
),
);
});
}
}

View File

@ -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<Color> 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<MonthlyExpenseDuration, String> 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<DashboardController>();
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<dynamic> data) =>
data.fold<double>(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<dynamic> 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<MonthlyExpenseDuration> 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<String>(
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<String>(
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<String>(
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<MonthlyExpenseDuration> 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<dynamic> 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: <ColumnSeries>[_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<dynamic, String> _buildColumnSeries() {
return ColumnSeries<dynamic, String>(
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}';
}
}
}

View File

@ -2,55 +2,51 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.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/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
class ProjectProgressChart extends StatelessWidget { class AttendanceDashboardChart extends StatelessWidget {
final List<ChartTaskData> data; AttendanceDashboardChart({Key? key}) : super(key: key);
final DashboardController controller = Get.find<DashboardController>();
ProjectProgressChart({super.key, required this.data}); final DashboardController _controller = Get.find<DashboardController>();
// ================= Flat Colors =================
static const List<Color> _flatColors = [ static const List<Color> _flatColors = [
Color(0xFFE57373), Color(0xFFE57373), // Red 300
Color(0xFF64B5F6), Color(0xFF64B5F6), // Blue 300
Color(0xFF81C784), Color(0xFF81C784), // Green 300
Color(0xFFFFB74D), Color(0xFFFFB74D), // Orange 300
Color(0xFFBA68C8), Color(0xFFBA68C8), // Purple 300
Color(0xFFFF8A65), Color(0xFFFF8A65), // Deep Orange 300
Color(0xFF4DB6AC), Color(0xFF4DB6AC), // Teal 300
Color(0xFFA1887F), Color(0xFFA1887F), // Brown 400
Color(0xFFDCE775), Color(0xFFDCE775), // Lime 300
Color(0xFF9575CD), Color(0xFF9575CD), // Deep Purple 300
Color(0xFF7986CB), Color(0xFF7986CB), // Indigo 300
Color(0xFFAED581), Color(0xFFAED581), // Light Green 300
Color(0xFFFF7043), Color(0xFFFF7043), // Deep Orange 400
Color(0xFF4FC3F7), Color(0xFF4FC3F7), // Light Blue 300
Color(0xFFFFD54F), Color(0xFFFFD54F), // Amber 300
Color(0xFF90A4AE), Color(0xFF90A4AE), // Blue Grey 300
Color(0xFFE573BB), Color(0xFFE573BB), // Pink 300
Color(0xFF81D4FA), Color(0xFF81D4FA), // Light Blue 200
Color(0xFFBCAAA4), Color(0xFFBCAAA4), // Brown 300
Color(0xFFA5D6A7), Color(0xFFA5D6A7), // Green 300
Color(0xFFCE93D8), Color(0xFFCE93D8), // Purple 200
Color(0xFFFF8A65), Color(0xFFFF8A65), // Deep Orange 300 (repeat to fill)
Color(0xFF80CBC4), Color(0xFF80CBC4), // Teal 200
Color(0xFFFFF176), Color(0xFFFFF176), // Yellow 300
Color(0xFF90CAF9), Color(0xFF90CAF9), // Blue 200
Color(0xFFE0E0E0), Color(0xFFE0E0E0), // Grey 300
Color(0xFFF48FB1), Color(0xFFF48FB1), // Pink 200
Color(0xFFA1887F), Color(0xFFA1887F), // Brown 400 (repeat)
Color(0xFFB0BEC5), Color(0xFFB0BEC5), // Blue Grey 200
Color(0xFF81C784), Color(0xFF81C784), // Green 300 (repeat)
Color(0xFFFFB74D), Color(0xFFFFB74D), // Orange 300 (repeat)
Color(0xFF64B5F6), Color(0xFF64B5F6), // Blue 300 (repeat)
]; ];
static final NumberFormat _commaFormatter = NumberFormat.decimalPattern();
Color _getTaskColor(String taskName) { Color _getRoleColor(String role) {
final index = taskName.hashCode % _flatColors.length; final index = role.hashCode.abs() % _flatColors.length;
return _flatColors[index]; return _flatColors[index];
} }
@ -59,42 +55,39 @@ class ProjectProgressChart extends StatelessWidget {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
return Obx(() { return Obx(() {
final isChartView = controller.projectIsChartView.value; final isChartView = _controller.attendanceIsChartView.value;
final selectedRange = controller.projectSelectedRange.value; final selectedRange = _controller.attendanceSelectedRange.value;
final filteredData = _getFilteredData();
return Container( return Container(
decoration: BoxDecoration( decoration: _containerDecoration,
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.04),
blurRadius: 6,
spreadRadius: 1,
offset: Offset(0, 2),
),
],
),
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
vertical: 16, vertical: 16,
horizontal: screenWidth < 600 ? 8 : 24, horizontal: screenWidth < 600 ? 8 : 20,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildHeader(selectedRange, isChartView, screenWidth), _Header(
const SizedBox(height: 14), selectedRange: selectedRange,
isChartView: isChartView,
screenWidth: screenWidth,
onToggleChanged: (isChart) =>
_controller.attendanceIsChartView.value = isChart,
onRangeChanged: _controller.updateAttendanceRange,
),
const SizedBox(height: 12),
Expanded( Expanded(
child: LayoutBuilder( child: filteredData.isEmpty
builder: (context, constraints) => AnimatedSwitcher( ? _NoDataMessage()
duration: const Duration(milliseconds: 300),
child: data.isEmpty
? _buildNoDataMessage()
: isChartView : isChartView
? _buildChart(constraints.maxHeight) ? _AttendanceChart(
: _buildTable(constraints.maxHeight, screenWidth), data: filteredData, getRoleColor: _getRoleColor)
), : _AttendanceTable(
), data: filteredData,
getRoleColor: _getRoleColor,
screenWidth: screenWidth),
), ),
], ],
), ),
@ -102,21 +95,62 @@ class ProjectProgressChart extends StatelessWidget {
}); });
} }
Widget _buildHeader( BoxDecoration get _containerDecoration => BoxDecoration(
String selectedRange, bool isChartView, double screenWidth) { 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<Map<String, dynamic>> _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<bool> onToggleChanged;
final ValueChanged<String> onRangeChanged;
@override
Widget build(BuildContext context) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.bodyMedium('Project Progress', fontWeight: 700), MyText.bodyMedium('Attendance Overview', fontWeight: 700),
MyText.bodySmall('Planned vs Completed', const SizedBox(height: 2),
color: Colors.grey.shade700), MyText.bodySmall('Role-wise present count',
color: Colors.grey),
], ],
), ),
), ),
@ -129,12 +163,10 @@ class ProjectProgressChart extends StatelessWidget {
color: Colors.grey, color: Colors.grey,
constraints: BoxConstraints( constraints: BoxConstraints(
minHeight: 30, minHeight: 30,
minWidth: (screenWidth < 400 ? 28 : 36), minWidth: screenWidth < 400 ? 28 : 36,
), ),
isSelected: [isChartView, !isChartView], isSelected: [isChartView, !isChartView],
onPressed: (index) { onPressed: (index) => onToggleChanged(index == 0),
controller.projectIsChartView.value = index == 0;
},
children: const [ children: const [
Icon(Icons.bar_chart_rounded, size: 15), Icon(Icons.bar_chart_rounded, size: 15),
Icon(Icons.table_chart, size: 15), Icon(Icons.table_chart, size: 15),
@ -142,36 +174,29 @@ class ProjectProgressChart extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 8),
Row( Row(
children: [ children: ["7D", "15D", "30D"]
_buildRangeButton("7D", selectedRange), .map(
_buildRangeButton("15D", selectedRange), (label) => Padding(
_buildRangeButton("30D", selectedRange), padding: const EdgeInsets.only(right: 4),
_buildRangeButton("3M", selectedRange),
_buildRangeButton("6M", selectedRange),
],
),
],
);
}
Widget _buildRangeButton(String label, String selectedRange) {
return Padding(
padding: const EdgeInsets.only(right: 4.0),
child: ChoiceChip( child: ChoiceChip(
label: Text(label, style: const TextStyle(fontSize: 12)), label: Text(label, style: const TextStyle(fontSize: 12)),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 0), padding:
const EdgeInsets.symmetric(horizontal: 5, vertical: 0),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
selected: selectedRange == label, selected: selectedRange == label,
onSelected: (_) => controller.updateProjectRange(label), onSelected: (_) => onRangeChanged(label),
selectedColor: Colors.blueAccent.withOpacity(0.15), selectedColor: Colors.blueAccent.withOpacity(0.15),
backgroundColor: Colors.grey.shade200, backgroundColor: Colors.grey.shade200,
labelStyle: TextStyle( labelStyle: TextStyle(
color: selectedRange == label ? Colors.blueAccent : Colors.black87, color: selectedRange == label
fontWeight: ? Colors.blueAccent
selectedRange == label ? FontWeight.w600 : FontWeight.normal, : Colors.black87,
fontWeight: selectedRange == label
? FontWeight.w600
: FontWeight.normal,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
@ -182,171 +207,29 @@ class ProjectProgressChart extends StatelessWidget {
), ),
), ),
), ),
);
}
Widget _buildChart(double height) {
final nonZeroData =
data.where((d) => d.planned != 0 || d.completed != 0).toList();
if (nonZeroData.isEmpty) {
return _buildNoDataContainer(height);
}
return Container(
height: height > 280 ? 280 : height,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.blueGrey.shade50,
borderRadius: BorderRadius.circular(5),
),
child: SfCartesianChart(
tooltipBehavior: TooltipBehavior(enable: true),
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,
),
primaryYAxis: NumericAxis(
labelFormat: '{value}',
axisLine: const AxisLine(width: 0),
majorTickLines: const MajorTickLines(size: 0),
),
series: <CartesianSeries>[
ColumnSeries<ChartTaskData, String>(
name: 'Planned',
dataSource: nonZeroData,
xValueMapper: (d, _) => DateFormat('MMM d').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;
return Text(
_commaFormatter.format(value),
style: const TextStyle(fontSize: 11),
);
},
),
),
ColumnSeries<ChartTaskData, String>(
name: 'Completed',
dataSource: nonZeroData,
xValueMapper: (d, _) => DateFormat('MMM d').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;
return Text(
_commaFormatter.format(value),
style: const TextStyle(fontSize: 11),
);
},
), ),
)
.toList(),
), ),
], ],
),
); );
} }
}
Widget _buildTable(double maxHeight, double screenWidth) { // No Data
final containerHeight = maxHeight > 300 ? 300.0 : maxHeight; class _NoDataMessage extends StatelessWidget {
final nonZeroData = @override
data.where((d) => d.planned != 0 || d.completed != 0).toList(); Widget build(BuildContext context) {
if (nonZeroData.isEmpty) {
return _buildNoDataContainer(containerHeight);
}
return Container(
height: containerHeight,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5),
color: Colors.grey.shade50,
),
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(),
),
),
),
);
},
),
);
}
Widget _buildNoDataContainer(double height) {
return Container(
height: height > 280 ? 280 : height,
decoration: BoxDecoration(
color: Colors.blueGrey.shade50,
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( return SizedBox(
height: 180, height: 180,
child: Center( child: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.info_outline, color: Colors.grey.shade400, size: 54), Icon(Icons.info_outline, color: Colors.grey.shade400, size: 48),
const SizedBox(height: 10), const SizedBox(height: 10),
MyText.bodyMedium( MyText.bodyMedium(
'No project progress data available for the selected range.', 'No attendance data available for this range.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
color: Colors.grey.shade500, color: Colors.grey.shade500,
), ),
@ -356,3 +239,237 @@ class ProjectProgressChart extends StatelessWidget {
); );
} }
} }
// Chart
class _AttendanceChart extends StatelessWidget {
const _AttendanceChart({
Key? key,
required this.data,
required this.getRoleColor,
}) : super(key: key);
final List<Map<String, dynamic>> 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: 600,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
),
child: SfCartesianChart(
tooltipBehavior: TooltipBehavior(enable: true, shared: true),
legend: Legend(isVisible: true, position: LegendPosition.bottom),
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) {
final key = '${role}_$date';
return {'date': date, 'present': formattedMap[key] ?? 0};
})
.where((d) => (d['present'] ?? 0) > 0)
.toList();
return StackedColumnSeries<Map<String, dynamic>, String>(
dataSource: seriesData,
xValueMapper: (d, _) => d['date'],
yValueMapper: (d, _) => d['present'],
name: role,
color: getRoleColor(role),
dataLabelSettings: DataLabelSettings(
isVisible: true,
builder: (dynamic data, _, __, ___, ____) {
return (data['present'] ?? 0) > 0
? Text(
NumberFormat.decimalPattern().format(data['present']),
style: const TextStyle(fontSize: 11),
)
: const SizedBox.shrink();
},
),
);
}).toList(),
),
);
}
}
// Table
class _AttendanceTable extends StatelessWidget {
const _AttendanceTable({
Key? key,
required this.data,
required this.getRoleColor,
required this.screenWidth,
}) : super(key: key);
final List<Map<String, dynamic>> 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: 300,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5),
),
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: Center(child: 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(
Center(
child: Text(
NumberFormat.decimalPattern()
.format(formattedMap[key] ?? 0),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13),
),
),
);
}),
],
);
}).toList(),
),
),
),
),
),
);
}
}
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(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(5),
),
child: MyText.labelSmall(role, fontWeight: 500),
);
}
}

View File

@ -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<DateTime?> startDate;
final Rx<DateTime?> 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<DateRangePickerWidget> createState() => _DateRangePickerWidgetState();
}
class _DateRangePickerWidgetState extends State<DateRangePickerWidget>
with UIMixin {
Future<void> _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<DateTime?> 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,
),
],
);
}
}

View File

@ -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 // Date Skeleton Loader
static Widget dateSkeletonLoader() { static Widget dateSkeletonLoader() {
return Container( return Container(
@ -45,68 +104,135 @@ class SkeletonLoaders {
); );
} }
// Chart Skeleton Loader // Expense By Status Skeleton Loader
static Widget chartSkeletonLoader() { static Widget expenseByStatusSkeletonLoader() {
return MyCard.bordered( return Container(
margin: MySpacing.only(bottom: 12), padding: const EdgeInsets.all(16),
paddingAll: 16, decoration: BoxDecoration(
borderRadiusAll: 16, color: Colors.white,
shadow: MyShadow( borderRadius: BorderRadius.circular(5),
elevation: 1.5, boxShadow: [
position: MyShadowPosition.bottom, BoxShadow(
color: Colors.grey.withOpacity(0.05),
blurRadius: 6,
spreadRadius: 1,
offset: const Offset(0, 2),
),
],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Chart Title Placeholder // Title
Container( Container(
height: 14, height: 16,
width: 120, width: 160,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
), ),
MySpacing.height(20), const SizedBox(height: 16),
// Chart Bars (variable height for realism) // 4 Status Rows
SizedBox( ...List.generate(4, (index) {
height: 180, return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end, children: [
children: List.generate(6, (index) { // Icon placeholder
return Expanded( Container(
child: Padding( height: 44,
padding: const EdgeInsets.symmetric(horizontal: 4), width: 44,
child: Container(
height:
(60 + (index * 20)).toDouble(), // fake chart shape
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), 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),
),
),
],
), ),
), ),
MySpacing.height(16), // Count + arrow placeholder
Container(
// X-Axis Labels height: 12,
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: List.generate(6, (index) {
return Container(
height: 10,
width: 30, width: 30,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4), 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),
),
),
],
), ),
], ],
), ),

View File

@ -0,0 +1,97 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart';
class TimestampImageHelper {
/// Adds a timestamp to an image file and returns a new File
static Future<File> addTimestamp({
required File imageFile,
Color textColor = Colors.white,
double fontSize = 60,
Color backgroundColor = Colors.black54,
double padding = 40,
double bottomPadding = 60,
}) async {
try {
// Read the image file
final bytes = await imageFile.readAsBytes();
final originalImage = await decodeImageFromList(bytes);
// Create a canvas
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
// Draw original image
final paint = Paint();
canvas.drawImage(originalImage, Offset.zero, paint);
// Timestamp text
final now = DateTime.now();
final timestamp = DateFormat('dd MMM yyyy hh:mm:ss a').format(now);
final textStyle = ui.TextStyle(
color: textColor,
fontSize: fontSize,
fontWeight: FontWeight.bold,
shadows: [
const ui.Shadow(
color: Colors.black,
offset: Offset(3, 3),
blurRadius: 6,
),
],
);
final paragraphStyle = ui.ParagraphStyle(textAlign: TextAlign.left);
final paragraphBuilder = ui.ParagraphBuilder(paragraphStyle)
..pushStyle(textStyle)
..addText(timestamp);
final paragraph = paragraphBuilder.build();
paragraph.layout(const ui.ParagraphConstraints(width: double.infinity));
final textWidth = paragraph.maxIntrinsicWidth;
final yPosition = originalImage.height - paragraph.height - bottomPadding;
final xPosition = (originalImage.width - textWidth) / 2;
// Draw background
final backgroundPaint = Paint()
..color = backgroundColor
..style = PaintingStyle.fill;
final backgroundRect = Rect.fromLTWH(
xPosition - padding,
yPosition - 15,
textWidth + padding * 2,
paragraph.height + 30,
);
canvas.drawRRect(
RRect.fromRectAndRadius(backgroundRect, const Radius.circular(8)),
backgroundPaint,
);
// Draw timestamp text
canvas.drawParagraph(paragraph, Offset(xPosition, yPosition));
// Convert canvas to image
final picture = recorder.endRecording();
final img = await picture.toImage(originalImage.width, originalImage.height);
final byteData = await img.toByteData(format: ui.ImageByteFormat.png);
final buffer = byteData!.buffer.asUint8List();
// Save to temporary file
final tempDir = await Directory.systemTemp.createTemp();
final timestampedFile = File('${tempDir.path}/timestamped_${DateTime.now().millisecondsSinceEpoch}.png');
await timestampedFile.writeAsBytes(buffer);
return timestampedFile;
} catch (e, stacktrace) {
logSafe("Error adding timestamp to image", level: LogLevel.error, error: e, stackTrace: stacktrace);
return imageFile; // fallback
}
}
}

View File

@ -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/permission_constants.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/date_range_picker.dart';
class AttendanceFilterBottomSheet extends StatefulWidget { class AttendanceFilterBottomSheet extends StatefulWidget {
final AttendanceController controller; final AttendanceController controller;
@ -34,16 +35,12 @@ class _AttendanceFilterBottomSheetState
} }
String getLabelText() { String getLabelText() {
final startDate = widget.controller.startDateAttendance; final start = DateTimeUtils.formatDate(
final endDate = widget.controller.endDateAttendance; widget.controller.startDateAttendance.value, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(
if (startDate != null && endDate != null) { widget.controller.endDateAttendance.value, 'dd MMM yyyy');
final start = DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy');
return "$start - $end"; return "$start - $end";
} }
return "Date Range";
}
List<Widget> buildMainFilters() { List<Widget> buildMainFilters() {
final hasRegularizationPermission = widget.permissionController final hasRegularizationPermission = widget.permissionController
@ -61,6 +58,7 @@ class _AttendanceFilterBottomSheetState
}).toList(); }).toList();
final List<Widget> widgets = [ final List<Widget> widgets = [
// 🔹 View Section
Padding( Padding(
padding: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.only(bottom: 4),
child: Align( child: Align(
@ -82,8 +80,7 @@ class _AttendanceFilterBottomSheetState
); );
}), }),
]; ];
// 🔹 Date Range (only for Attendance Logs)
// 🔹 Date Range only for attendanceLogs
if (tempSelectedTab == 'attendanceLogs') { if (tempSelectedTab == 'attendanceLogs') {
widgets.addAll([ widgets.addAll([
const Divider(), const Divider(),
@ -94,37 +91,16 @@ class _AttendanceFilterBottomSheetState
child: MyText.titleSmall("Date Range", fontWeight: 600), child: MyText.titleSmall("Date Range", fontWeight: 600),
), ),
), ),
InkWell( // Reusable DateRangePickerWidget
borderRadius: BorderRadius.circular(10), DateRangePickerWidget(
onTap: () async { startDate: widget.controller.startDateAttendance,
await widget.controller.selectDateRangeForAttendance( endDate: widget.controller.endDateAttendance,
context, startLabel: "Start Date",
widget.controller, endLabel: "End Date",
); onDateRangeSelected: (start, end) {
// Optional: trigger UI updates if needed
setState(() {}); 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),
],
),
),
), ),
]); ]);
} }

View File

@ -0,0 +1,74 @@
class ExpenseReportResponse {
final bool success;
final String message;
final List<ExpenseReportData> 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<String, dynamic> json) {
return ExpenseReportResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null
? List<ExpenseReportData>.from(
json['data'].map((x) => ExpenseReportData.fromJson(x)))
: [],
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.parse(json['timestamp']),
);
}
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() => {
'monthName': monthName,
'year': year,
'total': total,
'count': count,
};
}

View File

@ -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<String, dynamic> 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<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
class ExpenseTypeReportData {
final List<ExpenseTypeReportItem> report;
final double totalAmount;
ExpenseTypeReportData({
required this.report,
required this.totalAmount,
});
factory ExpenseTypeReportData.fromJson(Map<String, dynamic> json) {
return ExpenseTypeReportData(
report: json['report'] != null
? List<ExpenseTypeReportItem>.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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() => {
'projectName': projectName,
'totalApprovedAmount': totalApprovedAmount,
'totalPendingAmount': totalPendingAmount,
'totalRejectedAmount': totalRejectedAmount,
'totalProcessedAmount': totalProcessedAmount,
};
}

View File

@ -0,0 +1,74 @@
class ExpenseTypeResponse {
final bool success;
final String message;
final List<ExpenseTypeData> 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<String, dynamic> json) {
return ExpenseTypeResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null
? List<ExpenseTypeData>.from(
json['data'].map((x) => ExpenseTypeData.fromJson(x)))
: [],
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.parse(json['timestamp']),
);
}
Map<String, dynamic> 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<String, dynamic> json) {
return ExpenseTypeData(
id: json['id'] ?? '',
name: json['name'] ?? '',
noOfPersonsRequired: json['noOfPersonsRequired'] ?? false,
isAttachmentRequried: json['isAttachmentRequried'] ?? false,
description: json['description'] ?? '',
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'noOfPersonsRequired': noOfPersonsRequired,
'isAttachmentRequried': isAttachmentRequried,
'description': description,
};
}

View File

@ -0,0 +1,70 @@
class DashboardMonthlyExpenseResponse {
final bool success;
final String message;
final List<MonthlyExpenseData> 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<String, dynamic> json) {
return DashboardMonthlyExpenseResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: (json['data'] as List<dynamic>?)
?.map((e) => MonthlyExpenseData.fromJson(e))
.toList() ??
[],
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: json['timestamp'] ?? '',
);
}
Map<String, dynamic> 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<String, dynamic> json) {
return MonthlyExpenseData(
monthName: json['monthName'] ?? '',
year: json['year'] ?? 0,
total: (json['total'] ?? 0).toDouble(),
count: json['count'] ?? 0,
);
}
Map<String, dynamic> toJson() => {
'monthName': monthName,
'year': year,
'total': total,
'count': count,
};
}

View File

@ -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<String, dynamic> 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<String, dynamic> 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<Object?> 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<String, dynamic> 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<String, dynamic> 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<Object?> 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<String, dynamic> json) {
return ExpenseStatus(
count: json['count'] ?? 0,
totalAmount: (json['totalAmount'] ?? 0).toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'count': count,
'totalAmount': totalAmount,
};
}
ExpenseStatus copyWith({
int? count,
double? totalAmount,
}) {
return ExpenseStatus(
count: count ?? this.count,
totalAmount: totalAmount ?? this.totalAmount,
);
}
@override
List<Object?> get props => [count, totalAmount];
}

View File

@ -2,24 +2,33 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/document/user_document_controller.dart'; import 'package:marco/controller/document/user_document_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.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_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/model/document/document_filter_model.dart'; import 'package:marco/model/document/document_filter_model.dart';
import 'dart:convert'; 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 entityId;
final String entityTypeId; final String entityTypeId;
final DocumentController docController = Get.find<DocumentController>();
UserDocumentFilterBottomSheet({ const UserDocumentFilterBottomSheet({
super.key, super.key,
required this.entityId, required this.entityId,
required this.entityTypeId, required this.entityTypeId,
}); });
@override
State<UserDocumentFilterBottomSheet> createState() =>
_UserDocumentFilterBottomSheetState();
}
class _UserDocumentFilterBottomSheetState
extends State<UserDocumentFilterBottomSheet> with UIMixin {
final DocumentController docController = Get.find<DocumentController>();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final filterData = docController.filters.value; final filterData = docController.filters.value;
@ -51,8 +60,8 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
}; };
docController.fetchDocuments( docController.fetchDocuments(
entityTypeId: entityTypeId, entityTypeId: widget.entityTypeId,
entityId: entityId, entityId: widget.entityId,
filter: jsonEncode(combinedFilter), filter: jsonEncode(combinedFilter),
reset: true, 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( _buildField(
"Choose Date", "Choose Date",
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Segmented Buttons
Obx(() { Obx(() {
return Container( return Row(
decoration: BoxDecoration( children: [
border: Border.all(color: Colors.grey.shade300), // --- Upload Date ---
borderRadius: BorderRadius.circular(24), Expanded(
),
child: Row( child: Row(
children: [ children: [
Radio<bool>(
value: true,
groupValue:
docController.isUploadedAt.value,
onChanged: (val) => docController
.isUploadedAt.value = val!,
activeColor: contentTheme.primary,
),
MyText("Upload Date"),
],
),
),
// --- Update Date ---
Expanded( Expanded(
child: GestureDetector( child: Row(
onTap: () => children: [
docController.isUploadedAt.value = true, Radio<bool>(
child: Container( value: false,
padding: const EdgeInsets.symmetric( groupValue:
vertical: 10), docController.isUploadedAt.value,
decoration: BoxDecoration( onChanged: (val) => docController
color: docController.isUploadedAt.value .isUploadedAt.value = val!,
? Colors.indigo.shade400 activeColor: contentTheme.primary,
: 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,
),
),
),
),
),
),
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,
),
),
),
), ),
MyText("Update Date"),
],
), ),
), ),
], ],
),
); );
}), }),
MySpacing.height(12), MySpacing.height(12),
// Date Range // --- Date Range Picker ---
Row( DateRangePickerWidget(
children: [ startDate: docController.startDate,
Expanded( endDate: docController.endDate,
child: Obx(() { startLabel: "From Date",
return _dateButton( endLabel: "To Date",
label: docController.startDate.value == null onDateRangeSelected: (start, end) {
? 'From Date' if (start != null && end != null) {
: DateTimeUtils.formatDate( docController.startDate.value = start;
DateTime.parse( docController.endDate.value = end;
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();
}
},
);
}),
),
],
), ),
], ],
), ),
@ -251,7 +180,6 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
Obx(() { Obx(() {
return Container( return Container(
padding: MySpacing.all(12), padding: MySpacing.all(12),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -263,8 +191,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
groupValue: docController.isVerified.value, groupValue: docController.isVerified.value,
onChanged: (val) => onChanged: (val) =>
docController.isVerified.value = val, docController.isVerified.value = val,
activeColor: activeColor: contentTheme.primary,
Colors.indigo,
materialTapTargetSize: materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap, MaterialTapTargetSize.shrinkWrap,
), ),
@ -279,7 +206,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
groupValue: docController.isVerified.value, groupValue: docController.isVerified.value,
onChanged: (val) => onChanged: (val) =>
docController.isVerified.value = val, docController.isVerified.value = val,
activeColor: Colors.indigo, activeColor: contentTheme.primary,
materialTapTargetSize: materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap, MaterialTapTargetSize.shrinkWrap,
), ),
@ -294,7 +221,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
groupValue: docController.isVerified.value, groupValue: docController.isVerified.value,
onChanged: (val) => onChanged: (val) =>
docController.isVerified.value = val, docController.isVerified.value = val,
activeColor: Colors.indigo, activeColor: contentTheme.primary,
materialTapTargetSize: materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap, MaterialTapTargetSize.shrinkWrap,
), ),
@ -391,7 +318,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
(states) { (states) {
if (states if (states
.contains(MaterialState.selected)) { .contains(MaterialState.selected)) {
return Colors.indigo; // checked Indigo return contentTheme.primary;
} }
return Colors.white; // unchecked White 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,
),
),
],
),
),
);
}
} }

View File

@ -1,7 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/controller/expense/add_expense_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/payment_types_model.dart';
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart'; import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
@ -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_snackbar.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/widgets/expense/expense_form_widgets.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 /// Show bottom sheet wrapper
Future<T?> showAddExpenseBottomSheet<T>({ Future<T?> showAddExpenseBottomSheet<T>({
@ -19,7 +19,10 @@ Future<T?> showAddExpenseBottomSheet<T>({
Map<String, dynamic>? existingExpense, Map<String, dynamic>? existingExpense,
}) { }) {
return Get.bottomSheet<T>( return Get.bottomSheet<T>(
_AddExpenseBottomSheet(isEdit: isEdit, existingExpense: existingExpense), _AddExpenseBottomSheet(
isEdit: isEdit,
existingExpense: existingExpense,
),
isScrollControlled: true, isScrollControlled: true,
); );
} }
@ -38,7 +41,8 @@ class _AddExpenseBottomSheet extends StatefulWidget {
State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState(); State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState();
} }
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
with UIMixin {
final AddExpenseController controller = Get.put(AddExpenseController()); final AddExpenseController controller = Get.put(AddExpenseController());
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
@ -46,6 +50,95 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
final GlobalKey _expenseTypeDropdownKey = GlobalKey(); final GlobalKey _expenseTypeDropdownKey = GlobalKey();
final GlobalKey _paymentModeDropdownKey = GlobalKey(); final GlobalKey _paymentModeDropdownKey = GlobalKey();
/// Show employee list
Future<void> _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<void> _showOptionList<T>(
List<T> options,
String Function(T) getLabel,
ValueChanged<T> 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<T>(
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<T>(
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx( return Obx(
@ -55,183 +148,243 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
title: widget.isEdit ? "Edit Expense" : "Add Expense", title: widget.isEdit ? "Edit Expense" : "Add Expense",
isSubmitting: controller.isSubmitting.value, isSubmitting: controller.isSubmitting.value,
onCancel: Get.back, onCancel: Get.back,
onSubmit: _handleSubmit, onSubmit: () {
if (_formKey.currentState!.validate() && _validateSelections()) {
controller.submitOrUpdateExpense();
} else {
_showError("Please fill all required fields correctly");
}
},
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildCreateProjectButton(), _buildDropdownField<String>(
_buildProjectDropdown(),
_gap(),
_buildExpenseTypeDropdown(),
if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
true) ...[
_gap(),
_buildNumberField(
icon: Icons.people_outline,
title: "No. of Persons",
controller: controller.noOfPersonsController,
hint: "Enter No. of Persons",
validator: Validators.requiredField,
),
],
_gap(),
_buildPaymentModeDropdown(),
_gap(),
_buildPaidBySection(),
_gap(),
_buildAmountField(),
_gap(),
_buildSupplierField(),
_gap(),
_buildTransactionDateField(),
_gap(),
_buildTransactionIdField(),
_gap(),
_buildLocationField(),
_gap(),
_buildAttachmentsSection(),
_gap(),
_buildDescriptionField(),
],
),
),
),
),
);
}
/// 🟦 UI SECTION BUILDERS
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 _buildProjectDropdown() {
return _buildDropdownField<String>(
icon: Icons.work_outline, icon: Icons.work_outline,
title: "Project", title: "Project",
requiredField: true, requiredField: true,
value: controller.selectedProject.value.isEmpty value: controller.selectedProject.value.isEmpty
? "Select Project" ? "Select Project"
: controller.selectedProject.value, : controller.selectedProject.value,
onTap: _showProjectSelector, onTap: () => _showOptionList<String>(
controller.globalProjects.toList(),
(p) => p,
(val) => controller.selectedProject.value = val,
_projectDropdownKey,
),
dropdownKey: _projectDropdownKey, dropdownKey: _projectDropdownKey,
); ),
} _gap(),
Widget _buildExpenseTypeDropdown() { _buildDropdownField<ExpenseTypeModel>(
return _buildDropdownField<ExpenseTypeModel>(
icon: Icons.category_outlined, icon: Icons.category_outlined,
title: "Expense Type", title: "Expense Type",
requiredField: true, requiredField: true,
value: value: controller.selectedExpenseType.value?.name ??
controller.selectedExpenseType.value?.name ?? "Select Expense Type", "Select Expense Type",
onTap: () => _showOptionList( onTap: () => _showOptionList<ExpenseTypeModel>(
controller.expenseTypes.toList(), controller.expenseTypes.toList(),
(e) => e.name, (e) => e.name,
(val) => controller.selectedExpenseType.value = val, (val) => controller.selectedExpenseType.value = val,
_expenseTypeDropdownKey, _expenseTypeDropdownKey,
), ),
dropdownKey: _expenseTypeDropdownKey, dropdownKey: _expenseTypeDropdownKey,
); ),
}
Widget _buildPaymentModeDropdown() { // Persons if required
return _buildDropdownField<PaymentModeModel>( if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
true) ...[
_gap(),
_buildTextFieldSection(
icon: Icons.people_outline,
title: "No. of Persons",
controller: controller.noOfPersonsController,
hint: "Enter No. of Persons",
keyboardType: TextInputType.number,
validator: Validators.requiredField,
),
],
_gap(),
_buildTextFieldSection(
icon: Icons.confirmation_number_outlined,
title: "GST No.",
controller: controller.gstController,
hint: "Enter GST No.",
),
_gap(),
_buildDropdownField<PaymentModeModel>(
icon: Icons.payment, icon: Icons.payment,
title: "Payment Mode", title: "Payment Mode",
requiredField: true, requiredField: true,
value: value: controller.selectedPaymentMode.value?.name ??
controller.selectedPaymentMode.value?.name ?? "Select Payment Mode", "Select Payment Mode",
onTap: () => _showOptionList( onTap: () => _showOptionList<PaymentModeModel>(
controller.paymentModes.toList(), controller.paymentModes.toList(),
(p) => p.name, (p) => p.name,
(val) => controller.selectedPaymentMode.value = val, (val) => controller.selectedPaymentMode.value = val,
_paymentModeDropdownKey, _paymentModeDropdownKey,
), ),
dropdownKey: _paymentModeDropdownKey, dropdownKey: _paymentModeDropdownKey,
); ),
} _gap(),
Widget _buildPaidBySection() { _buildPaidBySection(),
return _buildTileSelector( _gap(),
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( _buildTextFieldSection(
icon: Icons.currency_rupee, icon: Icons.currency_rupee,
title: "Amount", title: "Amount",
controller: controller.amountController, controller: controller.amountController,
hint: "Enter Amount", hint: "Enter Amount",
validator: (v) => keyboardType: TextInputType.number,
Validators.isNumeric(v ?? "") ? null : "Enter valid amount", validator: (v) => Validators.isNumeric(v ?? "")
); ? null
: "Enter valid amount",
),
_gap(),
Widget _buildSupplierField() => _buildTextField( _buildTextFieldSection(
icon: Icons.store_mall_directory_outlined, icon: Icons.store_mall_directory_outlined,
title: "Supplier Name/Transporter Name/Other", title: "Supplier Name/Transporter Name/Other",
controller: controller.supplierController, controller: controller.supplierController,
hint: "Enter Supplier Name/Transporter Name or Other", hint: "Enter Supplier Name/Transporter Name or Other",
validator: Validators.nameValidator, validator: Validators.nameValidator,
); ),
_gap(),
Widget _buildTransactionIdField() { _buildTextFieldSection(
final paymentMode =
controller.selectedPaymentMode.value?.name.toLowerCase() ?? '';
final isRequired = paymentMode.isNotEmpty &&
paymentMode != 'cash' &&
paymentMode != 'cheque';
return _buildTextField(
icon: Icons.confirmation_number_outlined, icon: Icons.confirmation_number_outlined,
title: "Transaction ID", title: "Transaction ID",
controller: controller.transactionIdController, controller: controller.transactionIdController,
hint: "Enter Transaction ID", hint: "Enter Transaction ID",
validator: (v) { validator: (v) => (v != null && v.isNotEmpty)
if (isRequired) { ? Validators.transactionIdValidator(v)
if (v == null || v.isEmpty) : null,
return "Transaction ID is required for this payment mode"; ),
return Validators.transactionIdValidator(v); _gap(),
_buildTransactionDateField(),
_gap(),
_buildLocationField(),
_gap(),
_buildAttachmentsSection(),
_gap(),
_buildTextFieldSection(
icon: Icons.description_outlined,
title: "Description",
controller: controller.descriptionController,
hint: "Enter Description",
maxLines: 3,
validator: Validators.requiredField,
),
],
),
),
),
),
);
} }
return null;
}, Widget _gap([double h = 16]) => MySpacing.height(h);
requiredField: isRequired,
Widget _buildDropdownField<T>({
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<String>? 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 _buildPaidBySection() {
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() { Widget _buildTransactionDateField() {
return Obx(() => _buildTileSelector( return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
icon: Icons.calendar_today, icon: Icons.calendar_today,
title: "Transaction Date", title: "Transaction Date",
required: true, requiredField: true),
displayText: controller.selectedTransactionDate.value == null MySpacing.height(6),
? "Select Transaction Date" GestureDetector(
: DateFormat('dd MMM yyyy')
.format(controller.selectedTransactionDate.value!),
onTap: () => controller.pickTransactionDate(context), onTap: () => controller.pickTransactionDate(context),
)); child: AbsorbPointer(
child: CustomTextField(
controller: controller.transactionDateController,
hint: "Select Transaction Date",
validator: Validators.requiredField,
),
),
),
],
);
} }
Widget _buildLocationField() { Widget _buildLocationField() {
@ -278,192 +431,33 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
title: "Attachments", title: "Attachments",
requiredField: true, requiredField: true,
), ),
MySpacing.height(6), MySpacing.height(10),
AttachmentsSection( Obx(() {
if (controller.isProcessingAttachment.value) {
return Center(
child: Column(
children: [
CircularProgressIndicator(
color: contentTheme.primary,
),
const SizedBox(height: 8),
Text(
"Processing image, please wait...",
style: TextStyle(
fontSize: 14,
color: contentTheme.primary,
),
),
],
),
);
}
return AttachmentsSection(
attachments: controller.attachments, attachments: controller.attachments,
existingAttachments: controller.existingAttachments, existingAttachments: controller.existingAttachments,
onRemoveNew: controller.removeAttachment, onRemoveNew: controller.removeAttachment,
onRemoveExisting: _confirmRemoveAttachment, onRemoveExisting: (item) async {
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<T>({
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<String>? 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<String>? 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<void> _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<String>(
context: context,
position: _getPopupMenuPosition(_projectDropdownKey),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: displayList.map((opt) {
final isSpecial = opt == specialOption;
return PopupMenuItem<String>(
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,
),
),
);
}).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<void> _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<void> _confirmRemoveAttachment(item) async {
await showDialog( await showDialog(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
@ -484,75 +478,15 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
message: 'Attachment has been removed.', message: 'Attachment has been removed.',
type: SnackbarType.success, type: SnackbarType.success,
); );
Navigator.pop(context);
}, },
), ),
); );
} },
onAdd: controller.pickAttachments,
Future<void> _showOptionList<T>(
List<T> options,
String Function(T) getLabel,
ValueChanged<T> onSelected,
GlobalKey triggerKey,
) async {
final selected = await showMenu<T>(
context: context,
position: _getPopupMenuPosition(triggerKey),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: options
.map((opt) => PopupMenuItem<T>(
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");
}
}
} }

View File

@ -104,9 +104,7 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
// Filter logs if "pending only" // Filter logs if "pending only"
final showPendingOnly = widget.controller.showPendingOnly.value; final showPendingOnly = widget.controller.showPendingOnly.value;
final filteredLogs = showPendingOnly final filteredLogs = showPendingOnly
? allLogs ? allLogs.where((emp) => emp.activity == 1).toList()
.where((emp) => emp.activity == 1 )
.toList()
: allLogs; : allLogs;
// Group logs by date string // Group logs by date string
@ -126,11 +124,9 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
return db.compareTo(da); return db.compareTo(da);
}); });
final dateRangeText = widget.controller.startDateAttendance != null && final dateRangeText =
widget.controller.endDateAttendance != null '${DateTimeUtils.formatDate(widget.controller.startDateAttendance.value, 'dd MMM yyyy')} - '
? '${DateTimeUtils.formatDate(widget.controller.startDateAttendance!, 'dd MMM yyyy')} - ' '${DateTimeUtils.formatDate(widget.controller.endDateAttendance.value, 'dd MMM yyyy')}';
'${DateTimeUtils.formatDate(widget.controller.endDateAttendance!, 'dd MMM yyyy')}'
: 'Select date range';
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -34,12 +34,12 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// Listen for future project selection changes // 🔁 Listen for project changes
ever<String>(projectController.selectedProjectId, (projectId) async { ever<String>(projectController.selectedProjectId, (projectId) async {
if (projectId.isNotEmpty) await _loadData(projectId); if (projectId.isNotEmpty) await _loadData(projectId);
}); });
// Load initial data // 🚀 Load initial data only once the screen is shown
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) _loadData(projectId); if (projectId.isNotEmpty) _loadData(projectId);
}); });
@ -67,8 +67,8 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
case 'attendanceLogs': case 'attendanceLogs':
await attendanceController.fetchAttendanceLogs( await attendanceController.fetchAttendanceLogs(
projectId, projectId,
dateFrom: attendanceController.startDateAttendance, dateFrom: attendanceController.startDateAttendance.value,
dateTo: attendanceController.endDateAttendance, dateTo: attendanceController.endDateAttendance.value,
); );
break; break;
case 'regularizationRequests': case 'regularizationRequests':
@ -402,4 +402,13 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
), ),
); );
} }
@override
void dispose() {
// 🧹 Clean up the controller when user leaves this screen
if (Get.isRegistered<AttendanceController>()) {
Get.delete<AttendanceController>();
}
super.dispose();
}
} }

View File

@ -10,7 +10,11 @@ import 'package:marco/helpers/widgets/my_container.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.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/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/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 { class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key}); const DashboardScreen({super.key});
@ -51,14 +55,24 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
children: [ children: [
_buildDashboardStats(context), _buildDashboardStats(context),
MySpacing.height(24), MySpacing.height(24),
// 📊 Attendance Section
_buildAttendanceChartSection(), _buildAttendanceChartSection(),
MySpacing.height(24),
ExpenseByStatusWidget(controller: dashboardController),
MySpacing.height(24),
// Expense Type Report Chart
ExpenseTypeReportChart(),
MySpacing.height(24),
MonthlyExpenseDashboardChart(),
], ],
), ),
), ),
); );
} }
/// Attendance Chart Section
Widget _buildAttendanceChartSection() { Widget _buildAttendanceChartSection() {
return GetBuilder<ProjectController>( return GetBuilder<ProjectController>(
id: 'dashboard_controller', id: 'dashboard_controller',
@ -81,7 +95,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
); );
} }
/// No Project Assigned Message
Widget _buildNoProjectMessage() { Widget _buildNoProjectMessage() {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
@ -106,8 +119,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
); );
} }
/// Dashboard Statistics Section
Widget _buildDashboardStats(BuildContext context) { Widget _buildDashboardStats(BuildContext context) {
final stats = [ final stats = [
_StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success, _StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success,
@ -150,7 +161,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
); );
} }
/// Stat Card (Compact + Small)
Widget _buildStatCard( Widget _buildStatCard(
_StatItem statItem, bool isProjectSelected, double width) { _StatItem statItem, bool isProjectSelected, double width) {
const double cardHeight = 60; const double cardHeight = 60;
@ -195,7 +205,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
); );
} }
/// Compact Icon (smaller)
Widget _buildStatCardIconCompact(_StatItem statItem, {double size = 12}) { Widget _buildStatCardIconCompact(_StatItem statItem, {double size = 12}) {
return MyContainer.rounded( return MyContainer.rounded(
paddingAll: 4, paddingAll: 4,
@ -208,7 +217,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
); );
} }
/// Handle Tap
void _handleStatCardTap(_StatItem statItem, bool isEnabled) { void _handleStatCardTap(_StatItem statItem, bool isEnabled) {
if (!isEnabled) { if (!isEnabled) {
Get.defaultDialog( Get.defaultDialog(

File diff suppressed because it is too large Load Diff

View File

@ -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/model/employees/add_employee_bottom_sheet.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class EmployeeDetailPage extends StatefulWidget { class EmployeeDetailPage extends StatefulWidget {
final String employeeId; final String employeeId;
final bool fromProfile; final bool fromProfile;
@ -30,7 +29,6 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
final EmployeesScreenController controller = final EmployeesScreenController controller =
Get.put(EmployeesScreenController()); Get.put(EmployeesScreenController());
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -61,124 +59,112 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
} }
} }
Widget _buildLabelValueRow(String label, String value, Widget _buildDetailRow({
{bool isMultiLine = false}) { required IconData icon,
final lowerLabel = label.toLowerCase(); required String label,
final isEmail = lowerLabel == 'email'; required String value,
final isPhone = VoidCallback? onTap,
lowerLabel == 'phone number' || lowerLabel == 'emergency phone number'; VoidCallback? onLongPress,
bool isActionable = false,
void handleTap() { }) {
if (value == 'NA') return; return Padding(
if (isEmail) { padding: const EdgeInsets.symmetric(vertical: 12),
LauncherUtils.launchEmail(value); child: InkWell(
} else if (isPhone) { onTap: isActionable && value != 'NA' ? onTap : null,
LauncherUtils.launchPhone(value); onLongPress: isActionable && value != 'NA' ? onLongPress : null,
} borderRadius: BorderRadius.circular(5),
} child: Row(
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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (isMultiLine) ...[ Container(
Text( padding: const EdgeInsets.all(8),
label, decoration: BoxDecoration(
style: const TextStyle( color: contentTheme.primary.withOpacity(0.1),
fontWeight: FontWeight.bold, borderRadius: BorderRadius.circular(5),
color: Colors.black87, ),
fontSize: 14, child: Icon(
icon,
size: 20,
color: contentTheme.primary,
), ),
), ),
MySpacing.height(4), MySpacing.width(16),
valueWidget, Expanded(
] 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,
),
children: [
TextSpan(
text: value,
style: TextStyle(
fontWeight: FontWeight.normal,
color:
(isEmail || isPhone) ? Colors.indigo : Colors.black54,
decoration: (isEmail || isPhone)
? TextDecoration.underline
: TextDecoration.none,
),
),
],
),
),
),
MySpacing.height(10),
Divider(color: Colors.grey[300], height: 1),
MySpacing.height(10),
],
);
}
Widget _buildInfoCard(employee) {
return Card(
elevation: 3,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 16, 12, 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MySpacing.height(12), Text(
_buildLabelValueRow('Email', _getDisplayValue(employee.email)), label,
_buildLabelValueRow( style: TextStyle(
'Phone Number', _getDisplayValue(employee.phoneNumber)), fontSize: 12,
_buildLabelValueRow('Emergency Contact Person', color: Colors.grey[600],
_getDisplayValue(employee.emergencyContactPerson)), fontWeight: FontWeight.w500,
_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,
), ),
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,
),
),
],
),
),
if (isActionable && value != 'NA')
Icon(
Icons.chevron_right,
color: Colors.grey[400],
size: 20,
),
],
),
),
);
}
Widget _buildSectionCard({
required String title,
required IconData titleIcon,
required List<Widget> children,
}) {
return Card(
elevation: 2,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,14 +210,23 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Row( // Header Section
Card(
elevation: 2,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [ children: [
Avatar( Avatar(
firstName: employee.firstName, firstName: employee.firstName,
lastName: employee.lastName, lastName: employee.lastName,
size: 45, size: 45,
), ),
MySpacing.width(12), MySpacing.width(16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -249,11 +244,11 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
), ),
), ),
IconButton( IconButton(
icon: icon: Icon(Icons.edit,
Icon(Icons.edit, size: 24, color: contentTheme.primary), size: 24, color: contentTheme.primary),
onPressed: () async { onPressed: () async {
final result = final result = await showModalBottomSheet<
await showModalBottomSheet<Map<String, dynamic>>( Map<String, dynamic>>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
@ -275,14 +270,159 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
); );
if (result != null) { if (result != null) {
controller.fetchEmployeeDetails(widget.employeeId); controller
.fetchEmployeeDetails(widget.employeeId);
} }
}, },
), ),
], ],
), ),
MySpacing.height(14), ),
_buildInfoCard(employee), ),
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(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,
),
],
),
], ],
), ),
), ),

View File

@ -1,15 +1,18 @@
// ignore_for_file: must_be_immutable
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/controller/expense/expense_screen_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.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_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.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 ExpenseController expenseController;
final ScrollController scrollController; final ScrollController scrollController;
@ -19,12 +22,18 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
required this.scrollController, required this.scrollController,
}); });
// FIX: create search adapter @override
State<ExpenseFilterBottomSheet> createState() =>
_ExpenseFilterBottomSheetState();
}
class _ExpenseFilterBottomSheetState extends State<ExpenseFilterBottomSheet>
with UIMixin {
/// Search employees for Paid By / Created By filters
Future<List<EmployeeModel>> searchEmployeesForBottomSheet( Future<List<EmployeeModel>> searchEmployeesForBottomSheet(
String query) async { String query) async {
await expenseController await widget.expenseController.searchEmployees(query);
.searchEmployees(query); // async method, returns void return widget.expenseController.employeeSearchResults.toList();
return expenseController.employeeSearchResults.toList();
} }
@override @override
@ -34,20 +43,21 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
title: 'Filter Expenses', title: 'Filter Expenses',
onCancel: () => Get.back(), onCancel: () => Get.back(),
onSubmit: () { onSubmit: () {
expenseController.fetchExpenses(); widget.expenseController.fetchExpenses();
Get.back(); Get.back();
}, },
submitText: 'Submit', submitText: 'Submit',
submitColor: contentTheme.primary,
submitIcon: Icons.check_circle_outline, submitIcon: Icons.check_circle_outline,
child: SingleChildScrollView( child: SingleChildScrollView(
controller: scrollController, controller: widget.scrollController,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton( child: TextButton(
onPressed: () => expenseController.clearFilters(), onPressed: () => widget.expenseController.clearFilters(),
child: MyText( child: MyText(
"Reset Filter", "Reset Filter",
style: MyTextStyle.labelMedium( style: MyTextStyle.labelMedium(
@ -58,15 +68,15 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
), ),
), ),
MySpacing.height(8), MySpacing.height(8),
_buildProjectFilter(context), _buildProjectFilter(),
MySpacing.height(16), MySpacing.height(16),
_buildStatusFilter(context), _buildStatusFilter(),
MySpacing.height(16), MySpacing.height(16),
_buildDateRangeFilter(context), _buildDateRangeFilter(),
MySpacing.height(16), MySpacing.height(16),
_buildPaidByFilter(context), _buildPaidByFilter(),
MySpacing.height(16), MySpacing.height(16),
_buildCreatedByFilter(context), _buildCreatedByFilter(),
], ],
), ),
), ),
@ -85,190 +95,145 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
); );
} }
Widget _buildProjectFilter(BuildContext context) { Widget _buildProjectFilter() {
return _buildField( return _buildField(
"Project", "Project",
_popupSelector( _popupSelector(
context, currentValue: widget.expenseController.selectedProject.value.isEmpty
currentValue: expenseController.selectedProject.value.isEmpty
? 'Select Project' ? 'Select Project'
: expenseController.selectedProject.value, : widget.expenseController.selectedProject.value,
items: expenseController.globalProjects, items: widget.expenseController.globalProjects,
onSelected: (value) => expenseController.selectedProject.value = value, onSelected: (value) =>
widget.expenseController.selectedProject.value = value,
), ),
); );
} }
Widget _buildStatusFilter(BuildContext context) { Widget _buildStatusFilter() {
return _buildField( return _buildField(
"Expense Status", "Expense Status",
_popupSelector( _popupSelector(
context, currentValue: widget.expenseController.selectedStatus.value.isEmpty
currentValue: expenseController.selectedStatus.value.isEmpty
? 'Select Expense Status' ? 'Select Expense Status'
: expenseController.expenseStatuses : widget.expenseController.expenseStatuses
.firstWhereOrNull( .firstWhereOrNull((e) =>
(e) => e.id == expenseController.selectedStatus.value) e.id == widget.expenseController.selectedStatus.value)
?.name ?? ?.name ??
'Select Expense Status', 'Select Expense Status',
items: expenseController.expenseStatuses.map((e) => e.name).toList(), items: widget.expenseController.expenseStatuses
.map((e) => e.name)
.toList(),
onSelected: (name) { onSelected: (name) {
final status = expenseController.expenseStatuses final status = widget.expenseController.expenseStatuses
.firstWhere((e) => e.name == name); .firstWhere((e) => e.name == name);
expenseController.selectedStatus.value = status.id; widget.expenseController.selectedStatus.value = status.id;
}, },
), ),
); );
} }
Widget _buildDateRangeFilter(BuildContext context) { Widget _buildDateRangeFilter() {
return _buildField( return _buildField(
"Date Filter", "Date Filter",
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// --- Radio Buttons for Transaction Date / Created At ---
Obx(() { Obx(() {
return SizedBox( return Row(
width: double.infinity, // Make it full width children: [
child: SegmentedButton<String>( // --- Transaction Date ---
segments: expenseController.dateTypes Expanded(
.map( child: Row(
(type) => ButtonSegment( children: [
value: type, Radio<String>(
label: Center( value: "Transaction Date",
// Center label text groupValue:
child: MyText( widget.expenseController.selectedDateType.value,
type, onChanged: (val) {
style: MyTextStyle.bodySmall( if (val != null) {
fontWeight: 600, widget.expenseController.selectedDateType.value =
fontSize: 13, val;
height: 1.2,
),
),
),
),
)
.toList(),
selected: {expenseController.selectedDateType.value},
onSelectionChanged: (newSelection) {
if (newSelection.isNotEmpty) {
expenseController.selectedDateType.value =
newSelection.first;
} }
}, },
style: ButtonStyle( activeColor: contentTheme.primary,
visualDensity:
const VisualDensity(horizontal: -2, vertical: -2),
padding: MaterialStateProperty.all(
const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
),
backgroundColor: MaterialStateProperty.resolveWith(
(states) => states.contains(MaterialState.selected)
? Colors.indigo.shade100
: Colors.grey.shade100,
),
foregroundColor: MaterialStateProperty.resolveWith(
(states) => states.contains(MaterialState.selected)
? Colors.indigo
: Colors.black87,
),
shape: MaterialStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
side: MaterialStateProperty.resolveWith(
(states) => BorderSide(
color: states.contains(MaterialState.selected)
? Colors.indigo
: Colors.grey.shade300,
width: 1,
),
),
),
),
);
}),
MySpacing.height(16),
Row(
children: [
Expanded(
child: _dateButton(
label: expenseController.startDate.value == null
? 'Start Date'
: DateTimeUtils.formatDate(
expenseController.startDate.value!, 'dd MMM yyyy'),
onTap: () => _selectDate(
context,
expenseController.startDate,
lastDate: expenseController.endDate.value,
),
),
),
MySpacing.width(12),
Expanded(
child: _dateButton(
label: expenseController.endDate.value == null
? 'End Date'
: DateTimeUtils.formatDate(
expenseController.endDate.value!, 'dd MMM yyyy'),
onTap: () => _selectDate(
context,
expenseController.endDate,
firstDate: expenseController.startDate.value,
), ),
Flexible(
child: MyText(
"Transaction Date",
), ),
), ),
], ],
), ),
),
// --- Created At ---
Expanded(
child: Row(
children: [
Radio<String>(
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),
// --- 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( return _buildField(
"Paid By", "Paid By",
_employeeSelector( _employeeSelector(
context: context, selectedEmployees: widget.expenseController.selectedPaidByEmployees,
selectedEmployees: expenseController.selectedPaidByEmployees, searchEmployees: searchEmployeesForBottomSheet,
searchEmployees: searchEmployeesForBottomSheet, // FIXED
title: 'Search Paid By', title: 'Search Paid By',
), ),
); );
} }
Widget _buildCreatedByFilter(BuildContext context) { Widget _buildCreatedByFilter() {
return _buildField( return _buildField(
"Created By", "Created By",
_employeeSelector( _employeeSelector(
context: context, selectedEmployees: widget.expenseController.selectedCreatedByEmployees,
selectedEmployees: expenseController.selectedCreatedByEmployees, searchEmployees: searchEmployeesForBottomSheet,
searchEmployees: searchEmployeesForBottomSheet, // FIXED
title: 'Search Created By', title: 'Search Created By',
), ),
); );
} }
Future<void> _selectDate( Widget _popupSelector({
BuildContext context,
Rx<DateTime?> 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, {
required String currentValue, required String currentValue,
required List<String> items, required List<String> items,
required ValueChanged<String> onSelected, required ValueChanged<String> onSelected,
@ -306,39 +271,31 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
); );
} }
Widget _dateButton({required String label, required VoidCallback onTap}) { Widget _employeeSelector({
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<void> _showEmployeeSelectorBottomSheet({
required BuildContext context,
required RxList<EmployeeModel> selectedEmployees, required RxList<EmployeeModel> selectedEmployees,
required Future<List<EmployeeModel>> Function(String) searchEmployees, required Future<List<EmployeeModel>> Function(String) searchEmployees,
String title = 'Select Employee', String title = 'Search Employee',
}) async { }) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() {
if (selectedEmployees.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: 8,
children: selectedEmployees
.map(
(emp) => Chip(
label: MyText(emp.name),
onDeleted: () => selectedEmployees.remove(emp),
),
)
.toList(),
);
}),
MySpacing.height(8),
GestureDetector(
onTap: () async {
final List<EmployeeModel>? result = final List<EmployeeModel>? result =
await showModalBottomSheet<List<EmployeeModel>>( await showModalBottomSheet<List<EmployeeModel>>(
context: context, context: context,
@ -352,42 +309,8 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
title: title, title: title,
), ),
); );
if (result != null) { if (result != null) selectedEmployees.assignAll(result);
selectedEmployees.assignAll(result); },
}
}
Widget _employeeSelector({
required BuildContext context,
required RxList<EmployeeModel> selectedEmployees,
required Future<List<EmployeeModel>> Function(String) searchEmployees,
String title = 'Search Employee',
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() {
if (selectedEmployees.isEmpty) {
return const SizedBox.shrink();
}
return Wrap(
spacing: 8,
children: selectedEmployees
.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,
),
child: Container( child: Container(
padding: MySpacing.all(12), padding: MySpacing.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -407,5 +330,4 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
], ],
); );
} }
} }

View File

@ -28,12 +28,17 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
final permissionController = Get.find<PermissionController>(); final permissionController = Get.find<PermissionController>();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
// Delay fetch until after UI & controller are ready
WidgetsBinding.instance.addPostFrameCallback((_) {
final expenseController = Get.find<ExpenseController>();
expenseController.fetchExpenses(); expenseController.fetchExpenses();
} });
}
@override @override
void dispose() { void dispose() {

View File

@ -112,11 +112,11 @@ class _FAQScreenState extends State<FAQScreen> with UIMixin {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1), color: contentTheme.primary.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon(LucideIcons.badge_help, child: Icon(LucideIcons.badge_help,
color: Colors.blue, size: 24), color: contentTheme.primary, size: 24),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(

View File

@ -11,6 +11,8 @@ import 'package:marco/model/employees/employee_info.dart';
import 'package:marco/controller/auth/mpin_controller.dart'; import 'package:marco/controller/auth/mpin_controller.dart';
import 'package:marco/view/employees/employee_profile_screen.dart'; import 'package:marco/view/employees/employee_profile_screen.dart';
import 'package:marco/helpers/theme/theme_editor_widget.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 { class UserProfileBar extends StatefulWidget {
@ -122,6 +124,7 @@ class _UserProfileBarState extends State<UserProfileBar>
), ),
); );
} }
Widget _userProfileSection(bool condensed) { Widget _userProfileSection(bool condensed) {
final padding = MySpacing.fromLTRB( final padding = MySpacing.fromLTRB(
condensed ? 16 : 26, condensed ? 16 : 26,
@ -206,6 +209,17 @@ class _UserProfileBarState extends State<UserProfileBar>
_menuItemRow( _menuItemRow(
icon: LucideIcons.badge_alert, icon: LucideIcons.badge_alert,
label: 'Support', label: 'Support',
onTap: () {
Get.to(() => SupportScreen());
},
),
SizedBox(height: spacingHeight),
_menuItemRow(
icon: LucideIcons.badge_help,
label: 'FAQ',
onTap: () {
Get.to(() => FAQScreen());
},
), ),
SizedBox(height: spacingHeight), SizedBox(height: spacingHeight),
_menuItemRow( _menuItemRow(

View File

@ -16,10 +16,10 @@ class _SupportScreenState extends State<SupportScreen> with UIMixin {
final List<Map<String, dynamic>> contacts = [ final List<Map<String, dynamic>> contacts = [
{ {
"type": "email", "type": "email",
"label": "info@marcoaiot.com", "label": "support@onfieldwork.com",
"subLabel": "Email us your queries", "subLabel": "Email us your queries",
"icon": LucideIcons.mail, "icon": LucideIcons.mail,
"action": "mailto:info@marcoaiot.com?subject=Support Request" "action": "mailto:support@onfieldwork.com?subject=Support Request"
}, },
{ {
"type": "phone", "type": "phone",
@ -34,13 +34,11 @@ class _SupportScreenState extends State<SupportScreen> with UIMixin {
final Uri uri = Uri.parse(action); final Uri uri = Uri.parse(action);
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
// Use LaunchMode.externalApplication for mailto/tel
await launchUrl( await launchUrl(
uri, uri,
mode: LaunchMode.externalApplication, mode: LaunchMode.externalApplication,
); );
} else { } else {
// Fallback if no app found
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No app found to open this link.')), const SnackBar(content: Text('No app found to open this link.')),
); );
@ -95,10 +93,11 @@ class _SupportScreenState extends State<SupportScreen> with UIMixin {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1), color: contentTheme.primary.withOpacity(0.1),
shape: BoxShape.circle, 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), const SizedBox(width: 16),
Expanded( Expanded(
@ -145,10 +144,10 @@ class _SupportScreenState extends State<SupportScreen> with UIMixin {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1), color: contentTheme.primary.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon(icon, color: Colors.red, size: 28), child: Icon(icon, color: contentTheme.primary, size: 28),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
@ -176,9 +175,7 @@ class _SupportScreenState extends State<SupportScreen> with UIMixin {
), ),
body: SafeArea( body: SafeArea(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () async { onRefresh: () async {},
// Optional: Implement refresh logic
},
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
child: Column( child: Column(
@ -190,7 +187,7 @@ class _SupportScreenState extends State<SupportScreen> with UIMixin {
child: MyText.titleLarge( child: MyText.titleLarge(
"Need Help?", "Need Help?",
fontWeight: 700, fontWeight: 700,
color: Colors.red, color: contentTheme.primary,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),

View File

@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "marco") set(BINARY_NAME "marco")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # 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 # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.

View File

@ -385,7 +385,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";
@ -399,7 +399,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";
@ -413,7 +413,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";

View File

@ -8,7 +8,7 @@
PRODUCT_NAME = marco PRODUCT_NAME = marco
// The application's bundle identifier // The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.onfieldwork.marcoaiot PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot
// The copyright displayed in application information // The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved. PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved.

View File

@ -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 # 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 # 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. # 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: environment:
sdk: ^3.5.3 sdk: ^3.5.3
@ -80,6 +80,7 @@ dependencies:
googleapis_auth: ^2.0.0 googleapis_auth: ^2.0.0
device_info_plus: ^11.3.0 device_info_plus: ^11.3.0
flutter_local_notifications: 19.4.0 flutter_local_notifications: 19.4.0
equatable: ^2.0.7
timeline_tile: ^2.0.0 timeline_tile: ^2.0.0
dev_dependencies: dev_dependencies: