feat: Re-enable Firebase Messaging and FCM token handling; improve local storage initialization and logging #69
@ -39,6 +39,7 @@ class AttendanceController extends GetxController {
|
|||||||
final isLoadingRegularizationLogs = true.obs;
|
final isLoadingRegularizationLogs = true.obs;
|
||||||
final isLoadingLogView = true.obs;
|
final isLoadingLogView = true.obs;
|
||||||
final uploadingStates = <String, RxBool>{}.obs;
|
final uploadingStates = <String, RxBool>{}.obs;
|
||||||
|
var showPendingOnly = false.obs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
@ -73,6 +74,36 @@ class AttendanceController extends GetxController {
|
|||||||
"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> fetchProjects() async {
|
Future<void> fetchProjects() async {
|
||||||
isLoadingProjects.value = true;
|
isLoadingProjects.value = true;
|
||||||
|
|
||||||
|
@ -79,8 +79,7 @@ class LoginController extends MyController {
|
|||||||
enableRemoteLogging();
|
enableRemoteLogging();
|
||||||
logSafe("✅ Remote logging enabled after login.");
|
logSafe("✅ Remote logging enabled after login.");
|
||||||
|
|
||||||
// ✅ Commented out FCM token registration after login
|
|
||||||
/*
|
|
||||||
final fcmToken = await LocalStorage.getFcmToken();
|
final fcmToken = await LocalStorage.getFcmToken();
|
||||||
if (fcmToken?.isNotEmpty ?? false) {
|
if (fcmToken?.isNotEmpty ?? false) {
|
||||||
final success = await AuthService.registerDeviceToken(fcmToken!);
|
final success = await AuthService.registerDeviceToken(fcmToken!);
|
||||||
@ -90,7 +89,7 @@ class LoginController extends MyController {
|
|||||||
: "⚠️ Failed to register FCM token after login.",
|
: "⚠️ Failed to register FCM token after login.",
|
||||||
level: LogLevel.warning);
|
level: LogLevel.warning);
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
logSafe("Login successful for user: ${loginData['username']}");
|
logSafe("Login successful for user: ${loginData['username']}");
|
||||||
Get.toNamed('/home');
|
Get.toNamed('/home');
|
||||||
|
@ -6,7 +6,7 @@ import 'package:marco/helpers/widgets/my_form_validator.dart';
|
|||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
import 'package:marco/view/dashboard/dashboard_screen.dart';
|
import 'package:marco/view/dashboard/dashboard_screen.dart';
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
// import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart'; // 🔴 Commented out
|
import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart';
|
||||||
|
|
||||||
class MPINController extends GetxController {
|
class MPINController extends GetxController {
|
||||||
final MyFormValidator basicValidator = MyFormValidator();
|
final MyFormValidator basicValidator = MyFormValidator();
|
||||||
@ -256,14 +256,12 @@ class MPINController extends GetxController {
|
|||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
|
||||||
// ✅ Fetch FCM Token here (DISABLED)
|
final fcmToken = await FirebaseNotificationService().getFcmToken();
|
||||||
// final fcmToken = await FirebaseNotificationService().getFcmToken();
|
|
||||||
|
|
||||||
final response = await AuthService.verifyMpin(
|
final response = await AuthService.verifyMpin(
|
||||||
mpin: enteredMPIN,
|
mpin: enteredMPIN,
|
||||||
mpinToken: mpinToken,
|
mpinToken: mpinToken,
|
||||||
// fcmToken: fcmToken ?? '', // 🔴 Commented out
|
fcmToken: fcmToken ?? '',
|
||||||
fcmToken: '', // ✅ Passing empty string instead
|
|
||||||
);
|
);
|
||||||
|
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
@ -7,6 +7,8 @@ import 'package:get/get.dart';
|
|||||||
import 'package:geocoding/geocoding.dart';
|
import 'package:geocoding/geocoding.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:mime/mime.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||||
import 'package:marco/helpers/services/api_service.dart';
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
@ -15,21 +17,30 @@ 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:mime/mime.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
|
|
||||||
class AddExpenseController extends GetxController {
|
class AddExpenseController extends GetxController {
|
||||||
// --- Text Controllers ---
|
// --- Text Controllers ---
|
||||||
final amountController = TextEditingController();
|
final controllers = <TextEditingController>[
|
||||||
final descriptionController = TextEditingController();
|
TextEditingController(), // amount
|
||||||
final supplierController = TextEditingController();
|
TextEditingController(), // description
|
||||||
final transactionIdController = TextEditingController();
|
TextEditingController(), // supplier
|
||||||
final gstController = TextEditingController();
|
TextEditingController(), // transactionId
|
||||||
final locationController = TextEditingController();
|
TextEditingController(), // gst
|
||||||
final transactionDateController = TextEditingController();
|
TextEditingController(), // location
|
||||||
final noOfPersonsController = TextEditingController();
|
TextEditingController(), // transactionDate
|
||||||
|
TextEditingController(), // noOfPersons
|
||||||
|
TextEditingController(), // employeeSearch
|
||||||
|
];
|
||||||
|
|
||||||
final employeeSearchController = TextEditingController();
|
TextEditingController get amountController => controllers[0];
|
||||||
|
TextEditingController get descriptionController => controllers[1];
|
||||||
|
TextEditingController get supplierController => controllers[2];
|
||||||
|
TextEditingController get transactionIdController => controllers[3];
|
||||||
|
TextEditingController get gstController => controllers[4];
|
||||||
|
TextEditingController get locationController => controllers[5];
|
||||||
|
TextEditingController get transactionDateController => controllers[6];
|
||||||
|
TextEditingController get noOfPersonsController => controllers[7];
|
||||||
|
TextEditingController get employeeSearchController => controllers[8];
|
||||||
|
|
||||||
// --- Reactive State ---
|
// --- Reactive State ---
|
||||||
final isLoading = false.obs;
|
final isLoading = false.obs;
|
||||||
@ -59,29 +70,19 @@ class AddExpenseController extends GetxController {
|
|||||||
|
|
||||||
final expenseController = Get.find<ExpenseController>();
|
final expenseController = Get.find<ExpenseController>();
|
||||||
final ImagePicker _picker = ImagePicker();
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
fetchMasterData();
|
loadMasterData();
|
||||||
fetchGlobalProjects();
|
employeeSearchController.addListener(
|
||||||
employeeSearchController.addListener(() {
|
() => searchEmployees(employeeSearchController.text),
|
||||||
searchEmployees(employeeSearchController.text);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onClose() {
|
void onClose() {
|
||||||
for (var c in [
|
for (var c in controllers) {
|
||||||
amountController,
|
|
||||||
descriptionController,
|
|
||||||
supplierController,
|
|
||||||
transactionIdController,
|
|
||||||
gstController,
|
|
||||||
locationController,
|
|
||||||
transactionDateController,
|
|
||||||
noOfPersonsController,
|
|
||||||
employeeSearchController,
|
|
||||||
]) {
|
|
||||||
c.dispose();
|
c.dispose();
|
||||||
}
|
}
|
||||||
super.onClose();
|
super.onClose();
|
||||||
@ -92,11 +93,19 @@ class AddExpenseController extends GetxController {
|
|||||||
if (query.trim().isEmpty) return employeeSearchResults.clear();
|
if (query.trim().isEmpty) return employeeSearchResults.clear();
|
||||||
isSearchingEmployees.value = true;
|
isSearchingEmployees.value = true;
|
||||||
try {
|
try {
|
||||||
final data =
|
final data = await ApiService.searchEmployeesBasic(
|
||||||
await ApiService.searchEmployeesBasic(searchString: query.trim());
|
searchString: query.trim(),
|
||||||
employeeSearchResults.assignAll(
|
|
||||||
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (data is List) {
|
||||||
|
employeeSearchResults.assignAll(
|
||||||
|
data
|
||||||
|
.map((e) => EmployeeModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
employeeSearchResults.clear();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logSafe("Error searching employees: $e", level: LogLevel.error);
|
logSafe("Error searching employees: $e", level: LogLevel.error);
|
||||||
employeeSearchResults.clear();
|
employeeSearchResults.clear();
|
||||||
@ -105,64 +114,77 @@ class AddExpenseController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Form Population: Edit Mode ---
|
// --- Form Population (Edit) ---
|
||||||
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
|
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
|
||||||
isEditMode.value = true;
|
isEditMode.value = true;
|
||||||
editingExpenseId = '${data['id']}';
|
editingExpenseId = '${data['id']}';
|
||||||
|
|
||||||
selectedProject.value = data['projectName'] ?? '';
|
selectedProject.value = data['projectName'] ?? '';
|
||||||
amountController.text = data['amount']?.toString() ?? '';
|
amountController.text = '${data['amount'] ?? ''}';
|
||||||
supplierController.text = data['supplerName'] ?? '';
|
supplierController.text = data['supplerName'] ?? '';
|
||||||
descriptionController.text = data['description'] ?? '';
|
descriptionController.text = data['description'] ?? '';
|
||||||
transactionIdController.text = data['transactionId'] ?? '';
|
transactionIdController.text = data['transactionId'] ?? '';
|
||||||
locationController.text = data['location'] ?? '';
|
locationController.text = data['location'] ?? '';
|
||||||
noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString();
|
noOfPersonsController.text = '${data['noOfPersons'] ?? 0}';
|
||||||
|
|
||||||
// Transaction Date
|
_setTransactionDate(data['transactionDate']);
|
||||||
if (data['transactionDate'] != null) {
|
_setDropdowns(data);
|
||||||
try {
|
await _setPaidBy(data);
|
||||||
final parsed = DateTime.parse(data['transactionDate']);
|
_setAttachments(data['attachments']);
|
||||||
selectedTransactionDate.value = parsed;
|
|
||||||
transactionDateController.text =
|
|
||||||
DateFormat('dd-MM-yyyy').format(parsed);
|
|
||||||
} catch (_) {
|
|
||||||
selectedTransactionDate.value = null;
|
|
||||||
transactionDateController.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dropdown
|
|
||||||
selectedExpenseType.value =
|
|
||||||
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
|
|
||||||
selectedPaymentMode.value =
|
|
||||||
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
|
|
||||||
|
|
||||||
// Paid By
|
|
||||||
final paidById = '${data['paidById']}';
|
|
||||||
selectedPaidBy.value =
|
|
||||||
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
|
|
||||||
if (selectedPaidBy.value == null && data['paidByFirstName'] != null) {
|
|
||||||
await searchEmployees(
|
|
||||||
'${data['paidByFirstName']} ${data['paidByLastName']}');
|
|
||||||
selectedPaidBy.value = employeeSearchResults
|
|
||||||
.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attachments
|
|
||||||
existingAttachments.clear();
|
|
||||||
if (data['attachments'] is List) {
|
|
||||||
existingAttachments.addAll(
|
|
||||||
List<Map<String, dynamic>>.from(data['attachments'])
|
|
||||||
.map((e) => {...e, 'isActive': true}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logPrefilledData();
|
_logPrefilledData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _setTransactionDate(dynamic dateStr) {
|
||||||
|
if (dateStr == null) {
|
||||||
|
selectedTransactionDate.value = null;
|
||||||
|
transactionDateController.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final parsed = DateTime.parse(dateStr);
|
||||||
|
selectedTransactionDate.value = parsed;
|
||||||
|
transactionDateController.text = DateFormat('dd-MM-yyyy').format(parsed);
|
||||||
|
} catch (_) {
|
||||||
|
selectedTransactionDate.value = null;
|
||||||
|
transactionDateController.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setDropdowns(Map<String, dynamic> data) {
|
||||||
|
selectedExpenseType.value =
|
||||||
|
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
|
||||||
|
selectedPaymentMode.value =
|
||||||
|
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setPaidBy(Map<String, dynamic> data) async {
|
||||||
|
final paidById = '${data['paidById']}';
|
||||||
|
selectedPaidBy.value =
|
||||||
|
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
|
||||||
|
|
||||||
|
if (selectedPaidBy.value == null && data['paidByFirstName'] != null) {
|
||||||
|
await searchEmployees(
|
||||||
|
'${data['paidByFirstName']} ${data['paidByLastName']}',
|
||||||
|
);
|
||||||
|
selectedPaidBy.value = employeeSearchResults
|
||||||
|
.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setAttachments(dynamic attachmentsData) {
|
||||||
|
existingAttachments.clear();
|
||||||
|
if (attachmentsData is List) {
|
||||||
|
existingAttachments.addAll(
|
||||||
|
List<Map<String, dynamic>>.from(attachmentsData).map(
|
||||||
|
(e) => {...e, 'isActive': true},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _logPrefilledData() {
|
void _logPrefilledData() {
|
||||||
logSafe('--- Prefilled Expense Data ---', level: LogLevel.info);
|
final info = [
|
||||||
[
|
|
||||||
'ID: $editingExpenseId',
|
'ID: $editingExpenseId',
|
||||||
'Project: ${selectedProject.value}',
|
'Project: ${selectedProject.value}',
|
||||||
'Amount: ${amountController.text}',
|
'Amount: ${amountController.text}',
|
||||||
@ -177,7 +199,10 @@ class AddExpenseController extends GetxController {
|
|||||||
'Paid By: ${selectedPaidBy.value?.name}',
|
'Paid By: ${selectedPaidBy.value?.name}',
|
||||||
'Attachments: ${attachments.length}',
|
'Attachments: ${attachments.length}',
|
||||||
'Existing Attachments: ${existingAttachments.length}',
|
'Existing Attachments: ${existingAttachments.length}',
|
||||||
].forEach((str) => logSafe(str, level: LogLevel.info));
|
];
|
||||||
|
for (var line in info) {
|
||||||
|
logSafe(line, level: LogLevel.info);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Pickers ---
|
// --- Pickers ---
|
||||||
@ -199,7 +224,6 @@ class AddExpenseController extends GetxController {
|
|||||||
now.minute,
|
now.minute,
|
||||||
now.second,
|
now.second,
|
||||||
);
|
);
|
||||||
|
|
||||||
selectedTransactionDate.value = finalDateTime;
|
selectedTransactionDate.value = finalDateTime;
|
||||||
transactionDateController.text =
|
transactionDateController.text =
|
||||||
DateFormat('dd MMM yyyy').format(finalDateTime);
|
DateFormat('dd MMM yyyy').format(finalDateTime);
|
||||||
@ -214,8 +238,9 @@ class AddExpenseController extends GetxController {
|
|||||||
allowMultiple: true,
|
allowMultiple: true,
|
||||||
);
|
);
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
attachments
|
attachments.addAll(
|
||||||
.addAll(result.paths.whereType<String>().map((path) => File(path)));
|
result.paths.whereType<String>().map(File.new),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_errorSnackbar("Attachment error: $e");
|
_errorSnackbar("Attachment error: $e");
|
||||||
@ -224,12 +249,20 @@ class AddExpenseController extends GetxController {
|
|||||||
|
|
||||||
void removeAttachment(File file) => attachments.remove(file);
|
void removeAttachment(File file) => attachments.remove(file);
|
||||||
|
|
||||||
|
Future<void> pickFromCamera() async {
|
||||||
|
try {
|
||||||
|
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
|
||||||
|
if (pickedFile != null) attachments.add(File(pickedFile.path));
|
||||||
|
} catch (e) {
|
||||||
|
_errorSnackbar("Camera error: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Location ---
|
// --- Location ---
|
||||||
Future<void> fetchCurrentLocation() async {
|
Future<void> fetchCurrentLocation() async {
|
||||||
isFetchingLocation.value = true;
|
isFetchingLocation.value = true;
|
||||||
try {
|
try {
|
||||||
final permission = await _ensureLocationPermission();
|
if (!await _ensureLocationPermission()) return;
|
||||||
if (!permission) return;
|
|
||||||
|
|
||||||
final position = await Geolocator.getCurrentPosition();
|
final position = await Geolocator.getCurrentPosition();
|
||||||
final placemarks =
|
final placemarks =
|
||||||
@ -241,7 +274,7 @@ class AddExpenseController extends GetxController {
|
|||||||
placemarks.first.street,
|
placemarks.first.street,
|
||||||
placemarks.first.locality,
|
placemarks.first.locality,
|
||||||
placemarks.first.administrativeArea,
|
placemarks.first.administrativeArea,
|
||||||
placemarks.first.country
|
placemarks.first.country,
|
||||||
].where((e) => e?.isNotEmpty == true).join(", ")
|
].where((e) => e?.isNotEmpty == true).join(", ")
|
||||||
: "${position.latitude}, ${position.longitude}";
|
: "${position.latitude}, ${position.longitude}";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -271,19 +304,23 @@ class AddExpenseController extends GetxController {
|
|||||||
|
|
||||||
// --- Data Fetching ---
|
// --- Data Fetching ---
|
||||||
Future<void> loadMasterData() async =>
|
Future<void> loadMasterData() async =>
|
||||||
await Future.wait([fetchMasterData(), fetchGlobalProjects()]);
|
Future.wait([fetchMasterData(), fetchGlobalProjects()]);
|
||||||
|
|
||||||
Future<void> fetchMasterData() async {
|
Future<void> fetchMasterData() async {
|
||||||
try {
|
try {
|
||||||
final types = await ApiService.getMasterExpenseTypes();
|
final types = await ApiService.getMasterExpenseTypes();
|
||||||
if (types is List)
|
if (types is List) {
|
||||||
expenseTypes.value =
|
expenseTypes.value = types
|
||||||
types.map((e) => ExpenseTypeModel.fromJson(e)).toList();
|
.map((e) => ExpenseTypeModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
final modes = await ApiService.getMasterPaymentModes();
|
final modes = await ApiService.getMasterPaymentModes();
|
||||||
if (modes is List)
|
if (modes is List) {
|
||||||
paymentModes.value =
|
paymentModes.value = modes
|
||||||
modes.map((e) => PaymentModeModel.fromJson(e)).toList();
|
.map((e) => PaymentModeModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_errorSnackbar("Failed to fetch master data");
|
_errorSnackbar("Failed to fetch master data");
|
||||||
}
|
}
|
||||||
@ -295,8 +332,8 @@ class AddExpenseController extends GetxController {
|
|||||||
if (response != null) {
|
if (response != null) {
|
||||||
final names = <String>[];
|
final names = <String>[];
|
||||||
for (var item in response) {
|
for (var item in response) {
|
||||||
final name = item['name']?.toString().trim(),
|
final name = item['name']?.toString().trim();
|
||||||
id = item['id']?.toString().trim();
|
final id = item['id']?.toString().trim();
|
||||||
if (name != null && id != null) {
|
if (name != null && id != null) {
|
||||||
projectsMap[name] = id;
|
projectsMap[name] = id;
|
||||||
names.add(name);
|
names.add(name);
|
||||||
@ -309,17 +346,6 @@ class AddExpenseController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> pickFromCamera() async {
|
|
||||||
try {
|
|
||||||
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
|
|
||||||
if (pickedFile != null) {
|
|
||||||
attachments.add(File(pickedFile.path));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_errorSnackbar("Camera error: $e");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Submission ---
|
// --- Submission ---
|
||||||
Future<void> submitOrUpdateExpense() async {
|
Future<void> submitOrUpdateExpense() async {
|
||||||
if (isSubmitting.value) return;
|
if (isSubmitting.value) return;
|
||||||
@ -332,24 +358,7 @@ class AddExpenseController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final payload = await _buildExpensePayload();
|
final payload = await _buildExpensePayload();
|
||||||
|
final success = await _submitToApi(payload);
|
||||||
final success = isEditMode.value && editingExpenseId != null
|
|
||||||
? await ApiService.editExpenseApi(
|
|
||||||
expenseId: editingExpenseId!, payload: payload)
|
|
||||||
: await ApiService.createExpenseApi(
|
|
||||||
projectId: payload['projectId'],
|
|
||||||
expensesTypeId: payload['expensesTypeId'],
|
|
||||||
paymentModeId: payload['paymentModeId'],
|
|
||||||
paidById: payload['paidById'],
|
|
||||||
transactionDate: DateTime.parse(payload['transactionDate']),
|
|
||||||
transactionId: payload['transactionId'],
|
|
||||||
description: payload['description'],
|
|
||||||
location: payload['location'],
|
|
||||||
supplerName: payload['supplerName'],
|
|
||||||
amount: payload['amount'],
|
|
||||||
noOfPersons: payload['noOfPersons'],
|
|
||||||
billAttachments: payload['billAttachments'],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
await expenseController.fetchExpenses();
|
await expenseController.fetchExpenses();
|
||||||
@ -370,61 +379,71 @@ class AddExpenseController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _submitToApi(Map<String, dynamic> payload) async {
|
||||||
|
if (isEditMode.value && editingExpenseId != null) {
|
||||||
|
return ApiService.editExpenseApi(
|
||||||
|
expenseId: editingExpenseId!,
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ApiService.createExpenseApi(
|
||||||
|
projectId: payload['projectId'],
|
||||||
|
expensesTypeId: payload['expensesTypeId'],
|
||||||
|
paymentModeId: payload['paymentModeId'],
|
||||||
|
paidById: payload['paidById'],
|
||||||
|
transactionDate: DateTime.parse(payload['transactionDate']),
|
||||||
|
transactionId: payload['transactionId'],
|
||||||
|
description: payload['description'],
|
||||||
|
location: payload['location'],
|
||||||
|
supplerName: payload['supplerName'],
|
||||||
|
amount: payload['amount'],
|
||||||
|
noOfPersons: payload['noOfPersons'],
|
||||||
|
billAttachments: payload['billAttachments'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _buildExpensePayload() async {
|
Future<Map<String, dynamic>> _buildExpensePayload() async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
// --- Existing Attachments Payload (for edit mode only) ---
|
final existingPayload = isEditMode.value
|
||||||
final List<Map<String, dynamic>> existingAttachmentPayloads =
|
? existingAttachments
|
||||||
isEditMode.value
|
.map((e) => {
|
||||||
? existingAttachments
|
"documentId": e['documentId'],
|
||||||
.map<Map<String, dynamic>>((e) => {
|
"fileName": e['fileName'],
|
||||||
"documentId": e['documentId'],
|
"contentType": e['contentType'],
|
||||||
"fileName": e['fileName'],
|
"fileSize": 0,
|
||||||
"contentType": e['contentType'],
|
|
||||||
"fileSize": 0,
|
|
||||||
"description": "",
|
|
||||||
"url": e['url'],
|
|
||||||
"isActive": e['isActive'] ?? true,
|
|
||||||
"base64Data": "", // <-- always empty now
|
|
||||||
})
|
|
||||||
.toList()
|
|
||||||
: <Map<String, dynamic>>[];
|
|
||||||
|
|
||||||
// --- New Attachments Payload (always include if attachments exist) ---
|
|
||||||
final List<Map<String, dynamic>> newAttachmentPayloads =
|
|
||||||
attachments.isNotEmpty
|
|
||||||
? await Future.wait(attachments.map((file) async {
|
|
||||||
final bytes = await file.readAsBytes();
|
|
||||||
final length = await file.length();
|
|
||||||
return <String, dynamic>{
|
|
||||||
"fileName": file.path.split('/').last,
|
|
||||||
"base64Data": base64Encode(bytes),
|
|
||||||
"contentType":
|
|
||||||
lookupMimeType(file.path) ?? 'application/octet-stream',
|
|
||||||
"fileSize": length,
|
|
||||||
"description": "",
|
"description": "",
|
||||||
};
|
"url": e['url'],
|
||||||
}))
|
"isActive": e['isActive'] ?? true,
|
||||||
: <Map<String, dynamic>>[];
|
"base64Data": "",
|
||||||
|
})
|
||||||
|
.toList()
|
||||||
|
: <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
final newPayload = await Future.wait(
|
||||||
|
attachments.map((file) async {
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
return {
|
||||||
|
"fileName": file.path.split('/').last,
|
||||||
|
"base64Data": base64Encode(bytes),
|
||||||
|
"contentType":
|
||||||
|
lookupMimeType(file.path) ?? 'application/octet-stream',
|
||||||
|
"fileSize": await file.length(),
|
||||||
|
"description": "",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// --- Selected Expense Type ---
|
|
||||||
final type = selectedExpenseType.value!;
|
final type = selectedExpenseType.value!;
|
||||||
|
|
||||||
// --- Combine all attachments ---
|
return {
|
||||||
final List<Map<String, dynamic>> combinedAttachments = [
|
|
||||||
...existingAttachmentPayloads,
|
|
||||||
...newAttachmentPayloads
|
|
||||||
];
|
|
||||||
|
|
||||||
// --- Build Payload ---
|
|
||||||
final payload = <String, dynamic>{
|
|
||||||
if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId,
|
if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId,
|
||||||
"projectId": projectsMap[selectedProject.value]!,
|
"projectId": projectsMap[selectedProject.value]!,
|
||||||
"expensesTypeId": type.id,
|
"expensesTypeId": type.id,
|
||||||
"paymentModeId": selectedPaymentMode.value!.id,
|
"paymentModeId": selectedPaymentMode.value!.id,
|
||||||
"paidById": selectedPaidBy.value!.id,
|
"paidById": selectedPaidBy.value!.id,
|
||||||
"transactionDate": (selectedTransactionDate.value?.toUtc() ?? now.toUtc())
|
"transactionDate":
|
||||||
.toIso8601String(),
|
(selectedTransactionDate.value ?? now).toUtc().toIso8601String(),
|
||||||
"transactionId": transactionIdController.text,
|
"transactionId": transactionIdController.text,
|
||||||
"description": descriptionController.text,
|
"description": descriptionController.text,
|
||||||
"location": locationController.text,
|
"location": locationController.text,
|
||||||
@ -433,11 +452,13 @@ class AddExpenseController extends GetxController {
|
|||||||
"noOfPersons": type.noOfPersonsRequired == true
|
"noOfPersons": type.noOfPersonsRequired == true
|
||||||
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
|
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
|
||||||
: 0,
|
: 0,
|
||||||
"billAttachments":
|
"billAttachments": [
|
||||||
combinedAttachments.isEmpty ? null : combinedAttachments,
|
...existingPayload,
|
||||||
|
...newPayload,
|
||||||
|
].isEmpty
|
||||||
|
? null
|
||||||
|
: [...existingPayload, ...newPayload],
|
||||||
};
|
};
|
||||||
|
|
||||||
return payload;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String validateForm() {
|
String validateForm() {
|
||||||
@ -450,28 +471,27 @@ class AddExpenseController extends GetxController {
|
|||||||
if (amountController.text.trim().isEmpty) missing.add("Amount");
|
if (amountController.text.trim().isEmpty) missing.add("Amount");
|
||||||
if (descriptionController.text.trim().isEmpty) missing.add("Description");
|
if (descriptionController.text.trim().isEmpty) missing.add("Description");
|
||||||
|
|
||||||
// Date Required
|
if (selectedTransactionDate.value == null) {
|
||||||
if (selectedTransactionDate.value == null) missing.add("Transaction Date");
|
missing.add("Transaction Date");
|
||||||
if (selectedTransactionDate.value != null &&
|
} else if (selectedTransactionDate.value!.isAfter(DateTime.now())) {
|
||||||
selectedTransactionDate.value!.isAfter(DateTime.now())) {
|
|
||||||
missing.add("Valid Transaction Date");
|
missing.add("Valid Transaction Date");
|
||||||
}
|
}
|
||||||
|
|
||||||
final amount = double.tryParse(amountController.text.trim());
|
if (double.tryParse(amountController.text.trim()) == null) {
|
||||||
if (amount == null) missing.add("Valid Amount");
|
missing.add("Valid Amount");
|
||||||
|
}
|
||||||
|
|
||||||
// Attachment: at least one required at all times
|
final hasActiveExisting =
|
||||||
bool hasActiveExisting =
|
|
||||||
existingAttachments.any((e) => e['isActive'] != false);
|
existingAttachments.any((e) => e['isActive'] != false);
|
||||||
if (attachments.isEmpty && !hasActiveExisting) missing.add("Attachment");
|
if (attachments.isEmpty && !hasActiveExisting) {
|
||||||
|
missing.add("Attachment");
|
||||||
|
}
|
||||||
|
|
||||||
return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}.";
|
return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Snackbar Helper ---
|
// --- Snackbar Helper ---
|
||||||
void _errorSnackbar(String msg, [String title = "Error"]) => showAppSnackbar(
|
void _errorSnackbar(String msg, [String title = "Error"]) {
|
||||||
title: title,
|
showAppSnackbar(title: title, message: msg, type: SnackbarType.error);
|
||||||
message: msg,
|
}
|
||||||
type: SnackbarType.error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
class ApiEndpoints {
|
class ApiEndpoints {
|
||||||
static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
// static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
||||||
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
static const String baseUrl = "https://api.marcoaiot.com/api";
|
||||||
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
||||||
|
|
||||||
// Dashboard Module API Endpoints
|
// Dashboard Module API Endpoints
|
||||||
|
@ -2,14 +2,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:url_strategy/url_strategy.dart';
|
import 'package:url_strategy/url_strategy.dart';
|
||||||
// import 'package:firebase_core/firebase_core.dart'; // ❌ Commented out Firebase
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
|
||||||
import 'package:marco/controller/permission_controller.dart';
|
import 'package:marco/controller/permission_controller.dart';
|
||||||
import 'package:marco/controller/project_controller.dart';
|
import 'package:marco/controller/project_controller.dart';
|
||||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
import 'package:marco/helpers/services/auth_service.dart';
|
import 'package:marco/helpers/services/auth_service.dart';
|
||||||
// import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart'; // ❌ Commented out FCM
|
import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart';
|
||||||
import 'package:marco/helpers/services/device_info_service.dart';
|
import 'package:marco/helpers/services/device_info_service.dart';
|
||||||
import 'package:marco/helpers/theme/theme_customizer.dart';
|
import 'package:marco/helpers/theme/theme_customizer.dart';
|
||||||
import 'package:marco/helpers/theme/app_theme.dart';
|
import 'package:marco/helpers/theme/app_theme.dart';
|
||||||
@ -20,7 +20,7 @@ Future<void> initializeApp() async {
|
|||||||
|
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
_setupUI(),
|
_setupUI(),
|
||||||
// _setupFirebase(), // ❌ Commented out Firebase init
|
_setupFirebase(),
|
||||||
_setupLocalStorage(),
|
_setupLocalStorage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ Future<void> initializeApp() async {
|
|||||||
await _handleAuthTokens();
|
await _handleAuthTokens();
|
||||||
await _setupTheme();
|
await _setupTheme();
|
||||||
await _setupControllers();
|
await _setupControllers();
|
||||||
// await _setupFirebaseMessaging(); // ❌ Commented out FCM init
|
await _setupFirebaseMessaging();
|
||||||
|
|
||||||
_finalizeAppStyle();
|
_finalizeAppStyle();
|
||||||
|
|
||||||
@ -56,17 +56,19 @@ Future<void> _setupUI() async {
|
|||||||
logSafe("💡 UI setup completed.");
|
logSafe("💡 UI setup completed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ❌ Commented out Firebase setup
|
|
||||||
/*
|
|
||||||
Future<void> _setupFirebase() async {
|
Future<void> _setupFirebase() async {
|
||||||
await Firebase.initializeApp();
|
await Firebase.initializeApp();
|
||||||
logSafe("💡 Firebase initialized.");
|
logSafe("💡 Firebase initialized.");
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
Future<void> _setupLocalStorage() async {
|
Future<void> _setupLocalStorage() async {
|
||||||
await LocalStorage.init();
|
if (!LocalStorage.isInitialized) {
|
||||||
logSafe("💡 Local storage initialized.");
|
await LocalStorage.init();
|
||||||
|
logSafe("💡 Local storage initialized.");
|
||||||
|
} else {
|
||||||
|
logSafe("ℹ️ Local storage already initialized, skipping.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setupDeviceInfo() async {
|
Future<void> _setupDeviceInfo() async {
|
||||||
@ -118,12 +120,12 @@ Future<void> _setupControllers() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ❌ Commented out Firebase Messaging setup
|
// ❌ Commented out Firebase Messaging setup
|
||||||
/*
|
|
||||||
Future<void> _setupFirebaseMessaging() async {
|
Future<void> _setupFirebaseMessaging() async {
|
||||||
await FirebaseNotificationService().initialize();
|
await FirebaseNotificationService().initialize();
|
||||||
logSafe("💡 Firebase Messaging initialized.");
|
logSafe("💡 Firebase Messaging initialized.");
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
void _finalizeAppStyle() {
|
void _finalizeAppStyle() {
|
||||||
AppStyle.init();
|
AppStyle.init();
|
||||||
|
@ -54,7 +54,20 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final body = {"fcmToken": fcmToken};
|
final body = {"fcmToken": fcmToken};
|
||||||
|
final headers = {
|
||||||
|
..._headers,
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
};
|
||||||
|
final endpoint = "$_baseUrl/auth/set/device-token";
|
||||||
|
|
||||||
|
// 🔹 Log request details
|
||||||
|
logSafe("📡 Device Token API Request");
|
||||||
|
logSafe("➡️ Endpoint: $endpoint");
|
||||||
|
logSafe("➡️ Headers: ${jsonEncode(headers)}");
|
||||||
|
logSafe("➡️ Payload: ${jsonEncode(body)}");
|
||||||
|
|
||||||
final data = await _post("/auth/set/device-token", body, authToken: token);
|
final data = await _post("/auth/set/device-token", body, authToken: token);
|
||||||
|
|
||||||
if (data != null && data['success'] == true) {
|
if (data != null && data['success'] == true) {
|
||||||
logSafe("✅ Device token registered successfully.");
|
logSafe("✅ Device token registered successfully.");
|
||||||
return true;
|
return true;
|
||||||
|
@ -1,9 +1,5 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:googleapis_auth/auth_io.dart';
|
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
|
||||||
|
|
||||||
import 'package:marco/helpers/services/local_notification_service.dart';
|
import 'package:marco/helpers/services/local_notification_service.dart';
|
||||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||||
@ -15,10 +11,6 @@ class FirebaseNotificationService {
|
|||||||
final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
||||||
final Logger _logger = Logger();
|
final Logger _logger = Logger();
|
||||||
|
|
||||||
static const _fcmScopes = [
|
|
||||||
'https://www.googleapis.com/auth/firebase.messaging',
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Initialize FCM (Firebase.initializeApp() should be called once globally)
|
/// Initialize FCM (Firebase.initializeApp() should be called once globally)
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
_logger.i('✅ FirebaseMessaging initializing...');
|
_logger.i('✅ FirebaseMessaging initializing...');
|
||||||
@ -119,80 +111,8 @@ class FirebaseNotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a test notification using FCM v1 API
|
|
||||||
Future<void> sendTestNotification(String deviceToken) async {
|
|
||||||
try {
|
|
||||||
final client = await _getAuthenticatedHttpClient();
|
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
final projectId = await _getProjectId();
|
|
||||||
if (projectId == null) return;
|
|
||||||
|
|
||||||
_logger.i('🏗 Firebase Project ID: $projectId');
|
|
||||||
|
|
||||||
final url = Uri.parse(
|
|
||||||
'https://fcm.googleapis.com/v1/projects/$projectId/messages:send');
|
|
||||||
final payload = _buildNotificationPayload(deviceToken);
|
|
||||||
|
|
||||||
final response = await client.post(
|
|
||||||
url,
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: jsonEncode(payload),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
_logger.i('✅ Test notification sent successfully');
|
|
||||||
} else {
|
|
||||||
_logger.e('❌ Send failed: ${response.statusCode} ${response.body}');
|
|
||||||
}
|
|
||||||
|
|
||||||
client.close();
|
|
||||||
} catch (e, s) {
|
|
||||||
_logger.e('❌ Error sending notification', error: e, stackTrace: s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Authenticated HTTP client using service account
|
|
||||||
Future<http.Client?> _getAuthenticatedHttpClient() async {
|
|
||||||
try {
|
|
||||||
final credentials = ServiceAccountCredentials.fromJson(
|
|
||||||
json.decode(await rootBundle.loadString('assets/service-account.json')),
|
|
||||||
);
|
|
||||||
return clientViaServiceAccount(credentials, _fcmScopes);
|
|
||||||
} catch (e, s) {
|
|
||||||
_logger.e('❌ Failed to authenticate', error: e, stackTrace: s);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get Project ID from service account
|
|
||||||
Future<String?> _getProjectId() async {
|
|
||||||
try {
|
|
||||||
final jsonMap = json
|
|
||||||
.decode(await rootBundle.loadString('assets/service-account.json'));
|
|
||||||
return jsonMap['project_id'];
|
|
||||||
} catch (e) {
|
|
||||||
_logger.e('❌ Failed to load project_id: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build FCM v1 payload
|
|
||||||
Map<String, dynamic> _buildNotificationPayload(String token) => {
|
|
||||||
"message": {
|
|
||||||
"token": token,
|
|
||||||
"notification": {
|
|
||||||
"title": "Test Notification",
|
|
||||||
"body": "This is a test message from Flutter (v1 API)"
|
|
||||||
},
|
|
||||||
"data": {
|
|
||||||
"click_action": "FLUTTER_NOTIFICATION_CLICK",
|
|
||||||
"type": "expense_updated", // Example
|
|
||||||
"expense_id": "1234"
|
|
||||||
},
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Handle tap on notification
|
/// Handle tap on notification
|
||||||
void _handleNotificationTap(RemoteMessage message) {
|
void _handleNotificationTap(RemoteMessage message) {
|
||||||
_logger.i('📌 Notification tapped: ${message.data}');
|
_logger.i('📌 Notification tapped: ${message.data}');
|
||||||
|
@ -6,6 +6,10 @@ import 'package:marco/controller/task_planning/daily_task_controller.dart';
|
|||||||
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart';
|
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart';
|
||||||
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||||
import 'package:marco/controller/expense/expense_detail_controller.dart';
|
import 'package:marco/controller/expense/expense_detail_controller.dart';
|
||||||
|
import 'package:marco/controller/directory/directory_controller.dart';
|
||||||
|
import 'package:marco/controller/directory/notes_controller.dart';
|
||||||
|
import 'package:marco/controller/document/user_document_controller.dart';
|
||||||
|
import 'package:marco/controller/document/document_details_controller.dart';
|
||||||
|
|
||||||
/// Handles incoming FCM notification actions and updates UI/controllers.
|
/// Handles incoming FCM notification actions and updates UI/controllers.
|
||||||
class NotificationActionHandler {
|
class NotificationActionHandler {
|
||||||
@ -37,7 +41,7 @@ class NotificationActionHandler {
|
|||||||
static void _handleByType(String type, Map<String, dynamic> data) {
|
static void _handleByType(String type, Map<String, dynamic> data) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'expense_updated':
|
case 'expense_updated':
|
||||||
// No specific handler yet
|
_handleExpenseUpdated(data);
|
||||||
break;
|
break;
|
||||||
case 'attendance_updated':
|
case 'attendance_updated':
|
||||||
_handleAttendanceUpdated(data);
|
_handleAttendanceUpdated(data);
|
||||||
@ -51,12 +55,14 @@ class NotificationActionHandler {
|
|||||||
static void _handleByKeyword(
|
static void _handleByKeyword(
|
||||||
String keyword, String? action, Map<String, dynamic> data) {
|
String keyword, String? action, Map<String, dynamic> data) {
|
||||||
switch (keyword) {
|
switch (keyword) {
|
||||||
|
/// 🔹 Attendance
|
||||||
case 'Attendance':
|
case 'Attendance':
|
||||||
if (_isAttendanceAction(action)) {
|
if (_isAttendanceAction(action)) {
|
||||||
_handleAttendanceUpdated(data);
|
_handleAttendanceUpdated(data);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
/// 🔹 Tasks
|
||||||
case 'Report_Task':
|
case 'Report_Task':
|
||||||
_handleTaskUpdated(data, isComment: false);
|
_handleTaskUpdated(data, isComment: false);
|
||||||
break;
|
break;
|
||||||
@ -64,11 +70,7 @@ class NotificationActionHandler {
|
|||||||
case 'Task_Comment':
|
case 'Task_Comment':
|
||||||
_handleTaskUpdated(data, isComment: true);
|
_handleTaskUpdated(data, isComment: true);
|
||||||
break;
|
break;
|
||||||
case 'Expenses_Modified':
|
|
||||||
_handleExpenseUpdated(data);
|
|
||||||
break;
|
|
||||||
|
|
||||||
// ✅ New cases
|
|
||||||
case 'Task_Modified':
|
case 'Task_Modified':
|
||||||
case 'WorkArea_Modified':
|
case 'WorkArea_Modified':
|
||||||
case 'Floor_Modified':
|
case 'Floor_Modified':
|
||||||
@ -76,11 +78,41 @@ class NotificationActionHandler {
|
|||||||
_handleTaskPlanningUpdated(data);
|
_handleTaskPlanningUpdated(data);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
/// 🔹 Expenses
|
||||||
|
case 'Expenses_Modified':
|
||||||
|
_handleExpenseUpdated(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
/// 🔹 Documents
|
||||||
|
case 'Employee_Document_Modified':
|
||||||
|
case 'Project_Document_Modified':
|
||||||
|
_handleDocumentModified(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
/// 🔹 Directory / Contacts
|
||||||
|
case 'Contact_Modified':
|
||||||
|
_handleContactModified(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Contact_Note_Modified':
|
||||||
|
_handleContactNoteModified(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Bucket_Modified':
|
||||||
|
_handleBucketModified(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Bucket_Assigned':
|
||||||
|
_handleBucketAssigned(data);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
_logger.w('⚠️ Unhandled notification keyword: $keyword');
|
_logger.w('⚠️ Unhandled notification keyword: $keyword');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ---------------------- HANDLERS ----------------------
|
||||||
|
|
||||||
static void _handleTaskPlanningUpdated(Map<String, dynamic> data) {
|
static void _handleTaskPlanningUpdated(Map<String, dynamic> data) {
|
||||||
final projectId = data['ProjectId'];
|
final projectId = data['ProjectId'];
|
||||||
if (projectId == null) {
|
if (projectId == null) {
|
||||||
@ -99,7 +131,6 @@ class NotificationActionHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates the set of allowed Attendance actions
|
|
||||||
static bool _isAttendanceAction(String? action) {
|
static bool _isAttendanceAction(String? action) {
|
||||||
const validActions = {
|
const validActions = {
|
||||||
'CHECK_IN',
|
'CHECK_IN',
|
||||||
@ -132,7 +163,6 @@ class NotificationActionHandler {
|
|||||||
// Update Expense Detail (if open and matches this expenseId)
|
// Update Expense Detail (if open and matches this expenseId)
|
||||||
_safeControllerUpdate<ExpenseDetailController>(
|
_safeControllerUpdate<ExpenseDetailController>(
|
||||||
onFound: (controller) async {
|
onFound: (controller) async {
|
||||||
// only refresh if the open screen is for this expense
|
|
||||||
if (controller.expense.value?.id == expenseId) {
|
if (controller.expense.value?.id == expenseId) {
|
||||||
await controller.fetchExpenseDetails();
|
await controller.fetchExpenseDetails();
|
||||||
_logger
|
_logger
|
||||||
@ -166,7 +196,97 @@ class NotificationActionHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generic reusable method for safe GetX controller access + log handling
|
/// ---------------------- DOCUMENT HANDLER ----------------------
|
||||||
|
static void _handleDocumentModified(Map<String, dynamic> data) {
|
||||||
|
final entityTypeId = data['EntityTypeId'];
|
||||||
|
final entityId = data['EntityId'];
|
||||||
|
|
||||||
|
if (entityTypeId == null || entityId == null) {
|
||||||
|
_logger.w(
|
||||||
|
"⚠️ Document update received without EntityTypeId/EntityId: $data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh document list
|
||||||
|
_safeControllerUpdate<DocumentController>(
|
||||||
|
onFound: (controller) async {
|
||||||
|
await controller.fetchDocuments(
|
||||||
|
entityTypeId: entityTypeId,
|
||||||
|
entityId: entityId,
|
||||||
|
reset: true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
notFoundMessage: '⚠️ DocumentController not found, cannot refresh list.',
|
||||||
|
successMessage: '✅ DocumentController refreshed from notification.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh document details (if open and matches)
|
||||||
|
// Refresh document details (if open and matches)
|
||||||
|
final documentId = data['DocumentId'];
|
||||||
|
if (documentId != null) {
|
||||||
|
_safeControllerUpdate<DocumentDetailsController>(
|
||||||
|
onFound: (controller) async {
|
||||||
|
if (controller.documentDetails.value?.data?.id == documentId) {
|
||||||
|
await controller.fetchDocumentDetails(documentId);
|
||||||
|
_logger.i(
|
||||||
|
"✅ DocumentDetailsController refreshed for Document $documentId");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notFoundMessage: 'ℹ️ DocumentDetailsController not active, skipping.',
|
||||||
|
successMessage: '✅ DocumentDetailsController checked for refresh.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ---------------------- DIRECTORY HANDLERS ----------------------
|
||||||
|
|
||||||
|
static void _handleContactModified(Map<String, dynamic> data) {
|
||||||
|
_safeControllerUpdate<DirectoryController>(
|
||||||
|
onFound: (controller) => controller.fetchContacts(),
|
||||||
|
notFoundMessage: '⚠️ DirectoryController not found, cannot refresh.',
|
||||||
|
successMessage: '✅ Directory contacts refreshed from notification.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _handleContactNoteModified(Map<String, dynamic> data) {
|
||||||
|
final contactId = data['contactId'];
|
||||||
|
|
||||||
|
_safeControllerUpdate<DirectoryController>(
|
||||||
|
onFound: (controller) {
|
||||||
|
if (contactId != null) {
|
||||||
|
controller.fetchCommentsForContact(contactId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notFoundMessage:
|
||||||
|
'⚠️ DirectoryController not found, cannot refresh notes.',
|
||||||
|
successMessage: '✅ Directory comments refreshed from notification.',
|
||||||
|
);
|
||||||
|
|
||||||
|
_safeControllerUpdate<NotesController>(
|
||||||
|
onFound: (controller) => controller.fetchNotes(),
|
||||||
|
notFoundMessage: '⚠️ NotesController not found, cannot refresh.',
|
||||||
|
successMessage: '✅ Notes refreshed from notification.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _handleBucketModified(Map<String, dynamic> data) {
|
||||||
|
_safeControllerUpdate<DirectoryController>(
|
||||||
|
onFound: (controller) => controller.fetchBuckets(),
|
||||||
|
notFoundMessage: '⚠️ DirectoryController not found, cannot refresh.',
|
||||||
|
successMessage: '✅ Buckets refreshed from notification.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _handleBucketAssigned(Map<String, dynamic> data) {
|
||||||
|
_safeControllerUpdate<DirectoryController>(
|
||||||
|
onFound: (controller) => controller.fetchBuckets(),
|
||||||
|
notFoundMessage: '⚠️ DirectoryController not found, cannot refresh.',
|
||||||
|
successMessage: '✅ Bucket assignments refreshed from notification.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ---------------------- UTILITY ----------------------
|
||||||
|
|
||||||
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,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:marco/controller/project_controller.dart';
|
import 'package:marco/controller/project_controller.dart';
|
||||||
import 'package:marco/helpers/services/auth_service.dart';
|
import 'package:marco/helpers/services/auth_service.dart';
|
||||||
import 'package:marco/helpers/services/localizations/language.dart';
|
import 'package:marco/helpers/services/localizations/language.dart';
|
||||||
@ -19,10 +20,13 @@ class LocalStorage {
|
|||||||
static const String _employeeInfoKey = "employee_info";
|
static const String _employeeInfoKey = "employee_info";
|
||||||
static const String _mpinTokenKey = "mpinToken";
|
static const String _mpinTokenKey = "mpinToken";
|
||||||
static const String _isMpinKey = "isMpin";
|
static const String _isMpinKey = "isMpin";
|
||||||
static const String _fcmTokenKey = 'fcm_token';
|
static const String _fcmTokenKey = "fcm_token";
|
||||||
static const String _menuStorageKey = "dynamic_menus";
|
static const String _menuStorageKey = "dynamic_menus";
|
||||||
|
|
||||||
static SharedPreferences? _preferencesInstance;
|
static SharedPreferences? _preferencesInstance;
|
||||||
|
static bool _initialized = false;
|
||||||
|
|
||||||
|
static bool get isInitialized => _initialized;
|
||||||
|
|
||||||
static SharedPreferences get preferences {
|
static SharedPreferences get preferences {
|
||||||
if (_preferencesInstance == null) {
|
if (_preferencesInstance == null) {
|
||||||
@ -31,49 +35,54 @@ class LocalStorage {
|
|||||||
return _preferencesInstance!;
|
return _preferencesInstance!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialization
|
/// Initialization (idempotent)
|
||||||
static Future<void> init() async {
|
static Future<void> init() async {
|
||||||
|
if (_initialized) return;
|
||||||
_preferencesInstance = await SharedPreferences.getInstance();
|
_preferencesInstance = await SharedPreferences.getInstance();
|
||||||
await initData();
|
await _initData();
|
||||||
|
_initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> initData() async {
|
static Future<void> _initData() async {
|
||||||
AuthService.isLoggedIn = preferences.getBool(_loggedInUserKey) ?? false;
|
AuthService.isLoggedIn = preferences.getBool(_loggedInUserKey) ?? false;
|
||||||
ThemeCustomizer.fromJSON(preferences.getString(_themeCustomizerKey));
|
ThemeCustomizer.fromJSON(preferences.getString(_themeCustomizerKey));
|
||||||
}
|
}
|
||||||
/// ================== Sidebar Menu ==================
|
|
||||||
static Future<bool> setMenus(List<MenuItem> menus) async {
|
|
||||||
try {
|
|
||||||
final jsonList = menus.map((e) => e.toJson()).toList();
|
|
||||||
return preferences.setString(_menuStorageKey, jsonEncode(jsonList));
|
|
||||||
} catch (e) {
|
|
||||||
print("Error saving menus: $e");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<MenuItem> getMenus() {
|
// ================== Sidebar Menu ==================
|
||||||
final storedJson = preferences.getString(_menuStorageKey);
|
static Future<bool> setMenus(List<MenuItem> menus) async {
|
||||||
if (storedJson == null) return [];
|
try {
|
||||||
try {
|
final jsonList = menus.map((e) => e.toJson()).toList();
|
||||||
return (jsonDecode(storedJson) as List)
|
return preferences.setString(_menuStorageKey, jsonEncode(jsonList));
|
||||||
.map((e) => MenuItem.fromJson(e as Map<String, dynamic>))
|
} catch (e) {
|
||||||
.toList();
|
print("Error saving menus: $e");
|
||||||
} catch (e) {
|
return false;
|
||||||
print("Error loading menus: $e");
|
}
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
|
|
||||||
|
|
||||||
/// ================== User Permissions ==================
|
static List<MenuItem> getMenus() {
|
||||||
static Future<bool> setUserPermissions(
|
if (!_initialized) return [];
|
||||||
List<UserPermission> permissions) async {
|
final storedJson = preferences.getString(_menuStorageKey);
|
||||||
|
if (storedJson == null) return [];
|
||||||
|
try {
|
||||||
|
return (jsonDecode(storedJson) as List)
|
||||||
|
.map((e) => MenuItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
print("Error loading menus: $e");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
|
||||||
|
|
||||||
|
// ================== User Permissions ==================
|
||||||
|
static Future<bool> setUserPermissions(List<UserPermission> permissions) async {
|
||||||
final jsonList = permissions.map((e) => e.toJson()).toList();
|
final jsonList = permissions.map((e) => e.toJson()).toList();
|
||||||
return preferences.setString(_userPermissionsKey, jsonEncode(jsonList));
|
return preferences.setString(_userPermissionsKey, jsonEncode(jsonList));
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<UserPermission> getUserPermissions() {
|
static List<UserPermission> getUserPermissions() {
|
||||||
|
if (!_initialized) return [];
|
||||||
final storedJson = preferences.getString(_userPermissionsKey);
|
final storedJson = preferences.getString(_userPermissionsKey);
|
||||||
if (storedJson == null) return [];
|
if (storedJson == null) return [];
|
||||||
return (jsonDecode(storedJson) as List)
|
return (jsonDecode(storedJson) as List)
|
||||||
@ -84,11 +93,12 @@ static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
|
|||||||
static Future<bool> removeUserPermissions() =>
|
static Future<bool> removeUserPermissions() =>
|
||||||
preferences.remove(_userPermissionsKey);
|
preferences.remove(_userPermissionsKey);
|
||||||
|
|
||||||
/// ================== Employee Info ==================
|
// ================== Employee Info ==================
|
||||||
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) =>
|
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) =>
|
||||||
preferences.setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson()));
|
preferences.setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson()));
|
||||||
|
|
||||||
static EmployeeInfo? getEmployeeInfo() {
|
static EmployeeInfo? getEmployeeInfo() {
|
||||||
|
if (!_initialized) return null;
|
||||||
final storedJson = preferences.getString(_employeeInfoKey);
|
final storedJson = preferences.getString(_employeeInfoKey);
|
||||||
return storedJson == null
|
return storedJson == null
|
||||||
? null
|
? null
|
||||||
@ -98,7 +108,7 @@ static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
|
|||||||
static Future<bool> removeEmployeeInfo() =>
|
static Future<bool> removeEmployeeInfo() =>
|
||||||
preferences.remove(_employeeInfoKey);
|
preferences.remove(_employeeInfoKey);
|
||||||
|
|
||||||
/// ================== Login / Logout ==================
|
// ================== Login / Logout ==================
|
||||||
static Future<bool> setLoggedInUser(bool loggedIn) =>
|
static Future<bool> setLoggedInUser(bool loggedIn) =>
|
||||||
preferences.setBool(_loggedInUserKey, loggedIn);
|
preferences.setBool(_loggedInUserKey, loggedIn);
|
||||||
|
|
||||||
@ -110,7 +120,6 @@ static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
|
|||||||
final refreshToken = getRefreshToken();
|
final refreshToken = getRefreshToken();
|
||||||
final fcmToken = getFcmToken();
|
final fcmToken = getFcmToken();
|
||||||
|
|
||||||
// Call API only if both tokens exist
|
|
||||||
if (refreshToken != null && fcmToken != null) {
|
if (refreshToken != null && fcmToken != null) {
|
||||||
await AuthService.logoutApi(refreshToken, fcmToken);
|
await AuthService.logoutApi(refreshToken, fcmToken);
|
||||||
}
|
}
|
||||||
@ -118,7 +127,6 @@ static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
|
|||||||
print("Logout API error: $e");
|
print("Logout API error: $e");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ===== Local Cleanup =====
|
|
||||||
await removeLoggedInUser();
|
await removeLoggedInUser();
|
||||||
await removeToken(_jwtTokenKey);
|
await removeToken(_jwtTokenKey);
|
||||||
await removeToken(_refreshTokenKey);
|
await removeToken(_refreshTokenKey);
|
||||||
@ -126,7 +134,7 @@ static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
|
|||||||
await removeEmployeeInfo();
|
await removeEmployeeInfo();
|
||||||
await removeMpinToken();
|
await removeMpinToken();
|
||||||
await removeIsMpin();
|
await removeIsMpin();
|
||||||
await removeMenus(); // clear menus on logout
|
await removeMenus();
|
||||||
await preferences.remove("mpin_verified");
|
await preferences.remove("mpin_verified");
|
||||||
await preferences.remove(_languageKey);
|
await preferences.remove(_languageKey);
|
||||||
await preferences.remove(_themeCustomizerKey);
|
await preferences.remove(_themeCustomizerKey);
|
||||||
@ -139,20 +147,22 @@ static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
|
|||||||
Get.offAllNamed('/auth/login-option');
|
Get.offAllNamed('/auth/login-option');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ================== Theme & Language ==================
|
// ================== Theme & Language ==================
|
||||||
static Future<bool> setCustomizer(ThemeCustomizer themeCustomizer) =>
|
static Future<bool> setCustomizer(ThemeCustomizer themeCustomizer) =>
|
||||||
preferences.setString(_themeCustomizerKey, themeCustomizer.toJSON());
|
preferences.setString(_themeCustomizerKey, themeCustomizer.toJSON());
|
||||||
|
|
||||||
static Future<bool> setLanguage(Language language) =>
|
static Future<bool> setLanguage(Language language) =>
|
||||||
preferences.setString(_languageKey, language.locale.languageCode);
|
preferences.setString(_languageKey, language.locale.languageCode);
|
||||||
|
|
||||||
static String? getLanguage() => preferences.getString(_languageKey);
|
static String? getLanguage() =>
|
||||||
|
_initialized ? preferences.getString(_languageKey) : null;
|
||||||
|
|
||||||
/// ================== Tokens ==================
|
// ================== Tokens ==================
|
||||||
static Future<bool> setToken(String key, String token) =>
|
static Future<bool> setToken(String key, String token) =>
|
||||||
preferences.setString(key, token);
|
preferences.setString(key, token);
|
||||||
|
|
||||||
static String? getToken(String key) => preferences.getString(key);
|
static String? getToken(String key) =>
|
||||||
|
_initialized ? preferences.getString(key) : null;
|
||||||
|
|
||||||
static Future<bool> removeToken(String key) => preferences.remove(key);
|
static Future<bool> removeToken(String key) => preferences.remove(key);
|
||||||
|
|
||||||
@ -166,34 +176,39 @@ static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
|
|||||||
|
|
||||||
static String? getRefreshToken() => getToken(_refreshTokenKey);
|
static String? getRefreshToken() => getToken(_refreshTokenKey);
|
||||||
|
|
||||||
/// ================== FCM Token ==================
|
// ================== FCM Token ==================
|
||||||
static Future<void> setFcmToken(String token) =>
|
static Future<void> setFcmToken(String token) =>
|
||||||
preferences.setString(_fcmTokenKey, token);
|
preferences.setString(_fcmTokenKey, token);
|
||||||
|
|
||||||
static String? getFcmToken() => preferences.getString(_fcmTokenKey);
|
static String? getFcmToken() =>
|
||||||
|
_initialized ? preferences.getString(_fcmTokenKey) : null;
|
||||||
|
|
||||||
/// ================== MPIN ==================
|
// ================== MPIN ==================
|
||||||
static Future<bool> setMpinToken(String token) =>
|
static Future<bool> setMpinToken(String token) =>
|
||||||
preferences.setString(_mpinTokenKey, token);
|
preferences.setString(_mpinTokenKey, token);
|
||||||
|
|
||||||
static String? getMpinToken() => preferences.getString(_mpinTokenKey);
|
static String? getMpinToken() =>
|
||||||
|
_initialized ? preferences.getString(_mpinTokenKey) : null;
|
||||||
|
|
||||||
static Future<bool> removeMpinToken() => preferences.remove(_mpinTokenKey);
|
static Future<bool> removeMpinToken() => preferences.remove(_mpinTokenKey);
|
||||||
|
|
||||||
static Future<bool> setIsMpin(bool value) =>
|
static Future<bool> setIsMpin(bool value) =>
|
||||||
preferences.setBool(_isMpinKey, value);
|
preferences.setBool(_isMpinKey, value);
|
||||||
|
|
||||||
static bool getIsMpin() => preferences.getBool(_isMpinKey) ?? false;
|
static bool getIsMpin() =>
|
||||||
|
_initialized ? preferences.getBool(_isMpinKey) ?? false : false;
|
||||||
|
|
||||||
static Future<bool> removeIsMpin() => preferences.remove(_isMpinKey);
|
static Future<bool> removeIsMpin() => preferences.remove(_isMpinKey);
|
||||||
|
|
||||||
/// ================== Generic Set/Get ==================
|
// ================== Generic Set/Get ==================
|
||||||
static Future<bool> setBool(String key, bool value) =>
|
static Future<bool> setBool(String key, bool value) =>
|
||||||
preferences.setBool(key, value);
|
preferences.setBool(key, value);
|
||||||
|
|
||||||
static bool? getBool(String key) => preferences.getBool(key);
|
static bool? getBool(String key) =>
|
||||||
|
_initialized ? preferences.getBool(key) : null;
|
||||||
|
|
||||||
static String? getString(String key) => preferences.getString(key);
|
static String? getString(String key) =>
|
||||||
|
_initialized ? preferences.getString(key) : null;
|
||||||
|
|
||||||
static Future<bool> saveString(String key, String value) =>
|
static Future<bool> saveString(String key, String value) =>
|
||||||
preferences.setString(key, value);
|
preferences.setString(key, value);
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
|
||||||
|
|
||||||
class DateTimeUtils {
|
class DateTimeUtils {
|
||||||
/// Converts a UTC datetime string to local time and formats it.
|
/// Converts a UTC datetime string to local time and formats it.
|
||||||
static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) {
|
static String convertUtcToLocal(String utcTimeString,
|
||||||
|
{String format = 'dd-MM-yyyy'}) {
|
||||||
try {
|
try {
|
||||||
logSafe('Received UTC string: $utcTimeString'); // 🔹 Log input
|
|
||||||
|
|
||||||
final parsed = DateTime.parse(utcTimeString);
|
final parsed = DateTime.parse(utcTimeString);
|
||||||
final utcDateTime = DateTime.utc(
|
final utcDateTime = DateTime.utc(
|
||||||
parsed.year,
|
parsed.year,
|
||||||
@ -20,16 +18,8 @@ class DateTimeUtils {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final localDateTime = utcDateTime.toLocal();
|
final localDateTime = utcDateTime.toLocal();
|
||||||
|
return _formatDateTime(localDateTime, format: format);
|
||||||
final formatted = _formatDateTime(localDateTime, format: format);
|
} catch (e) {
|
||||||
|
|
||||||
logSafe('Converted Local DateTime: $localDateTime'); // 🔹 Log raw local datetime
|
|
||||||
logSafe('Formatted Local DateTime: $formatted'); // 🔹 Log formatted string
|
|
||||||
|
|
||||||
return formatted;
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
logSafe('DateTime conversion failed: $e',
|
|
||||||
error: e, stackTrace: stackTrace);
|
|
||||||
return 'Invalid Date';
|
return 'Invalid Date';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -37,17 +27,24 @@ class DateTimeUtils {
|
|||||||
/// Public utility for formatting any DateTime.
|
/// Public utility for formatting any DateTime.
|
||||||
static String formatDate(DateTime date, String format) {
|
static String formatDate(DateTime date, String format) {
|
||||||
try {
|
try {
|
||||||
final formatted = DateFormat(format).format(date);
|
return DateFormat(format).format(date);
|
||||||
logSafe('Formatted DateTime ($date) => $formatted'); // 🔹 Log input/output
|
} catch (e) {
|
||||||
return formatted;
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace);
|
|
||||||
return 'Invalid Date';
|
return 'Invalid Date';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses a date string using the given format.
|
||||||
|
static DateTime? parseDate(String dateString, String format) {
|
||||||
|
try {
|
||||||
|
return DateFormat(format).parse(dateString);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Internal formatter with default format.
|
/// Internal formatter with default format.
|
||||||
static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) {
|
static String _formatDateTime(DateTime dateTime,
|
||||||
|
{String format = 'dd-MM-yyyy'}) {
|
||||||
return DateFormat(format).format(dateTime);
|
return DateFormat(format).format(dateTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
420
lib/helpers/widgets/expense/expense_form_widgets.dart
Normal file
420
lib/helpers/widgets/expense/expense_form_widgets.dart
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
// expense_form_widgets.dart
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
|
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/controller/expense/add_expense_controller.dart';
|
||||||
|
|
||||||
|
/// 🔹 Common Colors & Styles
|
||||||
|
final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]);
|
||||||
|
final _tileDecoration = BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// ==========================
|
||||||
|
/// Section Title
|
||||||
|
/// ==========================
|
||||||
|
class SectionTitle extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final bool requiredField;
|
||||||
|
|
||||||
|
const SectionTitle({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.requiredField = false,
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = Colors.grey[700];
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(text: title),
|
||||||
|
if (requiredField)
|
||||||
|
const TextSpan(
|
||||||
|
text: ' *',
|
||||||
|
style: TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ==========================
|
||||||
|
/// Custom Text Field
|
||||||
|
/// ==========================
|
||||||
|
class CustomTextField extends StatelessWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String hint;
|
||||||
|
final int maxLines;
|
||||||
|
final TextInputType keyboardType;
|
||||||
|
final String? Function(String?)? validator;
|
||||||
|
|
||||||
|
const CustomTextField({
|
||||||
|
required this.controller,
|
||||||
|
required this.hint,
|
||||||
|
this.maxLines = 1,
|
||||||
|
this.keyboardType = TextInputType.text,
|
||||||
|
this.validator,
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
maxLines: maxLines,
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
validator: validator,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: _hintStyle,
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ==========================
|
||||||
|
/// Dropdown Tile
|
||||||
|
/// ==========================
|
||||||
|
class DropdownTile extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const DropdownTile({
|
||||||
|
required this.title,
|
||||||
|
required this.onTap,
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
|
decoration: _tileDecoration,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(title,
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
const Icon(Icons.arrow_drop_down),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ==========================
|
||||||
|
/// Tile Container
|
||||||
|
/// ==========================
|
||||||
|
class TileContainer extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const TileContainer({required this.child, Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) =>
|
||||||
|
Container(padding: const EdgeInsets.all(14), decoration: _tileDecoration, child: child);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ==========================
|
||||||
|
/// Attachments Section
|
||||||
|
/// ==========================
|
||||||
|
class AttachmentsSection extends StatelessWidget {
|
||||||
|
final RxList<File> attachments;
|
||||||
|
final RxList<Map<String, dynamic>> existingAttachments;
|
||||||
|
final ValueChanged<File> onRemoveNew;
|
||||||
|
final ValueChanged<Map<String, dynamic>>? onRemoveExisting;
|
||||||
|
final VoidCallback onAdd;
|
||||||
|
|
||||||
|
const AttachmentsSection({
|
||||||
|
required this.attachments,
|
||||||
|
required this.existingAttachments,
|
||||||
|
required this.onRemoveNew,
|
||||||
|
this.onRemoveExisting,
|
||||||
|
required this.onAdd,
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
static const allowedImageExtensions = ['jpg', 'jpeg', 'png'];
|
||||||
|
|
||||||
|
bool _isImageFile(File file) {
|
||||||
|
final ext = file.path.split('.').last.toLowerCase();
|
||||||
|
return allowedImageExtensions.contains(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
final activeExisting = existingAttachments
|
||||||
|
.where((doc) => doc['isActive'] != false)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final imageFiles = attachments.where(_isImageFile).toList();
|
||||||
|
final imageExisting = activeExisting
|
||||||
|
.where((d) =>
|
||||||
|
(d['contentType']?.toString().startsWith('image/') ?? false))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (activeExisting.isNotEmpty) ...[
|
||||||
|
const Text("Existing Attachments",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: activeExisting.map((doc) {
|
||||||
|
final isImage =
|
||||||
|
doc['contentType']?.toString().startsWith('image/') ??
|
||||||
|
false;
|
||||||
|
final url = doc['url'];
|
||||||
|
final fileName = doc['fileName'] ?? 'Unnamed';
|
||||||
|
|
||||||
|
return _buildExistingTile(
|
||||||
|
context,
|
||||||
|
doc,
|
||||||
|
isImage,
|
||||||
|
url,
|
||||||
|
fileName,
|
||||||
|
imageExisting,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
...attachments.map((file) => GestureDetector(
|
||||||
|
onTap: () => _onNewTap(context, file, imageFiles),
|
||||||
|
child: _AttachmentTile(
|
||||||
|
file: file,
|
||||||
|
onRemove: () => onRemoveNew(file),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
_buildActionTile(Icons.attach_file, onAdd),
|
||||||
|
_buildActionTile(Icons.camera_alt,
|
||||||
|
() => Get.find<AddExpenseController>().pickFromCamera()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// helper for new file tap
|
||||||
|
void _onNewTap(BuildContext context, File file, List<File> imageFiles) {
|
||||||
|
if (_isImageFile(file)) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => ImageViewerDialog(
|
||||||
|
imageSources: imageFiles,
|
||||||
|
initialIndex: imageFiles.indexOf(file),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Info',
|
||||||
|
message: 'Preview for this file type is not supported.',
|
||||||
|
type: SnackbarType.info,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// helper for existing file tile
|
||||||
|
Widget _buildExistingTile(
|
||||||
|
BuildContext context,
|
||||||
|
Map<String, dynamic> doc,
|
||||||
|
bool isImage,
|
||||||
|
String? url,
|
||||||
|
String fileName,
|
||||||
|
List<Map<String, dynamic>> imageExisting,
|
||||||
|
) {
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
if (isImage) {
|
||||||
|
final sources = imageExisting.map((e) => e['url']).toList();
|
||||||
|
final idx = imageExisting.indexOf(doc);
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) =>
|
||||||
|
ImageViewerDialog(imageSources: sources, initialIndex: idx),
|
||||||
|
);
|
||||||
|
} else if (url != null && await canLaunchUrlString(url)) {
|
||||||
|
await launchUrlString(url, mode: LaunchMode.externalApplication);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Could not open the document.',
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: _tileDecoration.copyWith(
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(isImage ? Icons.image : Icons.insert_drive_file,
|
||||||
|
size: 20, color: Colors.grey[600]),
|
||||||
|
const SizedBox(width: 7),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 120),
|
||||||
|
child: Text(fileName,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onRemoveExisting != null)
|
||||||
|
Positioned(
|
||||||
|
top: -6,
|
||||||
|
right: -6,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||||
|
onPressed: () => onRemoveExisting?.call(doc),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionTile(IconData icon, VoidCallback onTap) => GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: _tileDecoration.copyWith(
|
||||||
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 30, color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ==========================
|
||||||
|
/// Attachment Tile
|
||||||
|
/// ==========================
|
||||||
|
class _AttachmentTile extends StatelessWidget {
|
||||||
|
final File file;
|
||||||
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
|
const _AttachmentTile({required this.file, required this.onRemove});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fileName = file.path.split('/').last;
|
||||||
|
final extension = fileName.split('.').last.toLowerCase();
|
||||||
|
final isImage = AttachmentsSection.allowedImageExtensions.contains(extension);
|
||||||
|
|
||||||
|
final (icon, color) = _fileIcon(extension);
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: _tileDecoration,
|
||||||
|
child: isImage
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.file(file, fit: BoxFit.cover),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 30),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(extension.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: -6,
|
||||||
|
right: -6,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||||
|
onPressed: onRemove,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// map extensions to icons/colors
|
||||||
|
static (IconData, Color) _fileIcon(String ext) {
|
||||||
|
switch (ext) {
|
||||||
|
case 'pdf':
|
||||||
|
return (Icons.picture_as_pdf, Colors.redAccent);
|
||||||
|
case 'doc':
|
||||||
|
case 'docx':
|
||||||
|
return (Icons.description, Colors.blueAccent);
|
||||||
|
case 'xls':
|
||||||
|
case 'xlsx':
|
||||||
|
return (Icons.table_chart, Colors.green);
|
||||||
|
case 'txt':
|
||||||
|
return (Icons.article, Colors.grey);
|
||||||
|
default:
|
||||||
|
return (Icons.insert_drive_file, Colors.blueGrey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -7,13 +7,22 @@ import 'package:marco/view/my_app.dart';
|
|||||||
import 'package:marco/helpers/theme/app_notifier.dart';
|
import 'package:marco/helpers/theme/app_notifier.dart';
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
import 'package:marco/view/layouts/offline_screen.dart';
|
import 'package:marco/view/layouts/offline_screen.dart';
|
||||||
|
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize logging system
|
||||||
await initLogging();
|
await initLogging();
|
||||||
logSafe("App starting...");
|
logSafe("App starting...");
|
||||||
|
|
||||||
|
// ✅ Ensure local storage is ready before enabling remote logging
|
||||||
|
await LocalStorage.init();
|
||||||
|
logSafe("💡 Local storage initialized (early init for logging).");
|
||||||
|
|
||||||
|
// Now safe to enable remote logging
|
||||||
enableRemoteLogging();
|
enableRemoteLogging();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await initializeApp();
|
await initializeApp();
|
||||||
logSafe("App initialized successfully.");
|
logSafe("App initialized successfully.");
|
||||||
|
@ -295,12 +295,15 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
if (value == null || value.trim().isEmpty) {
|
if (value == null || value.trim().isEmpty) {
|
||||||
return "Phone Number is required";
|
return "Phone Number is required";
|
||||||
}
|
}
|
||||||
|
if (value.trim().length != 10) {
|
||||||
|
return "Phone Number must be exactly 10 digits";
|
||||||
|
}
|
||||||
if (!RegExp(r'^\d{10}$').hasMatch(value.trim())) {
|
if (!RegExp(r'^\d{10}$').hasMatch(value.trim())) {
|
||||||
return "Enter a valid 10-digit number";
|
return "Enter a valid 10-digit number";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
LengthLimitingTextInputFormatter(10),
|
LengthLimitingTextInputFormatter(10),
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -10,38 +10,150 @@ import 'package:marco/helpers/widgets/my_text.dart';
|
|||||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
import 'package:marco/model/attendance/log_details_view.dart';
|
import 'package:marco/model/attendance/log_details_view.dart';
|
||||||
import 'package:marco/model/attendance/attendence_action_button.dart';
|
import 'package:marco/model/attendance/attendence_action_button.dart';
|
||||||
|
import 'package:marco/helpers/utils/attendance_actions.dart';
|
||||||
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
|
||||||
class AttendanceLogsTab extends StatelessWidget {
|
class AttendanceLogsTab extends StatefulWidget {
|
||||||
final AttendanceController controller;
|
final AttendanceController controller;
|
||||||
|
|
||||||
const AttendanceLogsTab({super.key, required this.controller});
|
const AttendanceLogsTab({super.key, required this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AttendanceLogsTab> createState() => _AttendanceLogsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
|
||||||
|
Widget _buildStatusHeader() {
|
||||||
|
return Obx(() {
|
||||||
|
if (!widget.controller.showPendingOnly.value) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
color: Colors.orange.shade50,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.pending_actions, color: Colors.orange, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
"Showing Pending Actions Only",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.orange,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
InkWell(
|
||||||
|
onTap: () => widget.controller.showPendingOnly.value = false,
|
||||||
|
child: const Icon(Icons.close, size: 18, color: Colors.orange),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return button text priority for sorting inside same date
|
||||||
|
int _getActionPriority(employee) {
|
||||||
|
final text = AttendanceButtonHelper.getButtonText(
|
||||||
|
activity: employee.activity,
|
||||||
|
checkIn: employee.checkIn,
|
||||||
|
checkOut: employee.checkOut,
|
||||||
|
isTodayApproved: AttendanceButtonHelper.isTodayApproved(
|
||||||
|
employee.activity,
|
||||||
|
employee.checkIn,
|
||||||
|
),
|
||||||
|
).toLowerCase();
|
||||||
|
|
||||||
|
final isYesterdayCheckIn = employee.checkIn != null &&
|
||||||
|
DateUtils.isSameDay(
|
||||||
|
employee.checkIn,
|
||||||
|
DateTime.now().subtract(const Duration(days: 1)),
|
||||||
|
);
|
||||||
|
final isMissingCheckout = employee.checkOut == null;
|
||||||
|
|
||||||
|
final isCheckoutAction =
|
||||||
|
text.contains("checkout") || text.contains("check out");
|
||||||
|
|
||||||
|
int priority;
|
||||||
|
if (isYesterdayCheckIn && isMissingCheckout && isCheckoutAction) {
|
||||||
|
priority = 0;
|
||||||
|
} else if (isCheckoutAction) {
|
||||||
|
priority = 0;
|
||||||
|
} else if (text.contains("regular")) {
|
||||||
|
priority = 1;
|
||||||
|
} else if (text == "requested") {
|
||||||
|
priority = 2;
|
||||||
|
} else if (text == "approved") {
|
||||||
|
priority = 3;
|
||||||
|
} else if (text == "rejected") {
|
||||||
|
priority = 4;
|
||||||
|
} else {
|
||||||
|
priority = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Use AppLogger instead of print
|
||||||
|
logSafe(
|
||||||
|
"[AttendanceLogs] Priority calculated "
|
||||||
|
"name=${employee.name}, activity=${employee.activity}, "
|
||||||
|
"checkIn=${employee.checkIn}, checkOut=${employee.checkOut}, "
|
||||||
|
"buttonText=$text, priority=$priority",
|
||||||
|
level: LogLevel.debug,
|
||||||
|
);
|
||||||
|
|
||||||
|
return priority;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
final logs = List.of(controller.attendanceLogs);
|
final allLogs = List.of(widget.controller.filteredLogs);
|
||||||
logs.sort((a, b) {
|
|
||||||
final aDate = a.checkIn ?? DateTime(0);
|
|
||||||
final bDate = b.checkIn ?? DateTime(0);
|
|
||||||
return bDate.compareTo(aDate);
|
|
||||||
});
|
|
||||||
|
|
||||||
final dateRangeText = controller.startDateAttendance != null &&
|
// Filter logs if "pending only"
|
||||||
controller.endDateAttendance != null
|
final showPendingOnly = widget.controller.showPendingOnly.value;
|
||||||
? '${DateTimeUtils.formatDate(controller.startDateAttendance!, 'dd MMM yyyy')} - '
|
final filteredLogs = showPendingOnly
|
||||||
'${DateTimeUtils.formatDate(controller.endDateAttendance!, 'dd MMM yyyy')}'
|
? allLogs
|
||||||
|
.where((emp) => emp.activity == 1 || emp.activity == 2)
|
||||||
|
.toList()
|
||||||
|
: allLogs;
|
||||||
|
|
||||||
|
// Group logs by date string
|
||||||
|
final groupedLogs = <String, List<dynamic>>{};
|
||||||
|
for (var log in filteredLogs) {
|
||||||
|
final dateKey = log.checkIn != null
|
||||||
|
? DateTimeUtils.formatDate(log.checkIn!, 'dd MMM yyyy')
|
||||||
|
: 'Unknown';
|
||||||
|
groupedLogs.putIfAbsent(dateKey, () => []).add(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort dates (latest first)
|
||||||
|
final sortedDates = groupedLogs.keys.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final da = DateTimeUtils.parseDate(a, 'dd MMM yyyy') ?? DateTime(0);
|
||||||
|
final db = DateTimeUtils.parseDate(b, 'dd MMM yyyy') ?? DateTime(0);
|
||||||
|
return db.compareTo(da);
|
||||||
|
});
|
||||||
|
|
||||||
|
final dateRangeText = widget.controller.startDateAttendance != null &&
|
||||||
|
widget.controller.endDateAttendance != null
|
||||||
|
? '${DateTimeUtils.formatDate(widget.controller.startDateAttendance!, 'dd MMM yyyy')} - '
|
||||||
|
'${DateTimeUtils.formatDate(widget.controller.endDateAttendance!, 'dd MMM yyyy')}'
|
||||||
: 'Select date range';
|
: 'Select date range';
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Header row
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
MyText.titleMedium("Attendance Logs", fontWeight: 600),
|
MyText.titleMedium("Attendance Logs", fontWeight: 600),
|
||||||
controller.isLoading.value
|
widget.controller.isLoading.value
|
||||||
? SkeletonLoaders.dateSkeletonLoader()
|
? SkeletonLoaders.dateSkeletonLoader()
|
||||||
: MyText.bodySmall(
|
: MyText.bodySmall(
|
||||||
dateRangeText,
|
dateRangeText,
|
||||||
@ -52,52 +164,49 @@ class AttendanceLogsTab extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (controller.isLoadingAttendanceLogs.value)
|
|
||||||
|
// Pending-only header
|
||||||
|
_buildStatusHeader(),
|
||||||
|
MySpacing.height(8),
|
||||||
|
|
||||||
|
// Content: loader, empty, or logs
|
||||||
|
if (widget.controller.isLoadingAttendanceLogs.value)
|
||||||
SkeletonLoaders.employeeListSkeletonLoader()
|
SkeletonLoaders.employeeListSkeletonLoader()
|
||||||
else if (logs.isEmpty)
|
else if (filteredLogs.isEmpty)
|
||||||
const SizedBox(
|
SizedBox(
|
||||||
height: 120,
|
height: 120,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text("No Attendance Logs Found for this Project"),
|
child: Text(showPendingOnly
|
||||||
|
? "No Pending Actions Found"
|
||||||
|
: "No Attendance Logs Found for this Project"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
MyCard.bordered(
|
MyCard.bordered(
|
||||||
paddingAll: 8,
|
paddingAll: 8,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: List.generate(logs.length, (index) {
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
final employee = logs[index];
|
children: [
|
||||||
final currentDate = employee.checkIn != null
|
for (final date in sortedDates) ...[
|
||||||
? DateTimeUtils.formatDate(
|
Padding(
|
||||||
employee.checkIn!, 'dd MMM yyyy')
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
: '';
|
child: MyText.bodyMedium(date, fontWeight: 700),
|
||||||
final previousDate =
|
),
|
||||||
index > 0 && logs[index - 1].checkIn != null
|
|
||||||
? DateTimeUtils.formatDate(
|
|
||||||
logs[index - 1].checkIn!, 'dd MMM yyyy')
|
|
||||||
: '';
|
|
||||||
final showDateHeader =
|
|
||||||
index == 0 || currentDate != previousDate;
|
|
||||||
|
|
||||||
return Column(
|
// Sort employees inside this date by action priority
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
for (final emp in (groupedLogs[date]!
|
||||||
children: [
|
..sort(
|
||||||
if (showDateHeader)
|
(a, b) => _getActionPriority(a)
|
||||||
Padding(
|
.compareTo(_getActionPriority(b)),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
))) ...[
|
||||||
child: MyText.bodyMedium(
|
|
||||||
currentDate,
|
|
||||||
fontWeight: 700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MyContainer(
|
MyContainer(
|
||||||
paddingAll: 8,
|
paddingAll: 8,
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Avatar(
|
Avatar(
|
||||||
firstName: employee.firstName,
|
firstName: emp.firstName,
|
||||||
lastName: employee.lastName,
|
lastName: emp.lastName,
|
||||||
size: 31,
|
size: 31,
|
||||||
),
|
),
|
||||||
MySpacing.width(16),
|
MySpacing.width(16),
|
||||||
@ -109,7 +218,7 @@ class AttendanceLogsTab extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
child: MyText.bodyMedium(
|
child: MyText.bodyMedium(
|
||||||
employee.name,
|
emp.name,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@ -117,7 +226,7 @@ class AttendanceLogsTab extends StatelessWidget {
|
|||||||
MySpacing.width(6),
|
MySpacing.width(6),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: MyText.bodySmall(
|
child: MyText.bodySmall(
|
||||||
'(${employee.designation})',
|
'(${emp.designation})',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: Colors.grey[700],
|
color: Colors.grey[700],
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@ -126,28 +235,28 @@ class AttendanceLogsTab extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
if (employee.checkIn != null ||
|
if (emp.checkIn != null ||
|
||||||
employee.checkOut != null)
|
emp.checkOut != null)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (employee.checkIn != null) ...[
|
if (emp.checkIn != null) ...[
|
||||||
const Icon(Icons.arrow_circle_right,
|
const Icon(Icons.arrow_circle_right,
|
||||||
size: 16, color: Colors.green),
|
size: 16, color: Colors.green),
|
||||||
MySpacing.width(4),
|
MySpacing.width(4),
|
||||||
MyText.bodySmall(
|
MyText.bodySmall(
|
||||||
DateTimeUtils.formatDate(
|
DateTimeUtils.formatDate(
|
||||||
employee.checkIn!, 'hh:mm a'),
|
emp.checkIn!, 'hh:mm a'),
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
),
|
),
|
||||||
MySpacing.width(16),
|
MySpacing.width(16),
|
||||||
],
|
],
|
||||||
if (employee.checkOut != null) ...[
|
if (emp.checkOut != null) ...[
|
||||||
const Icon(Icons.arrow_circle_left,
|
const Icon(Icons.arrow_circle_left,
|
||||||
size: 16, color: Colors.red),
|
size: 16, color: Colors.red),
|
||||||
MySpacing.width(4),
|
MySpacing.width(4),
|
||||||
MyText.bodySmall(
|
MyText.bodySmall(
|
||||||
DateTimeUtils.formatDate(
|
DateTimeUtils.formatDate(
|
||||||
employee.checkOut!, 'hh:mm a'),
|
emp.checkOut!, 'hh:mm a'),
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -158,13 +267,13 @@ class AttendanceLogsTab extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
AttendanceActionButton(
|
AttendanceActionButton(
|
||||||
employee: employee,
|
employee: emp,
|
||||||
attendanceController: controller,
|
attendanceController: widget.controller,
|
||||||
),
|
),
|
||||||
MySpacing.width(8),
|
MySpacing.width(8),
|
||||||
AttendanceLogViewButton(
|
AttendanceLogViewButton(
|
||||||
employee: employee,
|
employee: emp,
|
||||||
attendanceController: controller,
|
attendanceController: widget.controller,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -174,11 +283,10 @@ class AttendanceLogsTab extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (index != logs.length - 1)
|
Divider(color: Colors.grey.withOpacity(0.3)),
|
||||||
Divider(color: Colors.grey.withOpacity(0.3)),
|
|
||||||
],
|
],
|
||||||
);
|
],
|
||||||
}),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -14,6 +14,7 @@ import 'package:marco/view/Attendence/regularization_requests_tab.dart';
|
|||||||
import 'package:marco/view/Attendence/attendance_logs_tab.dart';
|
import 'package:marco/view/Attendence/attendance_logs_tab.dart';
|
||||||
import 'package:marco/view/Attendence/todays_attendance_tab.dart';
|
import 'package:marco/view/Attendence/todays_attendance_tab.dart';
|
||||||
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
||||||
|
|
||||||
class AttendanceScreen extends StatefulWidget {
|
class AttendanceScreen extends StatefulWidget {
|
||||||
const AttendanceScreen({super.key});
|
const AttendanceScreen({super.key});
|
||||||
|
|
||||||
@ -113,63 +114,188 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilterAndRefreshRow() {
|
Widget _buildFilterSearchRow() {
|
||||||
return Row(
|
return Padding(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
padding: MySpacing.xy(8, 8),
|
||||||
children: [
|
child: Row(
|
||||||
MyText.bodyMedium("Filter", fontWeight: 600),
|
children: [
|
||||||
Tooltip(
|
Expanded(
|
||||||
message: 'Filter Project',
|
child: SizedBox(
|
||||||
child: InkWell(
|
height: 35,
|
||||||
borderRadius: BorderRadius.circular(24),
|
child: Obx(() {
|
||||||
onTap: () async {
|
final query = attendanceController.searchQuery.value;
|
||||||
final result = await showModalBottomSheet<Map<String, dynamic>>(
|
return TextField(
|
||||||
context: context,
|
controller: TextEditingController(text: query)
|
||||||
isScrollControlled: true,
|
..selection = TextSelection.collapsed(offset: query.length),
|
||||||
backgroundColor: Colors.transparent,
|
onChanged: (value) {
|
||||||
shape: const RoundedRectangleBorder(
|
attendanceController.searchQuery.value = value;
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
},
|
||||||
),
|
decoration: InputDecoration(
|
||||||
builder: (context) => AttendanceFilterBottomSheet(
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
controller: attendanceController,
|
prefixIcon:
|
||||||
permissionController: permissionController,
|
const Icon(Icons.search, size: 20, color: Colors.grey),
|
||||||
selectedTab: selectedTab,
|
suffixIcon: query.isNotEmpty
|
||||||
),
|
? IconButton(
|
||||||
);
|
icon: const Icon(Icons.close,
|
||||||
|
size: 18, color: Colors.grey),
|
||||||
if (result != null) {
|
onPressed: () {
|
||||||
final selectedProjectId =
|
attendanceController.searchQuery.value = '';
|
||||||
projectController.selectedProjectId.value;
|
},
|
||||||
final selectedView = result['selectedTab'] as String?;
|
)
|
||||||
|
: null,
|
||||||
if (selectedProjectId.isNotEmpty) {
|
hintText: 'Search by name',
|
||||||
try {
|
filled: true,
|
||||||
await attendanceController
|
fillColor: Colors.white,
|
||||||
.fetchEmployeesByProject(selectedProjectId);
|
border: OutlineInputBorder(
|
||||||
await attendanceController
|
borderRadius: BorderRadius.circular(10),
|
||||||
.fetchAttendanceLogs(selectedProjectId);
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
await attendanceController
|
),
|
||||||
.fetchRegularizationLogs(selectedProjectId);
|
enabledBorder: OutlineInputBorder(
|
||||||
await attendanceController
|
borderRadius: BorderRadius.circular(10),
|
||||||
.fetchProjectData(selectedProjectId);
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
} catch (_) {}
|
),
|
||||||
|
),
|
||||||
attendanceController
|
);
|
||||||
.update(['attendance_dashboard_controller']);
|
}),
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedView != null && selectedView != selectedTab) {
|
|
||||||
setState(() => selectedTab = selectedView);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: Icon(Icons.tune, size: 18),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
MySpacing.width(8),
|
||||||
|
|
||||||
|
// 🛠️ Filter Icon (no red dot here anymore)
|
||||||
|
Container(
|
||||||
|
height: 35,
|
||||||
|
width: 35,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: BoxConstraints(),
|
||||||
|
icon: const Icon(Icons.tune, size: 20, color: Colors.black87),
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await showModalBottomSheet<Map<String, dynamic>>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.vertical(top: Radius.circular(12)),
|
||||||
|
),
|
||||||
|
builder: (context) => AttendanceFilterBottomSheet(
|
||||||
|
controller: attendanceController,
|
||||||
|
permissionController: permissionController,
|
||||||
|
selectedTab: selectedTab,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
final selectedProjectId =
|
||||||
|
projectController.selectedProjectId.value;
|
||||||
|
final selectedView = result['selectedTab'] as String?;
|
||||||
|
|
||||||
|
if (selectedProjectId.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
await attendanceController
|
||||||
|
.fetchEmployeesByProject(selectedProjectId);
|
||||||
|
await attendanceController
|
||||||
|
.fetchAttendanceLogs(selectedProjectId);
|
||||||
|
await attendanceController
|
||||||
|
.fetchRegularizationLogs(selectedProjectId);
|
||||||
|
await attendanceController
|
||||||
|
.fetchProjectData(selectedProjectId);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
attendanceController
|
||||||
|
.update(['attendance_dashboard_controller']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedView != null && selectedView != selectedTab) {
|
||||||
|
setState(() => selectedTab = selectedView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.width(8),
|
||||||
|
|
||||||
|
// ⋮ Pending Actions Menu (red dot here instead)
|
||||||
|
if (selectedTab == 'attendanceLogs')
|
||||||
|
Obx(() {
|
||||||
|
final showPending = attendanceController.showPendingOnly.value;
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 35,
|
||||||
|
width: 35,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: PopupMenuButton<int>(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: const Icon(Icons.more_vert,
|
||||||
|
size: 20, color: Colors.black87),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem<int>(
|
||||||
|
enabled: false,
|
||||||
|
height: 30,
|
||||||
|
child: Text(
|
||||||
|
"Preferences",
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem<int>(
|
||||||
|
value: 0,
|
||||||
|
enabled: false,
|
||||||
|
child: Obx(() => Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Expanded(
|
||||||
|
child: Text('Show Pending Actions')),
|
||||||
|
Switch.adaptive(
|
||||||
|
value: attendanceController
|
||||||
|
.showPendingOnly.value,
|
||||||
|
activeColor: Colors.indigo,
|
||||||
|
onChanged: (val) {
|
||||||
|
attendanceController
|
||||||
|
.showPendingOnly.value = val;
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showPending)
|
||||||
|
Positioned(
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
child: Container(
|
||||||
|
height: 8,
|
||||||
|
width: 8,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.red,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -222,8 +348,7 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
MySpacing.height(flexSpacing),
|
MySpacing.height(flexSpacing),
|
||||||
_buildFilterAndRefreshRow(),
|
_buildFilterSearchRow(),
|
||||||
MySpacing.height(flexSpacing),
|
|
||||||
MyFlex(
|
MyFlex(
|
||||||
children: [
|
children: [
|
||||||
MyFlexItem(
|
MyFlexItem(
|
||||||
|
@ -27,7 +27,7 @@ class RegularizationRequestsTab extends StatelessWidget {
|
|||||||
child: MyText.titleMedium("Regularization Requests", fontWeight: 600),
|
child: MyText.titleMedium("Regularization Requests", fontWeight: 600),
|
||||||
),
|
),
|
||||||
Obx(() {
|
Obx(() {
|
||||||
final employees = controller.regularizationLogs;
|
final employees = controller.filteredRegularizationLogs;
|
||||||
|
|
||||||
if (controller.isLoadingRegularizationLogs.value) {
|
if (controller.isLoadingRegularizationLogs.value) {
|
||||||
return SkeletonLoaders.employeeListSkeletonLoader();
|
return SkeletonLoaders.employeeListSkeletonLoader();
|
||||||
@ -37,7 +37,8 @@ class RegularizationRequestsTab extends StatelessWidget {
|
|||||||
return const SizedBox(
|
return const SizedBox(
|
||||||
height: 120,
|
height: 120,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text("No Regularization Requests Found for this Project"),
|
child:
|
||||||
|
Text("No Regularization Requests Found for this Project"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/controller/attendance/attendance_screen_controller.dart';
|
import 'package:marco/controller/attendance/attendance_screen_controller.dart';
|
||||||
import 'package:marco/helpers/utils/date_time_utils.dart';
|
import 'package:marco/helpers/utils/date_time_utils.dart';
|
||||||
import 'package:marco/helpers/widgets/avatar.dart';
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
import 'package:marco/helpers/widgets/my_card.dart';
|
import 'package:marco/helpers/widgets/my_card.dart';
|
||||||
import 'package:marco/helpers/widgets/my_container.dart';
|
import 'package:marco/helpers/widgets/my_container.dart';
|
||||||
@ -20,7 +20,7 @@ class TodaysAttendanceTab extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
final isLoading = controller.isLoadingEmployees.value;
|
final isLoading = controller.isLoadingEmployees.value;
|
||||||
final employees = controller.employees;
|
final employees = controller.filteredEmployees;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -30,10 +30,11 @@ class TodaysAttendanceTab extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: MyText.titleMedium("Today's Attendance", fontWeight: 600),
|
child:
|
||||||
|
MyText.titleMedium("Today's Attendance", fontWeight: 600),
|
||||||
),
|
),
|
||||||
MyText.bodySmall(
|
MyText.bodySmall(
|
||||||
DateTimeUtils.formatDate(DateTime.now(), 'dd MMM yyyy'),
|
DateTimeUtils.formatDate(DateTime.now(), 'dd MMM yyyy'),
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: Colors.grey[700],
|
color: Colors.grey[700],
|
||||||
),
|
),
|
||||||
@ -43,7 +44,9 @@ class TodaysAttendanceTab extends StatelessWidget {
|
|||||||
if (isLoading)
|
if (isLoading)
|
||||||
SkeletonLoaders.employeeListSkeletonLoader()
|
SkeletonLoaders.employeeListSkeletonLoader()
|
||||||
else if (employees.isEmpty)
|
else if (employees.isEmpty)
|
||||||
const SizedBox(height: 120, child: Center(child: Text("No Employees Assigned")))
|
const SizedBox(
|
||||||
|
height: 120,
|
||||||
|
child: Center(child: Text("No Employees Assigned")))
|
||||||
else
|
else
|
||||||
MyCard.bordered(
|
MyCard.bordered(
|
||||||
paddingAll: 8,
|
paddingAll: 8,
|
||||||
@ -57,7 +60,10 @@ class TodaysAttendanceTab extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Avatar(firstName: employee.firstName, lastName: employee.lastName, size: 31),
|
Avatar(
|
||||||
|
firstName: employee.firstName,
|
||||||
|
lastName: employee.lastName,
|
||||||
|
size: 31),
|
||||||
MySpacing.width(16),
|
MySpacing.width(16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -66,27 +72,39 @@ class TodaysAttendanceTab extends StatelessWidget {
|
|||||||
Wrap(
|
Wrap(
|
||||||
spacing: 6,
|
spacing: 6,
|
||||||
children: [
|
children: [
|
||||||
MyText.bodyMedium(employee.name, fontWeight: 600),
|
MyText.bodyMedium(employee.name,
|
||||||
MyText.bodySmall('(${employee.designation})', fontWeight: 600, color: Colors.grey[700]),
|
fontWeight: 600),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'(${employee.designation})',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.grey[700]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
if (employee.checkIn != null || employee.checkOut != null)
|
if (employee.checkIn != null ||
|
||||||
|
employee.checkOut != null)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (employee.checkIn != null)
|
if (employee.checkIn != null)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.arrow_circle_right, size: 16, color: Colors.green),
|
const Icon(
|
||||||
|
Icons.arrow_circle_right,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.green),
|
||||||
MySpacing.width(4),
|
MySpacing.width(4),
|
||||||
Text(DateTimeUtils.formatDate(employee.checkIn!, 'hh:mm a')),
|
Text(DateTimeUtils.formatDate(
|
||||||
|
employee.checkIn!,
|
||||||
|
'hh:mm a')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (employee.checkOut != null) ...[
|
if (employee.checkOut != null) ...[
|
||||||
MySpacing.width(16),
|
MySpacing.width(16),
|
||||||
const Icon(Icons.arrow_circle_left, size: 16, color: Colors.red),
|
const Icon(Icons.arrow_circle_left,
|
||||||
|
size: 16, color: Colors.red),
|
||||||
MySpacing.width(4),
|
MySpacing.width(4),
|
||||||
Text(DateTimeUtils.formatDate(employee.checkOut!, 'hh:mm a')),
|
Text(DateTimeUtils.formatDate(
|
||||||
|
employee.checkOut!, 'hh:mm a')),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -16,8 +16,6 @@ import 'package:marco/controller/dynamicMenu/dynamic_menu_controller.dart';
|
|||||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
import 'package:marco/helpers/widgets/dashbaord/dashboard_overview_widgets.dart';
|
import 'package:marco/helpers/widgets/dashbaord/dashboard_overview_widgets.dart';
|
||||||
|
|
||||||
// import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart'; // ❌ Commented out
|
|
||||||
|
|
||||||
class DashboardScreen extends StatefulWidget {
|
class DashboardScreen extends StatefulWidget {
|
||||||
const DashboardScreen({super.key});
|
const DashboardScreen({super.key});
|
||||||
|
|
||||||
@ -63,20 +61,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// ❌ Commented out FCM Test Button
|
|
||||||
/*
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
final fcmService = FirebaseNotificationService();
|
|
||||||
final token = await fcmService.getFcmToken();
|
|
||||||
if (token != null) {
|
|
||||||
await fcmService.sendTestNotification(token);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text("Send Test Notification"),
|
|
||||||
),
|
|
||||||
MySpacing.height(10),
|
|
||||||
*/
|
|
||||||
_buildDashboardStats(context),
|
_buildDashboardStats(context),
|
||||||
MySpacing.height(24),
|
MySpacing.height(24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@ -235,7 +219,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stat Card
|
|
||||||
/// Dashboard Statistics Section with Compact Cards
|
/// Dashboard Statistics Section with Compact Cards
|
||||||
Widget _buildDashboardStats(BuildContext context) {
|
Widget _buildDashboardStats(BuildContext context) {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
|
@ -18,7 +18,8 @@ class DailyTaskPlanningScreen extends StatefulWidget {
|
|||||||
DailyTaskPlanningScreen({super.key});
|
DailyTaskPlanningScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DailyTaskPlanningScreen> createState() => _DailyTaskPlanningScreenState();
|
State<DailyTaskPlanningScreen> createState() =>
|
||||||
|
_DailyTaskPlanningScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
||||||
@ -270,12 +271,11 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
|||||||
"${buildingKey}_${floor.floorName}_${area.areaName}";
|
"${buildingKey}_${floor.floorName}_${area.areaName}";
|
||||||
final isExpanded =
|
final isExpanded =
|
||||||
floorExpansionState[floorWorkAreaKey] ?? false;
|
floorExpansionState[floorWorkAreaKey] ?? false;
|
||||||
final totalPlanned = area.workItems
|
final workItems = area.workItems;
|
||||||
.map((wi) => wi.workItem.plannedWork ?? 0)
|
final totalPlanned = workItems.fold<double>(
|
||||||
.fold<double>(0, (prev, curr) => prev + curr);
|
0, (sum, wi) => sum + (wi.workItem.plannedWork ?? 0));
|
||||||
final totalCompleted = area.workItems
|
final totalCompleted = workItems.fold<double>(0,
|
||||||
.map((wi) => wi.workItem.completedWork ?? 0)
|
(sum, wi) => sum + (wi.workItem.completedWork ?? 0));
|
||||||
.fold<double>(0, (prev, curr) => prev + curr);
|
|
||||||
final totalProgress = totalPlanned == 0
|
final totalProgress = totalPlanned == 0
|
||||||
? 0.0
|
? 0.0
|
||||||
: (totalCompleted / totalPlanned).clamp(0.0, 1.0);
|
: (totalCompleted / totalPlanned).clamp(0.0, 1.0);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user