Compare commits
40 Commits
2c79d3eec8
...
059e7c6c8b
Author | SHA1 | Date | |
---|---|---|---|
059e7c6c8b | |||
![]() |
8a729f23fe | ||
![]() |
71f9e54d58 | ||
![]() |
1e1bcc3aa4 | ||
![]() |
5b5030ec36 | ||
![]() |
ffba37b767 | ||
![]() |
efd5021ab1 | ||
![]() |
91e2bb7bc8 | ||
![]() |
f4135a77d8 | ||
![]() |
aac65104ab | ||
![]() |
e059ee71f3 | ||
![]() |
a9067bd407 | ||
![]() |
b3b68b6258 | ||
![]() |
6907d176da | ||
![]() |
ae868bb0f6 | ||
![]() |
df0dd5d560 | ||
![]() |
1ad880a021 | ||
![]() |
fb28439d69 | ||
![]() |
2fef2e508e | ||
![]() |
a8c890a60d | ||
![]() |
77e27ff98e | ||
![]() |
445cd75e03 | ||
![]() |
5fb18a13d2 | ||
![]() |
43aeec4c6f | ||
![]() |
5e8158a410 | ||
![]() |
45ce53539c | ||
![]() |
7a2798401a | ||
![]() |
56b493c909 | ||
![]() |
087c77bbd2 | ||
![]() |
606c5e5971 | ||
![]() |
e7940941ed | ||
![]() |
62c49b5429 | ||
![]() |
b187f1843a | ||
![]() |
eabd988b32 | ||
![]() |
becdec1a79 | ||
![]() |
549d8cce3c | ||
![]() |
be71544ae4 | ||
![]() |
83ad10ffb4 | ||
a0f1602f4e | |||
8f87161d74 |
@ -1,3 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
android.enableJetifier=false
|
||||
|
@ -32,7 +32,7 @@ class ForgotPasswordController extends MyController {
|
||||
final email = data['email']?.toString() ?? '';
|
||||
|
||||
try {
|
||||
logSafe("Forgot password requested for: $email", sensitive: true);
|
||||
logSafe("Forgot password requested for: $email", );
|
||||
|
||||
final result = await AuthService.forgotPassword(email);
|
||||
|
||||
@ -50,7 +50,7 @@ class ForgotPasswordController extends MyController {
|
||||
message: errorMessage,
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
logSafe("Failed to send reset password email for $email: $errorMessage", level: LogLevel.warning, sensitive: true);
|
||||
logSafe("Failed to send reset password email for $email: $errorMessage", level: LogLevel.warning, );
|
||||
}
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Error during forgot password", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
|
@ -55,12 +55,12 @@ class LoginController extends MyController {
|
||||
|
||||
try {
|
||||
final loginData = basicValidator.getData();
|
||||
logSafe("Attempting login for user: ${loginData['username']}", sensitive: true);
|
||||
logSafe("Attempting login for user: ${loginData['username']}", );
|
||||
|
||||
final errors = await AuthService.loginUser(loginData);
|
||||
|
||||
if (errors != null) {
|
||||
logSafe("Login failed for user: ${loginData['username']} with errors: $errors", level: LogLevel.warning, sensitive: true);
|
||||
logSafe("Login failed for user: ${loginData['username']} with errors: $errors", level: LogLevel.warning, );
|
||||
|
||||
showAppSnackbar(
|
||||
title: "Login Failed",
|
||||
@ -73,7 +73,7 @@ class LoginController extends MyController {
|
||||
basicValidator.clearErrors();
|
||||
} else {
|
||||
await _handleRememberMe();
|
||||
logSafe("Login successful for user: ${loginData['username']}", sensitive: true);
|
||||
logSafe("Login successful for user: ${loginData['username']}", );
|
||||
Get.toNamed('/home');
|
||||
}
|
||||
} catch (e, stacktrace) {
|
||||
|
@ -29,7 +29,7 @@ class MPINController extends GetxController {
|
||||
}
|
||||
|
||||
void onDigitChanged(String value, int index, {bool isRetype = false}) {
|
||||
logSafe("onDigitChanged -> index: $index, value: $value, isRetype: $isRetype", sensitive: true);
|
||||
logSafe("onDigitChanged -> index: $index, value: $value, isRetype: $isRetype", );
|
||||
final nodes = isRetype ? retypeFocusNodes : focusNodes;
|
||||
if (value.isNotEmpty && index < 5) {
|
||||
nodes[index + 1].requestFocus();
|
||||
@ -47,7 +47,7 @@ class MPINController extends GetxController {
|
||||
}
|
||||
|
||||
final enteredMPIN = digitControllers.map((c) => c.text).join();
|
||||
logSafe("Entered MPIN: $enteredMPIN", sensitive: true);
|
||||
logSafe("Entered MPIN: $enteredMPIN", );
|
||||
|
||||
if (enteredMPIN.length < 6) {
|
||||
_showError("Please enter all 6 digits.");
|
||||
@ -56,7 +56,7 @@ class MPINController extends GetxController {
|
||||
|
||||
if (isNewUser.value) {
|
||||
final retypeMPIN = retypeControllers.map((c) => c.text).join();
|
||||
logSafe("Retyped MPIN: $retypeMPIN", sensitive: true);
|
||||
logSafe("Retyped MPIN: $retypeMPIN", );
|
||||
|
||||
if (retypeMPIN.length < 6) {
|
||||
_showError("Please enter all 6 digits in Retype MPIN.");
|
||||
@ -177,7 +177,7 @@ class MPINController extends GetxController {
|
||||
return false;
|
||||
}
|
||||
|
||||
logSafe("Calling AuthService.generateMpin for employeeId: $employeeId", sensitive: true);
|
||||
logSafe("Calling AuthService.generateMpin for employeeId: $employeeId", );
|
||||
|
||||
final response = await AuthService.generateMpin(
|
||||
employeeId: employeeId,
|
||||
@ -222,7 +222,7 @@ class MPINController extends GetxController {
|
||||
logSafe("verifyMPIN triggered");
|
||||
|
||||
final enteredMPIN = digitControllers.map((c) => c.text).join();
|
||||
logSafe("Entered MPIN: $enteredMPIN", sensitive: true);
|
||||
logSafe("Entered MPIN: $enteredMPIN", );
|
||||
|
||||
if (enteredMPIN.length < 6) {
|
||||
_showError("Please enter all 6 digits.");
|
||||
|
@ -25,6 +25,7 @@ class OTPController extends GetxController {
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
timer.value = 0;
|
||||
_loadSavedEmail();
|
||||
logSafe("[OTPController] Initialized");
|
||||
}
|
||||
|
||||
@ -53,7 +54,6 @@ class OTPController extends GetxController {
|
||||
"[OTPController] OTP send failed",
|
||||
level: LogLevel.warning,
|
||||
error: result['error'],
|
||||
|
||||
);
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
@ -85,6 +85,7 @@ class OTPController extends GetxController {
|
||||
if (success) {
|
||||
email.value = userEmail;
|
||||
isOTPSent.value = true;
|
||||
await _saveEmailIfRemembered(userEmail);
|
||||
_startTimer();
|
||||
_clearOTPFields();
|
||||
}
|
||||
@ -144,7 +145,7 @@ class OTPController extends GetxController {
|
||||
Get.offAllNamed('/home');
|
||||
} else {
|
||||
final error = result['error'] ?? "Failed to verify OTP";
|
||||
logSafe("[OTPController] OTP verification failed", level: LogLevel.warning, error: error, sensitive: true);
|
||||
logSafe("[OTPController] OTP verification failed", level: LogLevel.warning, error: error);
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: error,
|
||||
@ -189,10 +190,32 @@ class OTPController extends GetxController {
|
||||
for (final node in focusNodes) {
|
||||
node.unfocus();
|
||||
}
|
||||
|
||||
// Optionally remove saved email
|
||||
LocalStorage.removeToken('otp_email');
|
||||
}
|
||||
|
||||
bool _validateEmail(String email) {
|
||||
final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$');
|
||||
return regex.hasMatch(email);
|
||||
}
|
||||
|
||||
/// Save email to local storage if "remember me" is set
|
||||
Future<void> _saveEmailIfRemembered(String email) async {
|
||||
final remember = LocalStorage.getBool('remember_me') ?? false;
|
||||
if (remember) {
|
||||
await LocalStorage.setToken('otp_email', email);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load email from local storage if "remember me" is true
|
||||
Future<void> _loadSavedEmail() async {
|
||||
final remember = LocalStorage.getBool('remember_me') ?? false;
|
||||
if (remember) {
|
||||
final savedEmail = LocalStorage.getToken('otp_email') ?? '';
|
||||
emailController.text = savedEmail;
|
||||
email.value = savedEmail;
|
||||
logSafe("[OTPController] Loaded saved email from local storage: $savedEmail");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -146,7 +146,7 @@ class AddEmployeeController extends MyController {
|
||||
gender: selectedGender!.name,
|
||||
jobRoleId: selectedRoleId!,
|
||||
);
|
||||
|
||||
logSafe("Response: $response");
|
||||
if (response == true) {
|
||||
logSafe("Employee created successfully.");
|
||||
showAppSnackbar(
|
||||
|
@ -197,7 +197,7 @@ class AttendanceController extends GetxController {
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.teal),
|
||||
),
|
||||
dialogTheme: const DialogTheme(backgroundColor: Colors.white),
|
||||
dialogTheme: DialogThemeData(backgroundColor: Colors.white),
|
||||
),
|
||||
child: child!,
|
||||
),
|
||||
|
@ -20,7 +20,7 @@ class DashboardController extends GetxController {
|
||||
logSafe(
|
||||
'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}',
|
||||
level: LogLevel.info,
|
||||
sensitive: true,
|
||||
|
||||
);
|
||||
|
||||
if (projectController.selectedProjectId.value.isNotEmpty) {
|
||||
@ -30,7 +30,7 @@ class DashboardController extends GetxController {
|
||||
// React to project change
|
||||
ever<String>(projectController.selectedProjectId, (id) {
|
||||
if (id.isNotEmpty) {
|
||||
logSafe('Project changed to $id, fetching attendance', level: LogLevel.info, sensitive: true);
|
||||
logSafe('Project changed to $id, fetching attendance', level: LogLevel.info, );
|
||||
fetchRoleWiseAttendance();
|
||||
}
|
||||
});
|
||||
|
@ -106,15 +106,15 @@ class EmployeesScreenController extends GetxController {
|
||||
logSafe(
|
||||
"Employees fetched: ${employees.length} for project $projectId",
|
||||
level: LogLevel.info,
|
||||
sensitive: true,
|
||||
|
||||
);
|
||||
},
|
||||
onEmpty: () {
|
||||
employees.clear();
|
||||
logSafe("No employees found for project $projectId.", level: LogLevel.warning, sensitive: true);
|
||||
logSafe("No employees found for project $projectId.", level: LogLevel.warning, );
|
||||
},
|
||||
onError: (e) {
|
||||
logSafe("Error fetching employees for project $projectId", level: LogLevel.error, error: e, sensitive: true);
|
||||
logSafe("Error fetching employees for project $projectId", level: LogLevel.error, error: e, );
|
||||
},
|
||||
);
|
||||
|
||||
@ -131,15 +131,15 @@ class EmployeesScreenController extends GetxController {
|
||||
() => ApiService.getEmployeeDetails(employeeId),
|
||||
onSuccess: (data) {
|
||||
selectedEmployeeDetails.value = EmployeeDetailsModel.fromJson(data);
|
||||
logSafe("Employee details loaded for $employeeId", level: LogLevel.info, sensitive: true);
|
||||
logSafe("Employee details loaded for $employeeId", level: LogLevel.info, );
|
||||
},
|
||||
onEmpty: () {
|
||||
selectedEmployeeDetails.value = null;
|
||||
logSafe("No employee details found for $employeeId", level: LogLevel.warning, sensitive: true);
|
||||
logSafe("No employee details found for $employeeId", level: LogLevel.warning, );
|
||||
},
|
||||
onError: (e) {
|
||||
selectedEmployeeDetails.value = null;
|
||||
logSafe("Error fetching employee details for $employeeId", level: LogLevel.error, error: e, sensitive: true);
|
||||
logSafe("Error fetching employee details for $employeeId", level: LogLevel.error, error: e, );
|
||||
},
|
||||
);
|
||||
|
||||
|
73
lib/controller/directory/add_comment_controller.dart
Normal file
73
lib/controller/directory/add_comment_controller.dart
Normal file
@ -0,0 +1,73 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/controller/directory/directory_controller.dart';
|
||||
|
||||
class AddCommentController extends GetxController {
|
||||
final String contactId;
|
||||
|
||||
AddCommentController({required this.contactId});
|
||||
|
||||
final RxString note = ''.obs;
|
||||
final RxBool isSubmitting = false.obs;
|
||||
|
||||
Future<void> submitComment() async {
|
||||
if (note.value.trim().isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Comment",
|
||||
message: "Please enter a comment before submitting.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
|
||||
try {
|
||||
logSafe("Submitting comment for contactId: $contactId");
|
||||
|
||||
final success = await ApiService.addContactComment(
|
||||
note.value.trim(),
|
||||
contactId,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
logSafe("Comment added successfully.");
|
||||
|
||||
// Refresh UI
|
||||
final directoryController = Get.find<DirectoryController>();
|
||||
await directoryController.fetchCommentsForContact(contactId);
|
||||
|
||||
Get.back(result: true);
|
||||
|
||||
showAppSnackbar(
|
||||
title: "Comment Added",
|
||||
message: "Your comment has been successfully added.",
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
} else {
|
||||
logSafe("Comment submission failed", level: LogLevel.error);
|
||||
showAppSnackbar(
|
||||
title: "Submission Failed",
|
||||
message: "Unable to add the comment. Please try again later.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Error while submitting comment: $e", level: LogLevel.error);
|
||||
showAppSnackbar(
|
||||
title: "Unexpected Error",
|
||||
message: "Something went wrong while adding your comment.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void updateNote(String value) {
|
||||
note.value = value;
|
||||
logSafe("Note updated: ${value.trim()}");
|
||||
}
|
||||
}
|
356
lib/controller/directory/add_contact_controller.dart
Normal file
356
lib/controller/directory/add_contact_controller.dart
Normal file
@ -0,0 +1,356 @@
|
||||
// Updated AddContactController to support multiple emails and phones
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
|
||||
class AddContactController extends GetxController {
|
||||
final RxList<String> categories = <String>[].obs;
|
||||
final RxList<String> buckets = <String>[].obs;
|
||||
final RxList<String> globalProjects = <String>[].obs;
|
||||
final RxList<String> tags = <String>[].obs;
|
||||
|
||||
final RxString selectedCategory = ''.obs;
|
||||
final RxString selectedBucket = ''.obs;
|
||||
final RxString selectedProject = ''.obs;
|
||||
|
||||
final RxList<String> enteredTags = <String>[].obs;
|
||||
final RxList<String> filteredSuggestions = <String>[].obs;
|
||||
final RxList<String> organizationNames = <String>[].obs;
|
||||
final RxList<String> filteredOrgSuggestions = <String>[].obs;
|
||||
|
||||
final RxMap<String, String> categoriesMap = <String, String>{}.obs;
|
||||
final RxMap<String, String> bucketsMap = <String, String>{}.obs;
|
||||
final RxMap<String, String> projectsMap = <String, String>{}.obs;
|
||||
final RxMap<String, String> tagsMap = <String, String>{}.obs;
|
||||
final RxBool isInitialized = false.obs;
|
||||
final RxList<String> selectedProjects = <String>[].obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
logSafe("AddContactController initialized", level: LogLevel.debug);
|
||||
fetchInitialData();
|
||||
}
|
||||
|
||||
Future<void> fetchInitialData() async {
|
||||
logSafe("Fetching initial dropdown data", level: LogLevel.debug);
|
||||
await Future.wait([
|
||||
fetchBuckets(),
|
||||
fetchGlobalProjects(),
|
||||
fetchTags(),
|
||||
fetchCategories(),
|
||||
fetchOrganizationNames(),
|
||||
]);
|
||||
|
||||
// ✅ Mark initialization as done
|
||||
isInitialized.value = true;
|
||||
}
|
||||
|
||||
void resetForm() {
|
||||
selectedCategory.value = '';
|
||||
selectedProject.value = '';
|
||||
selectedBucket.value = '';
|
||||
enteredTags.clear();
|
||||
filteredSuggestions.clear();
|
||||
filteredOrgSuggestions.clear();
|
||||
selectedProjects.clear();
|
||||
}
|
||||
|
||||
Future<void> fetchBuckets() async {
|
||||
try {
|
||||
final response = await ApiService.getContactBucketList();
|
||||
if (response != null && response['data'] is List) {
|
||||
final names = <String>[];
|
||||
for (var item in response['data']) {
|
||||
if (item['name'] != null && item['id'] != null) {
|
||||
bucketsMap[item['name']] = item['id'].toString();
|
||||
names.add(item['name']);
|
||||
}
|
||||
}
|
||||
buckets.assignAll(names);
|
||||
logSafe("Fetched \${names.length} buckets");
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Failed to fetch buckets: \$e", level: LogLevel.error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchOrganizationNames() async {
|
||||
try {
|
||||
final orgs = await ApiService.getOrganizationList();
|
||||
organizationNames.assignAll(orgs);
|
||||
logSafe("Fetched \${orgs.length} organization names");
|
||||
} catch (e) {
|
||||
logSafe("Failed to load organization names: \$e", level: LogLevel.error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> submitContact({
|
||||
String? id,
|
||||
required String name,
|
||||
required String organization,
|
||||
required List<Map<String, String>> emails,
|
||||
required List<Map<String, String>> phones,
|
||||
required String address,
|
||||
required String description,
|
||||
}) async {
|
||||
final categoryId = categoriesMap[selectedCategory.value];
|
||||
final bucketId = bucketsMap[selectedBucket.value];
|
||||
final projectIds = selectedProjects
|
||||
.map((name) => projectsMap[name])
|
||||
.whereType<String>()
|
||||
.toList();
|
||||
|
||||
// === Per-field Validation with Specific Messages ===
|
||||
if (name.trim().isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Name",
|
||||
message: "Please enter the contact name.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (organization.trim().isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Organization",
|
||||
message: "Please enter the organization name.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (emails.isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Email",
|
||||
message: "Please add at least one email.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (phones.isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Phone Number",
|
||||
message: "Please add at least one phone number.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (address.trim().isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Address",
|
||||
message: "Please enter the address.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (description.trim().isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Description",
|
||||
message: "Please enter a description.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCategory.value.trim().isEmpty || categoryId == null) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Category",
|
||||
message: "Please select a contact category.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedBucket.value.trim().isEmpty || bucketId == null) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Bucket",
|
||||
message: "Please select a bucket.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedProjects.isEmpty || projectIds.isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Projects",
|
||||
message: "Please select at least one project.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (enteredTags.isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Tags",
|
||||
message: "Please enter at least one tag.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// === Submit if all validations passed ===
|
||||
try {
|
||||
final tagObjects = enteredTags.map((tagName) {
|
||||
final tagId = tagsMap[tagName];
|
||||
return tagId != null
|
||||
? {"id": tagId, "name": tagName}
|
||||
: {"name": tagName};
|
||||
}).toList();
|
||||
|
||||
final body = {
|
||||
if (id != null) "id": id,
|
||||
"name": name.trim(),
|
||||
"organization": organization.trim(),
|
||||
"contactCategoryId": categoryId,
|
||||
"projectIds": projectIds,
|
||||
"bucketIds": [bucketId],
|
||||
"tags": tagObjects,
|
||||
"contactEmails": emails,
|
||||
"contactPhones": phones,
|
||||
"address": address.trim(),
|
||||
"description": description.trim(),
|
||||
};
|
||||
|
||||
logSafe("${id != null ? 'Updating' : 'Creating'} contact");
|
||||
|
||||
final response = id != null
|
||||
? await ApiService.updateContact(id, body)
|
||||
: await ApiService.createContact(body);
|
||||
|
||||
if (response == true) {
|
||||
Get.back(result: true);
|
||||
showAppSnackbar(
|
||||
title: "Success",
|
||||
message: id != null
|
||||
? "Contact updated successfully"
|
||||
: "Contact created successfully",
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to ${id != null ? 'update' : 'create'} contact",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Submit contact error: $e", level: LogLevel.error);
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Something went wrong",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void filterOrganizationSuggestions(String query) {
|
||||
if (query.trim().isEmpty) {
|
||||
filteredOrgSuggestions.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
final lower = query.toLowerCase();
|
||||
filteredOrgSuggestions.assignAll(
|
||||
organizationNames
|
||||
.where((name) => name.toLowerCase().contains(lower))
|
||||
.toList(),
|
||||
);
|
||||
logSafe("Filtered organization suggestions for: \$query",
|
||||
level: LogLevel.debug);
|
||||
}
|
||||
|
||||
Future<void> fetchGlobalProjects() async {
|
||||
try {
|
||||
final response = await ApiService.getGlobalProjects();
|
||||
if (response != null) {
|
||||
final names = <String>[];
|
||||
for (var item in response) {
|
||||
final name = item['name']?.toString().trim();
|
||||
final id = item['id']?.toString().trim();
|
||||
if (name != null && id != null && name.isNotEmpty) {
|
||||
projectsMap[name] = id;
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
globalProjects.assignAll(names);
|
||||
logSafe("Fetched \${names.length} global projects");
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Failed to fetch global projects: \$e", level: LogLevel.error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchTags() async {
|
||||
try {
|
||||
final response = await ApiService.getContactTagList();
|
||||
if (response != null && response['data'] is List) {
|
||||
tags.assignAll(List<String>.from(
|
||||
response['data'].map((e) => e['name'] ?? '').where((e) => e != ''),
|
||||
));
|
||||
logSafe("Fetched \${tags.length} tags");
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Failed to fetch tags: \$e", level: LogLevel.error);
|
||||
}
|
||||
}
|
||||
|
||||
void filterSuggestions(String query) {
|
||||
if (query.trim().isEmpty) {
|
||||
filteredSuggestions.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
final lower = query.toLowerCase();
|
||||
filteredSuggestions.assignAll(
|
||||
tags
|
||||
.where((tag) =>
|
||||
tag.toLowerCase().contains(lower) && !enteredTags.contains(tag))
|
||||
.toList(),
|
||||
);
|
||||
logSafe("Filtered tag suggestions for: \$query", level: LogLevel.debug);
|
||||
}
|
||||
|
||||
void clearSuggestions() {
|
||||
filteredSuggestions.clear();
|
||||
logSafe("Cleared tag suggestions", level: LogLevel.debug);
|
||||
}
|
||||
|
||||
Future<void> fetchCategories() async {
|
||||
try {
|
||||
final response = await ApiService.getContactCategoryList();
|
||||
if (response != null && response['data'] is List) {
|
||||
final names = <String>[];
|
||||
for (var item in response['data']) {
|
||||
final name = item['name']?.toString().trim();
|
||||
final id = item['id']?.toString().trim();
|
||||
if (name != null && id != null && name.isNotEmpty) {
|
||||
categoriesMap[name] = id;
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
categories.assignAll(names);
|
||||
logSafe("Fetched \${names.length} contact categories");
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Failed to fetch categories: \$e", level: LogLevel.error);
|
||||
}
|
||||
}
|
||||
|
||||
void addEnteredTag(String tag) {
|
||||
if (tag.trim().isNotEmpty && !enteredTags.contains(tag.trim())) {
|
||||
enteredTags.add(tag.trim());
|
||||
logSafe("Added tag: \$tag", level: LogLevel.debug);
|
||||
}
|
||||
}
|
||||
|
||||
void removeEnteredTag(String tag) {
|
||||
enteredTags.remove(tag);
|
||||
logSafe("Removed tag: \$tag", level: LogLevel.debug);
|
||||
}
|
||||
}
|
256
lib/controller/directory/directory_controller.dart
Normal file
256
lib/controller/directory/directory_controller.dart
Normal file
@ -0,0 +1,256 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/model/directory/contact_model.dart';
|
||||
import 'package:marco/model/directory/contact_bucket_list_model.dart';
|
||||
import 'package:marco/model/directory/directory_comment_model.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
|
||||
class DirectoryController extends GetxController {
|
||||
RxList<ContactModel> allContacts = <ContactModel>[].obs;
|
||||
RxList<ContactModel> filteredContacts = <ContactModel>[].obs;
|
||||
RxList<ContactCategory> contactCategories = <ContactCategory>[].obs;
|
||||
RxList<String> selectedCategories = <String>[].obs;
|
||||
RxList<String> selectedBuckets = <String>[].obs;
|
||||
RxBool isActive = true.obs;
|
||||
RxBool isLoading = false.obs;
|
||||
RxList<ContactBucket> contactBuckets = <ContactBucket>[].obs;
|
||||
RxString searchQuery = ''.obs;
|
||||
RxBool showFabMenu = false.obs;
|
||||
final RxBool showFullEditorToolbar = false.obs;
|
||||
final RxBool isEditorFocused = false.obs;
|
||||
RxBool isNotesView = false.obs;
|
||||
|
||||
final Map<String, RxList<DirectoryComment>> contactCommentsMap = {};
|
||||
RxList<DirectoryComment> getCommentsForContact(String contactId) {
|
||||
return contactCommentsMap[contactId] ?? <DirectoryComment>[].obs;
|
||||
}
|
||||
|
||||
final editingCommentId = Rxn<String>();
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
fetchContacts();
|
||||
fetchBuckets();
|
||||
}
|
||||
// inside DirectoryController
|
||||
|
||||
Future<void> updateComment(DirectoryComment comment) async {
|
||||
try {
|
||||
logSafe(
|
||||
"Attempting to update comment. id: ${comment.id}, contactId: ${comment.contactId}");
|
||||
|
||||
final commentList = contactCommentsMap[comment.contactId];
|
||||
final oldComment =
|
||||
commentList?.firstWhereOrNull((c) => c.id == comment.id);
|
||||
|
||||
if (oldComment == null) {
|
||||
logSafe("Old comment not found. id: ${comment.id}");
|
||||
} else {
|
||||
logSafe("Old comment note: ${oldComment.note}");
|
||||
logSafe("New comment note: ${comment.note}");
|
||||
}
|
||||
|
||||
if (oldComment != null && oldComment.note.trim() == comment.note.trim()) {
|
||||
logSafe("No changes detected in comment. id: ${comment.id}");
|
||||
showAppSnackbar(
|
||||
title: "No Changes",
|
||||
message: "No changes were made to the comment.",
|
||||
type: SnackbarType.info,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final success = await ApiService.updateContactComment(
|
||||
comment.id,
|
||||
comment.note,
|
||||
comment.contactId,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
logSafe("Comment updated successfully. id: ${comment.id}");
|
||||
await fetchCommentsForContact(comment.contactId);
|
||||
|
||||
// ✅ Show success message
|
||||
showAppSnackbar(
|
||||
title: "Success",
|
||||
message: "Comment updated successfully.",
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
} else {
|
||||
logSafe("Failed to update comment via API. id: ${comment.id}");
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to update comment.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
logSafe("Update comment failed: ${e.toString()}");
|
||||
logSafe("StackTrace: ${stackTrace.toString()}");
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to update comment.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchCommentsForContact(String contactId) async {
|
||||
try {
|
||||
final data = await ApiService.getDirectoryComments(contactId);
|
||||
logSafe("Fetched comments for contact $contactId: $data");
|
||||
|
||||
final comments =
|
||||
data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? [];
|
||||
|
||||
if (!contactCommentsMap.containsKey(contactId)) {
|
||||
contactCommentsMap[contactId] = <DirectoryComment>[].obs;
|
||||
}
|
||||
|
||||
contactCommentsMap[contactId]!.assignAll(comments);
|
||||
contactCommentsMap[contactId]?.refresh();
|
||||
} catch (e) {
|
||||
logSafe("Error fetching comments for contact $contactId: $e",
|
||||
level: LogLevel.error);
|
||||
|
||||
contactCommentsMap[contactId] ??= <DirectoryComment>[].obs;
|
||||
contactCommentsMap[contactId]!.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchBuckets() async {
|
||||
try {
|
||||
final response = await ApiService.getContactBucketList();
|
||||
if (response != null && response['data'] is List) {
|
||||
final buckets = (response['data'] as List)
|
||||
.map((e) => ContactBucket.fromJson(e))
|
||||
.toList();
|
||||
contactBuckets.assignAll(buckets);
|
||||
} else {
|
||||
contactBuckets.clear();
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Bucket fetch error: $e", level: LogLevel.error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchContacts({bool active = true}) async {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
final response = await ApiService.getDirectoryData(isActive: active);
|
||||
|
||||
if (response != null) {
|
||||
final contacts = response.map((e) => ContactModel.fromJson(e)).toList();
|
||||
allContacts.assignAll(contacts);
|
||||
extractCategoriesFromContacts();
|
||||
applyFilters();
|
||||
} else {
|
||||
allContacts.clear();
|
||||
filteredContacts.clear();
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Directory fetch error: $e", level: LogLevel.error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void extractCategoriesFromContacts() {
|
||||
final uniqueCategories = <String, ContactCategory>{};
|
||||
|
||||
for (final contact in allContacts) {
|
||||
final category = contact.contactCategory;
|
||||
if (category != null && !uniqueCategories.containsKey(category.id)) {
|
||||
uniqueCategories[category.id] = category;
|
||||
}
|
||||
}
|
||||
|
||||
contactCategories.value = uniqueCategories.values.toList();
|
||||
}
|
||||
|
||||
void applyFilters() {
|
||||
final query = searchQuery.value.toLowerCase();
|
||||
|
||||
filteredContacts.value = allContacts.where((contact) {
|
||||
final categoryMatch = selectedCategories.isEmpty ||
|
||||
(contact.contactCategory != null &&
|
||||
selectedCategories.contains(contact.contactCategory!.id));
|
||||
|
||||
final bucketMatch = selectedBuckets.isEmpty ||
|
||||
contact.bucketIds.any((id) => selectedBuckets.contains(id));
|
||||
|
||||
// Name, org, email, phone, tags
|
||||
final nameMatch = contact.name.toLowerCase().contains(query);
|
||||
final orgMatch = contact.organization.toLowerCase().contains(query);
|
||||
|
||||
final emailMatch = contact.contactEmails
|
||||
.any((e) => e.emailAddress.toLowerCase().contains(query));
|
||||
|
||||
final phoneMatch = contact.contactPhones
|
||||
.any((p) => p.phoneNumber.toLowerCase().contains(query));
|
||||
|
||||
final tagMatch =
|
||||
contact.tags.any((tag) => tag.name.toLowerCase().contains(query));
|
||||
|
||||
final categoryNameMatch =
|
||||
contact.contactCategory?.name.toLowerCase().contains(query) ?? false;
|
||||
|
||||
final bucketNameMatch = contact.bucketIds.any((id) {
|
||||
final bucketName = contactBuckets
|
||||
.firstWhereOrNull((b) => b.id == id)
|
||||
?.name
|
||||
.toLowerCase() ??
|
||||
'';
|
||||
return bucketName.contains(query);
|
||||
});
|
||||
|
||||
final searchMatch = query.isEmpty ||
|
||||
nameMatch ||
|
||||
orgMatch ||
|
||||
emailMatch ||
|
||||
phoneMatch ||
|
||||
tagMatch ||
|
||||
categoryNameMatch ||
|
||||
bucketNameMatch;
|
||||
|
||||
return categoryMatch && bucketMatch && searchMatch;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void toggleCategory(String categoryId) {
|
||||
if (selectedCategories.contains(categoryId)) {
|
||||
selectedCategories.remove(categoryId);
|
||||
} else {
|
||||
selectedCategories.add(categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
void toggleBucket(String bucketId) {
|
||||
if (selectedBuckets.contains(bucketId)) {
|
||||
selectedBuckets.remove(bucketId);
|
||||
} else {
|
||||
selectedBuckets.add(bucketId);
|
||||
}
|
||||
}
|
||||
|
||||
void updateSearchQuery(String value) {
|
||||
searchQuery.value = value;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
String getBucketNames(ContactModel contact, List<ContactBucket> allBuckets) {
|
||||
return contact.bucketIds
|
||||
.map((id) => allBuckets.firstWhereOrNull((b) => b.id == id)?.name ?? '')
|
||||
.where((name) => name.isNotEmpty)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
bool hasActiveFilters() {
|
||||
return selectedCategories.isNotEmpty ||
|
||||
selectedBuckets.isNotEmpty ||
|
||||
searchQuery.value.trim().isNotEmpty;
|
||||
}
|
||||
}
|
126
lib/controller/directory/notes_controller.dart
Normal file
126
lib/controller/directory/notes_controller.dart
Normal file
@ -0,0 +1,126 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/model/directory/note_list_response_model.dart';
|
||||
|
||||
class NotesController extends GetxController {
|
||||
RxList<NoteModel> notesList = <NoteModel>[].obs;
|
||||
RxBool isLoading = false.obs;
|
||||
RxnString editingNoteId = RxnString();
|
||||
RxString searchQuery = ''.obs;
|
||||
|
||||
List<NoteModel> get filteredNotesList {
|
||||
if (searchQuery.isEmpty) return notesList;
|
||||
|
||||
final query = searchQuery.value.toLowerCase();
|
||||
return notesList.where((note) {
|
||||
return note.note.toLowerCase().contains(query) ||
|
||||
note.contactName.toLowerCase().contains(query) ||
|
||||
note.organizationName.toLowerCase().contains(query) ||
|
||||
note.createdBy.firstName.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
fetchNotes();
|
||||
}
|
||||
|
||||
Future<void> fetchNotes({int pageSize = 1000, int pageNumber = 1}) async {
|
||||
isLoading.value = true;
|
||||
logSafe(
|
||||
"📤 Fetching directory notes with pageSize=$pageSize & pageNumber=$pageNumber");
|
||||
|
||||
try {
|
||||
final response = await ApiService.getDirectoryNotes(
|
||||
pageSize: pageSize, pageNumber: pageNumber);
|
||||
logSafe("💡 Directory Notes Response: $response");
|
||||
|
||||
if (response == null) {
|
||||
logSafe("⚠️ Response is null while fetching directory notes");
|
||||
notesList.clear();
|
||||
} else {
|
||||
logSafe("💡 Directory Notes Response: $response");
|
||||
notesList.value = NotePaginationData.fromJson(response).data;
|
||||
}
|
||||
} catch (e, st) {
|
||||
logSafe("💥 Error occurred while fetching directory notes",
|
||||
error: e, stackTrace: st);
|
||||
notesList.clear();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateNote(NoteModel updatedNote) async {
|
||||
try {
|
||||
logSafe(
|
||||
"Attempting to update note. id: ${updatedNote.id}, contactId: ${updatedNote.contactId}");
|
||||
|
||||
final oldNote = notesList.firstWhereOrNull((n) => n.id == updatedNote.id);
|
||||
|
||||
if (oldNote != null && oldNote.note.trim() == updatedNote.note.trim()) {
|
||||
logSafe("No changes detected in note. id: ${updatedNote.id}");
|
||||
showAppSnackbar(
|
||||
title: "No Changes",
|
||||
message: "No changes were made to the note.",
|
||||
type: SnackbarType.info,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final success = await ApiService.updateContactComment(
|
||||
updatedNote.id,
|
||||
updatedNote.note,
|
||||
updatedNote.contactId,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
logSafe("Note updated successfully. id: ${updatedNote.id}");
|
||||
final index = notesList.indexWhere((n) => n.id == updatedNote.id);
|
||||
if (index != -1) {
|
||||
notesList[index] = updatedNote;
|
||||
notesList.refresh();
|
||||
}
|
||||
showAppSnackbar(
|
||||
title: "Success",
|
||||
message: "Note updated successfully.",
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to update note.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
logSafe("Update note failed: ${e.toString()}");
|
||||
logSafe("StackTrace: ${stackTrace.toString()}");
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to update note.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void addNote(NoteModel note) {
|
||||
notesList.insert(0, note);
|
||||
logSafe("Note added to list");
|
||||
}
|
||||
|
||||
void deleteNote(int index) {
|
||||
if (index >= 0 && index < notesList.length) {
|
||||
notesList.removeAt(index);
|
||||
logSafe("Note removed from list at index $index");
|
||||
}
|
||||
}
|
||||
|
||||
void clearAllNotes() {
|
||||
notesList.clear();
|
||||
logSafe("All notes cleared from list");
|
||||
}
|
||||
}
|
@ -17,8 +17,51 @@ class PermissionController extends GetxController {
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_loadDataFromAPI();
|
||||
_initialize();
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
final token = await _getAuthToken();
|
||||
if (token?.isNotEmpty ?? false) {
|
||||
await loadData(token!);
|
||||
_startAutoRefresh();
|
||||
} else {
|
||||
logSafe("Token is null or empty. Skipping API load and auto-refresh.", level: LogLevel.warning);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _getAuthToken() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('jwt_token');
|
||||
logSafe("Auth token retrieved: $token", level: LogLevel.debug);
|
||||
return token;
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Error retrieving auth token", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadData(String token) async {
|
||||
try {
|
||||
final userData = await PermissionService.fetchAllUserData(token);
|
||||
_updateState(userData);
|
||||
await _storeData();
|
||||
logSafe("Data loaded and state updated successfully.");
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Error loading data from API", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateState(Map<String, dynamic> userData) {
|
||||
try {
|
||||
permissions.assignAll(userData['permissions']);
|
||||
employeeInfo.value = userData['employeeInfo'];
|
||||
projectsInfo.assignAll(userData['projects']);
|
||||
logSafe("State updated with user data.");
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Error updating state", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _storeData() async {
|
||||
@ -50,54 +93,15 @@ class PermissionController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadDataFromAPI() async {
|
||||
void _startAutoRefresh() {
|
||||
_refreshTimer = Timer.periodic(Duration(minutes: 30), (timer) async {
|
||||
logSafe("Auto-refresh triggered.");
|
||||
final token = await _getAuthToken();
|
||||
if (token?.isNotEmpty ?? false) {
|
||||
await loadData(token!);
|
||||
} else {
|
||||
logSafe("No token found for loading API data.", level: LogLevel.warning);
|
||||
logSafe("Token missing during auto-refresh. Skipping.", level: LogLevel.warning);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadData(String token) async {
|
||||
try {
|
||||
final userData = await PermissionService.fetchAllUserData(token);
|
||||
_updateState(userData);
|
||||
await _storeData();
|
||||
logSafe("Data loaded and state updated successfully.");
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Error loading data from API", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateState(Map<String, dynamic> userData) {
|
||||
try {
|
||||
permissions.assignAll(userData['permissions']);
|
||||
employeeInfo.value = userData['employeeInfo'];
|
||||
projectsInfo.assignAll(userData['projects']);
|
||||
|
||||
logSafe("State updated with new user data.", sensitive: true);
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Error updating state", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _getAuthToken() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('jwt_token');
|
||||
logSafe("Auth token retrieved successfully.", sensitive: true);
|
||||
return token;
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Error retrieving auth token", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _startAutoRefresh() {
|
||||
_refreshTimer = Timer.periodic(Duration(minutes: 30), (timer) async {
|
||||
logSafe("Auto-refresh triggered.");
|
||||
await _loadDataFromAPI();
|
||||
});
|
||||
}
|
||||
|
||||
@ -116,7 +120,7 @@ class PermissionController extends GetxController {
|
||||
@override
|
||||
void onClose() {
|
||||
_refreshTimer?.cancel();
|
||||
logSafe("PermissionController disposed and timer cancelled.");
|
||||
logSafe("PermissionController disposed and auto-refresh timer cancelled.");
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ class ProjectController extends GetxController {
|
||||
isProjectSelectionExpanded.value = false;
|
||||
logSafe("Projects fetched: ${projects.length}");
|
||||
} else {
|
||||
logSafe("No projects found or API call failed.", level: LogLevel.warning);
|
||||
logSafe("No Global projects found or API call failed.", level: LogLevel.warning);
|
||||
}
|
||||
|
||||
isLoadingProjects.value = false;
|
||||
|
@ -147,6 +147,6 @@ class AddTaskController extends GetxController {
|
||||
void selectCategory(String id) {
|
||||
selectedCategoryId.value = id;
|
||||
selectedCategoryName.value = categoryIdNameMap[id];
|
||||
logSafe("Category selected", level: LogLevel.debug, sensitive: true);
|
||||
logSafe("Category selected", level: LogLevel.debug, );
|
||||
}
|
||||
}
|
||||
|
@ -50,12 +50,12 @@ class DailyTaskPlaningController extends GetxController {
|
||||
.where((e) => uploadingStates[e.id]?.value == true)
|
||||
.toList();
|
||||
selectedEmployees.value = selected;
|
||||
logSafe("Updated selected employees", level: LogLevel.debug, sensitive: true);
|
||||
logSafe("Updated selected employees", level: LogLevel.debug, );
|
||||
}
|
||||
|
||||
void onRoleSelected(String? roleId) {
|
||||
selectedRoleId.value = roleId;
|
||||
logSafe("Role selected", level: LogLevel.info, sensitive: true);
|
||||
logSafe("Role selected", level: LogLevel.info, );
|
||||
}
|
||||
|
||||
Future<void> fetchRoles() async {
|
||||
@ -137,7 +137,7 @@ class DailyTaskPlaningController extends GetxController {
|
||||
final data = response?['data'];
|
||||
if (data != null) {
|
||||
dailyTasks = [TaskPlanningDetailsModel.fromJson(data)];
|
||||
logSafe("Daily task Planning Details fetched", level: LogLevel.info, sensitive: true);
|
||||
logSafe("Daily task Planning Details fetched", level: LogLevel.info, );
|
||||
} else {
|
||||
logSafe("Data field is null", level: LogLevel.warning);
|
||||
}
|
||||
@ -164,14 +164,14 @@ class DailyTaskPlaningController extends GetxController {
|
||||
uploadingStates[emp.id] = false.obs;
|
||||
}
|
||||
logSafe("Employees fetched: ${employees.length} for project $projectId",
|
||||
level: LogLevel.info, sensitive: true);
|
||||
level: LogLevel.info, );
|
||||
} else {
|
||||
employees = [];
|
||||
logSafe("No employees found for project $projectId", level: LogLevel.warning, sensitive: true);
|
||||
logSafe("No employees found for project $projectId", level: LogLevel.warning, );
|
||||
}
|
||||
} catch (e, stack) {
|
||||
logSafe("Error fetching employees for project $projectId",
|
||||
level: LogLevel.error, error: e, stackTrace: stack, sensitive: true);
|
||||
level: LogLevel.error, error: e, stackTrace: stack, );
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
update();
|
||||
|
@ -272,18 +272,18 @@ class ReportTaskActionController extends MyController {
|
||||
final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75);
|
||||
if (pickedFile != null) {
|
||||
selectedImages.add(File(pickedFile.path));
|
||||
logSafe("Image added from camera: ${pickedFile.path}", sensitive: true);
|
||||
logSafe("Image added from camera: ${pickedFile.path}", );
|
||||
}
|
||||
} else {
|
||||
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
|
||||
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
|
||||
logSafe("${pickedFiles.length} images added from gallery.", sensitive: true);
|
||||
logSafe("${pickedFiles.length} images added from gallery.", );
|
||||
}
|
||||
}
|
||||
|
||||
void removeImageAt(int index) {
|
||||
if (index >= 0 && index < selectedImages.length) {
|
||||
logSafe("Removing image at index $index", sensitive: true);
|
||||
logSafe("Removing image at index $index", );
|
||||
selectedImages.removeAt(index);
|
||||
}
|
||||
}
|
||||
|
@ -83,7 +83,7 @@ class ReportTaskController extends MyController {
|
||||
required DateTime reportedDate,
|
||||
List<File>? images,
|
||||
}) async {
|
||||
logSafe("Reporting task for projectId", sensitive: true);
|
||||
logSafe("Reporting task for projectId", );
|
||||
final completedWork = completedWorkController.text.trim();
|
||||
if (completedWork.isEmpty || int.tryParse(completedWork) == null || int.parse(completedWork) < 0) {
|
||||
_showError("Completed work must be a positive number.");
|
||||
@ -138,7 +138,7 @@ class ReportTaskController extends MyController {
|
||||
required String comment,
|
||||
List<File>? images,
|
||||
}) async {
|
||||
logSafe("Submitting comment for project", sensitive: true);
|
||||
logSafe("Submitting comment for project", );
|
||||
|
||||
final commentField = commentController.text.trim();
|
||||
if (commentField.isEmpty) {
|
||||
@ -221,7 +221,7 @@ class ReportTaskController extends MyController {
|
||||
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
|
||||
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
|
||||
}
|
||||
logSafe("Images picked: ${selectedImages.length}", sensitive: true);
|
||||
logSafe("Images picked: ${selectedImages.length}", );
|
||||
} catch (e) {
|
||||
logSafe("Error picking images", level: LogLevel.warning, error: e);
|
||||
}
|
||||
|
@ -25,10 +25,22 @@ class ApiEndpoints {
|
||||
static const String getDailyTask = "/task/list";
|
||||
static const String reportTask = "/task/report";
|
||||
static const String commentTask = "/task/comment";
|
||||
static const String dailyTaskDetails = "/project/details";
|
||||
static const String dailyTaskDetails = "/project/details-old";
|
||||
static const String assignDailyTask = "/task/assign";
|
||||
static const String getWorkStatus = "/master/work-status";
|
||||
static const String approveReportAction = "/task/approve";
|
||||
static const String assignTask = "/project/task";
|
||||
static const String getmasterWorkCategories = "/Master/work-categories";
|
||||
|
||||
////// Directory Screen API Endpoints
|
||||
static const String getDirectoryContacts = "/directory";
|
||||
static const String getDirectoryBucketList = "/directory/buckets";
|
||||
static const String getDirectoryContactDetail = "/directory/notes";
|
||||
static const String getDirectoryContactCategory = "/master/contact-categories";
|
||||
static const String getDirectoryContactTags = "/master/contact-tags";
|
||||
static const String getDirectoryOrganization = "/directory/organization";
|
||||
static const String createContact = "/directory";
|
||||
static const String updateContact = "/directory";
|
||||
static const String getDirectoryNotes = "/directory/notes";
|
||||
static const String updateDirectoryNotes = "/directory/note";
|
||||
}
|
||||
|
@ -106,23 +106,45 @@ class ApiService {
|
||||
bool hasRetried = false,
|
||||
}) async {
|
||||
String? token = await _getToken();
|
||||
if (token == null) return null;
|
||||
if (token == null) {
|
||||
logSafe("Token is null. Cannot proceed with GET request.",
|
||||
level: LogLevel.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
|
||||
.replace(queryParameters: queryParams);
|
||||
logSafe("GET $uri");
|
||||
|
||||
logSafe("Initiating GET request", level: LogLevel.debug);
|
||||
logSafe("URL: $uri", level: LogLevel.debug);
|
||||
logSafe("Query Parameters: ${queryParams ?? {}}", level: LogLevel.debug);
|
||||
logSafe("Headers: ${_headers(token)}", level: LogLevel.debug);
|
||||
|
||||
try {
|
||||
final response =
|
||||
await http.get(uri, headers: _headers(token)).timeout(timeout);
|
||||
|
||||
logSafe("Response Status: ${response.statusCode}", level: LogLevel.debug);
|
||||
logSafe("Response Body: ${response.body}", level: LogLevel.debug);
|
||||
|
||||
if (response.statusCode == 401 && !hasRetried) {
|
||||
logSafe("Unauthorized. Attempting token refresh...");
|
||||
logSafe("Unauthorized (401). Attempting token refresh...",
|
||||
level: LogLevel.warning);
|
||||
|
||||
if (await AuthService.refreshToken()) {
|
||||
return await _getRequest(endpoint,
|
||||
queryParams: queryParams, hasRetried: true);
|
||||
logSafe("Token refresh succeeded. Retrying request...",
|
||||
level: LogLevel.info);
|
||||
return await _getRequest(
|
||||
endpoint,
|
||||
queryParams: queryParams,
|
||||
hasRetried: true,
|
||||
);
|
||||
}
|
||||
logSafe("Token refresh failed.");
|
||||
|
||||
logSafe("Token refresh failed. Aborting request.",
|
||||
level: LogLevel.error);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logSafe("HTTP GET Exception: $e", level: LogLevel.error);
|
||||
@ -141,7 +163,7 @@ class ApiService {
|
||||
|
||||
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
|
||||
logSafe("POST $uri\nHeaders: ${_headers(token)}\nBody: $body",
|
||||
sensitive: true);
|
||||
);
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
@ -162,6 +184,49 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<http.Response?> _putRequest(
|
||||
String endpoint,
|
||||
dynamic body, {
|
||||
Map<String, String>? additionalHeaders,
|
||||
Duration customTimeout = timeout,
|
||||
bool hasRetried = false,
|
||||
}) async {
|
||||
String? token = await _getToken();
|
||||
if (token == null) return null;
|
||||
|
||||
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
|
||||
logSafe(
|
||||
"PUT $uri\nHeaders: ${_headers(token)}\nBody: $body",
|
||||
);
|
||||
final headers = {
|
||||
..._headers(token),
|
||||
if (additionalHeaders != null) ...additionalHeaders,
|
||||
};
|
||||
|
||||
logSafe("PUT $uri\nHeaders: $headers\nBody: $body", );
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.put(uri, headers: headers, body: jsonEncode(body))
|
||||
.timeout(customTimeout);
|
||||
|
||||
if (response.statusCode == 401 && !hasRetried) {
|
||||
logSafe("Unauthorized PUT. Attempting token refresh...");
|
||||
if (await AuthService.refreshToken()) {
|
||||
return await _putRequest(endpoint, body,
|
||||
additionalHeaders: additionalHeaders,
|
||||
customTimeout: customTimeout,
|
||||
hasRetried: true);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logSafe("HTTP PUT Exception: $e", level: LogLevel.error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// === Dashboard Endpoints ===
|
||||
|
||||
static Future<List<dynamic>?> getDashboardAttendanceOverview(
|
||||
@ -177,6 +242,227 @@ class ApiService {
|
||||
: null);
|
||||
}
|
||||
|
||||
/// Directory calling the API
|
||||
static Future<Map<String, dynamic>?> getDirectoryNotes({
|
||||
int pageSize = 1000,
|
||||
int pageNumber = 1,
|
||||
}) async {
|
||||
final queryParams = {
|
||||
'pageSize': pageSize.toString(),
|
||||
'pageNumber': pageNumber.toString(),
|
||||
};
|
||||
|
||||
final response = await _getRequest(
|
||||
ApiEndpoints.getDirectoryNotes,
|
||||
queryParams: queryParams,
|
||||
);
|
||||
|
||||
final data = response != null
|
||||
? _parseResponse(response, label: 'Directory Notes')
|
||||
: null;
|
||||
|
||||
return data is Map<String, dynamic> ? data : null;
|
||||
}
|
||||
|
||||
static Future<bool> addContactComment(String note, String contactId) async {
|
||||
final payload = {
|
||||
"note": note,
|
||||
"contactId": contactId,
|
||||
};
|
||||
|
||||
final endpoint = ApiEndpoints.updateDirectoryNotes;
|
||||
|
||||
logSafe("Adding new comment with payload: $payload");
|
||||
logSafe("Sending add comment request to $endpoint");
|
||||
|
||||
try {
|
||||
final response = await _postRequest(endpoint, payload);
|
||||
|
||||
if (response == null) {
|
||||
logSafe("Add comment failed: null response", level: LogLevel.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
logSafe("Add comment response status: ${response.statusCode}");
|
||||
logSafe("Add comment response body: ${response.body}");
|
||||
|
||||
final json = jsonDecode(response.body);
|
||||
|
||||
if (json['success'] == true) {
|
||||
logSafe("Comment added successfully for contactId: $contactId");
|
||||
return true;
|
||||
} else {
|
||||
logSafe("Failed to add comment: ${json['message']}",
|
||||
level: LogLevel.warning);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
logSafe("Exception during addComment API: ${e.toString()}",
|
||||
level: LogLevel.error);
|
||||
logSafe("StackTrace: ${stack.toString()}", level: LogLevel.debug);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> updateContactComment(
|
||||
String commentId, String note, String contactId) async {
|
||||
final payload = {
|
||||
"id": commentId,
|
||||
"contactId": contactId,
|
||||
"note": note,
|
||||
};
|
||||
|
||||
final endpoint = "${ApiEndpoints.updateDirectoryNotes}/$commentId";
|
||||
|
||||
final headers = {
|
||||
"comment-id": commentId,
|
||||
};
|
||||
|
||||
logSafe("Updating comment with payload: $payload");
|
||||
logSafe("Headers for update comment: $headers");
|
||||
logSafe("Sending update comment request to $endpoint");
|
||||
|
||||
try {
|
||||
final response = await _putRequest(
|
||||
endpoint,
|
||||
payload,
|
||||
additionalHeaders: headers,
|
||||
);
|
||||
|
||||
if (response == null) {
|
||||
logSafe("Update comment failed: null response", level: LogLevel.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
logSafe("Update comment response status: ${response.statusCode}");
|
||||
logSafe("Update comment response body: ${response.body}");
|
||||
|
||||
final json = jsonDecode(response.body);
|
||||
|
||||
if (json['success'] == true) {
|
||||
logSafe("Comment updated successfully. commentId: $commentId");
|
||||
return true;
|
||||
} else {
|
||||
logSafe("Failed to update comment: ${json['message']}",
|
||||
level: LogLevel.warning);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
logSafe("Exception during updateComment API: ${e.toString()}",
|
||||
level: LogLevel.error);
|
||||
logSafe("StackTrace: ${stack.toString()}", level: LogLevel.debug);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<List<dynamic>?> getDirectoryComments(String contactId) async {
|
||||
final url = "${ApiEndpoints.getDirectoryNotes}/$contactId";
|
||||
final response = await _getRequest(url);
|
||||
final data = response != null
|
||||
? _parseResponse(response, label: 'Directory Comments')
|
||||
: null;
|
||||
|
||||
return data is List ? data : null;
|
||||
}
|
||||
|
||||
static Future<bool> updateContact(
|
||||
String contactId, Map<String, dynamic> payload) async {
|
||||
try {
|
||||
final endpoint = "${ApiEndpoints.updateContact}/$contactId";
|
||||
|
||||
logSafe("Updating contact [$contactId] with payload: $payload");
|
||||
|
||||
final response = await _putRequest(endpoint, payload);
|
||||
if (response != null) {
|
||||
final json = jsonDecode(response.body);
|
||||
if (json['success'] == true) {
|
||||
logSafe("Contact updated successfully.");
|
||||
return true;
|
||||
} else {
|
||||
logSafe("Update contact failed: ${json['message']}",
|
||||
level: LogLevel.warning);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Error updating contact: $e", level: LogLevel.error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> createContact(Map<String, dynamic> payload) async {
|
||||
try {
|
||||
logSafe("Submitting contact payload: $payload");
|
||||
|
||||
final response = await _postRequest(ApiEndpoints.createContact, payload);
|
||||
if (response != null) {
|
||||
final json = jsonDecode(response.body);
|
||||
if (json['success'] == true) {
|
||||
logSafe("Contact created successfully.");
|
||||
return true;
|
||||
} else {
|
||||
logSafe("Create contact failed: ${json['message']}",
|
||||
level: LogLevel.warning);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Error creating contact: $e", level: LogLevel.error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<List<String>> getOrganizationList() async {
|
||||
try {
|
||||
final url = ApiEndpoints.getDirectoryOrganization;
|
||||
logSafe("Sending GET request to: $url", level: LogLevel.info);
|
||||
|
||||
final response = await _getRequest(url);
|
||||
|
||||
logSafe("Response status: ${response?.statusCode}",
|
||||
level: LogLevel.debug);
|
||||
logSafe("Response body: ${response?.body}", level: LogLevel.debug);
|
||||
|
||||
if (response != null && response.statusCode == 200) {
|
||||
final body = jsonDecode(response.body);
|
||||
if (body['success'] == true && body['data'] is List) {
|
||||
return List<String>.from(body['data']);
|
||||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
logSafe("Failed to fetch organization names: $e", level: LogLevel.error);
|
||||
logSafe("Stack trace: $stackTrace", level: LogLevel.debug);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> getContactCategoryList() async =>
|
||||
_getRequest(ApiEndpoints.getDirectoryContactCategory).then((res) =>
|
||||
res != null
|
||||
? _parseResponseForAllData(res, label: 'Contact Category List')
|
||||
: null);
|
||||
|
||||
static Future<Map<String, dynamic>?> getContactTagList() async =>
|
||||
_getRequest(ApiEndpoints.getDirectoryContactTags).then((res) =>
|
||||
res != null
|
||||
? _parseResponseForAllData(res, label: 'Contact Tag List')
|
||||
: null);
|
||||
|
||||
static Future<List<dynamic>?> getDirectoryData(
|
||||
{required bool isActive}) async {
|
||||
final queryParams = {
|
||||
"active": isActive.toString(),
|
||||
};
|
||||
|
||||
return _getRequest(ApiEndpoints.getDirectoryContacts,
|
||||
queryParams: queryParams)
|
||||
.then((res) =>
|
||||
res != null ? _parseResponse(res, label: 'Directory Data') : null);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> getContactBucketList() async =>
|
||||
_getRequest(ApiEndpoints.getDirectoryBucketList).then((res) => res != null
|
||||
? _parseResponseForAllData(res, label: 'Contact Bucket List')
|
||||
: null);
|
||||
|
||||
// === Attendance APIs ===
|
||||
|
||||
static Future<List<dynamic>?> getProjects() async =>
|
||||
@ -319,7 +605,7 @@ class ApiService {
|
||||
"jobRoleId": jobRoleId,
|
||||
};
|
||||
final response = await _postRequest(
|
||||
ApiEndpoints.reportTask,
|
||||
ApiEndpoints.createEmployee,
|
||||
body,
|
||||
customTimeout: extendedTimeout,
|
||||
);
|
||||
|
@ -7,38 +7,66 @@ import 'package:marco/helpers/theme/theme_customizer.dart';
|
||||
import 'package:marco/helpers/theme/app_theme.dart';
|
||||
import 'package:url_strategy/url_strategy.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
|
||||
import 'package:marco/helpers/services/auth_service.dart';
|
||||
Future<void> initializeApp() async {
|
||||
try {
|
||||
logSafe("Starting app initialization...");
|
||||
logSafe("💡 Starting app initialization...");
|
||||
|
||||
setPathUrlStrategy();
|
||||
logSafe("URL strategy set.");
|
||||
logSafe("💡 URL strategy set.");
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Color.fromARGB(255, 255, 0, 0),
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
));
|
||||
logSafe("System UI overlay style set.");
|
||||
logSafe("💡 System UI overlay style set.");
|
||||
|
||||
await LocalStorage.init();
|
||||
logSafe("Local storage initialized.");
|
||||
logSafe("💡 Local storage initialized.");
|
||||
|
||||
// If a refresh token is found, try to refresh the JWT token
|
||||
final refreshToken = await LocalStorage.getRefreshToken();
|
||||
if (refreshToken != null && refreshToken.isNotEmpty) {
|
||||
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
|
||||
final success = await AuthService.refreshToken();
|
||||
|
||||
if (!success) {
|
||||
logSafe("⚠️ Refresh token invalid or expired. Skipping controller injection.");
|
||||
// Optionally, clear tokens and force logout here if needed
|
||||
}
|
||||
} else {
|
||||
logSafe("❌ No refresh token found. Skipping refresh.");
|
||||
}
|
||||
|
||||
await ThemeCustomizer.init();
|
||||
logSafe("Theme customizer initialized.");
|
||||
logSafe("💡 Theme customizer initialized.");
|
||||
|
||||
final token = LocalStorage.getString('jwt_token');
|
||||
if (token != null && token.isNotEmpty) {
|
||||
if (!Get.isRegistered<PermissionController>()) {
|
||||
Get.put(PermissionController());
|
||||
logSafe("PermissionController injected.");
|
||||
logSafe("💡 PermissionController injected.");
|
||||
}
|
||||
|
||||
if (!Get.isRegistered<ProjectController>()) {
|
||||
Get.put(ProjectController(), permanent: true);
|
||||
logSafe("ProjectController injected as permanent.");
|
||||
logSafe("💡 ProjectController injected as permanent.");
|
||||
}
|
||||
|
||||
// Load data into controllers if required
|
||||
await Get.find<PermissionController>().loadData(token);
|
||||
await Get.find<ProjectController>().fetchProjects();
|
||||
} else {
|
||||
logSafe("⚠️ No valid JWT token found. Skipping controller initialization.");
|
||||
}
|
||||
|
||||
AppStyle.init();
|
||||
logSafe("AppStyle initialized.");
|
||||
logSafe("💡 AppStyle initialized.");
|
||||
|
||||
logSafe("App initialization completed successfully.");
|
||||
logSafe("✅ App initialization completed successfully.");
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Error during app initialization",
|
||||
logSafe(
|
||||
"⛔ Error during app initialization",
|
||||
level: LogLevel.error,
|
||||
error: e,
|
||||
stackTrace: stacktrace,
|
||||
|
@ -254,10 +254,13 @@ class AuthService {
|
||||
final refreshToken = data['refreshToken'];
|
||||
final mpinToken = data['mpinToken'];
|
||||
|
||||
// Save tokens
|
||||
await LocalStorage.setJwtToken(jwtToken);
|
||||
await LocalStorage.setLoggedInUser(true);
|
||||
|
||||
if (refreshToken != null) await LocalStorage.setRefreshToken(refreshToken);
|
||||
if (refreshToken != null) {
|
||||
await LocalStorage.setRefreshToken(refreshToken);
|
||||
}
|
||||
|
||||
if (mpinToken != null && mpinToken.isNotEmpty) {
|
||||
await LocalStorage.setMpinToken(mpinToken);
|
||||
@ -267,12 +270,22 @@ class AuthService {
|
||||
await LocalStorage.removeMpinToken();
|
||||
}
|
||||
|
||||
final permissionController = Get.put(PermissionController());
|
||||
await permissionController.loadData(jwtToken);
|
||||
// Inject controllers if not already registered
|
||||
if (!Get.isRegistered<PermissionController>()) {
|
||||
Get.put(PermissionController());
|
||||
logSafe("✅ PermissionController injected after login.");
|
||||
}
|
||||
|
||||
if (!Get.isRegistered<ProjectController>()) {
|
||||
Get.put(ProjectController(), permanent: true);
|
||||
logSafe("✅ ProjectController injected after login.");
|
||||
}
|
||||
|
||||
// Load data into controllers
|
||||
await Get.find<PermissionController>().loadData(jwtToken);
|
||||
await Get.find<ProjectController>().fetchProjects();
|
||||
|
||||
isLoggedIn = true;
|
||||
logSafe("Login flow completed.");
|
||||
logSafe("✅ Login flow completed and controllers initialized.");
|
||||
}
|
||||
}
|
@ -19,10 +19,10 @@ class PermissionService {
|
||||
String token, {
|
||||
bool hasRetried = false,
|
||||
}) async {
|
||||
logSafe("Fetching user data...", sensitive: true);
|
||||
logSafe("Fetching user data...", );
|
||||
|
||||
if (_userDataCache.containsKey(token)) {
|
||||
logSafe("User data cache hit.", sensitive: true);
|
||||
logSafe("User data cache hit.", );
|
||||
return _userDataCache[token]!;
|
||||
}
|
||||
|
||||
|
38
lib/helpers/utils/date_time_utils.dart
Normal file
38
lib/helpers/utils/date_time_utils.dart
Normal file
@ -0,0 +1,38 @@
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
|
||||
class DateTimeUtils {
|
||||
static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) {
|
||||
try {
|
||||
logSafe('convertUtcToLocal: input="$utcTimeString", format="$format"');
|
||||
|
||||
final parsed = DateTime.parse(utcTimeString);
|
||||
final utcDateTime = DateTime.utc(
|
||||
parsed.year,
|
||||
parsed.month,
|
||||
parsed.day,
|
||||
parsed.hour,
|
||||
parsed.minute,
|
||||
parsed.second,
|
||||
parsed.millisecond,
|
||||
parsed.microsecond,
|
||||
);
|
||||
logSafe('Parsed (assumed UTC): $utcDateTime');
|
||||
|
||||
final localDateTime = utcDateTime.toLocal();
|
||||
logSafe('Converted to Local: $localDateTime');
|
||||
|
||||
final formatted = _formatDateTime(localDateTime, format: format);
|
||||
logSafe('Formatted Local Time: $formatted');
|
||||
|
||||
return formatted;
|
||||
} catch (e, stackTrace) {
|
||||
logSafe('DateTime conversion failed: $e', error: e, stackTrace: stackTrace);
|
||||
return 'Invalid Date';
|
||||
}
|
||||
}
|
||||
|
||||
static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) {
|
||||
return DateFormat(format).format(dateTime);
|
||||
}
|
||||
}
|
111
lib/helpers/utils/launcher_utils.dart
Normal file
111
lib/helpers/utils/launcher_utils.dart
Normal file
@ -0,0 +1,111 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
|
||||
class LauncherUtils {
|
||||
/// Launches the phone dialer with the provided phone number
|
||||
static Future<void> launchPhone(String phoneNumber) async {
|
||||
logSafe('Attempting to launch phone: $phoneNumber', );
|
||||
|
||||
final Uri url = Uri(scheme: 'tel', path: phoneNumber);
|
||||
await _tryLaunch(url, 'Could not launch phone');
|
||||
}
|
||||
|
||||
/// Launches the email app with the provided email address
|
||||
static Future<void> launchEmail(String email) async {
|
||||
logSafe('Attempting to launch email: $email', );
|
||||
|
||||
final Uri url = Uri(scheme: 'mailto', path: email);
|
||||
await _tryLaunch(url, 'Could not launch email');
|
||||
}
|
||||
|
||||
/// Launches WhatsApp with the provided phone number
|
||||
static Future<void> launchWhatsApp(String phoneNumber) async {
|
||||
logSafe('Attempting to launch WhatsApp with: $phoneNumber', );
|
||||
|
||||
String normalized = phoneNumber.replaceAll(RegExp(r'\D'), '');
|
||||
if (!normalized.startsWith('91')) {
|
||||
normalized = '91$normalized';
|
||||
}
|
||||
|
||||
logSafe('Normalized WhatsApp number: $normalized', );
|
||||
|
||||
if (normalized.length < 12) {
|
||||
logSafe('Invalid WhatsApp number: $normalized', );
|
||||
showAppSnackbar(
|
||||
title: 'Error',
|
||||
message: 'Invalid phone number for WhatsApp',
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final Uri url = Uri.parse('https://wa.me/$normalized');
|
||||
await _tryLaunch(url, 'Could not open WhatsApp');
|
||||
}
|
||||
|
||||
/// Copies text to clipboard with feedback
|
||||
static Future<void> copyToClipboard(String text, {required String typeLabel}) async {
|
||||
try {
|
||||
logSafe('Copying "$typeLabel" to clipboard');
|
||||
|
||||
HapticFeedback.lightImpact();
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
showAppSnackbar(
|
||||
title: 'Copied',
|
||||
message: '$typeLabel copied to clipboard',
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
} catch (e, st) {
|
||||
logSafe(
|
||||
'Failed to copy $typeLabel to clipboard: $e',
|
||||
stackTrace: st,
|
||||
level: LogLevel.error,
|
||||
|
||||
);
|
||||
showAppSnackbar(
|
||||
title: 'Error',
|
||||
message: 'Failed to copy $typeLabel',
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal function to launch a URL and show error if failed
|
||||
static Future<void> _tryLaunch(Uri url, String errorMsg) async {
|
||||
try {
|
||||
logSafe('Trying to launch URL: ${url.toString()}');
|
||||
|
||||
final bool launched = await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
|
||||
if (launched) {
|
||||
logSafe('URL launched successfully: ${url.toString()}');
|
||||
} else {
|
||||
logSafe(
|
||||
'launchUrl returned false: ${url.toString()}',
|
||||
level: LogLevel.warning,
|
||||
);
|
||||
showAppSnackbar(
|
||||
title: 'Error',
|
||||
message: errorMsg,
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
} catch (e, st) {
|
||||
logSafe(
|
||||
'Exception during launch of ${url.toString()}: $e',
|
||||
stackTrace: st,
|
||||
level: LogLevel.error,
|
||||
);
|
||||
showAppSnackbar(
|
||||
title: 'Error',
|
||||
message: '$errorMsg: $e',
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
98
lib/helpers/widgets/Directory/comment_editor_card.dart
Normal file
98
lib/helpers/widgets/Directory/comment_editor_card.dart
Normal file
@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||
|
||||
class CommentEditorCard extends StatelessWidget {
|
||||
final quill.QuillController controller;
|
||||
final VoidCallback onCancel;
|
||||
final Future<void> Function(quill.QuillController controller) onSave;
|
||||
|
||||
const CommentEditorCard({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onCancel,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
quill.QuillSimpleToolbar(
|
||||
controller: controller,
|
||||
configurations: const quill.QuillSimpleToolbarConfigurations(
|
||||
showBoldButton: true,
|
||||
showItalicButton: true,
|
||||
showUnderLineButton: true,
|
||||
showListBullets: false,
|
||||
showListNumbers: false,
|
||||
showAlignmentButtons: true,
|
||||
showLink: true,
|
||||
showFontSize: false,
|
||||
showFontFamily: false,
|
||||
showColorButton: false,
|
||||
showBackgroundColorButton: false,
|
||||
showUndo: false,
|
||||
showRedo: false,
|
||||
showCodeBlock: false,
|
||||
showQuote: false,
|
||||
showSuperscript: false,
|
||||
showSubscript: false,
|
||||
showInlineCode: false,
|
||||
showDirection: false,
|
||||
showListCheck: false,
|
||||
showStrikeThrough: false,
|
||||
showClearFormat: false,
|
||||
showDividers: false,
|
||||
showHeaderStyle: false,
|
||||
multiRowsDisplay: false,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 38),
|
||||
Container(
|
||||
height: 140,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: const Color(0xFFFDFDFD),
|
||||
),
|
||||
child: quill.QuillEditor.basic(
|
||||
controller: controller,
|
||||
configurations: const quill.QuillEditorConfigurations(
|
||||
autoFocus: true,
|
||||
expands: false,
|
||||
scrollable: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: onCancel,
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
label: const Text("Cancel"),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => onSave(controller),
|
||||
icon: const Icon(Icons.save, size: 18),
|
||||
label: const Text("Save"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.indigo,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -6,7 +6,7 @@ class Avatar extends StatelessWidget {
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final double size;
|
||||
final Color? backgroundColor; // Optional: allows override
|
||||
final Color? backgroundColor;
|
||||
final Color textColor;
|
||||
|
||||
const Avatar({
|
||||
@ -22,7 +22,7 @@ class Avatar extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
String initials = "${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}".toUpperCase();
|
||||
|
||||
final Color bgColor = backgroundColor ?? _generateColorFromName('$firstName$lastName');
|
||||
final Color bgColor = backgroundColor ?? _getFlatColorFromName('$firstName$lastName');
|
||||
|
||||
return MyContainer.rounded(
|
||||
height: size,
|
||||
@ -39,12 +39,28 @@ class Avatar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Generate a consistent "random-like" color from the name
|
||||
Color _generateColorFromName(String name) {
|
||||
final hash = name.hashCode;
|
||||
final r = (hash & 0xFF0000) >> 16;
|
||||
final g = (hash & 0x00FF00) >> 8;
|
||||
final b = (hash & 0x0000FF);
|
||||
return Color.fromARGB(255, r, g, b).withOpacity(1.0);
|
||||
// Use fixed flat color palette and pick based on hash
|
||||
Color _getFlatColorFromName(String name) {
|
||||
final colors = <Color>[
|
||||
Color(0xFFE57373), // Red
|
||||
Color(0xFFF06292), // Pink
|
||||
Color(0xFFBA68C8), // Purple
|
||||
Color(0xFF9575CD), // Deep Purple
|
||||
Color(0xFF7986CB), // Indigo
|
||||
Color(0xFF64B5F6), // Blue
|
||||
Color(0xFF4FC3F7), // Light Blue
|
||||
Color(0xFF4DD0E1), // Cyan
|
||||
Color(0xFF4DB6AC), // Teal
|
||||
Color(0xFF81C784), // Green
|
||||
Color(0xFFAED581), // Light Green
|
||||
Color(0xFFDCE775), // Lime
|
||||
Color(0xFFFFD54F), // Amber
|
||||
Color(0xFFFFB74D), // Orange
|
||||
Color(0xFFA1887F), // Brown
|
||||
Color(0xFF90A4AE), // Blue Grey
|
||||
];
|
||||
|
||||
int index = name.hashCode.abs() % colors.length;
|
||||
return colors[index];
|
||||
}
|
||||
}
|
||||
|
@ -184,6 +184,7 @@ static Widget buildLoadingSkeleton() {
|
||||
}
|
||||
|
||||
// Daily Progress Planning (Collapsed View)
|
||||
|
||||
static Widget dailyProgressPlanningSkeletonCollapsedOnly() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -225,4 +226,58 @@ static Widget buildLoadingSkeleton() {
|
||||
}),
|
||||
);
|
||||
}
|
||||
static Widget contactSkeletonCard() {
|
||||
return MyCard.bordered(
|
||||
margin: MySpacing.only(bottom: 12),
|
||||
paddingAll: 16,
|
||||
borderRadiusAll: 16,
|
||||
shadow: MyShadow(
|
||||
elevation: 1.5,
|
||||
position: MyShadowPosition.bottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 40,
|
||||
width: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 12,
|
||||
width: 100,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
MySpacing.height(6),
|
||||
Container(
|
||||
height: 10,
|
||||
width: 60,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
MySpacing.height(16),
|
||||
Container(height: 10, width: 150, color: Colors.grey.shade300),
|
||||
MySpacing.height(8),
|
||||
Container(height: 10, width: 100, color: Colors.grey.shade300),
|
||||
MySpacing.height(8),
|
||||
Container(height: 10, width: 120, color: Colors.grey.shade300),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -14,7 +14,7 @@ Future<Uint8List?> compressImageToUnder100KB(File file) async {
|
||||
const int maxWidth = 800;
|
||||
const int maxHeight = 800;
|
||||
|
||||
logSafe("Starting image compression...", sensitive: true);
|
||||
logSafe("Starting image compression...", );
|
||||
|
||||
while (quality >= 10) {
|
||||
try {
|
||||
@ -59,7 +59,7 @@ Future<File> saveCompressedImageToFile(Uint8List bytes) async {
|
||||
final file = File(filePath);
|
||||
final savedFile = await file.writeAsBytes(bytes);
|
||||
|
||||
logSafe("Compressed image saved to ${savedFile.path}", sensitive: true);
|
||||
logSafe("Compressed image saved to ${savedFile.path}", );
|
||||
return savedFile;
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Error saving compressed image", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
|
@ -2,7 +2,9 @@ import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:loading_animation_widget/loading_animation_widget.dart';
|
||||
import 'package:marco/images.dart';
|
||||
|
||||
import 'package:marco/helpers/widgets/my_card.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/utils/my_shadow.dart';
|
||||
class LoadingComponent extends StatelessWidget {
|
||||
final bool isLoading;
|
||||
final Widget child;
|
||||
@ -58,6 +60,59 @@ class LoadingComponent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
Widget contactSkeletonCard() {
|
||||
return MyCard.bordered(
|
||||
margin: MySpacing.only(bottom: 12),
|
||||
paddingAll: 16,
|
||||
borderRadiusAll: 16,
|
||||
shadow: MyShadow(
|
||||
elevation: 1.5,
|
||||
position: MyShadowPosition.bottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 40,
|
||||
width: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 12,
|
||||
width: 100,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
MySpacing.height(6),
|
||||
Container(
|
||||
height: 10,
|
||||
width: 60,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
MySpacing.height(16),
|
||||
Container(height: 10, width: 150, color: Colors.grey.shade300),
|
||||
MySpacing.height(8),
|
||||
Container(height: 10, width: 100, color: Colors.grey.shade300),
|
||||
MySpacing.height(8),
|
||||
Container(height: 10, width: 120, color: Colors.grey.shade300),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _LoadingAnimation extends StatelessWidget {
|
||||
final double imageSize;
|
||||
|
@ -147,16 +147,25 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
||||
pickedTime.minute,
|
||||
);
|
||||
|
||||
if (selectedDateTime.isAfter(checkInTime)) {
|
||||
return selectedDateTime;
|
||||
} else {
|
||||
final now = DateTime.now();
|
||||
|
||||
if (selectedDateTime.isBefore(checkInTime)) {
|
||||
showAppSnackbar(
|
||||
title: "Invalid Time",
|
||||
message: "Please select a time after check-in time.",
|
||||
message: "Time must be after check-in.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return null;
|
||||
} else if (selectedDateTime.isAfter(now)) {
|
||||
showAppSnackbar(
|
||||
title: "Invalid Time",
|
||||
message: "Future time is not allowed.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return selectedDateTime;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -217,6 +226,30 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
||||
break;
|
||||
}
|
||||
|
||||
DateTime? selectedTime;
|
||||
|
||||
// ✅ New condition: Yesterday Check-In + CheckOut action
|
||||
final isYesterdayCheckIn = widget.employee.checkIn != null &&
|
||||
DateUtils.isSameDay(
|
||||
widget.employee.checkIn,
|
||||
DateTime.now().subtract(const Duration(days: 1)),
|
||||
);
|
||||
|
||||
if (isYesterdayCheckIn &&
|
||||
widget.employee.checkOut == null &&
|
||||
actionText == ButtonActions.checkOut) {
|
||||
selectedTime = await showTimePickerForRegularization(
|
||||
context: context,
|
||||
checkInTime: widget.employee.checkIn!,
|
||||
);
|
||||
|
||||
if (selectedTime == null) {
|
||||
widget.attendanceController.uploadingStates[uniqueLogKey]?.value =
|
||||
false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final userComment = await _showCommentBottomSheet(context, actionText);
|
||||
if (userComment == null || userComment.isEmpty) {
|
||||
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
|
||||
@ -225,13 +258,14 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
||||
|
||||
bool success = false;
|
||||
if (actionText == ButtonActions.requestRegularize) {
|
||||
final selectedTime = await showTimePickerForRegularization(
|
||||
final regularizeTime = selectedTime ??
|
||||
await showTimePickerForRegularization(
|
||||
context: context,
|
||||
checkInTime: widget.employee.checkIn!,
|
||||
);
|
||||
if (selectedTime != null) {
|
||||
if (regularizeTime != null) {
|
||||
final formattedSelectedTime =
|
||||
DateFormat("hh:mm a").format(selectedTime);
|
||||
DateFormat("hh:mm a").format(regularizeTime);
|
||||
success = await widget.attendanceController.captureAndUploadAttendance(
|
||||
widget.employee.id,
|
||||
widget.employee.employeeId,
|
||||
@ -242,6 +276,18 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
||||
markTime: formattedSelectedTime,
|
||||
);
|
||||
}
|
||||
} else if (selectedTime != null) {
|
||||
// ✅ If selectedTime was picked in the new condition
|
||||
final formattedSelectedTime = DateFormat("hh:mm a").format(selectedTime);
|
||||
success = await widget.attendanceController.captureAndUploadAttendance(
|
||||
widget.employee.id,
|
||||
widget.employee.employeeId,
|
||||
selectedProjectId,
|
||||
comment: userComment,
|
||||
action: updatedAction,
|
||||
imageCapture: imageCapture,
|
||||
markTime: formattedSelectedTime,
|
||||
);
|
||||
} else {
|
||||
success = await widget.attendanceController.captureAndUploadAttendance(
|
||||
widget.employee.id,
|
||||
|
@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/task_planing/daily_task_planing_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_button.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
@ -197,16 +196,34 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
|
||||
),
|
||||
MySpacing.height(24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
MyButton(
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => Get.back(),
|
||||
icon: const Icon(Icons.close, color: Colors.red),
|
||||
label: MyText.bodyMedium("Cancel", color: Colors.red),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.red),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 14),
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _onAssignTaskPressed,
|
||||
backgroundColor: const Color.fromARGB(255, 95, 132, 255),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
icon: const Icon(Icons.check_circle_outline,
|
||||
color: Colors.white),
|
||||
label:
|
||||
MyText.bodyMedium("Assign Task", color: Colors.white),
|
||||
],
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.indigo,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 28, vertical: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/task_planing/report_task_controller.dart';
|
||||
import 'package:marco/helpers/theme/app_theme.dart';
|
||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:marco/helpers/widgets/my_button.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
@ -342,9 +341,6 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
|
||||
}
|
||||
},
|
||||
isLoading: controller.isLoading,
|
||||
splashColor: contentTheme.secondary.withAlpha(25),
|
||||
backgroundColor: Colors.blueAccent,
|
||||
loadingIndicatorColor: contentTheme.onPrimary,
|
||||
),
|
||||
MySpacing.height(10),
|
||||
if ((widget.taskData['taskComments'] as List<dynamic>?)
|
||||
@ -526,45 +522,52 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
|
||||
required VoidCallback onCancel,
|
||||
required Future<void> Function() onSubmit,
|
||||
required RxBool isLoading,
|
||||
required Color splashColor,
|
||||
required Color backgroundColor,
|
||||
required Color loadingIndicatorColor,
|
||||
double? buttonHeight,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
MyButton.text(
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onCancel,
|
||||
padding: MySpacing.xy(20, 16),
|
||||
splashColor: splashColor,
|
||||
child: MyText.bodySmall('Cancel'),
|
||||
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||
label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.red),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Obx(() {
|
||||
return MyButton(
|
||||
onPressed: isLoading.value ? null : onSubmit,
|
||||
elevation: 0,
|
||||
padding: MySpacing.xy(20, 16),
|
||||
backgroundColor: backgroundColor,
|
||||
borderRadiusAll: AppStyle.buttonRadius.medium,
|
||||
child: isLoading.value
|
||||
? SizedBox(
|
||||
width: buttonHeight ?? 16,
|
||||
height: buttonHeight ?? 16,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Obx(() {
|
||||
return ElevatedButton.icon(
|
||||
onPressed: isLoading.value ? null : () => onSubmit(),
|
||||
icon: isLoading.value
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
loadingIndicatorColor,
|
||||
),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: MyText.bodySmall(
|
||||
'Comment',
|
||||
color: loadingIndicatorColor,
|
||||
: const Icon(Icons.check_circle_outline, color: Colors.white, size: 18),
|
||||
label: isLoading.value
|
||||
? const SizedBox()
|
||||
: MyText.bodyMedium("Comment", color: Colors.white, fontWeight: 600),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.indigo,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -467,7 +467,6 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
|
||||
reportActionId: reportActionId,
|
||||
approvedTaskCount: approvedTaskCount,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
Navigator.of(context).pop();
|
||||
if (shouldShowAddTaskSheet) {
|
||||
@ -488,10 +487,8 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
|
||||
widget.taskData['plannedWork'] ??
|
||||
'0') ??
|
||||
0,
|
||||
activityId:
|
||||
widget.activityId,
|
||||
workAreaId:
|
||||
widget.workAreaId,
|
||||
activityId: widget.activityId,
|
||||
workAreaId: widget.workAreaId,
|
||||
onSubmit: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
@ -502,12 +499,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
|
||||
}
|
||||
},
|
||||
isLoading: controller.isLoading,
|
||||
splashColor: contentTheme.secondary.withAlpha(25),
|
||||
backgroundColor: Colors.blueAccent,
|
||||
loadingIndicatorColor: contentTheme.onPrimary,
|
||||
),
|
||||
|
||||
MySpacing.height(10),
|
||||
MySpacing.height(20),
|
||||
if ((widget.taskData['taskComments'] as List<dynamic>?)
|
||||
?.isNotEmpty ==
|
||||
true) ...[
|
||||
@ -687,45 +681,52 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
|
||||
required VoidCallback onCancel,
|
||||
required Future<void> Function() onSubmit,
|
||||
required RxBool isLoading,
|
||||
required Color splashColor,
|
||||
required Color backgroundColor,
|
||||
required Color loadingIndicatorColor,
|
||||
double? buttonHeight,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
MyButton.text(
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onCancel,
|
||||
padding: MySpacing.xy(20, 16),
|
||||
splashColor: splashColor,
|
||||
child: MyText.bodySmall('Cancel'),
|
||||
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||
label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.red),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Obx(() {
|
||||
return MyButton(
|
||||
onPressed: isLoading.value ? null : onSubmit,
|
||||
elevation: 0,
|
||||
padding: MySpacing.xy(20, 16),
|
||||
backgroundColor: backgroundColor,
|
||||
borderRadiusAll: AppStyle.buttonRadius.medium,
|
||||
child: isLoading.value
|
||||
? SizedBox(
|
||||
width: buttonHeight ?? 16,
|
||||
height: buttonHeight ?? 16,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Obx(() {
|
||||
return ElevatedButton.icon(
|
||||
onPressed: isLoading.value ? null : () => onSubmit(),
|
||||
icon: isLoading.value
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
loadingIndicatorColor,
|
||||
),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: MyText.bodySmall(
|
||||
'Submit Report',
|
||||
color: loadingIndicatorColor,
|
||||
: const Icon(Icons.send, color: Colors.white, size: 18),
|
||||
label: isLoading.value
|
||||
? const SizedBox()
|
||||
: MyText.bodyMedium("Submit", color: Colors.white, fontWeight: 600),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.indigo,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/task_planing/report_task_controller.dart';
|
||||
import 'package:marco/helpers/theme/app_theme.dart';
|
||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:marco/helpers/widgets/my_button.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
@ -347,28 +346,50 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
|
||||
);
|
||||
}),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
MyButton.text(
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
padding: MySpacing.xy(20, 16),
|
||||
splashColor: contentTheme.secondary.withAlpha(25),
|
||||
child: MyText.bodySmall('Cancel'),
|
||||
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||
label: MyText.bodyMedium(
|
||||
"Cancel",
|
||||
color: Colors.red,
|
||||
fontWeight: 600,
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Obx(() {
|
||||
final isLoading = controller.reportStatus.value == ApiStatus.loading;
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.red),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Obx(() {
|
||||
final isLoading =
|
||||
controller.reportStatus.value == ApiStatus.loading;
|
||||
|
||||
return MyButton(
|
||||
return ElevatedButton.icon(
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () async {
|
||||
if (controller.basicValidator.validateForm()) {
|
||||
final success = await controller.reportTask(
|
||||
projectId: controller.basicValidator.getController('task_id')?.text ?? '',
|
||||
comment: controller.basicValidator.getController('comment')?.text ?? '',
|
||||
projectId: controller.basicValidator
|
||||
.getController('task_id')
|
||||
?.text ??
|
||||
'',
|
||||
comment: controller.basicValidator
|
||||
.getController('comment')
|
||||
?.text ??
|
||||
'',
|
||||
completedTask: int.tryParse(
|
||||
controller.basicValidator.getController('completed_work')?.text ?? '') ??
|
||||
controller.basicValidator
|
||||
.getController('completed_work')
|
||||
?.text ??
|
||||
'') ??
|
||||
0,
|
||||
checklist: [],
|
||||
reportedDate: DateTime.now(),
|
||||
@ -379,11 +400,7 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
|
||||
}
|
||||
}
|
||||
},
|
||||
elevation: 0,
|
||||
padding: MySpacing.xy(20, 16),
|
||||
backgroundColor: Colors.blueAccent,
|
||||
borderRadiusAll: AppStyle.buttonRadius.medium,
|
||||
child: isLoading
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
@ -392,15 +409,28 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: MyText.bodySmall(
|
||||
'Report',
|
||||
color: contentTheme.onPrimary,
|
||||
: const Icon(Icons.check_circle_outline,
|
||||
color: Colors.white, size: 18),
|
||||
label: isLoading
|
||||
? const SizedBox.shrink()
|
||||
: MyText.bodyMedium(
|
||||
"Report",
|
||||
color: Colors.white,
|
||||
fontWeight: 600,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.indigo,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
136
lib/model/directory/add_comment_bottom_sheet.dart
Normal file
136
lib/model/directory/add_comment_bottom_sheet.dart
Normal file
@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||
import 'package:marco/controller/directory/add_comment_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart';
|
||||
|
||||
class AddCommentBottomSheet extends StatefulWidget {
|
||||
final String contactId;
|
||||
|
||||
const AddCommentBottomSheet({super.key, required this.contactId});
|
||||
|
||||
@override
|
||||
State<AddCommentBottomSheet> createState() => _AddCommentBottomSheetState();
|
||||
}
|
||||
|
||||
class _AddCommentBottomSheetState extends State<AddCommentBottomSheet> {
|
||||
late final AddCommentController controller;
|
||||
late final quill.QuillController quillController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = Get.put(AddCommentController(contactId: widget.contactId));
|
||||
quillController = quill.QuillController.basic();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
quillController.dispose();
|
||||
Get.delete<AddCommentController>();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: MediaQuery.of(context).viewInsets,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
MySpacing.height(12),
|
||||
Center(child: MyText.titleMedium("Add Comment", fontWeight: 700)),
|
||||
MySpacing.height(24),
|
||||
CommentEditorCard(
|
||||
controller: quillController,
|
||||
onCancel: () => Get.back(),
|
||||
onSave: (editorController) async {
|
||||
final delta = editorController.document.toDelta();
|
||||
final htmlOutput = _convertDeltaToHtml(delta);
|
||||
controller.updateNote(htmlOutput);
|
||||
await controller.submitComment();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _convertDeltaToHtml(dynamic delta) {
|
||||
final buffer = StringBuffer();
|
||||
bool inList = false;
|
||||
|
||||
for (var op in delta.toList()) {
|
||||
final data = op.data?.toString() ?? '';
|
||||
final attr = op.attributes ?? {};
|
||||
|
||||
final isListItem = attr.containsKey('list');
|
||||
final trimmedData = data.trim();
|
||||
|
||||
if (isListItem && !inList) {
|
||||
buffer.write('<ul>');
|
||||
inList = true;
|
||||
}
|
||||
|
||||
if (!isListItem && inList) {
|
||||
buffer.write('</ul>');
|
||||
inList = false;
|
||||
}
|
||||
|
||||
if (isListItem && trimmedData.isEmpty) continue;
|
||||
|
||||
if (isListItem) buffer.write('<li>');
|
||||
|
||||
if (attr.containsKey('bold')) buffer.write('<strong>');
|
||||
if (attr.containsKey('italic')) buffer.write('<em>');
|
||||
if (attr.containsKey('underline')) buffer.write('<u>');
|
||||
if (attr.containsKey('strike')) buffer.write('<s>');
|
||||
if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">');
|
||||
|
||||
buffer.write(trimmedData.replaceAll('\n', ''));
|
||||
|
||||
if (attr.containsKey('link')) buffer.write('</a>');
|
||||
if (attr.containsKey('strike')) buffer.write('</s>');
|
||||
if (attr.containsKey('underline')) buffer.write('</u>');
|
||||
if (attr.containsKey('italic')) buffer.write('</em>');
|
||||
if (attr.containsKey('bold')) buffer.write('</strong>');
|
||||
|
||||
if (isListItem) {
|
||||
buffer.write('</li>');
|
||||
} else if (data.contains('\n')) {
|
||||
buffer.write('<br>');
|
||||
}
|
||||
}
|
||||
|
||||
if (inList) buffer.write('</ul>');
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
698
lib/model/directory/add_contact_bottom_sheet.dart
Normal file
698
lib/model/directory/add_contact_bottom_sheet.dart
Normal file
@ -0,0 +1,698 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:marco/controller/directory/add_contact_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||
import 'package:marco/model/directory/contact_model.dart';
|
||||
|
||||
class AddContactBottomSheet extends StatefulWidget {
|
||||
final ContactModel? existingContact;
|
||||
const AddContactBottomSheet({super.key, this.existingContact});
|
||||
|
||||
@override
|
||||
State<AddContactBottomSheet> createState() => _AddContactBottomSheetState();
|
||||
}
|
||||
|
||||
class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
||||
final controller = Get.put(AddContactController());
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
final nameController = TextEditingController();
|
||||
final orgController = TextEditingController();
|
||||
final addressController = TextEditingController();
|
||||
final descriptionController = TextEditingController();
|
||||
final tagTextController = TextEditingController();
|
||||
|
||||
final RxList<TextEditingController> emailControllers =
|
||||
<TextEditingController>[].obs;
|
||||
final RxList<RxString> emailLabels = <RxString>[].obs;
|
||||
|
||||
final RxList<TextEditingController> phoneControllers =
|
||||
<TextEditingController>[].obs;
|
||||
final RxList<RxString> phoneLabels = <RxString>[].obs;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller.resetForm();
|
||||
|
||||
nameController.text = widget.existingContact?.name ?? '';
|
||||
orgController.text = widget.existingContact?.organization ?? '';
|
||||
addressController.text = widget.existingContact?.address ?? '';
|
||||
descriptionController.text = widget.existingContact?.description ?? '';
|
||||
tagTextController.clear();
|
||||
|
||||
if (widget.existingContact != null) {
|
||||
emailControllers.clear();
|
||||
emailLabels.clear();
|
||||
for (var email in widget.existingContact!.contactEmails) {
|
||||
emailControllers.add(TextEditingController(text: email.emailAddress));
|
||||
emailLabels.add((email.label).obs);
|
||||
}
|
||||
if (emailControllers.isEmpty) {
|
||||
emailControllers.add(TextEditingController());
|
||||
emailLabels.add('Office'.obs);
|
||||
}
|
||||
|
||||
phoneControllers.clear();
|
||||
phoneLabels.clear();
|
||||
for (var phone in widget.existingContact!.contactPhones) {
|
||||
phoneControllers.add(TextEditingController(text: phone.phoneNumber));
|
||||
phoneLabels.add((phone.label).obs);
|
||||
}
|
||||
if (phoneControllers.isEmpty) {
|
||||
phoneControllers.add(TextEditingController());
|
||||
phoneLabels.add('Work'.obs);
|
||||
}
|
||||
|
||||
controller.enteredTags.assignAll(
|
||||
widget.existingContact!.tags.map((tag) => tag.name).toList(),
|
||||
);
|
||||
|
||||
ever(controller.isInitialized, (bool ready) {
|
||||
if (ready) {
|
||||
final projectIds = widget.existingContact!.projectIds;
|
||||
final bucketId = widget.existingContact!.bucketIds.firstOrNull;
|
||||
final categoryName = widget.existingContact!.contactCategory?.name;
|
||||
|
||||
if (categoryName != null) {
|
||||
controller.selectedCategory.value = categoryName;
|
||||
}
|
||||
|
||||
if (projectIds != null) {
|
||||
final names = projectIds
|
||||
.map((id) {
|
||||
return controller.projectsMap.entries
|
||||
.firstWhereOrNull((e) => e.value == id)
|
||||
?.key;
|
||||
})
|
||||
.whereType<String>()
|
||||
.toList();
|
||||
controller.selectedProjects.assignAll(names);
|
||||
}
|
||||
if (bucketId != null) {
|
||||
final name = controller.bucketsMap.entries
|
||||
.firstWhereOrNull((e) => e.value == bucketId)
|
||||
?.key;
|
||||
if (name != null) {
|
||||
controller.selectedBucket.value = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
emailControllers.add(TextEditingController());
|
||||
emailLabels.add('Office'.obs);
|
||||
phoneControllers.add(TextEditingController());
|
||||
phoneLabels.add('Work'.obs);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
nameController.dispose();
|
||||
orgController.dispose();
|
||||
tagTextController.dispose();
|
||||
addressController.dispose();
|
||||
descriptionController.dispose();
|
||||
emailControllers.forEach((e) => e.dispose());
|
||||
phoneControllers.forEach((p) => p.dispose());
|
||||
Get.delete<AddContactController>();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
InputDecoration _inputDecoration(String hint) => InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
isDense: true,
|
||||
);
|
||||
|
||||
Widget _buildLabeledRow(
|
||||
String label,
|
||||
RxString selectedLabel,
|
||||
List<String> options,
|
||||
String inputLabel,
|
||||
TextEditingController controller,
|
||||
TextInputType inputType,
|
||||
{VoidCallback? onRemove}) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.labelMedium(label),
|
||||
MySpacing.height(8),
|
||||
_popupSelector(
|
||||
hint: "Label",
|
||||
selectedValue: selectedLabel,
|
||||
options: options),
|
||||
],
|
||||
),
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.labelMedium(inputLabel),
|
||||
MySpacing.height(8),
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: inputType,
|
||||
maxLength: inputType == TextInputType.phone ? 10 : null,
|
||||
inputFormatters: inputType == TextInputType.phone
|
||||
? [FilteringTextInputFormatter.digitsOnly]
|
||||
: [],
|
||||
decoration: _inputDecoration("Enter $inputLabel")
|
||||
.copyWith(counterText: ""),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty)
|
||||
return "$inputLabel is required";
|
||||
final trimmed = value.trim();
|
||||
if (inputType == TextInputType.phone) {
|
||||
if (!RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) {
|
||||
return "Enter valid phone number";
|
||||
}
|
||||
}
|
||||
|
||||
if (inputType == TextInputType.emailAddress &&
|
||||
!RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$')
|
||||
.hasMatch(trimmed)) {
|
||||
return "Enter valid email";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onRemove != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline, color: Colors.red),
|
||||
onPressed: onRemove,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmailList() => Column(
|
||||
children: List.generate(emailControllers.length, (index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildLabeledRow(
|
||||
"Email Label",
|
||||
emailLabels[index],
|
||||
["Office", "Personal", "Other"],
|
||||
"Email",
|
||||
emailControllers[index],
|
||||
TextInputType.emailAddress,
|
||||
onRemove: emailControllers.length > 1
|
||||
? () {
|
||||
emailControllers.removeAt(index);
|
||||
emailLabels.removeAt(index);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
Widget _buildPhoneList() => Column(
|
||||
children: List.generate(phoneControllers.length, (index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildLabeledRow(
|
||||
"Phone Label",
|
||||
phoneLabels[index],
|
||||
["Work", "Mobile", "Other"],
|
||||
"Phone",
|
||||
phoneControllers[index],
|
||||
TextInputType.phone,
|
||||
onRemove: phoneControllers.length > 1
|
||||
? () {
|
||||
phoneControllers.removeAt(index);
|
||||
phoneLabels.removeAt(index);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
Widget _popupSelector({
|
||||
required String hint,
|
||||
required RxString selectedValue,
|
||||
required List<String> options,
|
||||
}) {
|
||||
return Obx(() => GestureDetector(
|
||||
onTap: () async {
|
||||
final selected = await showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(100, 300, 100, 0),
|
||||
items: options.map((option) {
|
||||
return PopupMenuItem<String>(
|
||||
value: option,
|
||||
child: Text(option),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
if (selected != null) {
|
||||
selectedValue.value = selected;
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
selectedValue.value.isNotEmpty ? selectedValue.value : hint,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const Icon(Icons.expand_more, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Widget _sectionLabel(String title) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.labelLarge(title, fontWeight: 600),
|
||||
MySpacing.height(4),
|
||||
Divider(thickness: 1, color: Colors.grey.shade200),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _tagInputSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: TextField(
|
||||
controller: tagTextController,
|
||||
onChanged: controller.filterSuggestions,
|
||||
onSubmitted: (value) {
|
||||
controller.addEnteredTag(value);
|
||||
tagTextController.clear();
|
||||
controller.clearSuggestions();
|
||||
},
|
||||
decoration: _inputDecoration("Start typing to add tags"),
|
||||
),
|
||||
),
|
||||
Obx(() => controller.filteredSuggestions.isEmpty
|
||||
? const SizedBox()
|
||||
: Container(
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black12, blurRadius: 4)
|
||||
],
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: controller.filteredSuggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = controller.filteredSuggestions[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(suggestion),
|
||||
onTap: () {
|
||||
controller.addEnteredTag(suggestion);
|
||||
tagTextController.clear();
|
||||
controller.clearSuggestions();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
)),
|
||||
MySpacing.height(8),
|
||||
Obx(() => Wrap(
|
||||
spacing: 8,
|
||||
children: controller.enteredTags
|
||||
.map((tag) => Chip(
|
||||
label: Text(tag),
|
||||
onDeleted: () => controller.removeEnteredTag(tag),
|
||||
))
|
||||
.toList(),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField(String label, TextEditingController controller,
|
||||
{int maxLines = 1}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.labelMedium(label),
|
||||
MySpacing.height(8),
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
decoration: _inputDecoration("Enter $label"),
|
||||
validator: (value) => value == null || value.trim().isEmpty
|
||||
? "$label is required"
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOrganizationField() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.labelMedium("Organization"),
|
||||
MySpacing.height(8),
|
||||
TextField(
|
||||
controller: orgController,
|
||||
onChanged: controller.filterOrganizationSuggestions,
|
||||
decoration: _inputDecoration("Enter organization"),
|
||||
),
|
||||
Obx(() => controller.filteredOrgSuggestions.isEmpty
|
||||
? const SizedBox()
|
||||
: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: controller.filteredOrgSuggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = controller.filteredOrgSuggestions[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(suggestion),
|
||||
onTap: () {
|
||||
orgController.text = suggestion;
|
||||
controller.filteredOrgSuggestions.clear();
|
||||
},
|
||||
);
|
||||
},
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
Get.delete<AddContactController>();
|
||||
},
|
||||
icon: const Icon(Icons.close, color: Colors.red),
|
||||
label:
|
||||
MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.red),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||
),
|
||||
),
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
if (formKey.currentState!.validate()) {
|
||||
final emails = emailControllers
|
||||
.asMap()
|
||||
.entries
|
||||
.where((entry) => entry.value.text.trim().isNotEmpty)
|
||||
.map((entry) => {
|
||||
"label": emailLabels[entry.key].value,
|
||||
"emailAddress": entry.value.text.trim(),
|
||||
})
|
||||
.toList();
|
||||
|
||||
final phones = phoneControllers
|
||||
.asMap()
|
||||
.entries
|
||||
.where((entry) => entry.value.text.trim().isNotEmpty)
|
||||
.map((entry) => {
|
||||
"label": phoneLabels[entry.key].value,
|
||||
"phoneNumber": entry.value.text.trim(),
|
||||
})
|
||||
.toList();
|
||||
|
||||
controller.submitContact(
|
||||
id: widget.existingContact?.id,
|
||||
name: nameController.text.trim(),
|
||||
organization: orgController.text.trim(),
|
||||
emails: emails,
|
||||
phones: phones,
|
||||
address: addressController.text.trim(),
|
||||
description: descriptionController.text.trim(),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.check_circle_outline, color: Colors.white),
|
||||
label:
|
||||
MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.indigo,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
if (!controller.isInitialized.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: MediaQuery.of(context).viewInsets,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: MyText.titleMedium(
|
||||
widget.existingContact != null
|
||||
? "Edit Contact"
|
||||
: "Create New Contact",
|
||||
fontWeight: 700,
|
||||
),
|
||||
),
|
||||
MySpacing.height(24),
|
||||
_sectionLabel("Basic Info"),
|
||||
MySpacing.height(16),
|
||||
_buildTextField("Name", nameController),
|
||||
MySpacing.height(16),
|
||||
_buildOrganizationField(),
|
||||
MySpacing.height(24),
|
||||
_sectionLabel("Contact Info"),
|
||||
MySpacing.height(16),
|
||||
Obx(() => _buildEmailList()),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
emailControllers.add(TextEditingController());
|
||||
emailLabels.add('Office'.obs);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Add Email"),
|
||||
),
|
||||
Obx(() => _buildPhoneList()),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
phoneControllers.add(TextEditingController());
|
||||
phoneLabels.add('Work'.obs);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Add Phone"),
|
||||
),
|
||||
MySpacing.height(24),
|
||||
_sectionLabel("Other Details"),
|
||||
MySpacing.height(16),
|
||||
MyText.labelMedium("Category"),
|
||||
MySpacing.height(8),
|
||||
_popupSelector(
|
||||
hint: "Select Category",
|
||||
selectedValue: controller.selectedCategory,
|
||||
options: controller.categories,
|
||||
),
|
||||
MySpacing.height(16),
|
||||
MyText.labelMedium("Select Projects"),
|
||||
MySpacing.height(8),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Select Projects'),
|
||||
content: Obx(() {
|
||||
return SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children:
|
||||
controller.globalProjects.map((project) {
|
||||
final isSelected = controller
|
||||
.selectedProjects
|
||||
.contains(project);
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
unselectedWidgetColor: Colors
|
||||
.black, // checkbox border when not selected
|
||||
checkboxTheme: CheckboxThemeData(
|
||||
fillColor: MaterialStateProperty
|
||||
.resolveWith<Color>((states) {
|
||||
if (states.contains(
|
||||
MaterialState.selected)) {
|
||||
return Colors
|
||||
.white; // fill when selected
|
||||
}
|
||||
return Colors.transparent;
|
||||
}),
|
||||
checkColor: MaterialStateProperty.all(
|
||||
Colors.black), // check mark color
|
||||
side: const BorderSide(
|
||||
color: Colors.black,
|
||||
width: 2), // border color
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: CheckboxListTile(
|
||||
dense: true,
|
||||
title: Text(project),
|
||||
value: isSelected,
|
||||
onChanged: (bool? selected) {
|
||||
if (selected == true) {
|
||||
controller.selectedProjects
|
||||
.add(project);
|
||||
} else {
|
||||
controller.selectedProjects
|
||||
.remove(project);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Done'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Obx(() {
|
||||
final selected = controller.selectedProjects;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
selected.isEmpty
|
||||
? "Select Projects"
|
||||
: selected.join(', '),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.expand_more, size: 20),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
MyText.labelMedium("Select Bucket"),
|
||||
MySpacing.height(8),
|
||||
_popupSelector(
|
||||
hint: "Select Bucket",
|
||||
selectedValue: controller.selectedBucket,
|
||||
options: controller.buckets,
|
||||
),
|
||||
MySpacing.height(16),
|
||||
MyText.labelMedium("Tags"),
|
||||
MySpacing.height(8),
|
||||
_tagInputSection(),
|
||||
MySpacing.height(16),
|
||||
_buildTextField("Address", addressController, maxLines: 2),
|
||||
MySpacing.height(16),
|
||||
_buildTextField("Description", descriptionController,
|
||||
maxLines: 2),
|
||||
MySpacing.height(24),
|
||||
_buildActionButtons(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
57
lib/model/directory/contact_bucket_list_model.dart
Normal file
57
lib/model/directory/contact_bucket_list_model.dart
Normal file
@ -0,0 +1,57 @@
|
||||
class ContactBucket {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final CreatedBy createdBy;
|
||||
final List<String> employeeIds;
|
||||
final int numberOfContacts;
|
||||
|
||||
ContactBucket({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.createdBy,
|
||||
required this.employeeIds,
|
||||
required this.numberOfContacts,
|
||||
});
|
||||
|
||||
factory ContactBucket.fromJson(Map<String, dynamic> json) {
|
||||
return ContactBucket(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
description: json['description'],
|
||||
createdBy: CreatedBy.fromJson(json['createdBy']),
|
||||
employeeIds: List<String>.from(json['employeeIds']),
|
||||
numberOfContacts: json['numberOfContacts'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CreatedBy {
|
||||
final String id;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String? photo;
|
||||
final String jobRoleId;
|
||||
final String jobRoleName;
|
||||
|
||||
CreatedBy({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
this.photo,
|
||||
required this.jobRoleId,
|
||||
required this.jobRoleName,
|
||||
});
|
||||
|
||||
factory CreatedBy.fromJson(Map<String, dynamic> json) {
|
||||
return CreatedBy(
|
||||
id: json['id'],
|
||||
firstName: json['firstName'],
|
||||
lastName: json['lastName'],
|
||||
photo: json['photo'],
|
||||
jobRoleId: json['jobRoleId'],
|
||||
jobRoleName: json['jobRoleName'],
|
||||
);
|
||||
}
|
||||
}
|
65
lib/model/directory/contact_category_model.dart
Normal file
65
lib/model/directory/contact_category_model.dart
Normal file
@ -0,0 +1,65 @@
|
||||
class ContactCategoryResponse {
|
||||
final bool success;
|
||||
final String message;
|
||||
final List<ContactCategory> data;
|
||||
final dynamic errors;
|
||||
final int statusCode;
|
||||
final DateTime timestamp;
|
||||
|
||||
ContactCategoryResponse({
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.data,
|
||||
required this.errors,
|
||||
required this.statusCode,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory ContactCategoryResponse.fromJson(Map<String, dynamic> json) {
|
||||
return ContactCategoryResponse(
|
||||
success: json['success'],
|
||||
message: json['message'],
|
||||
data: List<ContactCategory>.from(
|
||||
json['data'].map((x) => ContactCategory.fromJson(x)),
|
||||
),
|
||||
errors: json['errors'],
|
||||
statusCode: json['statusCode'],
|
||||
timestamp: DateTime.parse(json['timestamp']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'success': success,
|
||||
'message': message,
|
||||
'data': data.map((x) => x.toJson()).toList(),
|
||||
'errors': errors,
|
||||
'statusCode': statusCode,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
class ContactCategory {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
|
||||
ContactCategory({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
factory ContactCategory.fromJson(Map<String, dynamic> json) {
|
||||
return ContactCategory(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
description: json['description'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
};
|
||||
}
|
135
lib/model/directory/contact_model.dart
Normal file
135
lib/model/directory/contact_model.dart
Normal file
@ -0,0 +1,135 @@
|
||||
class ContactModel {
|
||||
final String id;
|
||||
final List<String>? projectIds;
|
||||
final String name;
|
||||
final List<ContactPhone> contactPhones;
|
||||
final List<ContactEmail> contactEmails;
|
||||
final ContactCategory? contactCategory;
|
||||
final List<String> bucketIds;
|
||||
final String description;
|
||||
final String organization;
|
||||
final String address;
|
||||
final List<Tag> tags;
|
||||
|
||||
ContactModel({
|
||||
required this.id,
|
||||
required this.projectIds,
|
||||
required this.name,
|
||||
required this.contactPhones,
|
||||
required this.contactEmails,
|
||||
required this.contactCategory,
|
||||
required this.bucketIds,
|
||||
required this.description,
|
||||
required this.organization,
|
||||
required this.address,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
factory ContactModel.fromJson(Map<String, dynamic> json) {
|
||||
return ContactModel(
|
||||
id: json['id'],
|
||||
projectIds: (json['projectIds'] as List?)?.map((e) => e as String).toList(),
|
||||
name: json['name'],
|
||||
contactPhones: (json['contactPhones'] as List)
|
||||
.map((e) => ContactPhone.fromJson(e))
|
||||
.toList(),
|
||||
contactEmails: (json['contactEmails'] as List)
|
||||
.map((e) => ContactEmail.fromJson(e))
|
||||
.toList(),
|
||||
contactCategory: json['contactCategory'] != null
|
||||
? ContactCategory.fromJson(json['contactCategory'])
|
||||
: null,
|
||||
bucketIds: (json['bucketIds'] as List).map((e) => e as String).toList(),
|
||||
description: json['description'],
|
||||
organization: json['organization'],
|
||||
address: json['address'],
|
||||
tags: (json['tags'] as List).map((e) => Tag.fromJson(e)).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ContactPhone {
|
||||
final String id;
|
||||
final String label;
|
||||
final String phoneNumber;
|
||||
final String contactId;
|
||||
|
||||
ContactPhone({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.phoneNumber,
|
||||
required this.contactId,
|
||||
});
|
||||
|
||||
factory ContactPhone.fromJson(Map<String, dynamic> json) {
|
||||
return ContactPhone(
|
||||
id: json['id'],
|
||||
label: json['label'],
|
||||
phoneNumber: json['phoneNumber'],
|
||||
contactId: json['contactId'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ContactEmail {
|
||||
final String id;
|
||||
final String label;
|
||||
final String emailAddress;
|
||||
final String contactId;
|
||||
|
||||
ContactEmail({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.emailAddress,
|
||||
required this.contactId,
|
||||
});
|
||||
|
||||
factory ContactEmail.fromJson(Map<String, dynamic> json) {
|
||||
return ContactEmail(
|
||||
id: json['id'],
|
||||
label: json['label'],
|
||||
emailAddress: json['emailAddress'],
|
||||
contactId: json['contactId'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ContactCategory {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
|
||||
ContactCategory({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
factory ContactCategory.fromJson(Map<String, dynamic> json) {
|
||||
return ContactCategory(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
description: json['description'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Tag {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
|
||||
Tag({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
factory Tag.fromJson(Map<String, dynamic> json) {
|
||||
return Tag(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
description: json['description'],
|
||||
);
|
||||
}
|
||||
}
|
245
lib/model/directory/contact_profile_comment_model.dart
Normal file
245
lib/model/directory/contact_profile_comment_model.dart
Normal file
@ -0,0 +1,245 @@
|
||||
class ContactProfileResponse {
|
||||
final bool success;
|
||||
final String message;
|
||||
final ContactData data;
|
||||
final int statusCode;
|
||||
final String timestamp;
|
||||
|
||||
ContactProfileResponse({
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.data,
|
||||
required this.statusCode,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory ContactProfileResponse.fromJson(Map<String, dynamic> json) {
|
||||
return ContactProfileResponse(
|
||||
success: json['success'],
|
||||
message: json['message'],
|
||||
data: ContactData.fromJson(json['data']),
|
||||
statusCode: json['statusCode'],
|
||||
timestamp: json['timestamp'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ContactData {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? description;
|
||||
final String organization;
|
||||
final String address;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
final User createdBy;
|
||||
final User updatedBy;
|
||||
final List<ContactPhone> contactPhones;
|
||||
final List<ContactEmail> contactEmails;
|
||||
final ContactCategory? contactCategory;
|
||||
final List<ProjectInfo> projects;
|
||||
final List<Bucket> buckets;
|
||||
final List<Tag> tags;
|
||||
|
||||
ContactData({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.description,
|
||||
required this.organization,
|
||||
required this.address,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.createdBy,
|
||||
required this.updatedBy,
|
||||
required this.contactPhones,
|
||||
required this.contactEmails,
|
||||
this.contactCategory,
|
||||
required this.projects,
|
||||
required this.buckets,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
factory ContactData.fromJson(Map<String, dynamic> json) {
|
||||
return ContactData(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
description: json['description'],
|
||||
organization: json['organization'],
|
||||
address: json['address'],
|
||||
createdAt: json['createdAt'],
|
||||
updatedAt: json['updatedAt'],
|
||||
createdBy: User.fromJson(json['createdBy']),
|
||||
updatedBy: User.fromJson(json['updatedBy']),
|
||||
contactPhones: (json['contactPhones'] as List)
|
||||
.map((e) => ContactPhone.fromJson(e))
|
||||
.toList(),
|
||||
contactEmails: (json['contactEmails'] as List)
|
||||
.map((e) => ContactEmail.fromJson(e))
|
||||
.toList(),
|
||||
contactCategory: json['contactCategory'] != null
|
||||
? ContactCategory.fromJson(json['contactCategory'])
|
||||
: null,
|
||||
projects: (json['projects'] as List)
|
||||
.map((e) => ProjectInfo.fromJson(e))
|
||||
.toList(),
|
||||
buckets:
|
||||
(json['buckets'] as List).map((e) => Bucket.fromJson(e)).toList(),
|
||||
tags: (json['tags'] as List).map((e) => Tag.fromJson(e)).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class User {
|
||||
final String id;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String? photo;
|
||||
final String jobRoleId;
|
||||
final String jobRoleName;
|
||||
|
||||
User({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
this.photo,
|
||||
required this.jobRoleId,
|
||||
required this.jobRoleName,
|
||||
});
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) {
|
||||
return User(
|
||||
id: json['id'],
|
||||
firstName: json['firstName'],
|
||||
lastName: json['lastName'],
|
||||
photo: json['photo'],
|
||||
jobRoleId: json['jobRoleId'],
|
||||
jobRoleName: json['jobRoleName'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ContactPhone {
|
||||
final String id;
|
||||
final String label;
|
||||
final String phoneNumber;
|
||||
final String contactId;
|
||||
|
||||
ContactPhone({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.phoneNumber,
|
||||
required this.contactId,
|
||||
});
|
||||
|
||||
factory ContactPhone.fromJson(Map<String, dynamic> json) {
|
||||
return ContactPhone(
|
||||
id: json['id'],
|
||||
label: json['label'],
|
||||
phoneNumber: json['phoneNumber'],
|
||||
contactId: json['contactId'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ContactEmail {
|
||||
final String id;
|
||||
final String label;
|
||||
final String emailAddress;
|
||||
final String contactId;
|
||||
|
||||
ContactEmail({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.emailAddress,
|
||||
required this.contactId,
|
||||
});
|
||||
|
||||
factory ContactEmail.fromJson(Map<String, dynamic> json) {
|
||||
return ContactEmail(
|
||||
id: json['id'],
|
||||
label: json['label'],
|
||||
emailAddress: json['emailAddress'],
|
||||
contactId: json['contactId'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ContactCategory {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? description;
|
||||
|
||||
ContactCategory({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.description,
|
||||
});
|
||||
|
||||
factory ContactCategory.fromJson(Map<String, dynamic> json) {
|
||||
return ContactCategory(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
description: json['description'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectInfo {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
ProjectInfo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
factory ProjectInfo.fromJson(Map<String, dynamic> json) {
|
||||
return ProjectInfo(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Bucket {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final User createdBy;
|
||||
|
||||
Bucket({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.createdBy,
|
||||
});
|
||||
|
||||
factory Bucket.fromJson(Map<String, dynamic> json) {
|
||||
return Bucket(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
description: json['description'],
|
||||
createdBy: User.fromJson(json['createdBy']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Tag {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? description;
|
||||
|
||||
Tag({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.description,
|
||||
});
|
||||
|
||||
factory Tag.fromJson(Map<String, dynamic> json) {
|
||||
return Tag(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
description: json['description'],
|
||||
);
|
||||
}
|
||||
}
|
65
lib/model/directory/contact_tag_model.dart
Normal file
65
lib/model/directory/contact_tag_model.dart
Normal file
@ -0,0 +1,65 @@
|
||||
class ContactTagResponse {
|
||||
final bool success;
|
||||
final String message;
|
||||
final List<ContactTag> data;
|
||||
final dynamic errors;
|
||||
final int statusCode;
|
||||
final DateTime timestamp;
|
||||
|
||||
ContactTagResponse({
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.data,
|
||||
required this.errors,
|
||||
required this.statusCode,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory ContactTagResponse.fromJson(Map<String, dynamic> json) {
|
||||
return ContactTagResponse(
|
||||
success: json['success'],
|
||||
message: json['message'],
|
||||
data: List<ContactTag>.from(
|
||||
json['data'].map((x) => ContactTag.fromJson(x)),
|
||||
),
|
||||
errors: json['errors'],
|
||||
statusCode: json['statusCode'],
|
||||
timestamp: DateTime.parse(json['timestamp']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'success': success,
|
||||
'message': message,
|
||||
'data': data.map((x) => x.toJson()).toList(),
|
||||
'errors': errors,
|
||||
'statusCode': statusCode,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
class ContactTag {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
|
||||
ContactTag({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
factory ContactTag.fromJson(Map<String, dynamic> json) {
|
||||
return ContactTag(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
description: json['description'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
};
|
||||
}
|
145
lib/model/directory/directory_comment_model.dart
Normal file
145
lib/model/directory/directory_comment_model.dart
Normal file
@ -0,0 +1,145 @@
|
||||
class DirectoryCommentResponse {
|
||||
final bool success;
|
||||
final String message;
|
||||
final List<DirectoryComment> data;
|
||||
final int statusCode;
|
||||
final String? timestamp;
|
||||
|
||||
DirectoryCommentResponse({
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.data,
|
||||
required this.statusCode,
|
||||
this.timestamp,
|
||||
});
|
||||
|
||||
factory DirectoryCommentResponse.fromJson(Map<String, dynamic> json) {
|
||||
return DirectoryCommentResponse(
|
||||
success: json['success'] ?? false,
|
||||
message: json['message'] ?? '',
|
||||
data: (json['data'] as List<dynamic>?)
|
||||
?.map((e) => DirectoryComment.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
statusCode: json['statusCode'] ?? 0,
|
||||
timestamp: json['timestamp'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DirectoryComment {
|
||||
final String id;
|
||||
final String note;
|
||||
final String contactName;
|
||||
final String organizationName;
|
||||
final DateTime createdAt;
|
||||
final CommentUser createdBy;
|
||||
final DateTime? updatedAt;
|
||||
final CommentUser? updatedBy;
|
||||
final String contactId;
|
||||
final bool isActive;
|
||||
|
||||
DirectoryComment({
|
||||
required this.id,
|
||||
required this.note,
|
||||
required this.contactName,
|
||||
required this.organizationName,
|
||||
required this.createdAt,
|
||||
required this.createdBy,
|
||||
this.updatedAt,
|
||||
this.updatedBy,
|
||||
required this.contactId,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
factory DirectoryComment.fromJson(Map<String, dynamic> json) {
|
||||
return DirectoryComment(
|
||||
id: json['id'] ?? '',
|
||||
note: json['note'] ?? '',
|
||||
contactName: json['contactName'] ?? '',
|
||||
organizationName: json['organizationName'] ?? '',
|
||||
createdAt: DateTime.parse(json['createdAt']),
|
||||
createdBy: CommentUser.fromJson(json['createdBy']),
|
||||
updatedAt:
|
||||
json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null,
|
||||
updatedBy: json['updatedBy'] != null
|
||||
? CommentUser.fromJson(json['updatedBy'])
|
||||
: null,
|
||||
contactId: json['contactId'] ?? '',
|
||||
isActive: json['isActive'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
DirectoryComment copyWith({
|
||||
String? id,
|
||||
String? note,
|
||||
String? contactName,
|
||||
String? organizationName,
|
||||
DateTime? createdAt,
|
||||
CommentUser? createdBy,
|
||||
DateTime? updatedAt,
|
||||
CommentUser? updatedBy,
|
||||
String? contactId,
|
||||
bool? isActive,
|
||||
}) {
|
||||
return DirectoryComment(
|
||||
id: id ?? this.id,
|
||||
note: note ?? this.note,
|
||||
contactName: contactName ?? this.contactName,
|
||||
organizationName: organizationName ?? this.organizationName,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
createdBy: createdBy ?? this.createdBy,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
updatedBy: updatedBy ?? this.updatedBy,
|
||||
contactId: contactId ?? this.contactId,
|
||||
isActive: isActive ?? this.isActive,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CommentUser {
|
||||
final String id;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String? photo;
|
||||
final String jobRoleId;
|
||||
final String jobRoleName;
|
||||
|
||||
CommentUser({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
this.photo,
|
||||
required this.jobRoleId,
|
||||
required this.jobRoleName,
|
||||
});
|
||||
|
||||
factory CommentUser.fromJson(Map<String, dynamic> json) {
|
||||
return CommentUser(
|
||||
id: json['id'] ?? '',
|
||||
firstName: json['firstName'] ?? '',
|
||||
lastName: json['lastName'] ?? '',
|
||||
photo: json['photo'],
|
||||
jobRoleId: json['jobRoleId'] ?? '',
|
||||
jobRoleName: json['jobRoleName'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
CommentUser copyWith({
|
||||
String? id,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? photo,
|
||||
String? jobRoleId,
|
||||
String? jobRoleName,
|
||||
}) {
|
||||
return CommentUser(
|
||||
id: id ?? this.id,
|
||||
firstName: firstName ?? this.firstName,
|
||||
lastName: lastName ?? this.lastName,
|
||||
photo: photo ?? this.photo,
|
||||
jobRoleId: jobRoleId ?? this.jobRoleId,
|
||||
jobRoleName: jobRoleName ?? this.jobRoleName,
|
||||
);
|
||||
}
|
||||
}
|
170
lib/model/directory/directory_filter_bottom_sheet.dart
Normal file
170
lib/model/directory/directory_filter_bottom_sheet.dart
Normal file
@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/directory/directory_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
|
||||
class DirectoryFilterBottomSheet extends StatelessWidget {
|
||||
const DirectoryFilterBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = Get.find<DirectoryController>();
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
top: 12,
|
||||
left: 16,
|
||||
right: 16,
|
||||
),
|
||||
child: Obx(() {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
/// Drag handle
|
||||
Center(
|
||||
child: Container(
|
||||
height: 5,
|
||||
width: 50,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
/// Title
|
||||
Center(
|
||||
child: MyText.titleMedium(
|
||||
"Filter Contacts",
|
||||
fontWeight: 700,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
/// Categories
|
||||
if (controller.contactCategories.isNotEmpty) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
MyText.bodyMedium("Categories", fontWeight: 600),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 2,
|
||||
runSpacing: 0,
|
||||
children: controller.contactCategories.map((category) {
|
||||
final selected =
|
||||
controller.selectedCategories.contains(category.id);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: FilterChip(
|
||||
label: MyText.bodySmall(
|
||||
category.name,
|
||||
color: selected ? Colors.white : Colors.black87,
|
||||
),
|
||||
selected: selected,
|
||||
onSelected: (_) =>
|
||||
controller.toggleCategory(category.id),
|
||||
selectedColor: Colors.indigo,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
checkmarkColor: Colors.white,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
/// Buckets
|
||||
if (controller.contactBuckets.isNotEmpty) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
MyText.bodyMedium("Buckets", fontWeight: 600),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 2,
|
||||
runSpacing: 0,
|
||||
children: controller.contactBuckets.map((bucket) {
|
||||
final selected =
|
||||
controller.selectedBuckets.contains(bucket.id);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: FilterChip(
|
||||
label: MyText.bodySmall(
|
||||
bucket.name,
|
||||
color: selected ? Colors.white : Colors.black87,
|
||||
),
|
||||
selected: selected,
|
||||
onSelected: (_) => controller.toggleBucket(bucket.id),
|
||||
selectedColor: Colors.teal,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
checkmarkColor: Colors.white,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
/// Action Buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
controller.selectedCategories.clear();
|
||||
controller.selectedBuckets.clear();
|
||||
controller.searchQuery.value = '';
|
||||
controller.applyFilters();
|
||||
Get.back();
|
||||
},
|
||||
icon: const Icon(Icons.refresh, color: Colors.red),
|
||||
label: MyText.bodyMedium("Clear", color: Colors.red),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.red),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 7),
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
controller.applyFilters();
|
||||
Get.back();
|
||||
},
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: MyText.bodyMedium("Apply", color: Colors.white),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.indigo,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
142
lib/model/directory/note_list_response_model.dart
Normal file
142
lib/model/directory/note_list_response_model.dart
Normal file
@ -0,0 +1,142 @@
|
||||
class NoteListResponseModel {
|
||||
final bool success;
|
||||
final String message;
|
||||
final NotePaginationData data;
|
||||
final dynamic errors;
|
||||
final int statusCode;
|
||||
final DateTime timestamp;
|
||||
|
||||
NoteListResponseModel({
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.data,
|
||||
this.errors,
|
||||
required this.statusCode,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory NoteListResponseModel.fromJson(Map<String, dynamic> json) {
|
||||
return NoteListResponseModel(
|
||||
success: json['success'] ?? false,
|
||||
message: json['message'] ?? '',
|
||||
data: NotePaginationData.fromJson(json['data']),
|
||||
errors: json['errors'],
|
||||
statusCode: json['statusCode'] ?? 0,
|
||||
timestamp: DateTime.parse(json['timestamp']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotePaginationData {
|
||||
final int currentPage;
|
||||
final int pageSize;
|
||||
final int totalPages;
|
||||
final int totalRecords;
|
||||
final List<NoteModel> data;
|
||||
|
||||
NotePaginationData({
|
||||
required this.currentPage,
|
||||
required this.pageSize,
|
||||
required this.totalPages,
|
||||
required this.totalRecords,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
factory NotePaginationData.fromJson(Map<String, dynamic> json) {
|
||||
return NotePaginationData(
|
||||
currentPage: json['currentPage'] ?? 0,
|
||||
pageSize: json['pageSize'] ?? 0,
|
||||
totalPages: json['totalPages'] ?? 0,
|
||||
totalRecords: json['totalRecords'] ?? 0,
|
||||
data: List<NoteModel>.from(
|
||||
(json['data'] ?? []).map((x) => NoteModel.fromJson(x)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NoteModel {
|
||||
final String id;
|
||||
final String note;
|
||||
final String contactName;
|
||||
final String organizationName;
|
||||
final DateTime createdAt;
|
||||
final UserModel createdBy;
|
||||
final DateTime? updatedAt;
|
||||
final UserModel? updatedBy;
|
||||
final String contactId;
|
||||
final bool isActive;
|
||||
|
||||
NoteModel({
|
||||
required this.id,
|
||||
required this.note,
|
||||
required this.contactName,
|
||||
required this.organizationName,
|
||||
required this.createdAt,
|
||||
required this.createdBy,
|
||||
this.updatedAt,
|
||||
this.updatedBy,
|
||||
required this.contactId,
|
||||
required this.isActive,
|
||||
});
|
||||
NoteModel copyWith({String? note}) => NoteModel(
|
||||
id: id,
|
||||
note: note ?? this.note,
|
||||
contactName: contactName,
|
||||
organizationName: organizationName,
|
||||
createdAt: createdAt,
|
||||
createdBy: createdBy,
|
||||
updatedAt: updatedAt,
|
||||
updatedBy: updatedBy,
|
||||
contactId: contactId,
|
||||
isActive: isActive,
|
||||
);
|
||||
|
||||
factory NoteModel.fromJson(Map<String, dynamic> json) {
|
||||
return NoteModel(
|
||||
id: json['id'] ?? '',
|
||||
note: json['note'] ?? '',
|
||||
contactName: json['contactName'] ?? '',
|
||||
organizationName: json['organizationName'] ?? '',
|
||||
createdAt: DateTime.parse(json['createdAt']),
|
||||
createdBy: UserModel.fromJson(json['createdBy']),
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.tryParse(json['updatedAt'])
|
||||
: null,
|
||||
updatedBy: json['updatedBy'] != null
|
||||
? UserModel.fromJson(json['updatedBy'])
|
||||
: null,
|
||||
contactId: json['contactId'] ?? '',
|
||||
isActive: json['isActive'] ?? true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserModel {
|
||||
final String id;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String? photo;
|
||||
final String jobRoleId;
|
||||
final String jobRoleName;
|
||||
|
||||
UserModel({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
this.photo,
|
||||
required this.jobRoleId,
|
||||
required this.jobRoleName,
|
||||
});
|
||||
|
||||
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||||
return UserModel(
|
||||
id: json['id'] ?? '',
|
||||
firstName: json['firstName'] ?? '',
|
||||
lastName: json['lastName'] ?? '',
|
||||
photo: json['photo'],
|
||||
jobRoleId: json['jobRoleId'] ?? '',
|
||||
jobRoleName: json['jobRoleName'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
25
lib/model/directory/organization_list_model.dart
Normal file
25
lib/model/directory/organization_list_model.dart
Normal file
@ -0,0 +1,25 @@
|
||||
class OrganizationListModel {
|
||||
final bool success;
|
||||
final String message;
|
||||
final List<String> data;
|
||||
final int statusCode;
|
||||
final String timestamp;
|
||||
|
||||
OrganizationListModel({
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.data,
|
||||
required this.statusCode,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory OrganizationListModel.fromJson(Map<String, dynamic> json) {
|
||||
return OrganizationListModel(
|
||||
success: json['success'] ?? false,
|
||||
message: json['message'] ?? '',
|
||||
data: List<String>.from(json['data'] ?? []),
|
||||
statusCode: json['statusCode'] ?? 0,
|
||||
timestamp: json['timestamp'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
@ -13,7 +13,8 @@ class AddEmployeeBottomSheet extends StatefulWidget {
|
||||
State<AddEmployeeBottomSheet> createState() => _AddEmployeeBottomSheetState();
|
||||
}
|
||||
|
||||
class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UIMixin {
|
||||
class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
with UIMixin {
|
||||
final AddEmployeeController _controller = Get.put(AddEmployeeController());
|
||||
|
||||
late TextEditingController genderController;
|
||||
@ -27,7 +28,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
|
||||
}
|
||||
|
||||
RelativeRect _popupMenuPosition(BuildContext context) {
|
||||
final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||
final RenderBox overlay =
|
||||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||
return RelativeRect.fromLTRB(100, 300, overlay.size.width - 100, 0);
|
||||
}
|
||||
|
||||
@ -135,8 +137,14 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, -2))],
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, -2))
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
|
||||
@ -153,7 +161,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
|
||||
),
|
||||
),
|
||||
MySpacing.height(12),
|
||||
Text("Add Employee", style: MyTextStyle.titleLarge(fontWeight: 700)),
|
||||
Text("Add Employee",
|
||||
style: MyTextStyle.titleLarge(fontWeight: 700)),
|
||||
MySpacing.height(24),
|
||||
Form(
|
||||
key: _controller.basicValidator.formKey,
|
||||
@ -166,16 +175,20 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
|
||||
label: "First Name",
|
||||
hint: "e.g., John",
|
||||
icon: Icons.person,
|
||||
controller: _controller.basicValidator.getController('first_name')!,
|
||||
validator: _controller.basicValidator.getValidation('first_name'),
|
||||
controller: _controller.basicValidator
|
||||
.getController('first_name')!,
|
||||
validator: _controller.basicValidator
|
||||
.getValidation('first_name'),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
_inputWithIcon(
|
||||
label: "Last Name",
|
||||
hint: "e.g., Doe",
|
||||
icon: Icons.person_outline,
|
||||
controller: _controller.basicValidator.getController('last_name')!,
|
||||
validator: _controller.basicValidator.getValidation('last_name'),
|
||||
controller: _controller.basicValidator
|
||||
.getController('last_name')!,
|
||||
validator: _controller.basicValidator
|
||||
.getValidation('last_name'),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
_sectionLabel("Contact Details"),
|
||||
@ -185,7 +198,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@ -193,7 +207,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
|
||||
),
|
||||
child: PopupMenuButton<Map<String, String>>(
|
||||
onSelected: (country) {
|
||||
_controller.selectedCountryCode = country['code']!;
|
||||
_controller.selectedCountryCode =
|
||||
country['code']!;
|
||||
_controller.update();
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
@ -204,11 +219,14 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
|
||||
height: 200,
|
||||
width: 100,
|
||||
child: ListView(
|
||||
children: _controller.countries.map((country) {
|
||||
children: _controller.countries
|
||||
.map((country) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text("${country['name']} (${country['code']})"),
|
||||
onTap: () => Navigator.pop(context, country),
|
||||
title: Text(
|
||||
"${country['name']} (${country['code']})"),
|
||||
onTap: () =>
|
||||
Navigator.pop(context, country),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
@ -226,31 +244,42 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _controller.basicValidator.getController('phone_number'),
|
||||
controller: _controller.basicValidator
|
||||
.getController('phone_number'),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return "Phone number is required";
|
||||
}
|
||||
|
||||
final digitsOnly = value.trim();
|
||||
final minLength = _controller.minDigitsPerCountry[_controller.selectedCountryCode] ?? 7;
|
||||
final maxLength = _controller.maxDigitsPerCountry[_controller.selectedCountryCode] ?? 15;
|
||||
final minLength = _controller
|
||||
.minDigitsPerCountry[
|
||||
_controller.selectedCountryCode] ??
|
||||
7;
|
||||
final maxLength = _controller
|
||||
.maxDigitsPerCountry[
|
||||
_controller.selectedCountryCode] ??
|
||||
15;
|
||||
|
||||
if (!RegExp(r'^[0-9]+$').hasMatch(digitsOnly)) {
|
||||
if (!RegExp(r'^[0-9]+$')
|
||||
.hasMatch(digitsOnly)) {
|
||||
return "Only digits allowed";
|
||||
}
|
||||
|
||||
if (digitsOnly.length < minLength || digitsOnly.length > maxLength) {
|
||||
if (digitsOnly.length < minLength ||
|
||||
digitsOnly.length > maxLength) {
|
||||
return "Between $minLength–$maxLength digits";
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: _inputDecoration("e.g., 9876543210").copyWith(
|
||||
decoration: _inputDecoration("e.g., 9876543210")
|
||||
.copyWith(
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.contacts),
|
||||
onPressed: () => _controller.pickContact(context),
|
||||
onPressed: () =>
|
||||
_controller.pickContact(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -268,9 +297,11 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
|
||||
child: TextFormField(
|
||||
readOnly: true,
|
||||
controller: TextEditingController(
|
||||
text: _controller.selectedGender?.name.capitalizeFirst,
|
||||
text: _controller
|
||||
.selectedGender?.name.capitalizeFirst,
|
||||
),
|
||||
decoration: _inputDecoration("Select Gender").copyWith(
|
||||
decoration:
|
||||
_inputDecoration("Select Gender").copyWith(
|
||||
suffixIcon: const Icon(Icons.expand_more),
|
||||
),
|
||||
),
|
||||
@ -286,10 +317,14 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
|
||||
readOnly: true,
|
||||
controller: TextEditingController(
|
||||
text: _controller.roles.firstWhereOrNull(
|
||||
(role) => role['id'] == _controller.selectedRoleId,
|
||||
)?['name'] ?? "",
|
||||
(role) =>
|
||||
role['id'] ==
|
||||
_controller.selectedRoleId,
|
||||
)?['name'] ??
|
||||
"",
|
||||
),
|
||||
decoration: _inputDecoration("Select Role").copyWith(
|
||||
decoration:
|
||||
_inputDecoration("Select Role").copyWith(
|
||||
suffixIcon: const Icon(Icons.expand_more),
|
||||
),
|
||||
),
|
||||
@ -301,11 +336,16 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
label: MyText.bodyMedium("Cancel", fontWeight: 600),
|
||||
icon:
|
||||
const Icon(Icons.close, color: Colors.red),
|
||||
label: MyText.bodyMedium("Cancel",
|
||||
color: Colors.red, fontWeight: 600),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.grey),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
side: const BorderSide(color: Colors.red),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -313,23 +353,36 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
if (_controller.basicValidator.validateForm()) {
|
||||
final success = await _controller.createEmployees();
|
||||
if (_controller.basicValidator
|
||||
.validateForm()) {
|
||||
final success =
|
||||
await _controller.createEmployees();
|
||||
if (success) {
|
||||
final employeeController = Get.find<EmployeesScreenController>();
|
||||
final projectId = employeeController.selectedProjectId;
|
||||
final employeeController =
|
||||
Get.find<EmployeesScreenController>();
|
||||
final projectId =
|
||||
employeeController.selectedProjectId;
|
||||
|
||||
if (projectId == null) {
|
||||
await employeeController.fetchAllEmployees();
|
||||
await employeeController
|
||||
.fetchAllEmployees();
|
||||
} else {
|
||||
await employeeController.fetchEmployeesByProject(projectId);
|
||||
await employeeController
|
||||
.fetchEmployeesByProject(projectId);
|
||||
}
|
||||
|
||||
employeeController.update(['employee_screen_controller']);
|
||||
employeeController.update(
|
||||
['employee_screen_controller']);
|
||||
|
||||
_controller.basicValidator.getController("first_name")?.clear();
|
||||
_controller.basicValidator.getController("last_name")?.clear();
|
||||
_controller.basicValidator.getController("phone_number")?.clear();
|
||||
_controller.basicValidator
|
||||
.getController("first_name")
|
||||
?.clear();
|
||||
_controller.basicValidator
|
||||
.getController("last_name")
|
||||
?.clear();
|
||||
_controller.basicValidator
|
||||
.getController("phone_number")
|
||||
?.clear();
|
||||
_controller.selectedGender = null;
|
||||
_controller.selectedRoleId = null;
|
||||
_controller.update();
|
||||
@ -338,11 +391,16 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.check, size: 18),
|
||||
label: MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600),
|
||||
icon: const Icon(Icons.check_circle_outline,
|
||||
color: Colors.white),
|
||||
label: MyText.bodyMedium("Save",
|
||||
color: Colors.white, fontWeight: 600),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blueAccent,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
backgroundColor: Colors.indigo,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 28, vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -16,6 +16,7 @@ import 'package:marco/view/employees/employees_screen.dart';
|
||||
import 'package:marco/view/auth/login_option_screen.dart';
|
||||
import 'package:marco/view/auth/mpin_screen.dart';
|
||||
import 'package:marco/view/auth/mpin_auth_screen.dart';
|
||||
import 'package:marco/view/directory/directory_main_screen.dart';
|
||||
|
||||
class AuthMiddleware extends GetMiddleware {
|
||||
@override
|
||||
@ -60,6 +61,10 @@ getPageRoute() {
|
||||
name: '/dashboard/daily-task-progress',
|
||||
page: () => DailyProgressReportScreen(),
|
||||
middlewares: [AuthMiddleware()]),
|
||||
GetPage(
|
||||
name: '/dashboard/directory-main-page',
|
||||
page: () => DirectoryMainScreen(),
|
||||
middlewares: [AuthMiddleware()]),
|
||||
// Authentication
|
||||
GetPage(name: '/auth/login', page: () => LoginScreen()),
|
||||
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),
|
||||
@ -68,9 +73,7 @@ getPageRoute() {
|
||||
GetPage(
|
||||
name: '/auth/register_account',
|
||||
page: () => const RegisterAccountScreen()),
|
||||
GetPage(
|
||||
name: '/auth/forgot_password',
|
||||
page: () => ForgotPasswordScreen()),
|
||||
GetPage(name: '/auth/forgot_password', page: () => ForgotPasswordScreen()),
|
||||
GetPage(
|
||||
name: '/auth/reset_password', page: () => const ResetPasswordScreen()),
|
||||
// Error
|
||||
|
@ -8,7 +8,7 @@ import 'package:marco/helpers/widgets/my_button.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||
import 'package:marco/view/auth/request_demo_bottom_sheet.dart';
|
||||
|
||||
import 'package:marco/helpers/services/api_endpoints.dart';
|
||||
|
||||
class EmailLoginForm extends StatefulWidget {
|
||||
@ -136,22 +136,7 @@ class _EmailLoginFormState extends State<EmailLoginForm> with UIMixin {
|
||||
),
|
||||
),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
Center(
|
||||
child: MyButton.text(
|
||||
onPressed: () {
|
||||
OrganizationFormBottomSheet.show(context);
|
||||
},
|
||||
elevation: 0,
|
||||
padding: MySpacing.xy(12, 8),
|
||||
splashColor: contentTheme.secondary.withAlpha(30),
|
||||
child: MyText.bodySmall(
|
||||
"Request a Demo",
|
||||
color: contentTheme.brandRed,
|
||||
fontWeight: 600,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -1,15 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/auth/forgot_password_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_button.dart';
|
||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||
import 'package:marco/images.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/services/api_endpoints.dart';
|
||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/images.dart';
|
||||
|
||||
class ForgotPasswordScreen extends StatefulWidget {
|
||||
const ForgotPasswordScreen({super.key});
|
||||
@ -19,208 +18,273 @@ class ForgotPasswordScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
|
||||
with UIMixin {
|
||||
with UIMixin, SingleTickerProviderStateMixin {
|
||||
final ForgotPasswordController controller =
|
||||
Get.put(ForgotPasswordController());
|
||||
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _logoAnimation;
|
||||
|
||||
bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage");
|
||||
bool _isLoading = false;
|
||||
|
||||
void _handleForgotPassword() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
_logoAnimation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutBack,
|
||||
);
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
Get.delete<ForgotPasswordController>();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleForgotPassword() async {
|
||||
setState(() => _isLoading = true);
|
||||
await controller.onForgotPassword();
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: contentTheme.brandRed,
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
return Column(
|
||||
body: Stack(
|
||||
children: [
|
||||
const _RedWaveBackground(),
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 16),
|
||||
_buildWelcomeTextsAndChips(),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
ScaleTransition(
|
||||
scale: _logoAnimation,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius:
|
||||
BorderRadius.vertical(top: Radius.circular(32)),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Image.asset(Images.logoDark),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24, vertical: 32),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight - 120,
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
MyText(
|
||||
"Welcome to Marco",
|
||||
fontSize: 24,
|
||||
fontWeight: 800,
|
||||
color: Colors.black87,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
MyText(
|
||||
"Streamline Project Management\nBoost Productivity with Automation.",
|
||||
fontSize: 14,
|
||||
color: Colors.black54,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_isBetaEnvironment) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orangeAccent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: MyText(
|
||||
'BETA',
|
||||
color: Colors.white,
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 36),
|
||||
_buildForgotCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForgotCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Form(
|
||||
key: controller.basicValidator.formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
MyText.titleLarge(
|
||||
MyText(
|
||||
'Forgot Password',
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: Colors.black87,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
MyText.bodyMedium(
|
||||
const SizedBox(height: 10),
|
||||
MyText(
|
||||
"Enter your email and we'll send you instructions to reset your password.",
|
||||
fontSize: 14,
|
||||
color: Colors.black54,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const SizedBox(height: 30),
|
||||
TextFormField(
|
||||
validator: controller.basicValidator
|
||||
.getValidation('email'),
|
||||
controller: controller.basicValidator
|
||||
.getController('email'),
|
||||
validator: controller.basicValidator.getValidation('email'),
|
||||
controller: controller.basicValidator.getController('email'),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
style: MyTextStyle.labelMedium(),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
labelText: "Email Address",
|
||||
labelStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||
labelStyle: const TextStyle(color: Colors.black54),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
prefixIcon:
|
||||
const Icon(LucideIcons.mail, size: 20),
|
||||
prefixIcon: const Icon(LucideIcons.mail, size: 20),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 16),
|
||||
floatingLabelBehavior:
|
||||
FloatingLabelBehavior.auto,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
const SizedBox(height: 32),
|
||||
MyButton.rounded(
|
||||
onPressed:
|
||||
_isLoading ? null : _handleForgotPassword,
|
||||
onPressed: _isLoading ? null : _handleForgotPassword,
|
||||
elevation: 2,
|
||||
padding: MySpacing.xy(80, 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16),
|
||||
borderRadiusAll: 10,
|
||||
backgroundColor: _isLoading ? Colors.red.withOpacity(0.6) : contentTheme.brandRed,
|
||||
backgroundColor: _isLoading
|
||||
? Colors.red.withOpacity(0.6)
|
||||
: contentTheme.brandRed,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
color: Colors.white, strokeWidth: 2),
|
||||
)
|
||||
: MyText.labelLarge(
|
||||
: MyText.bodyMedium(
|
||||
'Send Reset Link',
|
||||
fontWeight: 700,
|
||||
color: Colors.white,
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await LocalStorage.logout();
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.arrow_back,
|
||||
size: 16, color: Colors.red),
|
||||
const SizedBox(width: 4),
|
||||
MyText.bodySmall(
|
||||
'Back to log in',
|
||||
const SizedBox(height: 20),
|
||||
TextButton.icon(
|
||||
onPressed: () async => await LocalStorage.logout(),
|
||||
icon: const Icon(Icons.arrow_back,
|
||||
size: 18, color: Colors.redAccent),
|
||||
label: MyText.bodyMedium(
|
||||
'Back to Login',
|
||||
color: contentTheme.brandRed,
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
color: contentTheme.brandRed,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildWelcomeTextsAndChips() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
MyText.titleMedium(
|
||||
"Welcome to Marco",
|
||||
fontWeight: 600,
|
||||
color: Colors.white,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
MyText.bodySmall(
|
||||
"Streamline Project Management and Boost Productivity with Automation.",
|
||||
color: Colors.white70,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_isBetaEnvironment) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildBetaLabel(),
|
||||
],
|
||||
],
|
||||
),
|
||||
// Same red wave background as MPINAuthScreen
|
||||
class _RedWaveBackground extends StatelessWidget {
|
||||
const _RedWaveBackground();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
painter: _WavePainter(),
|
||||
size: Size.infinite,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBetaLabel() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orangeAccent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: MyText.bodySmall(
|
||||
'BETA',
|
||||
fontWeight: 600,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
class _WavePainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint1 = Paint()
|
||||
..shader = const LinearGradient(
|
||||
colors: [Color(0xFFB71C1C), Color(0xFFD32F2F)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
|
||||
|
||||
final path1 = Path()
|
||||
..moveTo(0, size.height * 0.2)
|
||||
..quadraticBezierTo(size.width * 0.25, size.height * 0.05,
|
||||
size.width * 0.5, size.height * 0.15)
|
||||
..quadraticBezierTo(
|
||||
size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(0, 0)
|
||||
..close();
|
||||
|
||||
canvas.drawPath(path1, paint1);
|
||||
|
||||
final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15);
|
||||
final path2 = Path()
|
||||
..moveTo(0, size.height * 0.25)
|
||||
..quadraticBezierTo(
|
||||
size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(0, 0)
|
||||
..close();
|
||||
|
||||
canvas.drawPath(path2, paint2);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Image.asset(Images.logoDark, height: 70),
|
||||
);
|
||||
}
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
@ -1,101 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/images.dart';
|
||||
import 'package:marco/view/auth/email_login_form.dart';
|
||||
import 'package:marco/view/auth/otp_login_form.dart';
|
||||
import 'package:marco/helpers/services/api_endpoints.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart'; // Make sure this import is added
|
||||
import 'package:marco/view/auth/request_demo_bottom_sheet.dart';
|
||||
|
||||
enum LoginOption { email, otp }
|
||||
|
||||
class LoginOptionScreen extends StatefulWidget {
|
||||
class LoginOptionScreen extends StatelessWidget {
|
||||
const LoginOptionScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginOptionScreen> createState() => _LoginOptionScreenState();
|
||||
Widget build(BuildContext context) => const WelcomeScreen();
|
||||
}
|
||||
|
||||
class _LoginOptionScreenState extends State<LoginOptionScreen> with UIMixin {
|
||||
LoginOption _selectedOption = LoginOption.email;
|
||||
class WelcomeScreen extends StatefulWidget {
|
||||
const WelcomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<WelcomeScreen> createState() => _WelcomeScreenState();
|
||||
}
|
||||
|
||||
class _WelcomeScreenState extends State<WelcomeScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _logoAnimation;
|
||||
|
||||
bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage");
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: contentTheme.brandRed,
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 16),
|
||||
_buildWelcomeTextsAndChips(),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius:
|
||||
BorderRadius.vertical(top: Radius.circular(32)),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight - 200,
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildLoginForm(),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 8),
|
||||
Center(child: _buildVersionInfo()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
},
|
||||
_logoAnimation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutBack,
|
||||
);
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showLoginDialog(BuildContext context, LoginOption option) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false, // Prevent dismiss on outside tap
|
||||
builder: (_) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Row with title and close button
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MyText(
|
||||
option == LoginOption.email
|
||||
? "Login with Email"
|
||||
: "Login with OTP",
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
option == LoginOption.email
|
||||
? EmailLoginForm()
|
||||
: const OTPLoginScreen(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
const _RedWaveBackground(),
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: screenWidth < 500 ? double.infinity : 420,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Logo with circular background
|
||||
ScaleTransition(
|
||||
scale: _logoAnimation,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 3),
|
||||
)
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Image.asset(Images.logoDark, height: 70),
|
||||
);
|
||||
}
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Image.asset(Images.logoDark),
|
||||
),
|
||||
),
|
||||
|
||||
Widget _buildBetaLabel() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Welcome Text
|
||||
MyText(
|
||||
"Welcome to Marco",
|
||||
fontSize: 26,
|
||||
fontWeight: 800,
|
||||
color: Colors.black87,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
MyText(
|
||||
"Streamline Project Management\nBoost Productivity with Automation.",
|
||||
fontSize: 14,
|
||||
color: Colors.black54,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
if (_isBetaEnvironment) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orangeAccent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: MyText(
|
||||
'BETA',
|
||||
@ -103,123 +170,149 @@ class _LoginOptionScreenState extends State<LoginOptionScreen> with UIMixin {
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
],
|
||||
|
||||
Widget _buildLoginOptionChips() {
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
_buildOptionChip(
|
||||
title: "User Name",
|
||||
const SizedBox(height: 36),
|
||||
|
||||
_buildActionButton(
|
||||
context,
|
||||
label: "Login with Username",
|
||||
icon: LucideIcons.mail,
|
||||
value: LoginOption.email,
|
||||
option: LoginOption.email,
|
||||
),
|
||||
_buildOptionChip(
|
||||
title: "OTP",
|
||||
const SizedBox(height: 16),
|
||||
_buildActionButton(
|
||||
context,
|
||||
label: "Login with OTP",
|
||||
icon: LucideIcons.message_square,
|
||||
value: LoginOption.otp,
|
||||
option: LoginOption.otp,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildActionButton(
|
||||
context,
|
||||
label: "Request a Demo",
|
||||
icon: LucideIcons.phone_call,
|
||||
option: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWelcomeTextsAndChips() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 36),
|
||||
MyText(
|
||||
"Welcome to Marco",
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: Colors.white,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
MyText(
|
||||
"Streamline Project Management and Boost Productivity with Automation.",
|
||||
fontSize: 14,
|
||||
color: Colors.white70,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_isBetaEnvironment) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildBetaLabel(),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_buildLoginOptionChips(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOptionChip({
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required LoginOption value,
|
||||
}) {
|
||||
final bool isSelected = _selectedOption == value;
|
||||
|
||||
final Color selectedTextColor = contentTheme.brandRed;
|
||||
final Color unselectedTextColor = Colors.white;
|
||||
final Color selectedBgColor = Colors.grey[100]!;
|
||||
final Color unselectedBgColor = contentTheme.brandRed;
|
||||
|
||||
return ChoiceChip(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: isSelected ? selectedTextColor : unselectedTextColor,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
MyText(
|
||||
title,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: isSelected ? selectedTextColor : unselectedTextColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
selected: isSelected,
|
||||
onSelected: (_) => setState(() => _selectedOption = value),
|
||||
selectedColor: selectedBgColor,
|
||||
backgroundColor: unselectedBgColor,
|
||||
side: BorderSide(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
width: 1.2,
|
||||
),
|
||||
elevation: 3,
|
||||
shadowColor: Colors.black12,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginForm() {
|
||||
switch (_selectedOption) {
|
||||
case LoginOption.email:
|
||||
return EmailLoginForm();
|
||||
case LoginOption.otp:
|
||||
return const OTPLoginScreen();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildVersionInfo() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: MyText(
|
||||
'App version 1.0.0',
|
||||
color: Colors.grey.shade500,
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
required IconData icon,
|
||||
LoginOption? option,
|
||||
}) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(icon, size: 20, color: Colors.white),
|
||||
label: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: MyText(
|
||||
label,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFB71C1C), // Red background
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
elevation: 4,
|
||||
shadowColor: Colors.black26,
|
||||
),
|
||||
onPressed: () {
|
||||
if (option == null) {
|
||||
OrganizationFormBottomSheet.show(context);
|
||||
} else {
|
||||
_showLoginDialog(context, option);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom red wave background shifted lower to reduce red area at top
|
||||
class _RedWaveBackground extends StatelessWidget {
|
||||
const _RedWaveBackground();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
painter: _WavePainter(),
|
||||
size: Size.infinite,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WavePainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint1 = Paint()
|
||||
..shader = const LinearGradient(
|
||||
colors: [Color(0xFFB71C1C), Color(0xFFD32F2F)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
|
||||
|
||||
final path1 = Path()
|
||||
..moveTo(0, size.height * 0.2)
|
||||
..quadraticBezierTo(
|
||||
size.width * 0.25,
|
||||
size.height * 0.05,
|
||||
size.width * 0.5,
|
||||
size.height * 0.15,
|
||||
)
|
||||
..quadraticBezierTo(
|
||||
size.width * 0.75,
|
||||
size.height * 0.25,
|
||||
size.width,
|
||||
size.height * 0.1,
|
||||
)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(0, 0)
|
||||
..close();
|
||||
|
||||
canvas.drawPath(path1, paint1);
|
||||
|
||||
final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15);
|
||||
|
||||
final path2 = Path()
|
||||
..moveTo(0, size.height * 0.25)
|
||||
..quadraticBezierTo(
|
||||
size.width * 0.4,
|
||||
size.height * 0.1,
|
||||
size.width,
|
||||
size.height * 0.2,
|
||||
)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(0, 0)
|
||||
..close();
|
||||
|
||||
canvas.drawPath(path2, paint2);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
@ -16,170 +16,173 @@ class MPINAuthScreen extends StatefulWidget {
|
||||
State<MPINAuthScreen> createState() => _MPINAuthScreenState();
|
||||
}
|
||||
|
||||
class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
|
||||
class _MPINAuthScreenState extends State<MPINAuthScreen>
|
||||
with UIMixin, SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _logoAnimation;
|
||||
|
||||
bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage");
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
_logoAnimation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutBack,
|
||||
);
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
Get.delete<MPINController>();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final MPINController controller = Get.put(MPINController());
|
||||
final controller = Get.put(MPINController());
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: contentTheme.brandRed,
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
return Column(
|
||||
body: Stack(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 16),
|
||||
_buildWelcomeTextsAndChips(),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius:
|
||||
BorderRadius.vertical(top: Radius.circular(32)),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight - 120),
|
||||
child: Obx(() {
|
||||
final isNewUser = controller.isNewUser.value;
|
||||
|
||||
return IntrinsicHeight(
|
||||
const _RedWaveBackground(),
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MyText.headlineSmall(
|
||||
isNewUser ? 'Generate MPIN' : 'Enter MPIN',
|
||||
fontWeight: 700,
|
||||
color: Colors.black87,
|
||||
const SizedBox(height: 24),
|
||||
// Static Logo (not scrollable)
|
||||
ScaleTransition(
|
||||
scale: _logoAnimation,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Image.asset(Images.logoDark),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
MyText.bodyMedium(
|
||||
isNewUser
|
||||
? 'Set your 6-digit MPIN for quick login.'
|
||||
: 'Enter your 6-digit MPIN to continue.',
|
||||
color: Colors.black54,
|
||||
fontSize: 16,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_buildMPINForm(controller, isNewUser),
|
||||
const SizedBox(height: 40),
|
||||
_buildSubmitButton(controller, isNewUser),
|
||||
const SizedBox(height: 24),
|
||||
_buildFooterOptions(controller, isNewUser),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () async {
|
||||
await LocalStorage.logout();
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back,
|
||||
color: Colors.white),
|
||||
label: MyText.bodyMedium(
|
||||
'Back to Home Page',
|
||||
color: Colors.white,
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWelcomeTextsAndChips() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
// Scrollable content below the logo
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
children: [
|
||||
MyText.headlineSmall(
|
||||
const SizedBox(height: 12),
|
||||
MyText(
|
||||
"Welcome to Marco",
|
||||
fontWeight: 700,
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: 800,
|
||||
color: Colors.black87,
|
||||
textAlign: TextAlign.center,
|
||||
fontSize: 20,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
MyText.bodyMedium(
|
||||
"Streamline Project Management and Boost Productivity with Automation.",
|
||||
color: Colors.white70,
|
||||
const SizedBox(height: 10),
|
||||
MyText(
|
||||
"Streamline Project Management\nBoost Productivity with Automation.",
|
||||
fontSize: 14,
|
||||
color: Colors.black54,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_isBetaEnvironment) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildBetaLabel(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBetaLabel() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orangeAccent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: MyText.bodySmall(
|
||||
child: MyText(
|
||||
'BETA',
|
||||
color: Colors.white,
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 3),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 36),
|
||||
_buildMPINCard(controller),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Image.asset(Images.logoDark, height: 70),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMPINCard(MPINController controller) {
|
||||
return Obx(() {
|
||||
final isNewUser = controller.isNewUser.value;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
MyText(
|
||||
isNewUser ? 'Generate MPIN' : 'Enter MPIN',
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: Colors.black87,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
MyText(
|
||||
isNewUser
|
||||
? 'Set your 6-digit MPIN for quick login.'
|
||||
: 'Enter your 6-digit MPIN to continue.',
|
||||
fontSize: 14,
|
||||
color: Colors.black54,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
_buildMPINForm(controller, isNewUser),
|
||||
const SizedBox(height: 32),
|
||||
_buildSubmitButton(controller, isNewUser),
|
||||
const SizedBox(height: 20),
|
||||
_buildFooterOptions(controller, isNewUser),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildMPINForm(MPINController controller, bool isNewUser) {
|
||||
return Form(
|
||||
key: controller.formKey,
|
||||
@ -187,8 +190,8 @@ class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
|
||||
children: [
|
||||
_buildDigitRow(controller, isRetype: false),
|
||||
if (isNewUser) ...[
|
||||
const SizedBox(height: 24),
|
||||
MyText.bodyMedium(
|
||||
const SizedBox(height: 20),
|
||||
MyText(
|
||||
'Retype MPIN',
|
||||
fontWeight: 600,
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
@ -203,8 +206,10 @@ class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
|
||||
}
|
||||
|
||||
Widget _buildDigitRow(MPINController controller, {required bool isRetype}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
return Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 0,
|
||||
runSpacing: 12,
|
||||
children: List.generate(6, (index) {
|
||||
return _buildDigitBox(controller, index, isRetype);
|
||||
}),
|
||||
@ -221,7 +226,7 @@ class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
width: 40,
|
||||
width: 30,
|
||||
height: 55,
|
||||
child: TextFormField(
|
||||
controller: textController,
|
||||
@ -294,10 +299,7 @@ class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
|
||||
children: [
|
||||
if (isNewUser)
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
Get.delete<MPINController>();
|
||||
Get.toNamed('/dashboard');
|
||||
},
|
||||
onPressed: () => Get.toNamed('/dashboard'),
|
||||
icon: const Icon(Icons.arrow_back,
|
||||
size: 18, color: Colors.redAccent),
|
||||
label: MyText.bodyMedium(
|
||||
@ -310,7 +312,6 @@ class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
|
||||
if (showBackToLogin)
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
Get.delete<MPINController>();
|
||||
await LocalStorage.logout();
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back,
|
||||
@ -327,3 +328,53 @@ class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _RedWaveBackground extends StatelessWidget {
|
||||
const _RedWaveBackground();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
painter: _WavePainter(),
|
||||
size: Size.infinite,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WavePainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint1 = Paint()
|
||||
..shader = const LinearGradient(
|
||||
colors: [Color(0xFFB71C1C), Color(0xFFD32F2F)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
|
||||
|
||||
final path1 = Path()
|
||||
..moveTo(0, size.height * 0.2)
|
||||
..quadraticBezierTo(size.width * 0.25, size.height * 0.05,
|
||||
size.width * 0.5, size.height * 0.15)
|
||||
..quadraticBezierTo(
|
||||
size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(0, 0)
|
||||
..close();
|
||||
|
||||
canvas.drawPath(path1, paint1);
|
||||
|
||||
final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15);
|
||||
final path2 = Path()
|
||||
..moveTo(0, size.height * 0.25)
|
||||
..quadraticBezierTo(
|
||||
size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(0, 0)
|
||||
..close();
|
||||
|
||||
canvas.drawPath(path2, paint2);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
@ -187,28 +187,48 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: contentTheme.brandRed,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.red),
|
||||
label: MyText.bodyMedium("Back", color: Colors.red),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.red),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 14),
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _loading ? null : _submitForm,
|
||||
child: _loading
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: MyText.labelLarge('Submit', color: Colors.white),
|
||||
: const Icon(Icons.check_circle_outline,
|
||||
color: Colors.white),
|
||||
label: _loading
|
||||
? const SizedBox.shrink()
|
||||
: MyText.bodyMedium("Submit", color: Colors.white),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.indigo,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 28, vertical: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
|
@ -71,52 +71,62 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(80),
|
||||
preferredSize: const Size.fromHeight(72),
|
||||
child: AppBar(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
elevation: 0.5,
|
||||
foregroundColor: Colors.black,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 0,
|
||||
centerTitle: false,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(top: 15.0),
|
||||
child: IconButton(
|
||||
title: Padding(
|
||||
padding: MySpacing.xy(16, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new,
|
||||
color: Colors.black, size: 20),
|
||||
onPressed: () {
|
||||
Get.offNamed('/dashboard');
|
||||
},
|
||||
onPressed: () => Get.offNamed('/dashboard'),
|
||||
),
|
||||
),
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.only(top: 15.0),
|
||||
MySpacing.width(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MyText.titleLarge(
|
||||
'Attendance',
|
||||
fontWeight: 700,
|
||||
color: Colors.black,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
MySpacing.height(2),
|
||||
GetBuilder<ProjectController>(
|
||||
builder: (projectController) {
|
||||
final projectName =
|
||||
projectController.selectedProject?.name ??
|
||||
'Select Project';
|
||||
return MyText.bodySmall(
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(Icons.work_outline,
|
||||
size: 14, color: Colors.grey),
|
||||
MySpacing.width(4),
|
||||
Expanded(
|
||||
child: MyText.bodySmall(
|
||||
projectName,
|
||||
fontWeight: 600,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
|
@ -25,6 +25,7 @@ class DashboardScreen extends StatefulWidget {
|
||||
static const String dailyTasksRoute = "/dashboard/daily-task-planing";
|
||||
static const String dailyTasksProgressRoute =
|
||||
"/dashboard/daily-task-progress";
|
||||
static const String directoryMainPageRoute = "/dashboard/directory-main-page";
|
||||
|
||||
@override
|
||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||
@ -154,6 +155,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
DashboardScreen.dailyTasksRoute),
|
||||
_StatItem(LucideIcons.list_todo, "Daily Task Progress", contentTheme.info,
|
||||
DashboardScreen.dailyTasksProgressRoute),
|
||||
_StatItem(LucideIcons.folder, "Directory", contentTheme.info,
|
||||
DashboardScreen.directoryMainPageRoute),
|
||||
];
|
||||
|
||||
return GetBuilder<ProjectController>(
|
||||
|
548
lib/view/directory/contact_detail_screen.dart
Normal file
548
lib/view/directory/contact_detail_screen.dart
Normal file
@ -0,0 +1,548 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:flutter_html/flutter_html.dart' as html;
|
||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||
import 'package:marco/controller/project_controller.dart';
|
||||
import 'package:marco/controller/directory/directory_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/model/directory/contact_model.dart';
|
||||
import 'package:marco/helpers/widgets/avatar.dart';
|
||||
import 'package:marco/helpers/utils/launcher_utils.dart';
|
||||
import 'package:tab_indicator_styler/tab_indicator_styler.dart';
|
||||
import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart';
|
||||
import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart';
|
||||
import 'package:marco/model/directory/add_comment_bottom_sheet.dart';
|
||||
import 'package:marco/helpers/utils/date_time_utils.dart';
|
||||
import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
|
||||
|
||||
class ContactDetailScreen extends StatefulWidget {
|
||||
final ContactModel contact;
|
||||
|
||||
const ContactDetailScreen({super.key, required this.contact});
|
||||
|
||||
@override
|
||||
State<ContactDetailScreen> createState() => _ContactDetailScreenState();
|
||||
}
|
||||
|
||||
String _convertDeltaToHtml(dynamic delta) {
|
||||
final buffer = StringBuffer();
|
||||
bool inList = false;
|
||||
|
||||
for (var op in delta.toList()) {
|
||||
final data = op.data?.toString() ?? '';
|
||||
final attr = op.attributes ?? {};
|
||||
|
||||
final isListItem = attr.containsKey('list');
|
||||
|
||||
// Start list
|
||||
if (isListItem && !inList) {
|
||||
buffer.write('<ul>');
|
||||
inList = true;
|
||||
}
|
||||
|
||||
// Close list if we are not in list mode anymore
|
||||
if (!isListItem && inList) {
|
||||
buffer.write('</ul>');
|
||||
inList = false;
|
||||
}
|
||||
|
||||
if (isListItem) buffer.write('<li>');
|
||||
|
||||
// Apply inline styles
|
||||
if (attr.containsKey('bold')) buffer.write('<strong>');
|
||||
if (attr.containsKey('italic')) buffer.write('<em>');
|
||||
if (attr.containsKey('underline')) buffer.write('<u>');
|
||||
if (attr.containsKey('strike')) buffer.write('<s>');
|
||||
if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">');
|
||||
|
||||
buffer.write(data.replaceAll('\n', ''));
|
||||
|
||||
if (attr.containsKey('link')) buffer.write('</a>');
|
||||
if (attr.containsKey('strike')) buffer.write('</s>');
|
||||
if (attr.containsKey('underline')) buffer.write('</u>');
|
||||
if (attr.containsKey('italic')) buffer.write('</em>');
|
||||
if (attr.containsKey('bold')) buffer.write('</strong>');
|
||||
|
||||
if (isListItem)
|
||||
buffer.write('</li>');
|
||||
else if (data.contains('\n')) buffer.write('<br>');
|
||||
}
|
||||
|
||||
if (inList) buffer.write('</ul>');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
||||
late final DirectoryController directoryController;
|
||||
late final ProjectController projectController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
directoryController = Get.find<DirectoryController>();
|
||||
projectController = Get.find<ProjectController>();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!directoryController.contactCommentsMap
|
||||
.containsKey(widget.contact.id)) {
|
||||
directoryController.fetchCommentsForContact(widget.contact.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: _buildMainAppBar(),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSubHeader(),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
_buildDetailsTab(),
|
||||
_buildCommentsTab(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildMainAppBar() {
|
||||
return AppBar(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
elevation: 0.2,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 0,
|
||||
title: Padding(
|
||||
padding: MySpacing.xy(16, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new,
|
||||
color: Colors.black, size: 20),
|
||||
onPressed: () => Get.back(),
|
||||
),
|
||||
MySpacing.width(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MyText.titleLarge('Contact Profile',
|
||||
fontWeight: 700, color: Colors.black),
|
||||
MySpacing.height(2),
|
||||
GetBuilder<ProjectController>(
|
||||
builder: (projectController) {
|
||||
final projectName =
|
||||
projectController.selectedProject?.name ??
|
||||
'Select Project';
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(Icons.work_outline,
|
||||
size: 14, color: Colors.grey),
|
||||
MySpacing.width(4),
|
||||
Expanded(
|
||||
child: MyText.bodySmall(
|
||||
projectName,
|
||||
fontWeight: 600,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubHeader() {
|
||||
return Padding(
|
||||
padding: MySpacing.xy(16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Avatar(
|
||||
firstName: widget.contact.name.split(" ").first,
|
||||
lastName: widget.contact.name.split(" ").length > 1
|
||||
? widget.contact.name.split(" ").last
|
||||
: "",
|
||||
size: 35,
|
||||
backgroundColor: Colors.indigo,
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleSmall(widget.contact.name,
|
||||
fontWeight: 600, color: Colors.black),
|
||||
MySpacing.height(2),
|
||||
MyText.bodySmall(widget.contact.organization,
|
||||
fontWeight: 500, color: Colors.grey[700]),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
TabBar(
|
||||
labelColor: Colors.red,
|
||||
unselectedLabelColor: Colors.black,
|
||||
indicator: MaterialIndicator(
|
||||
color: Colors.red,
|
||||
height: 4,
|
||||
topLeftRadius: 8,
|
||||
topRightRadius: 8,
|
||||
bottomLeftRadius: 8,
|
||||
bottomRightRadius: 8,
|
||||
),
|
||||
tabs: const [
|
||||
Tab(text: "Details"),
|
||||
Tab(text: "Comments"),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailsTab() {
|
||||
final email = widget.contact.contactEmails.isNotEmpty
|
||||
? widget.contact.contactEmails.first.emailAddress
|
||||
: "-";
|
||||
|
||||
final phone = widget.contact.contactPhones.isNotEmpty
|
||||
? widget.contact.contactPhones.first.phoneNumber
|
||||
: "-";
|
||||
|
||||
final tags = widget.contact.tags.map((e) => e.name).join(", ");
|
||||
|
||||
final bucketNames = widget.contact.bucketIds
|
||||
.map((id) => directoryController.contactBuckets
|
||||
.firstWhereOrNull((b) => b.id == id)
|
||||
?.name)
|
||||
.whereType<String>()
|
||||
.join(", ");
|
||||
|
||||
final projectNames = widget.contact.projectIds
|
||||
?.map((id) => projectController.projects
|
||||
.firstWhereOrNull((p) => p.id == id)
|
||||
?.name)
|
||||
.whereType<String>()
|
||||
.join(", ") ??
|
||||
"-";
|
||||
|
||||
final category = widget.contact.contactCategory?.name ?? "-";
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: MySpacing.fromLTRB(8, 8, 8, 80),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MySpacing.height(12),
|
||||
_infoCard("Basic Info", [
|
||||
_iconInfoRow(Icons.email, "Email", email,
|
||||
onTap: () => LauncherUtils.launchEmail(email),
|
||||
onLongPress: () => LauncherUtils.copyToClipboard(email,
|
||||
typeLabel: "Email")),
|
||||
_iconInfoRow(Icons.phone, "Phone", phone,
|
||||
onTap: () => LauncherUtils.launchPhone(phone),
|
||||
onLongPress: () => LauncherUtils.copyToClipboard(phone,
|
||||
typeLabel: "Phone")),
|
||||
_iconInfoRow(
|
||||
Icons.location_on, "Address", widget.contact.address),
|
||||
]),
|
||||
_infoCard("Organization", [
|
||||
_iconInfoRow(Icons.business, "Organization",
|
||||
widget.contact.organization),
|
||||
_iconInfoRow(Icons.category, "Category", category),
|
||||
]),
|
||||
_infoCard("Meta Info", [
|
||||
_iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"),
|
||||
_iconInfoRow(Icons.folder_shared, "Contact Buckets",
|
||||
bucketNames.isNotEmpty ? bucketNames : "-"),
|
||||
_iconInfoRow(Icons.work_outline, "Projects", projectNames),
|
||||
]),
|
||||
_infoCard("Description", [
|
||||
MySpacing.height(6),
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: MyText.bodyMedium(
|
||||
widget.contact.description,
|
||||
color: Colors.grey[800],
|
||||
maxLines: 10,
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
),
|
||||
])
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
child: FloatingActionButton.extended(
|
||||
backgroundColor: Colors.red,
|
||||
onPressed: () {
|
||||
Get.bottomSheet(
|
||||
AddContactBottomSheet(existingContact: widget.contact),
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.edit, color: Colors.white),
|
||||
label: const Text(
|
||||
"Edit Contact",
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommentsTab(BuildContext context) {
|
||||
return Obx(() {
|
||||
final contactId = widget.contact.id;
|
||||
|
||||
if (!directoryController.contactCommentsMap.containsKey(contactId)) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final comments = directoryController
|
||||
.getCommentsForContact(contactId)
|
||||
.reversed
|
||||
.toList();
|
||||
|
||||
final editingId = directoryController.editingCommentId.value;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
comments.isEmpty
|
||||
? Center(
|
||||
child:
|
||||
MyText.bodyLarge("No comments yet.", color: Colors.grey),
|
||||
)
|
||||
: Padding(
|
||||
padding: MySpacing.xy(12, 12),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
itemCount: comments.length,
|
||||
separatorBuilder: (_, __) => MySpacing.height(14),
|
||||
itemBuilder: (_, index) {
|
||||
final comment = comments[index];
|
||||
final isEditing = editingId == comment.id;
|
||||
|
||||
final initials = comment.createdBy.firstName.isNotEmpty
|
||||
? comment.createdBy.firstName[0].toUpperCase()
|
||||
: "?";
|
||||
|
||||
final decodedDelta = HtmlToDelta().convert(comment.note);
|
||||
|
||||
final quillController = isEditing
|
||||
? quill.QuillController(
|
||||
document: quill.Document.fromDelta(decodedDelta),
|
||||
selection: TextSelection.collapsed(
|
||||
offset: decodedDelta.length),
|
||||
)
|
||||
: null;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
padding: MySpacing.xy(8, 7),
|
||||
decoration: BoxDecoration(
|
||||
color: isEditing ? Colors.indigo[50] : Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isEditing
|
||||
? Colors.indigo
|
||||
: Colors.grey.shade300,
|
||||
width: 1.2,
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header Row
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Avatar(
|
||||
firstName: initials,
|
||||
lastName: '',
|
||||
size: 36),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.bodyMedium(
|
||||
"By: ${comment.createdBy.firstName}",
|
||||
fontWeight: 600,
|
||||
color: Colors.indigo[800],
|
||||
),
|
||||
MySpacing.height(4),
|
||||
MyText.bodySmall(
|
||||
DateTimeUtils.convertUtcToLocal(
|
||||
comment.createdAt.toString(),
|
||||
format: 'dd MMM yyyy, hh:mm a',
|
||||
),
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isEditing ? Icons.close : Icons.edit,
|
||||
size: 20,
|
||||
color: Colors.indigo,
|
||||
),
|
||||
onPressed: () {
|
||||
directoryController.editingCommentId.value =
|
||||
isEditing ? null : comment.id;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// Comment Content
|
||||
if (isEditing && quillController != null)
|
||||
CommentEditorCard(
|
||||
controller: quillController,
|
||||
onCancel: () {
|
||||
directoryController.editingCommentId.value =
|
||||
null;
|
||||
},
|
||||
onSave: (controller) async {
|
||||
final delta = controller.document.toDelta();
|
||||
final htmlOutput = _convertDeltaToHtml(delta);
|
||||
final updated =
|
||||
comment.copyWith(note: htmlOutput);
|
||||
await directoryController
|
||||
.updateComment(updated);
|
||||
directoryController.editingCommentId.value =
|
||||
null;
|
||||
},
|
||||
)
|
||||
else
|
||||
html.Html(
|
||||
data: comment.note,
|
||||
style: {
|
||||
"body": html.Style(
|
||||
margin: html.Margins.zero,
|
||||
padding: html.HtmlPaddings.zero,
|
||||
fontSize: html.FontSize.medium,
|
||||
color: Colors.black87,
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Floating Action Button
|
||||
if (directoryController.editingCommentId.value == null)
|
||||
Positioned(
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
child: FloatingActionButton.extended(
|
||||
backgroundColor: Colors.red,
|
||||
onPressed: () {
|
||||
Get.bottomSheet(
|
||||
AddCommentBottomSheet(contactId: contactId),
|
||||
isScrollControlled: true,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add_comment, color: Colors.white),
|
||||
label: const Text(
|
||||
"Add Comment",
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _iconInfoRow(IconData icon, String label, String value,
|
||||
{VoidCallback? onTap, VoidCallback? onLongPress}) {
|
||||
return Padding(
|
||||
padding: MySpacing.y(8),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: Colors.indigo),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.bodySmall(label,
|
||||
fontWeight: 600, color: Colors.black87),
|
||||
MySpacing.height(2),
|
||||
MyText.bodyMedium(value, color: Colors.grey[800]),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoCard(String title, List<Widget> children) {
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 2,
|
||||
margin: MySpacing.bottom(12),
|
||||
child: Padding(
|
||||
padding: MySpacing.xy(16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleSmall(title,
|
||||
fontWeight: 700, color: Colors.indigo[700]),
|
||||
MySpacing.height(8),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
195
lib/view/directory/directory_main_screen.dart
Normal file
195
lib/view/directory/directory_main_screen.dart
Normal file
@ -0,0 +1,195 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:marco/controller/directory/directory_controller.dart';
|
||||
import 'package:marco/controller/directory/notes_controller.dart';
|
||||
import 'package:marco/controller/project_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
|
||||
import 'package:marco/view/directory/directory_view.dart';
|
||||
import 'package:marco/view/directory/notes_view.dart';
|
||||
|
||||
class DirectoryMainScreen extends StatelessWidget {
|
||||
DirectoryMainScreen({super.key});
|
||||
|
||||
final DirectoryController controller = Get.put(DirectoryController());
|
||||
final NotesController notesController = Get.put(NotesController());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(72),
|
||||
child: AppBar(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
elevation: 0.5,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 0,
|
||||
title: Padding(
|
||||
padding: MySpacing.xy(16, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new,
|
||||
color: Colors.black, size: 20),
|
||||
onPressed: () => Get.offNamed('/dashboard'),
|
||||
),
|
||||
MySpacing.width(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MyText.titleLarge(
|
||||
'Directory',
|
||||
fontWeight: 700,
|
||||
color: Colors.black,
|
||||
),
|
||||
MySpacing.height(2),
|
||||
GetBuilder<ProjectController>(
|
||||
builder: (projectController) {
|
||||
final projectName =
|
||||
projectController.selectedProject?.name ??
|
||||
'Select Project';
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(Icons.work_outline,
|
||||
size: 14, color: Colors.grey),
|
||||
MySpacing.width(4),
|
||||
Expanded(
|
||||
child: MyText.bodySmall(
|
||||
projectName,
|
||||
fontWeight: 600,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Toggle between Directory and Notes
|
||||
Padding(
|
||||
padding: MySpacing.fromLTRB(8, 12, 8, 5),
|
||||
child: Obx(() {
|
||||
final isNotesView = controller.isNotesView.value;
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F0F0),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => controller.isNotesView.value = false,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 6, horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: !isNotesView
|
||||
? Colors.red
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.contacts,
|
||||
size: 16,
|
||||
color: !isNotesView
|
||||
? Colors.white
|
||||
: Colors.grey),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Directory',
|
||||
style: TextStyle(
|
||||
color: !isNotesView
|
||||
? Colors.white
|
||||
: Colors.grey,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => controller.isNotesView.value = true,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 6, horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
isNotesView ? Colors.red : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.notes,
|
||||
size: 16,
|
||||
color: isNotesView
|
||||
? Colors.white
|
||||
: Colors.grey),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Notes',
|
||||
style: TextStyle(
|
||||
color: isNotesView
|
||||
? Colors.white
|
||||
: Colors.grey,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
// Main View
|
||||
Expanded(
|
||||
child: Obx(() =>
|
||||
controller.isNotesView.value ? NotesView() : DirectoryView()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
370
lib/view/directory/directory_view.dart
Normal file
370
lib/view/directory/directory_view.dart
Normal file
@ -0,0 +1,370 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
|
||||
import 'package:marco/controller/directory/directory_controller.dart';
|
||||
import 'package:marco/helpers/utils/launcher_utils.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/avatar.dart';
|
||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||
import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
|
||||
import 'package:marco/model/directory/directory_filter_bottom_sheet.dart';
|
||||
import 'package:marco/view/directory/contact_detail_screen.dart';
|
||||
|
||||
class DirectoryView extends StatelessWidget {
|
||||
final DirectoryController controller = Get.find();
|
||||
final TextEditingController searchController = TextEditingController();
|
||||
|
||||
Future<void> _refreshDirectory() async {
|
||||
try {
|
||||
await controller.fetchContacts();
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('Error refreshing directory data: ${e.toString()}');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
floatingActionButton: FloatingActionButton(
|
||||
backgroundColor: Colors.red,
|
||||
onPressed: () async {
|
||||
final result = await Get.bottomSheet(
|
||||
AddContactBottomSheet(),
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
if (result == true) {
|
||||
controller.fetchContacts();
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: MySpacing.xy(8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 35,
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
onChanged: (value) {
|
||||
controller.searchQuery.value = value;
|
||||
controller.applyFilters();
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12),
|
||||
prefixIcon: const Icon(Icons.search,
|
||||
size: 20, color: Colors.grey),
|
||||
hintText: 'Search contacts...',
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
MySpacing.width(8),
|
||||
Tooltip(
|
||||
message: 'Refresh Data',
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
onTap: _refreshDirectory,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(0),
|
||||
child: Icon(Icons.refresh, color: Colors.green, size: 28),
|
||||
),
|
||||
),
|
||||
),
|
||||
MySpacing.width(8),
|
||||
Obx(() {
|
||||
final isFilterActive = controller.hasActiveFilters();
|
||||
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: IconButton(
|
||||
icon: Icon(Icons.filter_alt_outlined,
|
||||
size: 20,
|
||||
color: isFilterActive
|
||||
? Colors.indigo
|
||||
: Colors.black87),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) =>
|
||||
const DirectoryFilterBottomSheet(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (isFilterActive)
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: Container(
|
||||
height: 8,
|
||||
width: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
MySpacing.width(10),
|
||||
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) => [
|
||||
PopupMenuItem<int>(
|
||||
value: 0,
|
||||
enabled: false,
|
||||
child: Obx(() => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
MyText.bodySmall('Show Inactive',
|
||||
fontWeight: 600),
|
||||
Switch.adaptive(
|
||||
value: !controller.isActive.value,
|
||||
activeColor: Colors.indigo,
|
||||
onChanged: (val) {
|
||||
controller.isActive.value = !val;
|
||||
controller.fetchContacts(active: !val);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return ListView.separated(
|
||||
itemCount: 10,
|
||||
separatorBuilder: (_, __) => MySpacing.height(12),
|
||||
itemBuilder: (_, __) => SkeletonLoaders.contactSkeletonCard(),
|
||||
);
|
||||
}
|
||||
|
||||
if (controller.filteredContacts.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.contact_page_outlined,
|
||||
size: 60, color: Colors.grey),
|
||||
const SizedBox(height: 12),
|
||||
MyText.bodyMedium('No contacts found.', fontWeight: 500),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 80),
|
||||
itemCount: controller.filteredContacts.length,
|
||||
separatorBuilder: (_, __) => MySpacing.height(12),
|
||||
itemBuilder: (_, index) {
|
||||
final contact = controller.filteredContacts[index];
|
||||
final nameParts = contact.name.trim().split(" ");
|
||||
final firstName = nameParts.first;
|
||||
final lastName = nameParts.length > 1 ? nameParts.last : "";
|
||||
final tags = contact.tags.map((tag) => tag.name).toList();
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Get.to(() => ContactDetailScreen(contact: contact));
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Avatar(
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
size: 35),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleSmall(contact.name,
|
||||
fontWeight: 600,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
MyText.bodySmall(contact.organization,
|
||||
color: Colors.grey[700],
|
||||
overflow: TextOverflow.ellipsis),
|
||||
MySpacing.height(8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...contact.contactEmails.map((e) =>
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
LauncherUtils.launchEmail(
|
||||
e.emailAddress),
|
||||
onLongPress: () =>
|
||||
LauncherUtils.copyToClipboard(
|
||||
e.emailAddress,
|
||||
typeLabel: 'Email'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.email_outlined,
|
||||
size: 16,
|
||||
color: Colors.indigo),
|
||||
MySpacing.width(4),
|
||||
ConstrainedBox(
|
||||
constraints:
|
||||
const BoxConstraints(
|
||||
maxWidth: 180),
|
||||
child: MyText.labelSmall(
|
||||
e.emailAddress,
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
color: Colors.indigo,
|
||||
decoration: TextDecoration
|
||||
.underline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
...contact.contactPhones.map((p) => Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 8,top: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
LauncherUtils.launchPhone(
|
||||
p.phoneNumber),
|
||||
onLongPress: () => LauncherUtils
|
||||
.copyToClipboard(
|
||||
p.phoneNumber,
|
||||
typeLabel:
|
||||
'Phone number'),
|
||||
child: Row(
|
||||
mainAxisSize:
|
||||
MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.phone_outlined,
|
||||
size: 16,
|
||||
color: Colors.indigo),
|
||||
MySpacing.width(4),
|
||||
ConstrainedBox(
|
||||
constraints:
|
||||
const BoxConstraints(
|
||||
maxWidth: 140),
|
||||
child: MyText.labelSmall(
|
||||
p.phoneNumber,
|
||||
overflow: TextOverflow
|
||||
.ellipsis,
|
||||
color: Colors.indigo,
|
||||
decoration:
|
||||
TextDecoration
|
||||
.underline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
MySpacing.width(8),
|
||||
GestureDetector(
|
||||
onTap: () => LauncherUtils
|
||||
.launchWhatsApp(
|
||||
p.phoneNumber),
|
||||
child: const FaIcon(
|
||||
FontAwesomeIcons.whatsapp,
|
||||
color: Colors.green,
|
||||
size: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
))
|
||||
],
|
||||
),
|
||||
if (tags.isNotEmpty) ...[
|
||||
MySpacing.height(2),
|
||||
MyText.labelSmall(tags.join(', '),
|
||||
color: Colors.grey[500],
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.arrow_forward_ios,
|
||||
color: Colors.grey, size: 16),
|
||||
MySpacing.height(8),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
281
lib/view/directory/notes_view.dart
Normal file
281
lib/view/directory/notes_view.dart
Normal file
@ -0,0 +1,281 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||
import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart';
|
||||
import 'package:flutter_html/flutter_html.dart' as html;
|
||||
|
||||
import 'package:marco/controller/directory/notes_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/avatar.dart';
|
||||
import 'package:marco/helpers/utils/date_time_utils.dart';
|
||||
import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart';
|
||||
|
||||
class NotesView extends StatelessWidget {
|
||||
final NotesController controller = Get.find();
|
||||
final TextEditingController searchController = TextEditingController();
|
||||
|
||||
NotesView({super.key});
|
||||
|
||||
Future<void> _refreshNotes() async {
|
||||
try {
|
||||
await controller.fetchNotes();
|
||||
} catch (e, st) {
|
||||
debugPrint('Error refreshing notes: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
String _convertDeltaToHtml(dynamic delta) {
|
||||
final buffer = StringBuffer();
|
||||
bool inList = false;
|
||||
|
||||
for (var op in delta.toList()) {
|
||||
final data = op.data?.toString() ?? '';
|
||||
final attr = op.attributes ?? {};
|
||||
final isListItem = attr.containsKey('list');
|
||||
|
||||
if (isListItem && !inList) {
|
||||
buffer.write('<ul>');
|
||||
inList = true;
|
||||
}
|
||||
if (!isListItem && inList) {
|
||||
buffer.write('</ul>');
|
||||
inList = false;
|
||||
}
|
||||
|
||||
if (isListItem) buffer.write('<li>');
|
||||
|
||||
if (attr.containsKey('bold')) buffer.write('<strong>');
|
||||
if (attr.containsKey('italic')) buffer.write('<em>');
|
||||
if (attr.containsKey('underline')) buffer.write('<u>');
|
||||
if (attr.containsKey('strike')) buffer.write('<s>');
|
||||
if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">');
|
||||
|
||||
buffer.write(data.replaceAll('\n', ''));
|
||||
|
||||
if (attr.containsKey('link')) buffer.write('</a>');
|
||||
if (attr.containsKey('strike')) buffer.write('</s>');
|
||||
if (attr.containsKey('underline')) buffer.write('</u>');
|
||||
if (attr.containsKey('italic')) buffer.write('</em>');
|
||||
if (attr.containsKey('bold')) buffer.write('</strong>');
|
||||
|
||||
if (isListItem)
|
||||
buffer.write('</li>');
|
||||
else if (data.contains('\n')) buffer.write('<br>');
|
||||
}
|
||||
|
||||
if (inList) buffer.write('</ul>');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
/// 🔍 Search + Refresh (Top Row)
|
||||
Padding(
|
||||
padding: MySpacing.xy(8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 35,
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
onChanged: (value) => controller.searchQuery.value = value,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
prefixIcon: const Icon(Icons.search,
|
||||
size: 20, color: Colors.grey),
|
||||
hintText: 'Search notes...',
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
MySpacing.width(8),
|
||||
Tooltip(
|
||||
message: 'Refresh Notes',
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
onTap: _refreshNotes,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: Icon(Icons.refresh, color: Colors.green, size: 26),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
/// 📄 Notes List View
|
||||
Expanded(
|
||||
child: Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final notes = controller.filteredNotesList;
|
||||
|
||||
if (notes.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.note_alt_outlined,
|
||||
size: 60, color: Colors.grey),
|
||||
const SizedBox(height: 12),
|
||||
MyText.bodyMedium('No notes found.', fontWeight: 500),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 80),
|
||||
itemCount: notes.length,
|
||||
separatorBuilder: (_, __) => MySpacing.height(12),
|
||||
itemBuilder: (_, index) {
|
||||
final note = notes[index];
|
||||
|
||||
return Obx(() {
|
||||
final isEditing = controller.editingNoteId.value == note.id;
|
||||
|
||||
final initials = note.contactName.trim().isNotEmpty
|
||||
? note.contactName
|
||||
.trim()
|
||||
.split(' ')
|
||||
.map((e) => e[0])
|
||||
.take(2)
|
||||
.join()
|
||||
.toUpperCase()
|
||||
: "NA";
|
||||
|
||||
final createdDate = DateTimeUtils.convertUtcToLocal(
|
||||
note.createdAt.toString(),
|
||||
format: 'dd MMM yyyy');
|
||||
final createdTime = DateTimeUtils.convertUtcToLocal(
|
||||
note.createdAt.toString(),
|
||||
format: 'hh:mm a');
|
||||
|
||||
final decodedDelta = HtmlToDelta().convert(note.note);
|
||||
final quillController = isEditing
|
||||
? quill.QuillController(
|
||||
document: quill.Document.fromDelta(decodedDelta),
|
||||
selection: TextSelection.collapsed(
|
||||
offset: decodedDelta.length),
|
||||
)
|
||||
: null;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
padding: MySpacing.xy(12, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isEditing ? Colors.indigo[50] : Colors.white,
|
||||
border: Border.all(
|
||||
color: isEditing ? Colors.indigo : Colors.grey.shade300,
|
||||
width: 1.1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
/// Header Row
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Avatar(firstName: initials, lastName: '', size: 40),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleSmall(
|
||||
"${note.contactName} (${note.organizationName})",
|
||||
fontWeight: 600,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: Colors.indigo[800],
|
||||
),
|
||||
MyText.bodySmall(
|
||||
"by ${note.createdBy.firstName} • $createdDate, $createdTime",
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isEditing ? Icons.close : Icons.edit,
|
||||
color: Colors.indigo,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
controller.editingNoteId.value =
|
||||
isEditing ? null : note.id;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
MySpacing.height(12),
|
||||
|
||||
/// Content
|
||||
if (isEditing && quillController != null)
|
||||
CommentEditorCard(
|
||||
controller: quillController,
|
||||
onCancel: () =>
|
||||
controller.editingNoteId.value = null,
|
||||
onSave: (quillCtrl) async {
|
||||
final delta = quillCtrl.document.toDelta();
|
||||
final htmlOutput = _convertDeltaToHtml(delta);
|
||||
final updated = note.copyWith(note: htmlOutput);
|
||||
await controller.updateNote(updated);
|
||||
controller.editingNoteId.value = null;
|
||||
},
|
||||
)
|
||||
else
|
||||
html.Html(
|
||||
data: note.note,
|
||||
style: {
|
||||
"body": html.Style(
|
||||
margin: html.Margins.zero,
|
||||
padding: html.HtmlPaddings.zero,
|
||||
fontSize: html.FontSize.medium,
|
||||
color: Colors.black87,
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -15,6 +15,7 @@ import 'package:marco/helpers/widgets/avatar.dart';
|
||||
import 'package:marco/model/employees/employee_detail_bottom_sheet.dart';
|
||||
import 'package:marco/controller/project_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||
|
||||
class EmployeesScreen extends StatefulWidget {
|
||||
const EmployeesScreen({super.key});
|
||||
|
||||
@ -74,52 +75,62 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(80),
|
||||
preferredSize: const Size.fromHeight(72),
|
||||
child: AppBar(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
elevation: 0.5,
|
||||
foregroundColor: Colors.black,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 0,
|
||||
centerTitle: false,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(top: 15.0), // Aligns with title
|
||||
child: IconButton(
|
||||
title: Padding(
|
||||
padding: MySpacing.xy(16, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new,
|
||||
color: Colors.black, size: 20),
|
||||
onPressed: () {
|
||||
Get.offNamed('/dashboard');
|
||||
},
|
||||
onPressed: () => Get.offNamed('/dashboard'),
|
||||
),
|
||||
),
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.only(top: 15.0),
|
||||
MySpacing.width(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MyText.titleLarge(
|
||||
'Employees',
|
||||
fontWeight: 700,
|
||||
color: Colors.black,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
MySpacing.height(2),
|
||||
GetBuilder<ProjectController>(
|
||||
builder: (projectController) {
|
||||
final projectName =
|
||||
projectController.selectedProject?.name ??
|
||||
'Select Project';
|
||||
return MyText.bodySmall(
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(Icons.work_outline,
|
||||
size: 14, color: Colors.grey),
|
||||
MySpacing.width(4),
|
||||
Expanded(
|
||||
child: MyText.bodySmall(
|
||||
projectName,
|
||||
fontWeight: 600,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButton: InkWell(
|
||||
|
@ -59,6 +59,11 @@ class _UserProfileBarState extends State<UserProfileBar>
|
||||
width: isCondensed ? 90 : 250,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
child: SafeArea(
|
||||
bottom: true,
|
||||
top: false,
|
||||
left: false,
|
||||
right: false,
|
||||
child: Column(
|
||||
children: [
|
||||
userProfileSection(),
|
||||
@ -69,6 +74,7 @@ class _UserProfileBarState extends State<UserProfileBar>
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -25,7 +25,7 @@ class MyApp extends StatelessWidget {
|
||||
}
|
||||
|
||||
final bool hasMpin = LocalStorage.getIsMpin();
|
||||
logSafe("MPIN enabled: $hasMpin", sensitive: true);
|
||||
logSafe("MPIN enabled: $hasMpin", );
|
||||
|
||||
if (hasMpin) {
|
||||
await LocalStorage.setBool("mpin_verified", false);
|
||||
|
@ -67,52 +67,62 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(80),
|
||||
preferredSize: const Size.fromHeight(72),
|
||||
child: AppBar(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
elevation: 0.5,
|
||||
foregroundColor: Colors.black,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 0,
|
||||
centerTitle: false,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(top: 15.0), // Aligns with title
|
||||
child: IconButton(
|
||||
title: Padding(
|
||||
padding: MySpacing.xy(16, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new,
|
||||
color: Colors.black, size: 20),
|
||||
onPressed: () {
|
||||
Get.offNamed('/dashboard');
|
||||
},
|
||||
onPressed: () => Get.offNamed('/dashboard'),
|
||||
),
|
||||
),
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.only(top: 15.0),
|
||||
MySpacing.width(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MyText.titleLarge(
|
||||
'Daily Task Progress',
|
||||
fontWeight: 700,
|
||||
color: Colors.black,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
MySpacing.height(2),
|
||||
GetBuilder<ProjectController>(
|
||||
builder: (projectController) {
|
||||
final projectName =
|
||||
projectController.selectedProject?.name ??
|
||||
'Select Project';
|
||||
return MyText.bodySmall(
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(Icons.work_outline,
|
||||
size: 14, color: Colors.grey),
|
||||
MySpacing.width(4),
|
||||
Expanded(
|
||||
child: MyText.bodySmall(
|
||||
projectName,
|
||||
fontWeight: 600,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
@ -451,7 +461,9 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
||||
: Colors.red[700],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (task.reportedDate == null ||
|
||||
@ -464,7 +476,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
||||
completed: completed.toInt(),
|
||||
refreshCallback: _refreshData,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 4),
|
||||
] else if (task.approvedBy == null) ...[
|
||||
TaskActionButtons.reportActionButton(
|
||||
context: context,
|
||||
@ -475,7 +487,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
||||
completed: completed.toInt(),
|
||||
refreshCallback: _refreshData,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
TaskActionButtons.commentButton(
|
||||
context: context,
|
||||
@ -486,6 +498,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
||||
refreshCallback: _refreshData,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
@ -53,52 +53,62 @@ class _DailyTaskPlaningScreenState extends State<DailyTaskPlaningScreen>
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(80),
|
||||
preferredSize: const Size.fromHeight(72),
|
||||
child: AppBar(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
elevation: 0.5,
|
||||
foregroundColor: Colors.black,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 0,
|
||||
centerTitle: false,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(top: 15.0), // Aligns with title
|
||||
child: IconButton(
|
||||
title: Padding(
|
||||
padding: MySpacing.xy(16, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new,
|
||||
color: Colors.black, size: 20),
|
||||
onPressed: () {
|
||||
Get.offNamed('/dashboard');
|
||||
},
|
||||
onPressed: () => Get.offNamed('/dashboard'),
|
||||
),
|
||||
),
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.only(top: 15.0),
|
||||
MySpacing.width(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MyText.titleLarge(
|
||||
'Daily Task Planning',
|
||||
'Daily Task Planing',
|
||||
fontWeight: 700,
|
||||
color: Colors.black,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
MySpacing.height(2),
|
||||
GetBuilder<ProjectController>(
|
||||
builder: (projectController) {
|
||||
final projectName =
|
||||
projectController.selectedProject?.name ??
|
||||
'Select Project';
|
||||
return MyText.bodySmall(
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(Icons.work_outline,
|
||||
size: 14, color: Colors.grey),
|
||||
MySpacing.width(4),
|
||||
Expanded(
|
||||
child: MyText.bodySmall(
|
||||
projectName,
|
||||
fontWeight: 600,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
|
@ -52,7 +52,7 @@ dependencies:
|
||||
intl: ^0.19.0
|
||||
syncfusion_flutter_core: ^28.1.33
|
||||
syncfusion_flutter_sliders: ^28.1.33
|
||||
file_picker: ^8.1.5
|
||||
file_picker: ^9.2.3
|
||||
timelines_plus: ^1.0.4
|
||||
syncfusion_flutter_charts: ^28.1.33
|
||||
appflowy_board: ^0.1.2
|
||||
@ -71,6 +71,12 @@ dependencies:
|
||||
flutter_contacts: ^1.1.9+2
|
||||
photo_view: ^0.15.0
|
||||
jwt_decoder: ^2.0.1
|
||||
font_awesome_flutter: ^10.8.0
|
||||
flutter_html: ^3.0.0
|
||||
tab_indicator_styler: ^2.0.0
|
||||
html_editor_enhanced: ^2.7.0
|
||||
flutter_quill_delta_from_html: ^1.5.2
|
||||
quill_delta: ^3.0.0-nullsafety.2
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
Loading…
x
Reference in New Issue
Block a user