Compare commits

...

40 Commits

Author SHA1 Message Date
059e7c6c8b Merge pull request 'Vaibhav_Feature-#541' (#52) from Vaibhav_Feature-#541 into main
Reviewed-on: #52
2025-07-11 07:39:25 +00:00
Vaibhav Surve
8a729f23fe _handleButtonPressed 2025-07-10 17:58:52 +05:30
Vaibhav Surve
71f9e54d58 feat(api): update daily task details endpoint to use new URL 2025-07-10 11:16:15 +05:30
Vaibhav Surve
1e1bcc3aa4 feat(otp): implement email saving and loading functionality in OTPController 2025-07-09 16:40:23 +05:30
Vaibhav Surve
5b5030ec36 feat(auth): refactor login success flow to inject controllers and load data conditionally 2025-07-09 15:48:15 +05:30
Vaibhav Surve
ffba37b767 feat(forgot-password): enhance ForgotPasswordScreen with logo animation and improved layout 2025-07-09 13:02:35 +05:30
Vaibhav Surve
efd5021ab1 feat(mpin-auth): enhance MPINAuthScreen with logo animation and improved layout 2025-07-09 12:43:19 +05:30
Vaibhav Surve
91e2bb7bc8 feat(login): enhance WelcomeScreen with animation and improved dialog layout 2025-07-09 12:27:58 +05:30
Vaibhav Surve
f4135a77d8 feat(login): refactor login option screen to include demo request button and improve layout 2025-07-09 11:53:30 +05:30
Vaibhav Surve
aac65104ab refactor(logging): remove sensitive flag from logSafe calls across multiple controllers and services 2025-07-09 11:35:35 +05:30
Vaibhav Surve
e059ee71f3 feat(user-profile): wrap content in SafeArea for improved layout on different devices 2025-07-08 17:58:08 +05:30
Vaibhav Surve
a9067bd407 feat(comment-editor): disable list buttons and adjust layout for improved usability 2025-07-08 17:57:53 +05:30
Vaibhav Surve
b3b68b6258 style(directory): format code for improved readability and consistency 2025-07-08 16:33:02 +05:30
Vaibhav Surve
6907d176da feat(directory): enhance toggle UI for Directory and Notes views with improved styling and animations 2025-07-08 16:06:11 +05:30
Vaibhav Surve
ae868bb0f6 feat(directory): reduce spacing and padding in category and bucket displays 2025-07-08 16:01:10 +05:30
Vaibhav Surve
df0dd5d560 feat(directory): adjust avatar size and alignment in contact list 2025-07-08 15:44:52 +05:30
Vaibhav Surve
1ad880a021 feat(notes): add feedback messages for note update actions 2025-07-08 15:33:35 +05:30
Vaibhav Surve
fb28439d69 feat(comment): add feedback messages for comment update actions 2025-07-08 15:32:20 +05:30
Vaibhav Surve
2fef2e508e feat(contact): support multiple project selection in AddContact functionality 2025-07-08 15:28:20 +05:30
Vaibhav Surve
a8c890a60d feat(comment): improve comment submission feedback and validation messages 2025-07-08 13:17:21 +05:30
Vaibhav Surve
77e27ff98e feat(contact): enhance AddContact functionality with validation and initialization state 2025-07-08 13:10:15 +05:30
Vaibhav Surve
445cd75e03 feat(contact): implement contact editing functionality and update API integration 2025-07-07 15:34:07 +05:30
Vaibhav Surve
5fb18a13d2 feat(directory): refactor DirectoryView layout for improved structure and readability 2025-07-07 14:11:15 +05:30
Vaibhav Surve
43aeec4c6f feat(directory): integrate NotesController in DirectoryMainScreen and improve code formatting in NotesView 2025-07-07 13:57:20 +05:30
Vaibhav Surve
5e8158a410 feat(directory): center align buttons in DirectoryMainScreen and adjust padding in Directory and Notes views 2025-07-07 13:48:57 +05:30
Vaibhav Surve
45ce53539c feat(directory): enhance search functionality in Directory and Notes views 2025-07-07 13:41:46 +05:30
Vaibhav Surve
7a2798401a feat: Add Notes functionality and integrate with Directory
- Introduced NotesController to manage notes fetching, updating, and state management.
- Created NoteListResponseModel and NoteModel to handle notes data structure.
- Implemented API service method to fetch directory notes.
- Added NotesView to display notes with editing capabilities.
- Updated DirectoryController to include a flag for toggling between Directory and Notes views.
- Refactored DirectoryMainScreen to accommodate the new NotesView and toggle functionality.
- Enhanced UI components for better user experience in both Directory and Notes views.
2025-07-07 12:55:52 +05:30
Vaibhav Surve
56b493c909 feat(directory): refactor contact card layout for improved structure and readability 2025-07-07 10:13:15 +05:30
Vaibhav Surve
087c77bbd2 feat(directory): enhance input validation and layout in AddContactBottomSheet 2025-07-05 17:38:26 +05:30
Vaibhav Surve
606c5e5971 feat(directory): refactor contact card layout for improved readability and interaction 2025-07-05 17:29:35 +05:30
Vaibhav Surve
e7940941ed feat(directory): enhance AddContact functionality to support multiple emails and phones, improve logging, and refactor contact detail display 2025-07-05 13:19:53 +05:30
Vaibhav Surve
62c49b5429 feat(directory): add form reset functionality in AddContactController and initialize fields in AddContactBottomSheet 2025-07-05 11:57:15 +05:30
Vaibhav Surve
b187f1843a chore: update gradle properties for improved performance and memory management 2025-07-05 11:30:13 +05:30
Vaibhav Surve
eabd988b32 feat(directory): change floating action button color to red for better visibility 2025-07-04 17:30:26 +05:30
Vaibhav Surve
becdec1a79 feat(directory): enhance contact card UI with improved layout and interaction elements 2025-07-04 17:29:26 +05:30
Vaibhav Surve
549d8cce3c feat(directory): add comment submission functionality and UI components 2025-07-04 16:55:50 +05:30
Vaibhav Surve
be71544ae4 feat(directory): implement comment editing functionality and enhance comment model 2025-07-04 15:09:49 +05:30
Vaibhav Surve
83ad10ffb4 feat: update UI components for improved consistency and add tab indicator styling 2025-07-03 13:22:04 +05:30
a0f1602f4e feat(directory): add contact profile and directory management features
- Implemented ContactProfileResponse and related models for handling contact details.
- Created ContactTagResponse and ContactTag models for managing contact tags.
- Added DirectoryCommentResponse and DirectoryComment models for comment management.
- Developed DirectoryFilterBottomSheet for filtering contacts.
- Introduced OrganizationListModel for organization data handling.
- Updated routes to include DirectoryMainScreen.
- Enhanced DashboardScreen to navigate to the new directory page.
- Created ContactDetailScreen for displaying detailed contact information.
- Developed DirectoryMainScreen for managing and displaying contacts.
- Added dependencies for font_awesome_flutter and flutter_html in pubspec.yaml.
2025-07-02 15:57:39 +05:30
8f87161d74 refactor: Replace MyButton with OutlinedButton and ElevatedButton in various bottom sheets for improved UI consistency 2025-06-28 13:14:22 +05:30
66 changed files with 6329 additions and 1072 deletions

View File

@ -1,3 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
android.enableJetifier=false

View File

@ -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);

View File

@ -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) {

View File

@ -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.");

View File

@ -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");
}
}
}

View File

@ -146,7 +146,7 @@ class AddEmployeeController extends MyController {
gender: selectedGender!.name,
jobRoleId: selectedRoleId!,
);
logSafe("Response: $response");
if (response == true) {
logSafe("Employee created successfully.");
showAppSnackbar(

View File

@ -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!,
),

View File

@ -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();
}
});

View File

@ -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, );
},
);

View 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()}");
}
}

View 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);
}
}

View 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;
}
}

View 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");
}
}

View File

@ -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();
}
}

View File

@ -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;

View File

@ -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, );
}
}

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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";
}

View File

@ -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,
);

View File

@ -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,

View File

@ -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.");
}
}

View File

@ -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]!;
}

View 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);
}
}

View 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,
);
}
}
}

View 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,
),
),
],
),
)
],
);
}
}

View File

@ -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];
}
}

View File

@ -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),
],
),
);
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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,

View File

@ -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),
),
),
],

View File

@ -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),
),
);
}),
),
],
);
}

View File

@ -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),
),
);
}),
),
],
);
}

View File

@ -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),
),
);
}),
),
],
),
],
),
),

View 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();
}
}

View 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(),
],
),
),
),
),
);
});
}
}

View 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'],
);
}
}

View 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,
};
}

View 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'],
);
}
}

View 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'],
);
}
}

View 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,
};
}

View 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,
);
}
}

View 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),
],
),
);
}),
);
}
}

View 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'] ?? '',
);
}
}

View 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'] ?? '',
);
}
}

View File

@ -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),
),
),
),

View File

@ -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

View File

@ -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,
),
),
),
],
),
);

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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(

View File

@ -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(

View File

@ -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>(

View 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,
],
),
),
);
}
}

View 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()),
),
],
),
),
);
}
}

View 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),
],
),
],
),
),
);
},
);
}),
),
],
),
);
}
}

View 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,
),
},
),
],
),
);
});
},
);
}),
),
],
);
}
}

View File

@ -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(

View File

@ -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>
],
),
),
),
);
}

View File

@ -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);

View File

@ -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,
),
],
),
)
],
),

View File

@ -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(

View File

@ -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