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 org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=false

View File

@ -32,7 +32,7 @@ class ForgotPasswordController extends MyController {
final email = data['email']?.toString() ?? ''; final email = data['email']?.toString() ?? '';
try { try {
logSafe("Forgot password requested for: $email", sensitive: true); logSafe("Forgot password requested for: $email", );
final result = await AuthService.forgotPassword(email); final result = await AuthService.forgotPassword(email);
@ -50,7 +50,7 @@ class ForgotPasswordController extends MyController {
message: errorMessage, message: errorMessage,
type: SnackbarType.error, 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) { } catch (e, stacktrace) {
logSafe("Error during forgot password", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Error during forgot password", level: LogLevel.error, error: e, stackTrace: stacktrace);

View File

@ -55,12 +55,12 @@ class LoginController extends MyController {
try { try {
final loginData = basicValidator.getData(); 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); final errors = await AuthService.loginUser(loginData);
if (errors != null) { 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( showAppSnackbar(
title: "Login Failed", title: "Login Failed",
@ -73,7 +73,7 @@ class LoginController extends MyController {
basicValidator.clearErrors(); basicValidator.clearErrors();
} else { } else {
await _handleRememberMe(); await _handleRememberMe();
logSafe("Login successful for user: ${loginData['username']}", sensitive: true); logSafe("Login successful for user: ${loginData['username']}", );
Get.toNamed('/home'); Get.toNamed('/home');
} }
} catch (e, stacktrace) { } catch (e, stacktrace) {

View File

@ -29,7 +29,7 @@ class MPINController extends GetxController {
} }
void onDigitChanged(String value, int index, {bool isRetype = false}) { 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; final nodes = isRetype ? retypeFocusNodes : focusNodes;
if (value.isNotEmpty && index < 5) { if (value.isNotEmpty && index < 5) {
nodes[index + 1].requestFocus(); nodes[index + 1].requestFocus();
@ -47,7 +47,7 @@ class MPINController extends GetxController {
} }
final enteredMPIN = digitControllers.map((c) => c.text).join(); final enteredMPIN = digitControllers.map((c) => c.text).join();
logSafe("Entered MPIN: $enteredMPIN", sensitive: true); logSafe("Entered MPIN: $enteredMPIN", );
if (enteredMPIN.length < 6) { if (enteredMPIN.length < 6) {
_showError("Please enter all 6 digits."); _showError("Please enter all 6 digits.");
@ -56,7 +56,7 @@ class MPINController extends GetxController {
if (isNewUser.value) { if (isNewUser.value) {
final retypeMPIN = retypeControllers.map((c) => c.text).join(); final retypeMPIN = retypeControllers.map((c) => c.text).join();
logSafe("Retyped MPIN: $retypeMPIN", sensitive: true); logSafe("Retyped MPIN: $retypeMPIN", );
if (retypeMPIN.length < 6) { if (retypeMPIN.length < 6) {
_showError("Please enter all 6 digits in Retype MPIN."); _showError("Please enter all 6 digits in Retype MPIN.");
@ -177,7 +177,7 @@ class MPINController extends GetxController {
return false; return false;
} }
logSafe("Calling AuthService.generateMpin for employeeId: $employeeId", sensitive: true); logSafe("Calling AuthService.generateMpin for employeeId: $employeeId", );
final response = await AuthService.generateMpin( final response = await AuthService.generateMpin(
employeeId: employeeId, employeeId: employeeId,
@ -222,7 +222,7 @@ class MPINController extends GetxController {
logSafe("verifyMPIN triggered"); logSafe("verifyMPIN triggered");
final enteredMPIN = digitControllers.map((c) => c.text).join(); final enteredMPIN = digitControllers.map((c) => c.text).join();
logSafe("Entered MPIN: $enteredMPIN", sensitive: true); logSafe("Entered MPIN: $enteredMPIN", );
if (enteredMPIN.length < 6) { if (enteredMPIN.length < 6) {
_showError("Please enter all 6 digits."); _showError("Please enter all 6 digits.");

View File

@ -25,6 +25,7 @@ class OTPController extends GetxController {
void onInit() { void onInit() {
super.onInit(); super.onInit();
timer.value = 0; timer.value = 0;
_loadSavedEmail();
logSafe("[OTPController] Initialized"); logSafe("[OTPController] Initialized");
} }
@ -53,7 +54,6 @@ class OTPController extends GetxController {
"[OTPController] OTP send failed", "[OTPController] OTP send failed",
level: LogLevel.warning, level: LogLevel.warning,
error: result['error'], error: result['error'],
); );
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
@ -85,6 +85,7 @@ class OTPController extends GetxController {
if (success) { if (success) {
email.value = userEmail; email.value = userEmail;
isOTPSent.value = true; isOTPSent.value = true;
await _saveEmailIfRemembered(userEmail);
_startTimer(); _startTimer();
_clearOTPFields(); _clearOTPFields();
} }
@ -144,7 +145,7 @@ class OTPController extends GetxController {
Get.offAllNamed('/home'); Get.offAllNamed('/home');
} else { } else {
final error = result['error'] ?? "Failed to verify OTP"; 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( showAppSnackbar(
title: "Error", title: "Error",
message: error, message: error,
@ -189,10 +190,32 @@ class OTPController extends GetxController {
for (final node in focusNodes) { for (final node in focusNodes) {
node.unfocus(); node.unfocus();
} }
// Optionally remove saved email
LocalStorage.removeToken('otp_email');
} }
bool _validateEmail(String email) { bool _validateEmail(String email) {
final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$'); final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$');
return regex.hasMatch(email); 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, gender: selectedGender!.name,
jobRoleId: selectedRoleId!, jobRoleId: selectedRoleId!,
); );
logSafe("Response: $response");
if (response == true) { if (response == true) {
logSafe("Employee created successfully."); logSafe("Employee created successfully.");
showAppSnackbar( showAppSnackbar(

View File

@ -197,7 +197,7 @@ class AttendanceController extends GetxController {
textButtonTheme: TextButtonThemeData( textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: Colors.teal), style: TextButton.styleFrom(foregroundColor: Colors.teal),
), ),
dialogTheme: const DialogTheme(backgroundColor: Colors.white), dialogTheme: DialogThemeData(backgroundColor: Colors.white),
), ),
child: child!, child: child!,
), ),

View File

@ -20,7 +20,7 @@ class DashboardController extends GetxController {
logSafe( logSafe(
'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}', 'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}',
level: LogLevel.info, level: LogLevel.info,
sensitive: true,
); );
if (projectController.selectedProjectId.value.isNotEmpty) { if (projectController.selectedProjectId.value.isNotEmpty) {
@ -30,7 +30,7 @@ class DashboardController extends GetxController {
// React to project change // React to project change
ever<String>(projectController.selectedProjectId, (id) { ever<String>(projectController.selectedProjectId, (id) {
if (id.isNotEmpty) { 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(); fetchRoleWiseAttendance();
} }
}); });

View File

@ -106,15 +106,15 @@ class EmployeesScreenController extends GetxController {
logSafe( logSafe(
"Employees fetched: ${employees.length} for project $projectId", "Employees fetched: ${employees.length} for project $projectId",
level: LogLevel.info, level: LogLevel.info,
sensitive: true,
); );
}, },
onEmpty: () { onEmpty: () {
employees.clear(); 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) { 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), () => ApiService.getEmployeeDetails(employeeId),
onSuccess: (data) { onSuccess: (data) {
selectedEmployeeDetails.value = EmployeeDetailsModel.fromJson(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: () { onEmpty: () {
selectedEmployeeDetails.value = null; 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) { onError: (e) {
selectedEmployeeDetails.value = null; 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

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/permission_service.dart'; import 'package:marco/helpers/services/permission_service.dart';
import 'package:marco/model/user_permission.dart'; import 'package:marco/model/user_permission.dart';
import 'package:marco/model/employee_info.dart'; import 'package:marco/model/employee_info.dart';
@ -17,8 +17,51 @@ class PermissionController extends GetxController {
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
_loadDataFromAPI(); _initialize();
_startAutoRefresh(); }
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 { Future<void> _storeData() async {
@ -50,54 +93,15 @@ class PermissionController extends GetxController {
} }
} }
Future<void> _loadDataFromAPI() async {
final token = await _getAuthToken();
if (token?.isNotEmpty ?? false) {
await loadData(token!);
} else {
logSafe("No token found for loading API data.", 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() { void _startAutoRefresh() {
_refreshTimer = Timer.periodic(Duration(minutes: 30), (timer) async { _refreshTimer = Timer.periodic(Duration(minutes: 30), (timer) async {
logSafe("Auto-refresh triggered."); logSafe("Auto-refresh triggered.");
await _loadDataFromAPI(); final token = await _getAuthToken();
if (token?.isNotEmpty ?? false) {
await loadData(token!);
} else {
logSafe("Token missing during auto-refresh. Skipping.", level: LogLevel.warning);
}
}); });
} }
@ -116,7 +120,7 @@ class PermissionController extends GetxController {
@override @override
void onClose() { void onClose() {
_refreshTimer?.cancel(); _refreshTimer?.cancel();
logSafe("PermissionController disposed and timer cancelled."); logSafe("PermissionController disposed and auto-refresh timer cancelled.");
super.onClose(); super.onClose();
} }
} }

View File

@ -66,7 +66,7 @@ class ProjectController extends GetxController {
isProjectSelectionExpanded.value = false; isProjectSelectionExpanded.value = false;
logSafe("Projects fetched: ${projects.length}"); logSafe("Projects fetched: ${projects.length}");
} else { } 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; isLoadingProjects.value = false;

View File

@ -147,6 +147,6 @@ class AddTaskController extends GetxController {
void selectCategory(String id) { void selectCategory(String id) {
selectedCategoryId.value = id; selectedCategoryId.value = id;
selectedCategoryName.value = categoryIdNameMap[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) .where((e) => uploadingStates[e.id]?.value == true)
.toList(); .toList();
selectedEmployees.value = selected; selectedEmployees.value = selected;
logSafe("Updated selected employees", level: LogLevel.debug, sensitive: true); logSafe("Updated selected employees", level: LogLevel.debug, );
} }
void onRoleSelected(String? roleId) { void onRoleSelected(String? roleId) {
selectedRoleId.value = roleId; selectedRoleId.value = roleId;
logSafe("Role selected", level: LogLevel.info, sensitive: true); logSafe("Role selected", level: LogLevel.info, );
} }
Future<void> fetchRoles() async { Future<void> fetchRoles() async {
@ -137,7 +137,7 @@ class DailyTaskPlaningController extends GetxController {
final data = response?['data']; final data = response?['data'];
if (data != null) { if (data != null) {
dailyTasks = [TaskPlanningDetailsModel.fromJson(data)]; 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 { } else {
logSafe("Data field is null", level: LogLevel.warning); logSafe("Data field is null", level: LogLevel.warning);
} }
@ -164,14 +164,14 @@ class DailyTaskPlaningController extends GetxController {
uploadingStates[emp.id] = false.obs; uploadingStates[emp.id] = false.obs;
} }
logSafe("Employees fetched: ${employees.length} for project $projectId", logSafe("Employees fetched: ${employees.length} for project $projectId",
level: LogLevel.info, sensitive: true); level: LogLevel.info, );
} else { } else {
employees = []; 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) { } catch (e, stack) {
logSafe("Error fetching employees for project $projectId", logSafe("Error fetching employees for project $projectId",
level: LogLevel.error, error: e, stackTrace: stack, sensitive: true); level: LogLevel.error, error: e, stackTrace: stack, );
} finally { } finally {
isLoading.value = false; isLoading.value = false;
update(); update();

View File

@ -272,18 +272,18 @@ class ReportTaskActionController extends MyController {
final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75); final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75);
if (pickedFile != null) { if (pickedFile != null) {
selectedImages.add(File(pickedFile.path)); selectedImages.add(File(pickedFile.path));
logSafe("Image added from camera: ${pickedFile.path}", sensitive: true); logSafe("Image added from camera: ${pickedFile.path}", );
} }
} else { } else {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); 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) { void removeImageAt(int index) {
if (index >= 0 && index < selectedImages.length) { if (index >= 0 && index < selectedImages.length) {
logSafe("Removing image at index $index", sensitive: true); logSafe("Removing image at index $index", );
selectedImages.removeAt(index); selectedImages.removeAt(index);
} }
} }

View File

@ -83,7 +83,7 @@ class ReportTaskController extends MyController {
required DateTime reportedDate, required DateTime reportedDate,
List<File>? images, List<File>? images,
}) async { }) async {
logSafe("Reporting task for projectId", sensitive: true); logSafe("Reporting task for projectId", );
final completedWork = completedWorkController.text.trim(); final completedWork = completedWorkController.text.trim();
if (completedWork.isEmpty || int.tryParse(completedWork) == null || int.parse(completedWork) < 0) { if (completedWork.isEmpty || int.tryParse(completedWork) == null || int.parse(completedWork) < 0) {
_showError("Completed work must be a positive number."); _showError("Completed work must be a positive number.");
@ -138,7 +138,7 @@ class ReportTaskController extends MyController {
required String comment, required String comment,
List<File>? images, List<File>? images,
}) async { }) async {
logSafe("Submitting comment for project", sensitive: true); logSafe("Submitting comment for project", );
final commentField = commentController.text.trim(); final commentField = commentController.text.trim();
if (commentField.isEmpty) { if (commentField.isEmpty) {
@ -221,7 +221,7 @@ class ReportTaskController extends MyController {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
} }
logSafe("Images picked: ${selectedImages.length}", sensitive: true); logSafe("Images picked: ${selectedImages.length}", );
} catch (e) { } catch (e) {
logSafe("Error picking images", level: LogLevel.warning, error: 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 getDailyTask = "/task/list";
static const String reportTask = "/task/report"; static const String reportTask = "/task/report";
static const String commentTask = "/task/comment"; 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 assignDailyTask = "/task/assign";
static const String getWorkStatus = "/master/work-status"; static const String getWorkStatus = "/master/work-status";
static const String approveReportAction = "/task/approve"; static const String approveReportAction = "/task/approve";
static const String assignTask = "/project/task"; static const String assignTask = "/project/task";
static const String getmasterWorkCategories = "/Master/work-categories"; 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, bool hasRetried = false,
}) async { }) async {
String? token = await _getToken(); 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") final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
.replace(queryParameters: queryParams); .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 { try {
final response = final response =
await http.get(uri, headers: _headers(token)).timeout(timeout); 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) { if (response.statusCode == 401 && !hasRetried) {
logSafe("Unauthorized. Attempting token refresh..."); logSafe("Unauthorized (401). Attempting token refresh...",
level: LogLevel.warning);
if (await AuthService.refreshToken()) { if (await AuthService.refreshToken()) {
return await _getRequest(endpoint, logSafe("Token refresh succeeded. Retrying request...",
queryParams: queryParams, hasRetried: true); 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; return response;
} catch (e) { } catch (e) {
logSafe("HTTP GET Exception: $e", level: LogLevel.error); logSafe("HTTP GET Exception: $e", level: LogLevel.error);
@ -141,7 +163,7 @@ class ApiService {
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
logSafe("POST $uri\nHeaders: ${_headers(token)}\nBody: $body", logSafe("POST $uri\nHeaders: ${_headers(token)}\nBody: $body",
sensitive: true); );
try { try {
final response = await http 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 === // === Dashboard Endpoints ===
static Future<List<dynamic>?> getDashboardAttendanceOverview( static Future<List<dynamic>?> getDashboardAttendanceOverview(
@ -177,6 +242,227 @@ class ApiService {
: null); : 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 === // === Attendance APIs ===
static Future<List<dynamic>?> getProjects() async => static Future<List<dynamic>?> getProjects() async =>
@ -319,7 +605,7 @@ class ApiService {
"jobRoleId": jobRoleId, "jobRoleId": jobRoleId,
}; };
final response = await _postRequest( final response = await _postRequest(
ApiEndpoints.reportTask, ApiEndpoints.createEmployee,
body, body,
customTimeout: extendedTimeout, customTimeout: extendedTimeout,
); );

View File

@ -7,42 +7,70 @@ import 'package:marco/helpers/theme/theme_customizer.dart';
import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/theme/app_theme.dart';
import 'package:url_strategy/url_strategy.dart'; import 'package:url_strategy/url_strategy.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/auth_service.dart';
Future<void> initializeApp() async { Future<void> initializeApp() async {
try { try {
logSafe("Starting app initialization..."); logSafe("💡 Starting app initialization...");
setPathUrlStrategy(); setPathUrlStrategy();
logSafe("URL strategy set."); logSafe("💡 URL strategy set.");
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Color.fromARGB(255, 255, 0, 0), statusBarColor: Color.fromARGB(255, 255, 0, 0),
statusBarIconBrightness: Brightness.light, statusBarIconBrightness: Brightness.light,
)); ));
logSafe("System UI overlay style set."); logSafe("💡 System UI overlay style set.");
await LocalStorage.init(); 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(); await ThemeCustomizer.init();
logSafe("Theme customizer initialized."); logSafe("💡 Theme customizer initialized.");
Get.put(PermissionController()); final token = LocalStorage.getString('jwt_token');
logSafe("PermissionController injected."); if (token != null && token.isNotEmpty) {
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
logSafe("💡 PermissionController injected.");
}
Get.put(ProjectController(), permanent: true); if (!Get.isRegistered<ProjectController>()) {
logSafe("ProjectController injected as permanent."); Get.put(ProjectController(), permanent: true);
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(); AppStyle.init();
logSafe("AppStyle initialized."); logSafe("💡 AppStyle initialized.");
logSafe("App initialization completed successfully."); logSafe("App initialization completed successfully.");
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error during app initialization", logSafe(
"⛔ Error during app initialization",
level: LogLevel.error, level: LogLevel.error,
error: e, error: e,
stackTrace: stacktrace, stackTrace: stacktrace,
); );
rethrow; rethrow;
} }
} }

View File

@ -247,32 +247,45 @@ class AuthService {
} }
/// Handle login success flow /// Handle login success flow
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async { static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
logSafe("Processing login success..."); logSafe("Processing login success...");
final jwtToken = data['token']; final jwtToken = data['token'];
final refreshToken = data['refreshToken']; final refreshToken = data['refreshToken'];
final mpinToken = data['mpinToken']; final mpinToken = data['mpinToken'];
await LocalStorage.setJwtToken(jwtToken); // Save tokens
await LocalStorage.setLoggedInUser(true); 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);
await LocalStorage.setIsMpin(true);
} else {
await LocalStorage.setIsMpin(false);
await LocalStorage.removeMpinToken();
}
final permissionController = Get.put(PermissionController());
await permissionController.loadData(jwtToken);
await Get.find<ProjectController>().fetchProjects();
isLoggedIn = true;
logSafe("Login flow completed.");
} }
if (mpinToken != null && mpinToken.isNotEmpty) {
await LocalStorage.setMpinToken(mpinToken);
await LocalStorage.setIsMpin(true);
} else {
await LocalStorage.setIsMpin(false);
await LocalStorage.removeMpinToken();
}
// 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 and controllers initialized.");
} }
}

View File

@ -19,10 +19,10 @@ class PermissionService {
String token, { String token, {
bool hasRetried = false, bool hasRetried = false,
}) async { }) async {
logSafe("Fetching user data...", sensitive: true); logSafe("Fetching user data...", );
if (_userDataCache.containsKey(token)) { if (_userDataCache.containsKey(token)) {
logSafe("User data cache hit.", sensitive: true); logSafe("User data cache hit.", );
return _userDataCache[token]!; 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 firstName;
final String lastName; final String lastName;
final double size; final double size;
final Color? backgroundColor; // Optional: allows override final Color? backgroundColor;
final Color textColor; final Color textColor;
const Avatar({ const Avatar({
@ -22,7 +22,7 @@ class Avatar extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
String initials = "${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}".toUpperCase(); 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( return MyContainer.rounded(
height: size, height: size,
@ -39,12 +39,28 @@ class Avatar extends StatelessWidget {
); );
} }
// Generate a consistent "random-like" color from the name // Use fixed flat color palette and pick based on hash
Color _generateColorFromName(String name) { Color _getFlatColorFromName(String name) {
final hash = name.hashCode; final colors = <Color>[
final r = (hash & 0xFF0000) >> 16; Color(0xFFE57373), // Red
final g = (hash & 0x00FF00) >> 8; Color(0xFFF06292), // Pink
final b = (hash & 0x0000FF); Color(0xFFBA68C8), // Purple
return Color.fromARGB(255, r, g, b).withOpacity(1.0); 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) // Daily Progress Planning (Collapsed View)
static Widget dailyProgressPlanningSkeletonCollapsedOnly() { static Widget dailyProgressPlanningSkeletonCollapsedOnly() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, 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 maxWidth = 800;
const int maxHeight = 800; const int maxHeight = 800;
logSafe("Starting image compression...", sensitive: true); logSafe("Starting image compression...", );
while (quality >= 10) { while (quality >= 10) {
try { try {
@ -59,7 +59,7 @@ Future<File> saveCompressedImageToFile(Uint8List bytes) async {
final file = File(filePath); final file = File(filePath);
final savedFile = await file.writeAsBytes(bytes); final savedFile = await file.writeAsBytes(bytes);
logSafe("Compressed image saved to ${savedFile.path}", sensitive: true); logSafe("Compressed image saved to ${savedFile.path}", );
return savedFile; return savedFile;
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error saving compressed image", level: LogLevel.error, error: e, stackTrace: 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:flutter/material.dart';
import 'package:loading_animation_widget/loading_animation_widget.dart'; import 'package:loading_animation_widget/loading_animation_widget.dart';
import 'package:marco/images.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 { class LoadingComponent extends StatelessWidget {
final bool isLoading; final bool isLoading;
final Widget child; 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 { class _LoadingAnimation extends StatelessWidget {
final double imageSize; final double imageSize;

View File

@ -147,16 +147,25 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
pickedTime.minute, pickedTime.minute,
); );
if (selectedDateTime.isAfter(checkInTime)) { final now = DateTime.now();
return selectedDateTime;
} else { if (selectedDateTime.isBefore(checkInTime)) {
showAppSnackbar( showAppSnackbar(
title: "Invalid Time", 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, type: SnackbarType.warning,
); );
return null; return null;
} }
return selectedDateTime;
} }
return null; return null;
} }
@ -217,6 +226,30 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
break; 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); final userComment = await _showCommentBottomSheet(context, actionText);
if (userComment == null || userComment.isEmpty) { if (userComment == null || userComment.isEmpty) {
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false; widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
@ -225,13 +258,14 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
bool success = false; bool success = false;
if (actionText == ButtonActions.requestRegularize) { if (actionText == ButtonActions.requestRegularize) {
final selectedTime = await showTimePickerForRegularization( final regularizeTime = selectedTime ??
context: context, await showTimePickerForRegularization(
checkInTime: widget.employee.checkIn!, context: context,
); checkInTime: widget.employee.checkIn!,
if (selectedTime != null) { );
if (regularizeTime != null) {
final formattedSelectedTime = final formattedSelectedTime =
DateFormat("hh:mm a").format(selectedTime); DateFormat("hh:mm a").format(regularizeTime);
success = await widget.attendanceController.captureAndUploadAttendance( success = await widget.attendanceController.captureAndUploadAttendance(
widget.employee.id, widget.employee.id,
widget.employee.employeeId, widget.employee.employeeId,
@ -242,6 +276,18 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
markTime: formattedSelectedTime, 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 { } else {
success = await widget.attendanceController.captureAndUploadAttendance( success = await widget.attendanceController.captureAndUploadAttendance(
widget.employee.id, widget.employee.id,

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/task_planing/daily_task_planing_controller.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_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
@ -197,16 +196,34 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
), ),
MySpacing.height(24), MySpacing.height(24),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ 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, onPressed: _onAssignTaskPressed,
backgroundColor: const Color.fromARGB(255, 95, 132, 255), icon: const Icon(Icons.check_circle_outline,
child: Row( color: Colors.white),
mainAxisSize: MainAxisSize.min, label:
children: [
MyText.bodyMedium("Assign Task", color: Colors.white), 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:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/task_planing/report_task_controller.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/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
@ -342,9 +341,6 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
} }
}, },
isLoading: controller.isLoading, isLoading: controller.isLoading,
splashColor: contentTheme.secondary.withAlpha(25),
backgroundColor: Colors.blueAccent,
loadingIndicatorColor: contentTheme.onPrimary,
), ),
MySpacing.height(10), MySpacing.height(10),
if ((widget.taskData['taskComments'] as List<dynamic>?) if ((widget.taskData['taskComments'] as List<dynamic>?)
@ -522,52 +518,59 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
); );
} }
Widget buildCommentActionButtons({ Widget buildCommentActionButtons({
required VoidCallback onCancel, required VoidCallback onCancel,
required Future<void> Function() onSubmit, required Future<void> Function() onSubmit,
required RxBool isLoading, required RxBool isLoading,
required Color splashColor, double? buttonHeight,
required Color backgroundColor, }) {
required Color loadingIndicatorColor, return Row(
double? buttonHeight, children: [
}) { Expanded(
return Row( child: OutlinedButton.icon(
mainAxisAlignment: MainAxisAlignment.end,
children: [
MyButton.text(
onPressed: onCancel, onPressed: onCancel,
padding: MySpacing.xy(20, 16), icon: const Icon(Icons.close, color: Colors.red, size: 18),
splashColor: splashColor, label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
child: MyText.bodySmall('Cancel'), style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
), ),
MySpacing.width(12), ),
Obx(() { const SizedBox(width: 16),
return MyButton( Expanded(
onPressed: isLoading.value ? null : onSubmit, child: Obx(() {
elevation: 0, return ElevatedButton.icon(
padding: MySpacing.xy(20, 16), onPressed: isLoading.value ? null : () => onSubmit(),
backgroundColor: backgroundColor, icon: isLoading.value
borderRadiusAll: AppStyle.buttonRadius.medium, ? const SizedBox(
child: isLoading.value width: 16,
? SizedBox( height: 16,
width: buttonHeight ?? 16,
height: buttonHeight ?? 16,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
loadingIndicatorColor,
),
), ),
) )
: MyText.bodySmall( : const Icon(Icons.check_circle_outline, color: Colors.white, size: 18),
'Comment', label: isLoading.value
color: loadingIndicatorColor, ? 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),
),
); );
}), }),
], ),
); ],
} );
}
Widget buildRow(String label, String? value, {IconData? icon}) { Widget buildRow(String label, String? value, {IconData? icon}) {
return Padding( return Padding(

View File

@ -467,7 +467,6 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
reportActionId: reportActionId, reportActionId: reportActionId,
approvedTaskCount: approvedTaskCount, approvedTaskCount: approvedTaskCount,
); );
if (success) { if (success) {
Navigator.of(context).pop(); Navigator.of(context).pop();
if (shouldShowAddTaskSheet) { if (shouldShowAddTaskSheet) {
@ -488,10 +487,8 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
widget.taskData['plannedWork'] ?? widget.taskData['plannedWork'] ??
'0') ?? '0') ??
0, 0,
activityId: activityId: widget.activityId,
widget.activityId, workAreaId: widget.workAreaId,
workAreaId:
widget.workAreaId,
onSubmit: () { onSubmit: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
@ -502,12 +499,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
} }
}, },
isLoading: controller.isLoading, 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>?) if ((widget.taskData['taskComments'] as List<dynamic>?)
?.isNotEmpty == ?.isNotEmpty ==
true) ...[ true) ...[
@ -683,52 +677,59 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
); );
} }
Widget buildCommentActionButtons({ Widget buildCommentActionButtons({
required VoidCallback onCancel, required VoidCallback onCancel,
required Future<void> Function() onSubmit, required Future<void> Function() onSubmit,
required RxBool isLoading, required RxBool isLoading,
required Color splashColor, double? buttonHeight,
required Color backgroundColor, }) {
required Color loadingIndicatorColor, return Row(
double? buttonHeight, children: [
}) { Expanded(
return Row( child: OutlinedButton.icon(
mainAxisAlignment: MainAxisAlignment.end,
children: [
MyButton.text(
onPressed: onCancel, onPressed: onCancel,
padding: MySpacing.xy(20, 16), icon: const Icon(Icons.close, color: Colors.red, size: 18),
splashColor: splashColor, label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
child: MyText.bodySmall('Cancel'), style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
), ),
MySpacing.width(12), ),
Obx(() { const SizedBox(width: 16),
return MyButton( Expanded(
onPressed: isLoading.value ? null : onSubmit, child: Obx(() {
elevation: 0, return ElevatedButton.icon(
padding: MySpacing.xy(20, 16), onPressed: isLoading.value ? null : () => onSubmit(),
backgroundColor: backgroundColor, icon: isLoading.value
borderRadiusAll: AppStyle.buttonRadius.medium, ? const SizedBox(
child: isLoading.value width: 16,
? SizedBox( height: 16,
width: buttonHeight ?? 16,
height: buttonHeight ?? 16,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
loadingIndicatorColor,
),
), ),
) )
: MyText.bodySmall( : const Icon(Icons.send, color: Colors.white, size: 18),
'Submit Report', label: isLoading.value
color: loadingIndicatorColor, ? 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),
),
); );
}), }),
], ),
); ],
} );
}
Widget buildRow(String label, String? value, {IconData? icon}) { Widget buildRow(String label, String? value, {IconData? icon}) {
return Padding( return Padding(

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/task_planing/report_task_controller.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/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
@ -346,61 +345,92 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
], ],
); );
}), }),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, children: [
children: [ Expanded(
MyButton.text( child: OutlinedButton.icon(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
padding: MySpacing.xy(20, 16), icon: const Icon(Icons.close, color: Colors.red, size: 18),
splashColor: contentTheme.secondary.withAlpha(25), label: MyText.bodyMedium(
child: MyText.bodySmall('Cancel'), "Cancel",
), color: Colors.red,
MySpacing.width(12), fontWeight: 600,
Obx(() { ),
final isLoading = controller.reportStatus.value == ApiStatus.loading; style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
return MyButton( shape: RoundedRectangleBorder(
onPressed: isLoading borderRadius: BorderRadius.circular(12),
? null
: () async {
if (controller.basicValidator.validateForm()) {
final success = await controller.reportTask(
projectId: controller.basicValidator.getController('task_id')?.text ?? '',
comment: controller.basicValidator.getController('comment')?.text ?? '',
completedTask: int.tryParse(
controller.basicValidator.getController('completed_work')?.text ?? '') ??
0,
checklist: [],
reportedDate: DateTime.now(),
images: controller.selectedImages,
);
if (success && widget.onReportSuccess != null) {
widget.onReportSuccess!();
}
}
},
elevation: 0,
padding: MySpacing.xy(20, 16),
backgroundColor: Colors.blueAccent,
borderRadiusAll: AppStyle.buttonRadius.medium,
child: isLoading
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: MyText.bodySmall(
'Report',
color: contentTheme.onPrimary,
), ),
); padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
}), ),
),
),
const SizedBox(width: 16),
Expanded(
child: Obx(() {
final isLoading =
controller.reportStatus.value == ApiStatus.loading;
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 ??
'',
completedTask: int.tryParse(
controller.basicValidator
.getController('completed_work')
?.text ??
'') ??
0,
checklist: [],
reportedDate: DateTime.now(),
images: controller.selectedImages,
);
if (success && widget.onReportSuccess != null) {
widget.onReportSuccess!();
}
}
},
icon: isLoading
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: 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(); State<AddEmployeeBottomSheet> createState() => _AddEmployeeBottomSheetState();
} }
class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UIMixin { class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
with UIMixin {
final AddEmployeeController _controller = Get.put(AddEmployeeController()); final AddEmployeeController _controller = Get.put(AddEmployeeController());
late TextEditingController genderController; late TextEditingController genderController;
@ -27,7 +28,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
} }
RelativeRect _popupMenuPosition(BuildContext context) { 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); return RelativeRect.fromLTRB(100, 300, overlay.size.width - 100, 0);
} }
@ -135,8 +137,14 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.cardColor, color: theme.cardColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), borderRadius:
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, -2))], const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 12,
offset: Offset(0, -2))
],
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
@ -153,7 +161,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
), ),
), ),
MySpacing.height(12), MySpacing.height(12),
Text("Add Employee", style: MyTextStyle.titleLarge(fontWeight: 700)), Text("Add Employee",
style: MyTextStyle.titleLarge(fontWeight: 700)),
MySpacing.height(24), MySpacing.height(24),
Form( Form(
key: _controller.basicValidator.formKey, key: _controller.basicValidator.formKey,
@ -166,16 +175,20 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
label: "First Name", label: "First Name",
hint: "e.g., John", hint: "e.g., John",
icon: Icons.person, icon: Icons.person,
controller: _controller.basicValidator.getController('first_name')!, controller: _controller.basicValidator
validator: _controller.basicValidator.getValidation('first_name'), .getController('first_name')!,
validator: _controller.basicValidator
.getValidation('first_name'),
), ),
MySpacing.height(16), MySpacing.height(16),
_inputWithIcon( _inputWithIcon(
label: "Last Name", label: "Last Name",
hint: "e.g., Doe", hint: "e.g., Doe",
icon: Icons.person_outline, icon: Icons.person_outline,
controller: _controller.basicValidator.getController('last_name')!, controller: _controller.basicValidator
validator: _controller.basicValidator.getValidation('last_name'), .getController('last_name')!,
validator: _controller.basicValidator
.getValidation('last_name'),
), ),
MySpacing.height(16), MySpacing.height(16),
_sectionLabel("Contact Details"), _sectionLabel("Contact Details"),
@ -185,7 +198,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
Row( Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -193,7 +207,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
), ),
child: PopupMenuButton<Map<String, String>>( child: PopupMenuButton<Map<String, String>>(
onSelected: (country) { onSelected: (country) {
_controller.selectedCountryCode = country['code']!; _controller.selectedCountryCode =
country['code']!;
_controller.update(); _controller.update();
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
@ -204,11 +219,14 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
height: 200, height: 200,
width: 100, width: 100,
child: ListView( child: ListView(
children: _controller.countries.map((country) { children: _controller.countries
.map((country) {
return ListTile( return ListTile(
dense: true, dense: true,
title: Text("${country['name']} (${country['code']})"), title: Text(
onTap: () => Navigator.pop(context, country), "${country['name']} (${country['code']})"),
onTap: () =>
Navigator.pop(context, country),
); );
}).toList(), }).toList(),
), ),
@ -226,31 +244,42 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
MySpacing.width(12), MySpacing.width(12),
Expanded( Expanded(
child: TextFormField( child: TextFormField(
controller: _controller.basicValidator.getController('phone_number'), controller: _controller.basicValidator
.getController('phone_number'),
validator: (value) { validator: (value) {
if (value == null || value.trim().isEmpty) { if (value == null || value.trim().isEmpty) {
return "Phone number is required"; return "Phone number is required";
} }
final digitsOnly = value.trim(); final digitsOnly = value.trim();
final minLength = _controller.minDigitsPerCountry[_controller.selectedCountryCode] ?? 7; final minLength = _controller
final maxLength = _controller.maxDigitsPerCountry[_controller.selectedCountryCode] ?? 15; .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"; return "Only digits allowed";
} }
if (digitsOnly.length < minLength || digitsOnly.length > maxLength) { if (digitsOnly.length < minLength ||
digitsOnly.length > maxLength) {
return "Between $minLength$maxLength digits"; return "Between $minLength$maxLength digits";
} }
return null; return null;
}, },
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
decoration: _inputDecoration("e.g., 9876543210").copyWith( decoration: _inputDecoration("e.g., 9876543210")
.copyWith(
suffixIcon: IconButton( suffixIcon: IconButton(
icon: const Icon(Icons.contacts), 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( child: TextFormField(
readOnly: true, readOnly: true,
controller: TextEditingController( 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), suffixIcon: const Icon(Icons.expand_more),
), ),
), ),
@ -286,10 +317,14 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
readOnly: true, readOnly: true,
controller: TextEditingController( controller: TextEditingController(
text: _controller.roles.firstWhereOrNull( text: _controller.roles.firstWhereOrNull(
(role) => role['id'] == _controller.selectedRoleId, (role) =>
)?['name'] ?? "", role['id'] ==
_controller.selectedRoleId,
)?['name'] ??
"",
), ),
decoration: _inputDecoration("Select Role").copyWith( decoration:
_inputDecoration("Select Role").copyWith(
suffixIcon: const Icon(Icons.expand_more), suffixIcon: const Icon(Icons.expand_more),
), ),
), ),
@ -301,11 +336,16 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
Expanded( Expanded(
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close, size: 18), icon:
label: MyText.bodyMedium("Cancel", fontWeight: 600), const Icon(Icons.close, color: Colors.red),
label: MyText.bodyMedium("Cancel",
color: Colors.red, fontWeight: 600),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.grey), side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), 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( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () async { onPressed: () async {
if (_controller.basicValidator.validateForm()) { if (_controller.basicValidator
final success = await _controller.createEmployees(); .validateForm()) {
final success =
await _controller.createEmployees();
if (success) { if (success) {
final employeeController = Get.find<EmployeesScreenController>(); final employeeController =
final projectId = employeeController.selectedProjectId; Get.find<EmployeesScreenController>();
final projectId =
employeeController.selectedProjectId;
if (projectId == null) { if (projectId == null) {
await employeeController.fetchAllEmployees(); await employeeController
.fetchAllEmployees();
} else { } 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
_controller.basicValidator.getController("last_name")?.clear(); .getController("first_name")
_controller.basicValidator.getController("phone_number")?.clear(); ?.clear();
_controller.basicValidator
.getController("last_name")
?.clear();
_controller.basicValidator
.getController("phone_number")
?.clear();
_controller.selectedGender = null; _controller.selectedGender = null;
_controller.selectedRoleId = null; _controller.selectedRoleId = null;
_controller.update(); _controller.update();
@ -338,11 +391,16 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UI
} }
} }
}, },
icon: const Icon(Icons.check, size: 18), icon: const Icon(Icons.check_circle_outline,
label: MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600), color: Colors.white),
label: MyText.bodyMedium("Save",
color: Colors.white, fontWeight: 600),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent, backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), 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/login_option_screen.dart';
import 'package:marco/view/auth/mpin_screen.dart'; import 'package:marco/view/auth/mpin_screen.dart';
import 'package:marco/view/auth/mpin_auth_screen.dart'; import 'package:marco/view/auth/mpin_auth_screen.dart';
import 'package:marco/view/directory/directory_main_screen.dart';
class AuthMiddleware extends GetMiddleware { class AuthMiddleware extends GetMiddleware {
@override @override
@ -60,17 +61,19 @@ getPageRoute() {
name: '/dashboard/daily-task-progress', name: '/dashboard/daily-task-progress',
page: () => DailyProgressReportScreen(), page: () => DailyProgressReportScreen(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
GetPage(
name: '/dashboard/directory-main-page',
page: () => DirectoryMainScreen(),
middlewares: [AuthMiddleware()]),
// Authentication // Authentication
GetPage(name: '/auth/login', page: () => LoginScreen()), GetPage(name: '/auth/login', page: () => LoginScreen()),
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()), GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),
GetPage(name: '/auth/mpin', page: () => MPINScreen()), GetPage(name: '/auth/mpin', page: () => MPINScreen()),
GetPage(name: '/auth/mpin-auth', page: () => MPINAuthScreen()), GetPage(name: '/auth/mpin-auth', page: () => MPINAuthScreen()),
GetPage( GetPage(
name: '/auth/register_account', name: '/auth/register_account',
page: () => const RegisterAccountScreen()), page: () => const RegisterAccountScreen()),
GetPage( GetPage(name: '/auth/forgot_password', page: () => ForgotPasswordScreen()),
name: '/auth/forgot_password',
page: () => ForgotPasswordScreen()),
GetPage( GetPage(
name: '/auth/reset_password', page: () => const ResetPasswordScreen()), name: '/auth/reset_password', page: () => const ResetPasswordScreen()),
// Error // 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_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/view/auth/request_demo_bottom_sheet.dart';
import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:marco/helpers/services/api_endpoints.dart';
class EmailLoginForm extends StatefulWidget { 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/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/auth/forgot_password_controller.dart'; import 'package:marco/controller/auth/forgot_password_controller.dart';
import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/images.dart';
import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.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/helpers/widgets/my_spacing.dart';
class ForgotPasswordScreen extends StatefulWidget { class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key}); const ForgotPasswordScreen({super.key});
@ -19,208 +18,273 @@ class ForgotPasswordScreen extends StatefulWidget {
} }
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
with UIMixin { with UIMixin, SingleTickerProviderStateMixin {
final ForgotPasswordController controller = final ForgotPasswordController controller =
Get.put(ForgotPasswordController()); Get.put(ForgotPasswordController());
late AnimationController _controller;
late Animation<double> _logoAnimation;
bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage");
bool _isLoading = false; bool _isLoading = false;
void _handleForgotPassword() async { @override
setState(() { void initState() {
_isLoading = true; 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(); await controller.onForgotPassword();
setState(() => _isLoading = false);
setState(() {
_isLoading = false;
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: contentTheme.brandRed, body: Stack(
body: SafeArea( children: [
child: LayoutBuilder(builder: (context, constraints) { const _RedWaveBackground(),
return Column( SafeArea(
children: [ child: Center(
const SizedBox(height: 24), child: Column(
_buildHeader(), mainAxisSize: MainAxisSize.min,
const SizedBox(height: 16), children: [
_buildWelcomeTextsAndChips(), const SizedBox(height: 24),
const SizedBox(height: 16), ScaleTransition(
Expanded( scale: _logoAnimation,
child: Container( child: Container(
width: double.infinity, width: 100,
decoration: const BoxDecoration( height: 100,
color: Colors.white, decoration: BoxDecoration(
borderRadius: color: Colors.white,
BorderRadius.vertical(top: Radius.circular(32)), shape: BoxShape.circle,
), boxShadow: const [
child: SingleChildScrollView( BoxShadow(
padding: const EdgeInsets.symmetric( color: Colors.black12,
horizontal: 24, vertical: 32), blurRadius: 10,
child: ConstrainedBox( offset: Offset(0, 4),
constraints: BoxConstraints( ),
minHeight: constraints.maxHeight - 120, ],
), ),
child: Form( padding: const EdgeInsets.all(20),
key: controller.basicValidator.formKey, child: Image.asset(Images.logoDark),
),
),
const SizedBox(height: 8),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
MyText.titleLarge( const SizedBox(height: 12),
'Forgot Password', MyText(
fontWeight: 700, "Welcome to Marco",
fontSize: 24,
fontWeight: 800,
color: Colors.black87, color: Colors.black87,
textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 10),
MyText.bodyMedium( MyText(
"Enter your email and we'll send you instructions to reset your password.", "Streamline Project Management\nBoost Productivity with Automation.",
fontSize: 14,
color: Colors.black54, color: Colors.black54,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 32), if (_isBetaEnvironment) ...[
TextFormField( const SizedBox(height: 12),
validator: controller.basicValidator Container(
.getValidation('email'), padding: const EdgeInsets.symmetric(
controller: controller.basicValidator horizontal: 10, vertical: 4),
.getController('email'), decoration: BoxDecoration(
keyboardType: TextInputType.emailAddress, color: Colors.orangeAccent,
style: MyTextStyle.labelMedium(), borderRadius: BorderRadius.circular(6),
decoration: InputDecoration( ),
labelText: "Email Address", child: MyText(
labelStyle: MyTextStyle.bodySmall(xMuted: true), 'BETA',
filled: true, color: Colors.white,
fillColor: Colors.grey.shade100, fontWeight: 600,
prefixIcon: fontSize: 12,
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,
), ),
), ],
const SizedBox(height: 40), const SizedBox(height: 36),
MyButton.rounded( _buildForgotCard(),
onPressed:
_isLoading ? null : _handleForgotPassword,
elevation: 2,
padding: MySpacing.xy(80, 16),
borderRadiusAll: 10,
backgroundColor: _isLoading ? Colors.red.withOpacity(0.6) : contentTheme.brandRed,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: MyText.labelLarge(
'Send Reset Link',
fontWeight: 700,
color: Colors.white,
),
),
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',
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(),
],
], ],
), ),
); );
} }
Widget _buildBetaLabel() { Widget _buildForgotCard() {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.orangeAccent,
borderRadius: BorderRadius.circular(4),
),
child: MyText.bodySmall(
'BETA',
fontWeight: 600,
color: Colors.white,
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(20),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Colors.black12, color: Colors.black12,
blurRadius: 6, blurRadius: 10,
offset: Offset(0, 3), offset: Offset(0, 4),
), ),
], ],
), ),
child: Image.asset(Images.logoDark, height: 70), child: Form(
key: controller.basicValidator.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
MyText(
'Forgot Password',
fontSize: 20,
fontWeight: 700,
color: Colors.black87,
textAlign: TextAlign.center,
),
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: 30),
TextFormField(
validator: controller.basicValidator.getValidation('email'),
controller: controller.basicValidator.getController('email'),
keyboardType: TextInputType.emailAddress,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
labelText: "Email Address",
labelStyle: const TextStyle(color: Colors.black54),
filled: true,
fillColor: Colors.grey.shade100,
prefixIcon: const Icon(LucideIcons.mail, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
),
const SizedBox(height: 32),
MyButton.rounded(
onPressed: _isLoading ? null : _handleForgotPassword,
elevation: 2,
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16),
borderRadiusAll: 10,
backgroundColor: _isLoading
? Colors.red.withOpacity(0.6)
: contentTheme.brandRed,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white, strokeWidth: 2),
)
: MyText.bodyMedium(
'Send Reset Link',
color: Colors.white,
fontWeight: 700,
fontSize: 16,
),
),
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,
),
),
],
),
),
); );
} }
} }
// Same red wave background as MPINAuthScreen
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

@ -1,225 +1,318 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.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/images.dart';
import 'package:marco/view/auth/email_login_form.dart'; import 'package:marco/view/auth/email_login_form.dart';
import 'package:marco/view/auth/otp_login_form.dart'; import 'package:marco/view/auth/otp_login_form.dart';
import 'package:marco/helpers/services/api_endpoints.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 } enum LoginOption { email, otp }
class LoginOptionScreen extends StatefulWidget { class LoginOptionScreen extends StatelessWidget {
const LoginOptionScreen({super.key}); const LoginOptionScreen({super.key});
@override @override
State<LoginOptionScreen> createState() => _LoginOptionScreenState(); Widget build(BuildContext context) => const WelcomeScreen();
} }
class _LoginOptionScreenState extends State<LoginOptionScreen> with UIMixin { class WelcomeScreen extends StatefulWidget {
LoginOption _selectedOption = LoginOption.email; 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"); bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage");
@override @override
Widget build(BuildContext context) { void initState() {
return Scaffold( super.initState();
backgroundColor: contentTheme.brandRed, _controller = AnimationController(
body: SafeArea( vsync: this,
child: LayoutBuilder( duration: const Duration(milliseconds: 800),
builder: (context, constraints) { );
return Column( _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: [ children: [
const SizedBox(height: 24), // Row with title and close button
_buildHeader(), Row(
const SizedBox(height: 16), children: [
_buildWelcomeTextsAndChips(), Expanded(
const SizedBox(height: 16), child: MyText(
Expanded( option == LoginOption.email
child: Container( ? "Login with Email"
width: double.infinity, : "Login with OTP",
decoration: const BoxDecoration( fontSize: 20,
color: Colors.white, fontWeight: 700,
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()),
],
),
),
), ),
), ),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 20),
option == LoginOption.email
? EmailLoginForm()
: const OTPLoginScreen(),
],
),
),
),
),
),
);
}
@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,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
padding: const EdgeInsets.all(20),
child: Image.asset(Images.logoDark),
),
),
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(6),
),
child: MyText(
'BETA',
color: Colors.white,
fontWeight: 600,
fontSize: 12,
),
),
],
const SizedBox(height: 36),
_buildActionButton(
context,
label: "Login with Username",
icon: LucideIcons.mail,
option: LoginOption.email,
),
const SizedBox(height: 16),
_buildActionButton(
context,
label: "Login with OTP",
icon: LucideIcons.message_square,
option: LoginOption.otp,
),
const SizedBox(height: 16),
_buildActionButton(
context,
label: "Request a Demo",
icon: LucideIcons.phone_call,
option: null,
),
const SizedBox(height: 36),
MyText(
'App version 1.0.0',
color: Colors.grey,
fontSize: 12,
),
],
), ),
), ),
], ),
); ),
},
),
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 6,
offset: Offset(0, 3),
)
],
),
child: Image.asset(Images.logoDark, height: 70),
);
}
Widget _buildBetaLabel() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.orangeAccent,
borderRadius: BorderRadius.circular(4),
),
child: MyText(
'BETA',
color: Colors.white,
fontWeight: 600,
fontSize: 12,
),
);
}
Widget _buildLoginOptionChips() {
return Wrap(
spacing: 12,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
_buildOptionChip(
title: "User Name",
icon: LucideIcons.mail,
value: LoginOption.email,
),
_buildOptionChip(
title: "OTP",
icon: LucideIcons.message_square,
value: LoginOption.otp,
),
],
);
}
Widget _buildWelcomeTextsAndChips() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
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({ Widget _buildActionButton(
required String title, BuildContext context, {
required String label,
required IconData icon, required IconData icon,
required LoginOption value, LoginOption? option,
}) { }) {
final bool isSelected = _selectedOption == value; return SizedBox(
width: double.infinity,
final Color selectedTextColor = contentTheme.brandRed; child: ElevatedButton.icon(
final Color unselectedTextColor = Colors.white; icon: Icon(icon, size: 20, color: Colors.white),
final Color selectedBgColor = Colors.grey[100]!; label: Padding(
final Color unselectedBgColor = contentTheme.brandRed; padding: const EdgeInsets.symmetric(vertical: 14),
child: MyText(
return ChoiceChip( label,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), fontSize: 16,
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 18,
color: isSelected ? selectedTextColor : unselectedTextColor,
),
const SizedBox(width: 6),
MyText(
title,
fontSize: 14,
fontWeight: 600, fontWeight: 600,
color: isSelected ? selectedTextColor : unselectedTextColor, color: Colors.white,
), ),
], ),
), style: ElevatedButton.styleFrom(
selected: isSelected, backgroundColor: const Color(0xFFB71C1C), // Red background
onSelected: (_) => setState(() => _selectedOption = value), foregroundColor: Colors.white,
selectedColor: selectedBgColor, shape: RoundedRectangleBorder(
backgroundColor: unselectedBgColor, borderRadius: BorderRadius.circular(14),
side: BorderSide( ),
color: Colors.white.withOpacity(0.6), elevation: 4,
width: 1.2, shadowColor: Colors.black26,
), ),
elevation: 3, onPressed: () {
shadowColor: Colors.black12, if (option == null) {
shape: RoundedRectangleBorder( OrganizationFormBottomSheet.show(context);
borderRadius: BorderRadius.circular(15), } else {
), _showLoginDialog(context, option);
); }
} },
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,
fontSize: 12,
), ),
); );
} }
} }
/// 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,168 +16,171 @@ class MPINAuthScreen extends StatefulWidget {
State<MPINAuthScreen> createState() => _MPINAuthScreenState(); 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"); 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 @override
void dispose() { void dispose() {
Get.delete<MPINController>(); Get.delete<MPINController>();
_controller.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final MPINController controller = Get.put(MPINController()); final controller = Get.put(MPINController());
return Scaffold( return Scaffold(
backgroundColor: contentTheme.brandRed, body: Stack(
body: SafeArea( children: [
child: LayoutBuilder(builder: (context, constraints) { const _RedWaveBackground(),
return Column( SafeArea(
children: [ child: Center(
_buildHeader(), child: Column(
const SizedBox(height: 16), mainAxisSize: MainAxisSize.min,
_buildWelcomeTextsAndChips(), children: [
const SizedBox(height: 16), const SizedBox(height: 24),
Expanded( // Static Logo (not scrollable)
child: Container( ScaleTransition(
width: double.infinity, scale: _logoAnimation,
decoration: const BoxDecoration( child: Container(
color: Colors.white, width: 100,
borderRadius: height: 100,
BorderRadius.vertical(top: Radius.circular(32)), 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),
),
), ),
child: SingleChildScrollView( const SizedBox(height: 8),
padding: const EdgeInsets.symmetric( // Scrollable content below the logo
horizontal: 24, vertical: 32), Expanded(
child: ConstrainedBox( child: SingleChildScrollView(
constraints: BoxConstraints( padding: const EdgeInsets.symmetric(horizontal: 24),
minHeight: constraints.maxHeight - 120), child: ConstrainedBox(
child: Obx(() { constraints: const BoxConstraints(maxWidth: 420),
final isNewUser = controller.isNewUser.value; child: Column(
children: [
return IntrinsicHeight( const SizedBox(height: 12),
child: Column( MyText(
crossAxisAlignment: CrossAxisAlignment.center, "Welcome to Marco",
children: [ fontSize: 24,
MyText.headlineSmall( fontWeight: 800,
isNewUser ? 'Generate MPIN' : 'Enter MPIN', color: Colors.black87,
fontWeight: 700, textAlign: TextAlign.center,
color: Colors.black87, ),
), const SizedBox(height: 10),
const SizedBox(height: 8), MyText(
MyText.bodyMedium( "Streamline Project Management\nBoost Productivity with Automation.",
isNewUser fontSize: 14,
? 'Set your 6-digit MPIN for quick login.' color: Colors.black54,
: 'Enter your 6-digit MPIN to continue.', textAlign: TextAlign.center,
color: Colors.black54, ),
fontSize: 16, if (_isBetaEnvironment) ...[
textAlign: TextAlign.center, const SizedBox(height: 12),
), Container(
const SizedBox(height: 32), padding: const EdgeInsets.symmetric(
_buildMPINForm(controller, isNewUser), horizontal: 10, vertical: 4),
const SizedBox(height: 40), decoration: BoxDecoration(
_buildSubmitButton(controller, isNewUser), color: Colors.orangeAccent,
const SizedBox(height: 24), borderRadius: BorderRadius.circular(6),
_buildFooterOptions(controller, isNewUser), ),
Padding( child: MyText(
padding: 'BETA',
const EdgeInsets.symmetric(horizontal: 24), color: Colors.white,
child: Align( fontWeight: 600,
alignment: Alignment.centerLeft, fontSize: 12,
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,
),
),
), ),
), ),
], ],
), const SizedBox(height: 36),
); _buildMPINCard(controller),
}), ],
),
),
), ),
), ),
), ],
), ),
], ),
); ),
}), ],
), ),
); );
} }
Widget _buildWelcomeTextsAndChips() { Widget _buildMPINCard(MPINController controller) {
return Padding( return Obx(() {
padding: const EdgeInsets.symmetric(horizontal: 20), final isNewUser = controller.isNewUser.value;
child: Column(
children: [ return Container(
MyText.headlineSmall( padding: const EdgeInsets.all(24),
"Welcome to Marco", decoration: BoxDecoration(
fontWeight: 700, color: Colors.white,
color: Colors.white, borderRadius: BorderRadius.circular(20),
textAlign: TextAlign.center, boxShadow: const [
fontSize: 20, BoxShadow(
), color: Colors.black12,
const SizedBox(height: 4), blurRadius: 10,
MyText.bodyMedium( offset: Offset(0, 4),
"Streamline Project Management and Boost Productivity with Automation.", ),
color: Colors.white70,
fontSize: 14,
textAlign: TextAlign.center,
),
if (_isBetaEnvironment) ...[
const SizedBox(height: 8),
_buildBetaLabel(),
], ],
], ),
), child: Column(
); children: [
} MyText(
isNewUser ? 'Generate MPIN' : 'Enter MPIN',
Widget _buildBetaLabel() { fontSize: 20,
return Container( fontWeight: 700,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), color: Colors.black87,
decoration: BoxDecoration( textAlign: TextAlign.center,
color: Colors.orangeAccent, ),
borderRadius: BorderRadius.circular(4), const SizedBox(height: 10),
), MyText(
child: MyText.bodySmall( isNewUser
'BETA', ? 'Set your 6-digit MPIN for quick login.'
color: Colors.white, : 'Enter your 6-digit MPIN to continue.',
fontWeight: 600, fontSize: 14,
fontSize: 12, color: Colors.black54,
), textAlign: TextAlign.center,
); ),
} const SizedBox(height: 30),
_buildMPINForm(controller, isNewUser),
Widget _buildHeader() { const SizedBox(height: 32),
return Container( _buildSubmitButton(controller, isNewUser),
padding: const EdgeInsets.all(12), const SizedBox(height: 20),
margin: const EdgeInsets.symmetric(horizontal: 24), _buildFooterOptions(controller, isNewUser),
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),
);
} }
Widget _buildMPINForm(MPINController controller, bool isNewUser) { Widget _buildMPINForm(MPINController controller, bool isNewUser) {
@ -187,8 +190,8 @@ class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
children: [ children: [
_buildDigitRow(controller, isRetype: false), _buildDigitRow(controller, isRetype: false),
if (isNewUser) ...[ if (isNewUser) ...[
const SizedBox(height: 24), const SizedBox(height: 20),
MyText.bodyMedium( MyText(
'Retype MPIN', 'Retype MPIN',
fontWeight: 600, fontWeight: 600,
color: Colors.black.withOpacity(0.6), color: Colors.black.withOpacity(0.6),
@ -203,8 +206,10 @@ class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
} }
Widget _buildDigitRow(MPINController controller, {required bool isRetype}) { Widget _buildDigitRow(MPINController controller, {required bool isRetype}) {
return Row( return Wrap(
mainAxisAlignment: MainAxisAlignment.center, alignment: WrapAlignment.center,
spacing: 0,
runSpacing: 12,
children: List.generate(6, (index) { children: List.generate(6, (index) {
return _buildDigitBox(controller, index, isRetype); return _buildDigitBox(controller, index, isRetype);
}), }),
@ -221,7 +226,7 @@ class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 6), margin: const EdgeInsets.symmetric(horizontal: 6),
width: 40, width: 30,
height: 55, height: 55,
child: TextFormField( child: TextFormField(
controller: textController, controller: textController,
@ -294,10 +299,7 @@ class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
children: [ children: [
if (isNewUser) if (isNewUser)
TextButton.icon( TextButton.icon(
onPressed: () async { onPressed: () => Get.toNamed('/dashboard'),
Get.delete<MPINController>();
Get.toNamed('/dashboard');
},
icon: const Icon(Icons.arrow_back, icon: const Icon(Icons.arrow_back,
size: 18, color: Colors.redAccent), size: 18, color: Colors.redAccent),
label: MyText.bodyMedium( label: MyText.bodyMedium(
@ -310,7 +312,6 @@ class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
if (showBackToLogin) if (showBackToLogin)
TextButton.icon( TextButton.icon(
onPressed: () async { onPressed: () async {
Get.delete<MPINController>();
await LocalStorage.logout(); await LocalStorage.logout();
}, },
icon: const Icon(Icons.arrow_back, 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), const SizedBox(height: 20),
SizedBox( Row(
width: double.infinity, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
child: ElevatedButton( children: [
style: ElevatedButton.styleFrom( OutlinedButton.icon(
backgroundColor: contentTheme.brandRed, onPressed: () => Navigator.pop(context),
padding: const EdgeInsets.symmetric(vertical: 16), icon: const Icon(Icons.arrow_back, color: Colors.red),
shape: RoundedRectangleBorder( label: MyText.bodyMedium("Back", color: Colors.red),
borderRadius: BorderRadius.circular(10), style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 14),
), ),
), ),
onPressed: _loading ? null : _submitForm, ElevatedButton.icon(
child: _loading onPressed: _loading ? null : _submitForm,
? const SizedBox( icon: _loading
width: 22, ? const SizedBox(
height: 22, width: 18,
child: CircularProgressIndicator( height: 18,
color: Colors.white, child: CircularProgressIndicator(
strokeWidth: 2, 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), const SizedBox(height: 8),
Center( Center(

View File

@ -71,48 +71,58 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: PreferredSize( appBar: PreferredSize(
preferredSize: const Size.fromHeight(80), preferredSize: const Size.fromHeight(72),
child: AppBar( child: AppBar(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5, elevation: 0.5,
foregroundColor: Colors.black, automaticallyImplyLeading: false,
titleSpacing: 0, titleSpacing: 0,
centerTitle: false,
leading: Padding(
padding: const EdgeInsets.only(top: 15.0),
child: IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () {
Get.offNamed('/dashboard');
},
),
),
title: Padding( title: Padding(
padding: const EdgeInsets.only(top: 15.0), padding: MySpacing.xy(16, 0),
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
MyText.titleLarge( IconButton(
'Attendance', icon: const Icon(Icons.arrow_back_ios_new,
fontWeight: 700, color: Colors.black, size: 20),
color: Colors.black, onPressed: () => Get.offNamed('/dashboard'),
), ),
const SizedBox(height: 2), MySpacing.width(8),
GetBuilder<ProjectController>( Expanded(
builder: (projectController) { child: Column(
final projectName = crossAxisAlignment: CrossAxisAlignment.start,
projectController.selectedProject?.name ?? mainAxisSize: MainAxisSize.min,
'Select Project'; children: [
return MyText.bodySmall( MyText.titleLarge(
projectName, 'Attendance',
fontWeight: 600, fontWeight: 700,
maxLines: 1, color: Colors.black,
overflow: TextOverflow.ellipsis, ),
color: Colors.grey[700], 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],
),
),
],
);
},
),
],
),
), ),
], ],
), ),

View File

@ -25,6 +25,7 @@ class DashboardScreen extends StatefulWidget {
static const String dailyTasksRoute = "/dashboard/daily-task-planing"; static const String dailyTasksRoute = "/dashboard/daily-task-planing";
static const String dailyTasksProgressRoute = static const String dailyTasksProgressRoute =
"/dashboard/daily-task-progress"; "/dashboard/daily-task-progress";
static const String directoryMainPageRoute = "/dashboard/directory-main-page";
@override @override
State<DashboardScreen> createState() => _DashboardScreenState(); State<DashboardScreen> createState() => _DashboardScreenState();
@ -154,6 +155,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
DashboardScreen.dailyTasksRoute), DashboardScreen.dailyTasksRoute),
_StatItem(LucideIcons.list_todo, "Daily Task Progress", contentTheme.info, _StatItem(LucideIcons.list_todo, "Daily Task Progress", contentTheme.info,
DashboardScreen.dailyTasksProgressRoute), DashboardScreen.dailyTasksProgressRoute),
_StatItem(LucideIcons.folder, "Directory", contentTheme.info,
DashboardScreen.directoryMainPageRoute),
]; ];
return GetBuilder<ProjectController>( 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/model/employees/employee_detail_bottom_sheet.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
class EmployeesScreen extends StatefulWidget { class EmployeesScreen extends StatefulWidget {
const EmployeesScreen({super.key}); const EmployeesScreen({super.key});
@ -73,49 +74,59 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
appBar: PreferredSize( appBar: PreferredSize(
preferredSize: const Size.fromHeight(80), preferredSize: const Size.fromHeight(72),
child: AppBar( child: AppBar(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5, elevation: 0.5,
foregroundColor: Colors.black, automaticallyImplyLeading: false,
titleSpacing: 0, titleSpacing: 0,
centerTitle: false,
leading: Padding(
padding: const EdgeInsets.only(top: 15.0), // Aligns with title
child: IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () {
Get.offNamed('/dashboard');
},
),
),
title: Padding( title: Padding(
padding: const EdgeInsets.only(top: 15.0), padding: MySpacing.xy(16, 0),
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
MyText.titleLarge( IconButton(
'Employees', icon: const Icon(Icons.arrow_back_ios_new,
fontWeight: 700, color: Colors.black, size: 20),
color: Colors.black, onPressed: () => Get.offNamed('/dashboard'),
), ),
const SizedBox(height: 2), MySpacing.width(8),
GetBuilder<ProjectController>( Expanded(
builder: (projectController) { child: Column(
final projectName = crossAxisAlignment: CrossAxisAlignment.start,
projectController.selectedProject?.name ?? mainAxisSize: MainAxisSize.min,
'Select Project'; children: [
return MyText.bodySmall( MyText.titleLarge(
projectName, 'Employees',
fontWeight: 600, fontWeight: 700,
maxLines: 1, color: Colors.black,
overflow: TextOverflow.ellipsis, ),
color: Colors.grey[700], 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],
),
),
],
);
},
),
],
),
), ),
], ],
), ),

View File

@ -59,14 +59,20 @@ class _UserProfileBarState extends State<UserProfileBar>
width: isCondensed ? 90 : 250, width: isCondensed ? 90 : 250,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut, curve: Curves.easeInOut,
child: Column( child: SafeArea(
children: [ bottom: true,
userProfileSection(), top: false,
MySpacing.height(8), left: false,
supportAndSettingsMenu(), right: false,
const Spacer(), child: Column(
logoutButton(), children: [
], userProfileSection(),
MySpacing.height(8),
supportAndSettingsMenu(),
const Spacer(),
logoutButton(),
],
),
), ),
), ),
); );

View File

@ -25,7 +25,7 @@ class MyApp extends StatelessWidget {
} }
final bool hasMpin = LocalStorage.getIsMpin(); final bool hasMpin = LocalStorage.getIsMpin();
logSafe("MPIN enabled: $hasMpin", sensitive: true); logSafe("MPIN enabled: $hasMpin", );
if (hasMpin) { if (hasMpin) {
await LocalStorage.setBool("mpin_verified", false); await LocalStorage.setBool("mpin_verified", false);

View File

@ -67,48 +67,58 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: PreferredSize( appBar: PreferredSize(
preferredSize: const Size.fromHeight(80), preferredSize: const Size.fromHeight(72),
child: AppBar( child: AppBar(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5, elevation: 0.5,
foregroundColor: Colors.black, automaticallyImplyLeading: false,
titleSpacing: 0, titleSpacing: 0,
centerTitle: false,
leading: Padding(
padding: const EdgeInsets.only(top: 15.0), // Aligns with title
child: IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () {
Get.offNamed('/dashboard');
},
),
),
title: Padding( title: Padding(
padding: const EdgeInsets.only(top: 15.0), padding: MySpacing.xy(16, 0),
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
MyText.titleLarge( IconButton(
'Daily Task Progress', icon: const Icon(Icons.arrow_back_ios_new,
fontWeight: 700, color: Colors.black, size: 20),
color: Colors.black, onPressed: () => Get.offNamed('/dashboard'),
), ),
const SizedBox(height: 2), MySpacing.width(8),
GetBuilder<ProjectController>( Expanded(
builder: (projectController) { child: Column(
final projectName = crossAxisAlignment: CrossAxisAlignment.start,
projectController.selectedProject?.name ?? mainAxisSize: MainAxisSize.min,
'Select Project'; children: [
return MyText.bodySmall( MyText.titleLarge(
projectName, 'Daily Task Progress',
fontWeight: 600, fontWeight: 700,
maxLines: 1, color: Colors.black,
overflow: TextOverflow.ellipsis, ),
color: Colors.grey[700], 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],
),
),
],
);
},
),
],
),
), ),
], ],
), ),
@ -451,41 +461,44 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
: Colors.red[700], : Colors.red[700],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( SingleChildScrollView(
mainAxisAlignment: MainAxisAlignment.end, scrollDirection: Axis.horizontal,
children: [ child: Row(
if (task.reportedDate == null || mainAxisAlignment: MainAxisAlignment.end,
task.reportedDate children: [
.toString() if (task.reportedDate == null ||
.isEmpty) ...[ task.reportedDate
TaskActionButtons.reportButton( .toString()
context: context, .isEmpty) ...[
task: task, TaskActionButtons.reportButton(
completed: completed.toInt(), context: context,
refreshCallback: _refreshData, task: task,
), completed: completed.toInt(),
const SizedBox(width: 8), refreshCallback: _refreshData,
] else if (task.approvedBy == null) ...[ ),
TaskActionButtons.reportActionButton( const SizedBox(width: 4),
] else if (task.approvedBy == null) ...[
TaskActionButtons.reportActionButton(
context: context,
task: task,
parentTaskID: parentTaskID,
workAreaId: workAreaId.toString(),
activityId: activityId.toString(),
completed: completed.toInt(),
refreshCallback: _refreshData,
),
const SizedBox(width: 5),
],
TaskActionButtons.commentButton(
context: context, context: context,
task: task, task: task,
parentTaskID: parentTaskID, parentTaskID: parentTaskID,
workAreaId: workAreaId.toString(), workAreaId: workAreaId.toString(),
activityId: activityId.toString(), activityId: activityId.toString(),
completed: completed.toInt(),
refreshCallback: _refreshData, refreshCallback: _refreshData,
), ),
const SizedBox(width: 8),
], ],
TaskActionButtons.commentButton( ),
context: context,
task: task,
parentTaskID: parentTaskID,
workAreaId: workAreaId.toString(),
activityId: activityId.toString(),
refreshCallback: _refreshData,
),
],
) )
], ],
), ),

View File

@ -53,48 +53,58 @@ class _DailyTaskPlaningScreenState extends State<DailyTaskPlaningScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: PreferredSize( appBar: PreferredSize(
preferredSize: const Size.fromHeight(80), preferredSize: const Size.fromHeight(72),
child: AppBar( child: AppBar(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5, elevation: 0.5,
foregroundColor: Colors.black, automaticallyImplyLeading: false,
titleSpacing: 0, titleSpacing: 0,
centerTitle: false,
leading: Padding(
padding: const EdgeInsets.only(top: 15.0), // Aligns with title
child: IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () {
Get.offNamed('/dashboard');
},
),
),
title: Padding( title: Padding(
padding: const EdgeInsets.only(top: 15.0), padding: MySpacing.xy(16, 0),
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
MyText.titleLarge( IconButton(
'Daily Task Planning', icon: const Icon(Icons.arrow_back_ios_new,
fontWeight: 700, color: Colors.black, size: 20),
color: Colors.black, onPressed: () => Get.offNamed('/dashboard'),
), ),
const SizedBox(height: 2), MySpacing.width(8),
GetBuilder<ProjectController>( Expanded(
builder: (projectController) { child: Column(
final projectName = crossAxisAlignment: CrossAxisAlignment.start,
projectController.selectedProject?.name ?? mainAxisSize: MainAxisSize.min,
'Select Project'; children: [
return MyText.bodySmall( MyText.titleLarge(
projectName, 'Daily Task Planing',
fontWeight: 600, fontWeight: 700,
maxLines: 1, color: Colors.black,
overflow: TextOverflow.ellipsis, ),
color: Colors.grey[700], 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],
),
),
],
);
},
),
],
),
), ),
], ],
), ),

View File

@ -52,7 +52,7 @@ dependencies:
intl: ^0.19.0 intl: ^0.19.0
syncfusion_flutter_core: ^28.1.33 syncfusion_flutter_core: ^28.1.33
syncfusion_flutter_sliders: ^28.1.33 syncfusion_flutter_sliders: ^28.1.33
file_picker: ^8.1.5 file_picker: ^9.2.3
timelines_plus: ^1.0.4 timelines_plus: ^1.0.4
syncfusion_flutter_charts: ^28.1.33 syncfusion_flutter_charts: ^28.1.33
appflowy_board: ^0.1.2 appflowy_board: ^0.1.2
@ -71,6 +71,12 @@ dependencies:
flutter_contacts: ^1.1.9+2 flutter_contacts: ^1.1.9+2
photo_view: ^0.15.0 photo_view: ^0.15.0
jwt_decoder: ^2.0.1 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: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter