From 8f87161d74bc6d94051335ba150aaed14e7f0794 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 28 Jun 2025 13:14:22 +0530 Subject: [PATCH 01/39] refactor: Replace MyButton with OutlinedButton and ElevatedButton in various bottom sheets for improved UI consistency --- .../assign_task_bottom_sheet .dart | 33 +++- .../comment_task_bottom_sheet.dart | 59 ++++--- .../report_action_bottom_sheet.dart | 64 ++++---- .../report_task_bottom_sheet.dart | 125 +++++++++------ .../employees/add_employee_bottom_sheet.dart | 146 ++++++++++++------ lib/view/auth/request_demo_bottom_sheet.dart | 60 ++++--- 6 files changed, 303 insertions(+), 184 deletions(-) diff --git a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart index 0bcd385..a61dbac 100644 --- a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart +++ b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/task_planing/daily_task_planing_controller.dart'; -import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; @@ -197,16 +196,34 @@ class _AssignTaskBottomSheetState extends State { ), MySpacing.height(24), Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - MyButton( + OutlinedButton.icon( + onPressed: () => Get.back(), + icon: const Icon(Icons.close, color: Colors.red), + label: MyText.bodyMedium("Cancel", color: Colors.red), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 14), + ), + ), + ElevatedButton.icon( onPressed: _onAssignTaskPressed, - backgroundColor: const Color.fromARGB(255, 95, 132, 255), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ + icon: const Icon(Icons.check_circle_outline, + color: Colors.white), + label: MyText.bodyMedium("Assign Task", color: Colors.white), - ], + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 28, vertical: 14), ), ), ], diff --git a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart index 90ab576..a646f7b 100644 --- a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/task_planing/report_task_controller.dart'; -import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; @@ -342,9 +341,6 @@ class _CommentTaskBottomSheetState extends State } }, isLoading: controller.isLoading, - splashColor: contentTheme.secondary.withAlpha(25), - backgroundColor: Colors.blueAccent, - loadingIndicatorColor: contentTheme.onPrimary, ), MySpacing.height(10), if ((widget.taskData['taskComments'] as List?) @@ -526,43 +522,46 @@ class _CommentTaskBottomSheetState extends State required VoidCallback onCancel, required Future Function() onSubmit, required RxBool isLoading, - required Color splashColor, - required Color backgroundColor, - required Color loadingIndicatorColor, double? buttonHeight, }) { return Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - MyButton.text( + OutlinedButton.icon( onPressed: onCancel, - padding: MySpacing.xy(20, 16), - splashColor: splashColor, - child: MyText.bodySmall('Cancel'), + 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), + ), ), - MySpacing.width(12), Obx(() { - return MyButton( - onPressed: isLoading.value ? null : onSubmit, - elevation: 0, - padding: MySpacing.xy(20, 16), - backgroundColor: backgroundColor, - borderRadiusAll: AppStyle.buttonRadius.medium, - child: isLoading.value - ? SizedBox( - width: buttonHeight ?? 16, - height: buttonHeight ?? 16, + return ElevatedButton.icon( + onPressed: isLoading.value ? null : () => onSubmit(), + icon: isLoading.value + ? const SizedBox( + width: 16, + height: 16, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - loadingIndicatorColor, - ), + valueColor: AlwaysStoppedAnimation(Colors.white), ), ) - : MyText.bodySmall( - 'Comment', - color: loadingIndicatorColor, - ), + : const Icon(Icons.check_circle_outline, color: Colors.white), + label: isLoading.value + ? const SizedBox() + : MyText.bodyMedium("Comment", color: Colors.white), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14), + ), ); }), ], diff --git a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart index 893d6c0..4013aea 100644 --- a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart @@ -488,10 +488,8 @@ class _ReportActionBottomSheetState extends State widget.taskData['plannedWork'] ?? '0') ?? 0, - activityId: - widget.activityId, - workAreaId: - widget.workAreaId, + activityId: widget.activityId, + workAreaId: widget.workAreaId, onSubmit: () { Navigator.of(context).pop(); }, @@ -502,9 +500,6 @@ class _ReportActionBottomSheetState extends State } }, isLoading: controller.isLoading, - splashColor: contentTheme.secondary.withAlpha(25), - backgroundColor: Colors.blueAccent, - loadingIndicatorColor: contentTheme.onPrimary, ), MySpacing.height(10), @@ -687,43 +682,46 @@ class _ReportActionBottomSheetState extends State required VoidCallback onCancel, required Future Function() onSubmit, required RxBool isLoading, - required Color splashColor, - required Color backgroundColor, - required Color loadingIndicatorColor, double? buttonHeight, }) { return Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - MyButton.text( + OutlinedButton.icon( onPressed: onCancel, - padding: MySpacing.xy(20, 16), - splashColor: splashColor, - child: MyText.bodySmall('Cancel'), + 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), + ), ), - MySpacing.width(12), Obx(() { - return MyButton( - onPressed: isLoading.value ? null : onSubmit, - elevation: 0, - padding: MySpacing.xy(20, 16), - backgroundColor: backgroundColor, - borderRadiusAll: AppStyle.buttonRadius.medium, - child: isLoading.value - ? SizedBox( - width: buttonHeight ?? 16, - height: buttonHeight ?? 16, + return ElevatedButton.icon( + onPressed: isLoading.value ? null : () => onSubmit(), + icon: isLoading.value + ? const SizedBox( + width: 16, + height: 16, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - loadingIndicatorColor, - ), + valueColor: AlwaysStoppedAnimation(Colors.white), ), ) - : MyText.bodySmall( - 'Submit Report', - color: loadingIndicatorColor, - ), + : const Icon(Icons.send), + label: isLoading.value + ? const SizedBox() + : 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), + ), ); }), ], diff --git a/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart index 9b86441..694751b 100644 --- a/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/task_planing/report_task_controller.dart'; -import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; @@ -347,58 +346,86 @@ class _ReportTaskBottomSheetState extends State ); }), Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - MyButton.text( + OutlinedButton.icon( onPressed: () => Navigator.of(context).pop(), - padding: MySpacing.xy(20, 16), - splashColor: contentTheme.secondary.withAlpha(25), - child: MyText.bodySmall('Cancel'), + icon: const Icon(Icons.close, color: Colors.red), + label: MyText.bodyMedium("Cancel", + color: Colors.red, fontWeight: 600), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 14), + ), ), - MySpacing.width(12), - Obx(() { - final isLoading = controller.reportStatus.value == ApiStatus.loading; - - return MyButton( - 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!(); - } - } - }, - 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(Colors.white), - ), - ) - : MyText.bodySmall( - 'Report', - color: contentTheme.onPrimary, - ), - ); -}), + 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( + 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: 28, vertical: 14), + ), + ); + }), ], ), ], diff --git a/lib/model/employees/add_employee_bottom_sheet.dart b/lib/model/employees/add_employee_bottom_sheet.dart index fa0c8d7..52d0cf5 100644 --- a/lib/model/employees/add_employee_bottom_sheet.dart +++ b/lib/model/employees/add_employee_bottom_sheet.dart @@ -13,7 +13,8 @@ class AddEmployeeBottomSheet extends StatefulWidget { State createState() => _AddEmployeeBottomSheetState(); } -class _AddEmployeeBottomSheetState extends State with UIMixin { +class _AddEmployeeBottomSheetState extends State + with UIMixin { final AddEmployeeController _controller = Get.put(AddEmployeeController()); late TextEditingController genderController; @@ -27,7 +28,8 @@ class _AddEmployeeBottomSheetState extends State with UI } RelativeRect _popupMenuPosition(BuildContext context) { - final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; return RelativeRect.fromLTRB(100, 300, overlay.size.width - 100, 0); } @@ -135,8 +137,14 @@ class _AddEmployeeBottomSheetState extends State with UI child: Container( decoration: BoxDecoration( color: theme.cardColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, -2))], + borderRadius: + const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 12, + offset: Offset(0, -2)) + ], ), child: Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), @@ -153,7 +161,8 @@ class _AddEmployeeBottomSheetState extends State with UI ), ), MySpacing.height(12), - Text("Add Employee", style: MyTextStyle.titleLarge(fontWeight: 700)), + Text("Add Employee", + style: MyTextStyle.titleLarge(fontWeight: 700)), MySpacing.height(24), Form( key: _controller.basicValidator.formKey, @@ -166,16 +175,20 @@ class _AddEmployeeBottomSheetState extends State with UI label: "First Name", hint: "e.g., John", icon: Icons.person, - controller: _controller.basicValidator.getController('first_name')!, - validator: _controller.basicValidator.getValidation('first_name'), + controller: _controller.basicValidator + .getController('first_name')!, + validator: _controller.basicValidator + .getValidation('first_name'), ), MySpacing.height(16), _inputWithIcon( label: "Last Name", hint: "e.g., Doe", icon: Icons.person_outline, - controller: _controller.basicValidator.getController('last_name')!, - validator: _controller.basicValidator.getValidation('last_name'), + controller: _controller.basicValidator + .getController('last_name')!, + validator: _controller.basicValidator + .getValidation('last_name'), ), MySpacing.height(16), _sectionLabel("Contact Details"), @@ -185,7 +198,8 @@ class _AddEmployeeBottomSheetState extends State with UI Row( children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 14), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(12), @@ -193,7 +207,8 @@ class _AddEmployeeBottomSheetState extends State with UI ), child: PopupMenuButton>( onSelected: (country) { - _controller.selectedCountryCode = country['code']!; + _controller.selectedCountryCode = + country['code']!; _controller.update(); }, itemBuilder: (context) => [ @@ -204,11 +219,14 @@ class _AddEmployeeBottomSheetState extends State with UI height: 200, width: 100, child: ListView( - children: _controller.countries.map((country) { + children: _controller.countries + .map((country) { return ListTile( dense: true, - title: Text("${country['name']} (${country['code']})"), - onTap: () => Navigator.pop(context, country), + title: Text( + "${country['name']} (${country['code']})"), + onTap: () => + Navigator.pop(context, country), ); }).toList(), ), @@ -226,31 +244,42 @@ class _AddEmployeeBottomSheetState extends State with UI MySpacing.width(12), Expanded( child: TextFormField( - controller: _controller.basicValidator.getController('phone_number'), + controller: _controller.basicValidator + .getController('phone_number'), validator: (value) { if (value == null || value.trim().isEmpty) { return "Phone number is required"; } final digitsOnly = value.trim(); - final minLength = _controller.minDigitsPerCountry[_controller.selectedCountryCode] ?? 7; - final maxLength = _controller.maxDigitsPerCountry[_controller.selectedCountryCode] ?? 15; + final minLength = _controller + .minDigitsPerCountry[ + _controller.selectedCountryCode] ?? + 7; + final maxLength = _controller + .maxDigitsPerCountry[ + _controller.selectedCountryCode] ?? + 15; - if (!RegExp(r'^[0-9]+$').hasMatch(digitsOnly)) { + if (!RegExp(r'^[0-9]+$') + .hasMatch(digitsOnly)) { return "Only digits allowed"; } - if (digitsOnly.length < minLength || digitsOnly.length > maxLength) { + if (digitsOnly.length < minLength || + digitsOnly.length > maxLength) { return "Between $minLength–$maxLength digits"; } return null; }, keyboardType: TextInputType.phone, - decoration: _inputDecoration("e.g., 9876543210").copyWith( + decoration: _inputDecoration("e.g., 9876543210") + .copyWith( suffixIcon: IconButton( icon: const Icon(Icons.contacts), - onPressed: () => _controller.pickContact(context), + onPressed: () => + _controller.pickContact(context), ), ), ), @@ -268,9 +297,11 @@ class _AddEmployeeBottomSheetState extends State with UI child: TextFormField( readOnly: true, controller: TextEditingController( - text: _controller.selectedGender?.name.capitalizeFirst, + text: _controller + .selectedGender?.name.capitalizeFirst, ), - decoration: _inputDecoration("Select Gender").copyWith( + decoration: + _inputDecoration("Select Gender").copyWith( suffixIcon: const Icon(Icons.expand_more), ), ), @@ -286,10 +317,14 @@ class _AddEmployeeBottomSheetState extends State with UI readOnly: true, controller: TextEditingController( text: _controller.roles.firstWhereOrNull( - (role) => role['id'] == _controller.selectedRoleId, - )?['name'] ?? "", + (role) => + role['id'] == + _controller.selectedRoleId, + )?['name'] ?? + "", ), - decoration: _inputDecoration("Select Role").copyWith( + decoration: + _inputDecoration("Select Role").copyWith( suffixIcon: const Icon(Icons.expand_more), ), ), @@ -301,11 +336,16 @@ class _AddEmployeeBottomSheetState extends State with UI Expanded( child: OutlinedButton.icon( onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close, size: 18), - label: MyText.bodyMedium("Cancel", fontWeight: 600), + icon: + const Icon(Icons.close, color: Colors.red), + label: MyText.bodyMedium("Cancel", + color: Colors.red, fontWeight: 600), style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.grey), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 14), ), ), ), @@ -313,23 +353,36 @@ class _AddEmployeeBottomSheetState extends State with UI Expanded( child: ElevatedButton.icon( onPressed: () async { - if (_controller.basicValidator.validateForm()) { - final success = await _controller.createEmployees(); + if (_controller.basicValidator + .validateForm()) { + final success = + await _controller.createEmployees(); if (success) { - final employeeController = Get.find(); - final projectId = employeeController.selectedProjectId; + final employeeController = + Get.find(); + final projectId = + employeeController.selectedProjectId; if (projectId == null) { - await employeeController.fetchAllEmployees(); + await employeeController + .fetchAllEmployees(); } else { - await employeeController.fetchEmployeesByProject(projectId); + await employeeController + .fetchEmployeesByProject(projectId); } - employeeController.update(['employee_screen_controller']); + employeeController.update( + ['employee_screen_controller']); - _controller.basicValidator.getController("first_name")?.clear(); - _controller.basicValidator.getController("last_name")?.clear(); - _controller.basicValidator.getController("phone_number")?.clear(); + _controller.basicValidator + .getController("first_name") + ?.clear(); + _controller.basicValidator + .getController("last_name") + ?.clear(); + _controller.basicValidator + .getController("phone_number") + ?.clear(); _controller.selectedGender = null; _controller.selectedRoleId = null; _controller.update(); @@ -338,11 +391,16 @@ class _AddEmployeeBottomSheetState extends State with UI } } }, - icon: const Icon(Icons.check, size: 18), - label: MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600), + icon: const Icon(Icons.check_circle_outline, + color: Colors.white), + label: MyText.bodyMedium("Save", + color: Colors.white, fontWeight: 600), style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueAccent, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric( + horizontal: 28, vertical: 14), ), ), ), diff --git a/lib/view/auth/request_demo_bottom_sheet.dart b/lib/view/auth/request_demo_bottom_sheet.dart index b3b21c4..6977089 100644 --- a/lib/view/auth/request_demo_bottom_sheet.dart +++ b/lib/view/auth/request_demo_bottom_sheet.dart @@ -187,28 +187,48 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin { ], ), const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: contentTheme.brandRed, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + OutlinedButton.icon( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.arrow_back, color: Colors.red), + label: MyText.bodyMedium("Back", color: Colors.red), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 14), ), ), - onPressed: _loading ? null : _submitForm, - child: _loading - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) - : MyText.labelLarge('Submit', color: Colors.white), - ), + ElevatedButton.icon( + onPressed: _loading ? null : _submitForm, + icon: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Icon(Icons.check_circle_outline, + color: Colors.white), + label: _loading + ? const SizedBox.shrink() + : MyText.bodyMedium("Submit", color: Colors.white), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 28, vertical: 14), + ), + ), + ], ), const SizedBox(height: 8), Center( -- 2.43.0 From a0f1602f4e2cb51ceec61fc647a9348a39ba3326 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 2 Jul 2025 15:57:39 +0530 Subject: [PATCH 02/39] 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. --- .../dashboard/add_employee_controller.dart | 2 +- .../attendance_screen_controller.dart | 2 +- .../directory/add_contact_controller.dart | 254 +++++++++ .../directory/directory_controller.dart | 165 ++++++ lib/helpers/services/api_endpoints.dart | 10 + lib/helpers/services/api_service.dart | 78 ++- lib/helpers/services/app_initializer.dart | 22 +- lib/helpers/utils/launcher_utils.dart | 98 ++++ lib/helpers/widgets/avatar.dart | 34 +- lib/helpers/widgets/my_custom_skeleton.dart | 55 ++ lib/helpers/widgets/my_loading_component.dart | 57 +- .../comment_task_bottom_sheet.dart | 44 +- .../report_action_bottom_sheet.dart | 47 +- .../report_task_bottom_sheet.dart | 167 +++--- .../directory/add_contact_bottom_sheet.dart | 444 +++++++++++++++ .../directory/contact_bucket_list_model.dart | 57 ++ .../directory/contact_category_model.dart | 65 +++ lib/model/directory/contact_model.dart | 135 +++++ .../contact_profile_comment_model.dart | 245 ++++++++ lib/model/directory/contact_tag_model.dart | 65 +++ .../directory/directory_comment_model.dart | 101 ++++ .../directory_filter_bottom_sheet.dart | 170 ++++++ .../directory/organization_list_model.dart | 25 + lib/routes.dart | 13 +- lib/view/dashboard/dashboard_screen.dart | 3 + lib/view/directory/contact_detail_screen.dart | 376 +++++++++++++ lib/view/directory/directory_main_screen.dart | 524 ++++++++++++++++++ pubspec.yaml | 2 + 28 files changed, 3107 insertions(+), 153 deletions(-) create mode 100644 lib/controller/directory/add_contact_controller.dart create mode 100644 lib/controller/directory/directory_controller.dart create mode 100644 lib/helpers/utils/launcher_utils.dart create mode 100644 lib/model/directory/add_contact_bottom_sheet.dart create mode 100644 lib/model/directory/contact_bucket_list_model.dart create mode 100644 lib/model/directory/contact_category_model.dart create mode 100644 lib/model/directory/contact_model.dart create mode 100644 lib/model/directory/contact_profile_comment_model.dart create mode 100644 lib/model/directory/contact_tag_model.dart create mode 100644 lib/model/directory/directory_comment_model.dart create mode 100644 lib/model/directory/directory_filter_bottom_sheet.dart create mode 100644 lib/model/directory/organization_list_model.dart create mode 100644 lib/view/directory/contact_detail_screen.dart create mode 100644 lib/view/directory/directory_main_screen.dart diff --git a/lib/controller/dashboard/add_employee_controller.dart b/lib/controller/dashboard/add_employee_controller.dart index 250e4cd..6b0e5c0 100644 --- a/lib/controller/dashboard/add_employee_controller.dart +++ b/lib/controller/dashboard/add_employee_controller.dart @@ -146,7 +146,7 @@ class AddEmployeeController extends MyController { gender: selectedGender!.name, jobRoleId: selectedRoleId!, ); - +logSafe("Response: $response"); if (response == true) { logSafe("Employee created successfully."); showAppSnackbar( diff --git a/lib/controller/dashboard/attendance_screen_controller.dart b/lib/controller/dashboard/attendance_screen_controller.dart index 45d227d..f477be9 100644 --- a/lib/controller/dashboard/attendance_screen_controller.dart +++ b/lib/controller/dashboard/attendance_screen_controller.dart @@ -197,7 +197,7 @@ class AttendanceController extends GetxController { textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom(foregroundColor: Colors.teal), ), - dialogTheme: const DialogTheme(backgroundColor: Colors.white), + dialogTheme: DialogThemeData(backgroundColor: Colors.white), ), child: child!, ), diff --git a/lib/controller/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart new file mode 100644 index 0000000..1c2bace --- /dev/null +++ b/lib/controller/directory/add_contact_controller.dart @@ -0,0 +1,254 @@ +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 categories = [].obs; + final RxList buckets = [].obs; + final RxList globalProjects = [].obs; + final RxList tags = [].obs; + + final RxString selectedCategory = ''.obs; + final RxString selectedBucket = ''.obs; + final RxString selectedProject = ''.obs; + + final RxList enteredTags = [].obs; + final RxList filteredSuggestions = [].obs; + final RxList organizationNames = [].obs; + final RxList filteredOrgSuggestions = [].obs; + + final RxMap categoriesMap = {}.obs; + final RxMap bucketsMap = {}.obs; + final RxMap projectsMap = {}.obs; + final RxMap tagsMap = {}.obs; + + @override + void onInit() { + super.onInit(); + logSafe("AddContactController initialized", level: LogLevel.debug); + fetchInitialData(); + } + + Future fetchInitialData() async { + logSafe("Fetching initial dropdown data", level: LogLevel.debug); + await Future.wait([ + fetchBuckets(), + fetchGlobalProjects(), + fetchTags(), + fetchCategories(), + fetchOrganizationNames(), + ]); + } + + Future fetchBuckets() async { + try { + final response = await ApiService.getContactBucketList(); + if (response != null && response['data'] is List) { + final names = []; + 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 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 submitContact({ + required String name, + required String organization, + required String email, + required String emailLabel, + required String phone, + required String phoneLabel, + required String address, + required String description, + }) async { + try { + final categoryId = categoriesMap[selectedCategory.value]; + final bucketId = bucketsMap[selectedBucket.value]; + final projectId = projectsMap[selectedProject.value]; + + final tagObjects = enteredTags.map((tagName) { + final tagId = tagsMap[tagName]; + return tagId != null + ? {"id": tagId, "name": tagName} + : {"name": tagName}; + }).toList(); + + final body = { + "name": name, + "organization": organization, + "contactCategoryId": categoryId, + "projectIds": projectId != null ? [projectId] : [], + "bucketIds": bucketId != null ? [bucketId] : [], + "tags": tagObjects, + "contactEmails": [ + { + "label": emailLabel, + "emailAddress": email, + } + ], + "contactPhones": [ + { + "label": phoneLabel, + "phoneNumber": phone, + } + ], + "address": address, + "description": description, + }; + + logSafe("Submitting contact", sensitive: true); + + final response = await ApiService.createContact(body); + if (response == true) { + logSafe("Contact creation succeeded"); + + // Send result back to previous screen + Get.back(result: true); + + showAppSnackbar( + title: "Success", + message: "Contact created successfully", + type: SnackbarType.success, + ); + } else { + logSafe("Contact creation failed", level: LogLevel.error); + showAppSnackbar( + title: "Error", + message: "Failed to create contact", + type: SnackbarType.error, + ); + } + } catch (e) { + logSafe("Contact creation 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 fetchGlobalProjects() async { + try { + final response = await ApiService.getGlobalProjects(); + if (response != null) { + final names = []; + 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 fetchTags() async { + try { + final response = await ApiService.getContactTagList(); + if (response != null && response['data'] is List) { + tags.assignAll(List.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 fetchCategories() async { + try { + final response = await ApiService.getContactCategoryList(); + if (response != null && response['data'] is List) { + final names = []; + 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); + } +} diff --git a/lib/controller/directory/directory_controller.dart b/lib/controller/directory/directory_controller.dart new file mode 100644 index 0000000..75f0974 --- /dev/null +++ b/lib/controller/directory/directory_controller.dart @@ -0,0 +1,165 @@ +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'; + +class DirectoryController extends GetxController { + RxList allContacts = [].obs; + RxList filteredContacts = [].obs; + RxList contactCategories = [].obs; + RxList selectedCategories = [].obs; + RxList selectedBuckets = [].obs; + RxBool isActive = true.obs; + RxBool isLoading = false.obs; + RxList contactBuckets = [].obs; + RxString searchQuery = ''.obs; + RxBool showFabMenu = false.obs; + RxMap> contactCommentsMap = + >{}.obs; + + @override + void onInit() { + super.onInit(); + fetchContacts(); + fetchBuckets(); + } + + void extractCategoriesFromContacts() { + final uniqueCategories = {}; + + 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(); + } + + Future fetchCommentsForContact(String contactId) async { + try { + final data = await ApiService.getDirectoryComments(contactId); + logSafe("Fetched comments for contact $contactId: $data"); + + if (data != null ) { + final comments = data.map((e) => DirectoryComment.fromJson(e)).toList(); + contactCommentsMap[contactId] = comments; + } else { + contactCommentsMap[contactId] = []; + } + + contactCommentsMap.refresh(); + } catch (e) { + logSafe("Error fetching comments for contact $contactId: $e", + level: LogLevel.error); + contactCommentsMap[contactId] = []; + contactCommentsMap.refresh(); + } +} + + + Future 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 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 applyFilters() { + final query = searchQuery.value.toLowerCase(); + + filteredContacts.value = allContacts.where((contact) { + // 1. Category filter + final categoryMatch = selectedCategories.isEmpty || + (contact.contactCategory != null && + selectedCategories.contains(contact.contactCategory!.id)); + + // 2. Bucket filter + final bucketMatch = selectedBuckets.isEmpty || + contact.bucketIds.any((id) => selectedBuckets.contains(id)); + + // 3. Search filter: match name, organization, email, or 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 tagMatch = + contact.tags.any((tag) => tag.name.toLowerCase().contains(query)); + + final searchMatch = + query.isEmpty || nameMatch || orgMatch || emailMatch || tagMatch; + + 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 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; + } +} diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 12809fd..18c70fe 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -31,4 +31,14 @@ class ApiEndpoints { static const String approveReportAction = "/task/approve"; static const String assignTask = "/project/task"; static const String getmasterWorkCategories = "/Master/work-categories"; + + ////// Directory Screen API Endpoints + static const String getDirectoryContacts = "/directory"; + static const String getDirectoryBucketList = "/directory/buckets"; + static const String getDirectoryContactDetail = "/directory/notes"; + static const String getDirectoryContactCategory = "/master/contact-categories"; + static const String getDirectoryContactTags = "/master/contact-tags"; + static const String getDirectoryOrganization = "/directory/organization"; + static const String createContact = "/directory"; + static const String getDirectoryNotes = "/directory/notes"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 805446b..cd63e6c 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -177,6 +177,82 @@ class ApiService { : null); } + /// Directly calling the API +static Future?> 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 createContact(Map payload) async { + try { + logSafe("Submitting contact payload: $payload", sensitive: true); + + 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> getOrganizationList() async { + try { + final response = await _getRequest(ApiEndpoints.getDirectoryOrganization); + if (response != null && response.statusCode == 200) { + final body = jsonDecode(response.body); + if (body['success'] == true && body['data'] is List) { + return List.from(body['data']); + } + } + } catch (e) { + logSafe("Failed to fetch organization names: $e", level: LogLevel.error); + } + return []; + } + + static Future?> getContactCategoryList() async => + _getRequest(ApiEndpoints.getDirectoryContactCategory).then((res) => + res != null + ? _parseResponseForAllData(res, label: 'Contact Category List') + : null); + + static Future?> getContactTagList() async => + _getRequest(ApiEndpoints.getDirectoryContactTags).then((res) => + res != null + ? _parseResponseForAllData(res, label: 'Contact Tag List') + : null); + + static Future?> 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?> getContactBucketList() async => + _getRequest(ApiEndpoints.getDirectoryBucketList).then((res) => res != null + ? _parseResponseForAllData(res, label: 'Contact Bucket List') + : null); + // === Attendance APIs === static Future?> getProjects() async => @@ -319,7 +395,7 @@ class ApiService { "jobRoleId": jobRoleId, }; final response = await _postRequest( - ApiEndpoints.reportTask, + ApiEndpoints.createEmployee, body, customTimeout: extendedTimeout, ); diff --git a/lib/helpers/services/app_initializer.dart b/lib/helpers/services/app_initializer.dart index 258e3be..d674a39 100644 --- a/lib/helpers/services/app_initializer.dart +++ b/lib/helpers/services/app_initializer.dart @@ -10,39 +10,39 @@ import 'package:marco/helpers/services/app_logger.dart'; Future initializeApp() async { try { - logSafe("Starting app initialization..."); + logSafe("πŸ’‘ Starting app initialization..."); setPathUrlStrategy(); - logSafe("URL strategy set."); + logSafe("πŸ’‘ URL strategy set."); SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( statusBarColor: Color.fromARGB(255, 255, 0, 0), statusBarIconBrightness: Brightness.light, )); - logSafe("System UI overlay style set."); + logSafe("πŸ’‘ System UI overlay style set."); await LocalStorage.init(); - logSafe("Local storage initialized."); + logSafe("πŸ’‘ Local storage initialized."); await ThemeCustomizer.init(); - logSafe("Theme customizer initialized."); + logSafe("πŸ’‘ Theme customizer initialized."); Get.put(PermissionController()); - logSafe("PermissionController injected."); + logSafe("πŸ’‘ PermissionController injected."); Get.put(ProjectController(), permanent: true); - logSafe("ProjectController injected as permanent."); + logSafe("πŸ’‘ ProjectController injected as permanent."); AppStyle.init(); - logSafe("AppStyle initialized."); + logSafe("πŸ’‘ AppStyle initialized."); - logSafe("App initialization completed successfully."); + logSafe("βœ… App initialization completed successfully."); } catch (e, stacktrace) { - logSafe("Error during app initialization", + logSafe("β›” Error during app initialization", level: LogLevel.error, error: e, stackTrace: stacktrace, ); - rethrow; + rethrow; } } diff --git a/lib/helpers/utils/launcher_utils.dart b/lib/helpers/utils/launcher_utils.dart new file mode 100644 index 0000000..daaf082 --- /dev/null +++ b/lib/helpers/utils/launcher_utils.dart @@ -0,0 +1,98 @@ +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 { + static Future launchPhone(String phoneNumber) async { + logSafe('Attempting to launch phone: $phoneNumber', sensitive: true); + + final Uri url = Uri(scheme: 'tel', path: phoneNumber); + await _tryLaunch(url, 'Could not launch phone'); + } + + static Future launchEmail(String email) async { + logSafe('Attempting to launch email: $email', sensitive: true); + + final Uri url = Uri(scheme: 'mailto', path: email); + await _tryLaunch(url, 'Could not launch email'); + } + + static Future launchWhatsApp(String phoneNumber) async { + logSafe('Attempting to launch WhatsApp with: $phoneNumber', + sensitive: true); + + String normalized = phoneNumber.replaceAll(RegExp(r'\D'), ''); + if (!normalized.startsWith('91')) { + normalized = '91$normalized'; + } + + logSafe('Normalized WhatsApp number: $normalized', sensitive: true); + + if (normalized.length < 12) { + logSafe('Invalid WhatsApp number: $normalized', sensitive: true); + 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'); + } + + static Future 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, sensitive: true); + + showAppSnackbar( + title: 'Error', + message: 'Failed to copy $typeLabel', + type: SnackbarType.error, + ); + } + } + + static Future _tryLaunch(Uri url, String errorMsg) async { + try { + logSafe('Trying to launch URL: ${url.toString()}'); + + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + logSafe('URL launched successfully: ${url.toString()}'); + } else { + logSafe( + 'Launch failed - canLaunchUrl 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, + ); + } + } +} diff --git a/lib/helpers/widgets/avatar.dart b/lib/helpers/widgets/avatar.dart index 16b546c..82f1dbf 100644 --- a/lib/helpers/widgets/avatar.dart +++ b/lib/helpers/widgets/avatar.dart @@ -6,7 +6,7 @@ class Avatar extends StatelessWidget { final String firstName; final String lastName; final double size; - final Color? backgroundColor; // Optional: allows override + final Color? backgroundColor; final Color textColor; const Avatar({ @@ -22,7 +22,7 @@ class Avatar extends StatelessWidget { Widget build(BuildContext context) { String initials = "${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}".toUpperCase(); - final Color bgColor = backgroundColor ?? _generateColorFromName('$firstName$lastName'); + final Color bgColor = backgroundColor ?? _getFlatColorFromName('$firstName$lastName'); return MyContainer.rounded( height: size, @@ -39,12 +39,28 @@ class Avatar extends StatelessWidget { ); } - // Generate a consistent "random-like" color from the name - Color _generateColorFromName(String name) { - final hash = name.hashCode; - final r = (hash & 0xFF0000) >> 16; - final g = (hash & 0x00FF00) >> 8; - final b = (hash & 0x0000FF); - return Color.fromARGB(255, r, g, b).withOpacity(1.0); + // Use fixed flat color palette and pick based on hash + Color _getFlatColorFromName(String name) { + final colors = [ + Color(0xFFE57373), // Red + Color(0xFFF06292), // Pink + Color(0xFFBA68C8), // Purple + Color(0xFF9575CD), // Deep Purple + Color(0xFF7986CB), // Indigo + Color(0xFF64B5F6), // Blue + Color(0xFF4FC3F7), // Light Blue + Color(0xFF4DD0E1), // Cyan + Color(0xFF4DB6AC), // Teal + Color(0xFF81C784), // Green + Color(0xFFAED581), // Light Green + Color(0xFFDCE775), // Lime + Color(0xFFFFD54F), // Amber + Color(0xFFFFB74D), // Orange + Color(0xFFA1887F), // Brown + Color(0xFF90A4AE), // Blue Grey + ]; + + int index = name.hashCode.abs() % colors.length; + return colors[index]; } } diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index 1ba3a3e..e8b0090 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -184,6 +184,7 @@ static Widget buildLoadingSkeleton() { } // Daily Progress Planning (Collapsed View) + static Widget dailyProgressPlanningSkeletonCollapsedOnly() { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -225,4 +226,58 @@ static Widget buildLoadingSkeleton() { }), ); } + static Widget contactSkeletonCard() { + return MyCard.bordered( + margin: MySpacing.only(bottom: 12), + paddingAll: 16, + borderRadiusAll: 16, + shadow: MyShadow( + elevation: 1.5, + position: MyShadowPosition.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + height: 40, + width: 40, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 12, + width: 100, + color: Colors.grey.shade300, + ), + MySpacing.height(6), + Container( + height: 10, + width: 60, + color: Colors.grey.shade300, + ), + ], + ), + ), + ], + ), + MySpacing.height(16), + Container(height: 10, width: 150, color: Colors.grey.shade300), + MySpacing.height(8), + Container(height: 10, width: 100, color: Colors.grey.shade300), + MySpacing.height(8), + Container(height: 10, width: 120, color: Colors.grey.shade300), + ], + ), + ); +} + } diff --git a/lib/helpers/widgets/my_loading_component.dart b/lib/helpers/widgets/my_loading_component.dart index f556cef..b716abb 100644 --- a/lib/helpers/widgets/my_loading_component.dart +++ b/lib/helpers/widgets/my_loading_component.dart @@ -2,7 +2,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:loading_animation_widget/loading_animation_widget.dart'; import 'package:marco/images.dart'; - +import 'package:marco/helpers/widgets/my_card.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/utils/my_shadow.dart'; class LoadingComponent extends StatelessWidget { final bool isLoading; final Widget child; @@ -58,6 +60,59 @@ class LoadingComponent extends StatelessWidget { ); } } +Widget contactSkeletonCard() { + return MyCard.bordered( + margin: MySpacing.only(bottom: 12), + paddingAll: 16, + borderRadiusAll: 16, + shadow: MyShadow( + elevation: 1.5, + position: MyShadowPosition.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + height: 40, + width: 40, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 12, + width: 100, + color: Colors.grey.shade300, + ), + MySpacing.height(6), + Container( + height: 10, + width: 60, + color: Colors.grey.shade300, + ), + ], + ), + ), + ], + ), + MySpacing.height(16), + Container(height: 10, width: 150, color: Colors.grey.shade300), + MySpacing.height(8), + Container(height: 10, width: 100, color: Colors.grey.shade300), + MySpacing.height(8), + Container(height: 10, width: 120, color: Colors.grey.shade300), + ], + ), + ); +} class _LoadingAnimation extends StatelessWidget { final double imageSize; diff --git a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart index a646f7b..a7f8ee1 100644 --- a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart @@ -518,28 +518,31 @@ class _CommentTaskBottomSheetState extends State ); } - Widget buildCommentActionButtons({ - required VoidCallback onCancel, - required Future Function() onSubmit, - required RxBool isLoading, - double? buttonHeight, - }) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton.icon( + Widget buildCommentActionButtons({ + required VoidCallback onCancel, + required Future Function() onSubmit, + required RxBool isLoading, + double? buttonHeight, +}) { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( onPressed: onCancel, - icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", color: Colors.red), + icon: const Icon(Icons.close, color: Colors.red, size: 18), + label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), style: OutlinedButton.styleFrom( side: const BorderSide(color: Colors.red), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), ), ), - Obx(() { + ), + const SizedBox(width: 16), + Expanded( + child: Obx(() { return ElevatedButton.icon( onPressed: isLoading.value ? null : () => onSubmit(), icon: isLoading.value @@ -551,22 +554,23 @@ class _CommentTaskBottomSheetState extends State valueColor: AlwaysStoppedAnimation(Colors.white), ), ) - : const Icon(Icons.check_circle_outline, color: Colors.white), + : const Icon(Icons.check_circle_outline, color: Colors.white, size: 18), label: isLoading.value ? const SizedBox() - : MyText.bodyMedium("Comment", color: Colors.white), + : 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: 28, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), ), ); }), - ], - ); - } + ), + ], + ); +} Widget buildRow(String label, String? value, {IconData? icon}) { return Padding( diff --git a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart index 4013aea..c6fd28c 100644 --- a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart @@ -467,7 +467,6 @@ class _ReportActionBottomSheetState extends State reportActionId: reportActionId, approvedTaskCount: approvedTaskCount, ); - if (success) { Navigator.of(context).pop(); if (shouldShowAddTaskSheet) { @@ -502,7 +501,7 @@ class _ReportActionBottomSheetState extends State isLoading: controller.isLoading, ), - MySpacing.height(10), + MySpacing.height(20), if ((widget.taskData['taskComments'] as List?) ?.isNotEmpty == true) ...[ @@ -678,28 +677,31 @@ class _ReportActionBottomSheetState extends State ); } - Widget buildCommentActionButtons({ - required VoidCallback onCancel, - required Future Function() onSubmit, - required RxBool isLoading, - double? buttonHeight, - }) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton.icon( + Widget buildCommentActionButtons({ + required VoidCallback onCancel, + required Future Function() onSubmit, + required RxBool isLoading, + double? buttonHeight, +}) { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( onPressed: onCancel, - icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", color: Colors.red), + icon: const Icon(Icons.close, color: Colors.red, size: 18), + label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), style: OutlinedButton.styleFrom( side: const BorderSide(color: Colors.red), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), ), ), - Obx(() { + ), + const SizedBox(width: 16), + Expanded( + child: Obx(() { return ElevatedButton.icon( onPressed: isLoading.value ? null : () => onSubmit(), icon: isLoading.value @@ -711,22 +713,23 @@ class _ReportActionBottomSheetState extends State valueColor: AlwaysStoppedAnimation(Colors.white), ), ) - : const Icon(Icons.send), + : const Icon(Icons.send, color: Colors.white, size: 18), label: isLoading.value ? const SizedBox() - : MyText.bodyMedium("Submit", color: Colors.white), + : 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: 28, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), ), ); }), - ], - ); - } + ), + ], + ); +} Widget buildRow(String label, String? value, {IconData? icon}) { return Padding( diff --git a/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart index 694751b..7991f4e 100644 --- a/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart @@ -345,89 +345,92 @@ class _ReportTaskBottomSheetState extends State ], ); }), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton.icon( - onPressed: () => Navigator.of(context).pop(), - 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(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 14), - ), - ), - Obx(() { - final isLoading = controller.reportStatus.value == - ApiStatus.loading; + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.red, size: 18), + label: MyText.bodyMedium( + "Cancel", + color: Colors.red, + fontWeight: 600, + ), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + 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(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), + ), + ); + }), + ), + ], +), - 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( - 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: 28, vertical: 14), - ), - ); - }), - ], - ), ], ), ), diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart new file mode 100644 index 0000000..5c26fd9 --- /dev/null +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -0,0 +1,444 @@ +// unchanged imports +import 'package:flutter/material.dart'; +import 'package:get/get.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'; + +class AddContactBottomSheet extends StatelessWidget { + AddContactBottomSheet({super.key}); + + final controller = Get.put(AddContactController()); + final formKey = GlobalKey(); + + final emailLabel = 'Office'.obs; + final phoneLabel = 'Work'.obs; + + final nameController = TextEditingController(); + final emailController = TextEditingController(); + final phoneController = TextEditingController(); + final orgController = TextEditingController(); + final tagTextController = TextEditingController(); + final addressController = TextEditingController(); + final descriptionController = TextEditingController(); + + 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 _popupSelector({ + required String hint, + required RxString selectedValue, + required List options, + }) { + return Obx(() => GestureDetector( + onTap: () async { + final selected = await showMenu( + context: Navigator.of(Get.context!).overlay!.context, + position: const RelativeRect.fromLTRB(100, 300, 100, 0), + items: options + .map((e) => PopupMenuItem(value: e, child: Text(e))) + .toList(), + ); + if (selected != null) selectedValue.value = selected; + }, + child: AbsorbPointer( + child: SizedBox( + height: 48, + child: TextFormField( + readOnly: true, + initialValue: selectedValue.value, + style: const TextStyle(fontSize: 14), + decoration: _inputDecoration(hint) + .copyWith(suffixIcon: const Icon(Icons.expand_more)), + ), + ), + ), + )); + } + + Widget _dropdownField({ + required String label, + required RxString selectedValue, + required RxList options, + }) { + return Obx(() => SizedBox( + height: 48, + child: PopupMenuButton( + onSelected: (value) => selectedValue.value = value, + itemBuilder: (_) => options + .map((item) => PopupMenuItem(value: item, child: Text(item))) + .toList(), + padding: EdgeInsets.zero, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade100, + ), + alignment: Alignment.centerLeft, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + selectedValue.value.isEmpty ? label : selectedValue.value, + style: const TextStyle(fontSize: 14), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ), + )); + } + + 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() + : _buildSuggestionsList()), + MySpacing.height(8), + Obx(() => Wrap( + spacing: 8, + children: controller.enteredTags + .map((tag) => Chip( + label: Text(tag), + onDeleted: () => controller.removeEnteredTag(tag), + )) + .toList(), + )), + ], + ); + } + + Widget _buildSuggestionsList() => 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, offset: Offset(0, 2)), + ], + ), + 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(); + }, + ); + }, + ), + ); + + Widget _sectionLabel(String title) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelLarge(title, fontWeight: 600), + MySpacing.height(4), + Divider(thickness: 1, color: Colors.grey.shade200), + ], + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SingleChildScrollView( + padding: MediaQuery.of(context).viewInsets, + child: Container( + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: const [ + BoxShadow( + color: Colors.black12, blurRadius: 12, offset: Offset(0, -2)) + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + 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("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), + _buildLabeledRow( + "Email Label", + emailLabel, + ["Office", "Personal", "Other"], + "Email", + emailController, + TextInputType.emailAddress), + MySpacing.height(16), + _buildLabeledRow( + "Phone Label", + phoneLabel, + ["Work", "Mobile", "Other"], + "Phone", + phoneController, + TextInputType.phone), + MySpacing.height(24), + _sectionLabel("Other Details"), + MySpacing.height(16), + MyText.labelMedium("Category"), + MySpacing.height(8), + _dropdownField( + label: "Select Category", + selectedValue: controller.selectedCategory, + options: controller.categories, + ), + MySpacing.height(16), + MyText.labelMedium("Select Projects"), + MySpacing.height(8), + _dropdownField( + label: "Select Project", + selectedValue: controller.selectedProject, + options: controller.globalProjects, + ), + MySpacing.height(16), + MyText.labelMedium("Tags"), + MySpacing.height(8), + _tagInputSection(), + MySpacing.height(16), + MyText.labelMedium("Select Bucket"), + MySpacing.height(8), + _dropdownField( + label: "Select Bucket", + selectedValue: controller.selectedBucket, + options: controller.buckets, + ), + MySpacing.height(16), + _buildTextField("Address", addressController, maxLines: 2), + MySpacing.height(16), + _buildTextField("Description", descriptionController, + maxLines: 2), + MySpacing.height(24), + _buildActionButtons(), + ], + ), + ), + ), + ), + ); + } + + 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 _buildLabeledRow( + String label, + RxString selectedLabel, + List options, + String inputLabel, + TextEditingController controller, + TextInputType inputType, + ) { + return Row( + 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), + SizedBox( + height: 48, + child: TextFormField( + controller: controller, + keyboardType: inputType, + decoration: _inputDecoration("Enter $inputLabel"), + validator: (value) => + (value == null || value.trim().isEmpty) + ? "$inputLabel is required" + : null, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildActionButtons() { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => Get.back(), + 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()) { + controller.submitContact( + name: nameController.text.trim(), + organization: orgController.text.trim(), + email: emailController.text.trim(), + emailLabel: emailLabel.value, + phone: phoneController.text.trim(), + phoneLabel: phoneLabel.value, + 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), + ), + ), + ), + ], + ); + } +} diff --git a/lib/model/directory/contact_bucket_list_model.dart b/lib/model/directory/contact_bucket_list_model.dart new file mode 100644 index 0000000..8a9bc42 --- /dev/null +++ b/lib/model/directory/contact_bucket_list_model.dart @@ -0,0 +1,57 @@ +class ContactBucket { + final String id; + final String name; + final String description; + final CreatedBy createdBy; + final List 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 json) { + return ContactBucket( + id: json['id'], + name: json['name'], + description: json['description'], + createdBy: CreatedBy.fromJson(json['createdBy']), + employeeIds: List.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 json) { + return CreatedBy( + id: json['id'], + firstName: json['firstName'], + lastName: json['lastName'], + photo: json['photo'], + jobRoleId: json['jobRoleId'], + jobRoleName: json['jobRoleName'], + ); + } +} diff --git a/lib/model/directory/contact_category_model.dart b/lib/model/directory/contact_category_model.dart new file mode 100644 index 0000000..4d1dd85 --- /dev/null +++ b/lib/model/directory/contact_category_model.dart @@ -0,0 +1,65 @@ +class ContactCategoryResponse { + final bool success; + final String message; + final List 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 json) { + return ContactCategoryResponse( + success: json['success'], + message: json['message'], + data: List.from( + json['data'].map((x) => ContactCategory.fromJson(x)), + ), + errors: json['errors'], + statusCode: json['statusCode'], + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map 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 json) { + return ContactCategory( + id: json['id'], + name: json['name'], + description: json['description'], + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + }; +} diff --git a/lib/model/directory/contact_model.dart b/lib/model/directory/contact_model.dart new file mode 100644 index 0000000..e03e61b --- /dev/null +++ b/lib/model/directory/contact_model.dart @@ -0,0 +1,135 @@ +class ContactModel { + final String id; + final List? projectIds; + final String name; + final List contactPhones; + final List contactEmails; + final ContactCategory? contactCategory; + final List bucketIds; + final String description; + final String organization; + final String address; + final List 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 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 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 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 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 json) { + return Tag( + id: json['id'], + name: json['name'], + description: json['description'], + ); + } +} diff --git a/lib/model/directory/contact_profile_comment_model.dart b/lib/model/directory/contact_profile_comment_model.dart new file mode 100644 index 0000000..dc3f8ab --- /dev/null +++ b/lib/model/directory/contact_profile_comment_model.dart @@ -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 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 contactPhones; + final List contactEmails; + final ContactCategory? contactCategory; + final List projects; + final List buckets; + final List 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 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 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 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 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 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 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 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 json) { + return Tag( + id: json['id'], + name: json['name'], + description: json['description'], + ); + } +} diff --git a/lib/model/directory/contact_tag_model.dart b/lib/model/directory/contact_tag_model.dart new file mode 100644 index 0000000..6a939dc --- /dev/null +++ b/lib/model/directory/contact_tag_model.dart @@ -0,0 +1,65 @@ +class ContactTagResponse { + final bool success; + final String message; + final List 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 json) { + return ContactTagResponse( + success: json['success'], + message: json['message'], + data: List.from( + json['data'].map((x) => ContactTag.fromJson(x)), + ), + errors: json['errors'], + statusCode: json['statusCode'], + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map 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 json) { + return ContactTag( + id: json['id'], + name: json['name'], + description: json['description'] ?? '', + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + }; +} diff --git a/lib/model/directory/directory_comment_model.dart b/lib/model/directory/directory_comment_model.dart new file mode 100644 index 0000000..af6741f --- /dev/null +++ b/lib/model/directory/directory_comment_model.dart @@ -0,0 +1,101 @@ +class DirectoryCommentResponse { + final bool success; + final String message; + final List 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 json) { + return DirectoryCommentResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: (json['data'] as List?) + ?.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 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, + ); + } +} + +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 json) { + return CommentUser( + id: json['id'] ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'] ?? '', + photo: json['photo'], + jobRoleId: json['jobRoleId'] ?? '', + jobRoleName: json['jobRoleName'] ?? '', + ); + } +} diff --git a/lib/model/directory/directory_filter_bottom_sheet.dart b/lib/model/directory/directory_filter_bottom_sheet.dart new file mode 100644 index 0000000..2095ecf --- /dev/null +++ b/lib/model/directory/directory_filter_bottom_sheet.dart @@ -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(); + + 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: 8, + runSpacing: 8, + 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: 24), + ], + + /// Buckets + if (controller.contactBuckets.isNotEmpty) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodyMedium("Buckets", fontWeight: 600), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + 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: 30), + + /// 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: 20, vertical: 14), + ), + ), + 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: 28, vertical: 14), + ), + ), + ], + ), + const SizedBox(height: 10), + ], + ), + ); + }), + ); + } +} diff --git a/lib/model/directory/organization_list_model.dart b/lib/model/directory/organization_list_model.dart new file mode 100644 index 0000000..8507025 --- /dev/null +++ b/lib/model/directory/organization_list_model.dart @@ -0,0 +1,25 @@ +class OrganizationListModel { + final bool success; + final String message; + final List 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 json) { + return OrganizationListModel( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: List.from(json['data'] ?? []), + statusCode: json['statusCode'] ?? 0, + timestamp: json['timestamp'] ?? '', + ); + } +} diff --git a/lib/routes.dart b/lib/routes.dart index 084a465..391eeb9 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -16,6 +16,7 @@ import 'package:marco/view/employees/employees_screen.dart'; import 'package:marco/view/auth/login_option_screen.dart'; import 'package:marco/view/auth/mpin_screen.dart'; import 'package:marco/view/auth/mpin_auth_screen.dart'; +import 'package:marco/view/directory/directory_main_screen.dart'; class AuthMiddleware extends GetMiddleware { @override @@ -60,17 +61,19 @@ getPageRoute() { name: '/dashboard/daily-task-progress', page: () => DailyProgressReportScreen(), middlewares: [AuthMiddleware()]), + GetPage( + name: '/dashboard/directory-main-page', + page: () => DirectoryMainScreen(), + middlewares: [AuthMiddleware()]), // Authentication GetPage(name: '/auth/login', page: () => LoginScreen()), GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()), - GetPage(name: '/auth/mpin', page: () => MPINScreen()), - GetPage(name: '/auth/mpin-auth', page: () => MPINAuthScreen()), + GetPage(name: '/auth/mpin', page: () => MPINScreen()), + GetPage(name: '/auth/mpin-auth', page: () => MPINAuthScreen()), GetPage( name: '/auth/register_account', page: () => const RegisterAccountScreen()), - GetPage( - name: '/auth/forgot_password', - page: () => ForgotPasswordScreen()), + GetPage(name: '/auth/forgot_password', page: () => ForgotPasswordScreen()), GetPage( name: '/auth/reset_password', page: () => const ResetPasswordScreen()), // Error diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 5aa671a..de064f4 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -25,6 +25,7 @@ class DashboardScreen extends StatefulWidget { static const String dailyTasksRoute = "/dashboard/daily-task-planing"; static const String dailyTasksProgressRoute = "/dashboard/daily-task-progress"; + static const String directoryMainPageRoute = "/dashboard/directory-main-page"; @override State createState() => _DashboardScreenState(); @@ -154,6 +155,8 @@ class _DashboardScreenState extends State with UIMixin { DashboardScreen.dailyTasksRoute), _StatItem(LucideIcons.list_todo, "Daily Task Progress", contentTheme.info, DashboardScreen.dailyTasksProgressRoute), + _StatItem(LucideIcons.folder, "Directory", contentTheme.info, + DashboardScreen.directoryMainPageRoute), ]; return GetBuilder( diff --git a/lib/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart new file mode 100644 index 0000000..7ffa1eb --- /dev/null +++ b/lib/view/directory/contact_detail_screen.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +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:flutter_html/flutter_html.dart'; +class ContactDetailScreen extends StatelessWidget { + final ContactModel contact; + const ContactDetailScreen({super.key, required this.contact}); + @override + Widget build(BuildContext context) { + final directoryController = Get.find(); + final projectController = Get.find(); + Future.microtask(() { + if (!directoryController.contactCommentsMap.containsKey(contact.id)) { + directoryController.fetchCommentsForContact(contact.id); + } + }); + final email = contact.contactEmails.isNotEmpty + ? contact.contactEmails.first.emailAddress + : "-"; + final phone = contact.contactPhones.isNotEmpty + ? contact.contactPhones.first.phoneNumber + : "-"; + final createdDate = DateTime.now(); + final formattedDate = DateFormat('MMMM dd, yyyy').format(createdDate); + final tags = contact.tags.map((e) => e.name).join(", "); + final bucketNames = contact.bucketIds + .map((id) => directoryController.contactBuckets + .firstWhereOrNull((b) => b.id == id) + ?.name) + .whereType() + .join(", "); + final projectNames = contact.projectIds + ?.map((id) => projectController.projects + .firstWhereOrNull((p) => p.id == id) + ?.name) + .whereType() + .join(", ") ?? + "-"; + final category = contact.contactCategory?.name ?? "-"; + + return DefaultTabController( + length: 2, + child: Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: PreferredSize( + preferredSize: const Size.fromHeight(170), + child: AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + flexibleSpace: SafeArea( + child: Padding( + padding: MySpacing.xy(10, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Back button and title + Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.back(), + ), + const SizedBox(width: 4), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge('Contact Profile', + fontWeight: 700, color: Colors.black), + const SizedBox(height: 2), + GetBuilder( + builder: (_) { + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; + return MyText.bodySmall( + projectName, + fontWeight: 600, + color: Colors.grey[700], + ); + }, + ), + ], + ), + ], + ), + const SizedBox(height: 12), + // Avatar + name + org + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Avatar( + firstName: contact.name.split(" ").first, + lastName: contact.name.split(" ").length > 1 + ? contact.name.split(" ").last + : "", + size: 35, + backgroundColor: Colors.indigo, + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall( + contact.name, + fontWeight: 600, + color: Colors.black, + ), + const SizedBox(height: 2), + MyText.titleSmall( + contact.organization, + fontWeight: 500, + color: Colors.grey[700], + ), + ], + ), + ], + ), + + const SizedBox(height: 6), + + // Tab Bar + const TabBar( + indicatorColor: Colors.indigo, + labelColor: Colors.indigo, + unselectedLabelColor: Colors.grey, + tabs: [ + Tab(text: "Details"), + Tab(text: "Comments"), + ], + ), + ], + ), + ), + ), + ), + ), + body: TabBarView( + children: [ + // Details Tab + SingleChildScrollView( + padding: MySpacing.xy(9, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _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.calendar_today, "Created", formattedDate), + _iconInfoRow(Icons.location_on, "Address", contact.address), + ]), + _infoCard("Organization", [ + _iconInfoRow( + Icons.business, "Organization", contact.organization), + _iconInfoRow(Icons.category, "Category", category), + ]), + _infoCard("Meta Info", [ + _iconInfoRow( + Icons.label, "Tags", tags.isNotEmpty ? tags : "-"), + _iconInfoRow(Icons.folder_shared, "Contat Buckets", + bucketNames.isNotEmpty ? bucketNames : "-"), + _iconInfoRow(Icons.work_outline, "Projects", projectNames), + ]), + _infoCard("Description", [ + const SizedBox(height: 6), + SizedBox( + width: double.infinity, + child: MyText.bodyMedium( + contact.description, + color: Colors.grey[800], + maxLines: 10, + ), + ), + ]), + ], + ), + ), + + // Comments Tab + // Improved Comments Tab + Obx(() { + final comments = + directoryController.contactCommentsMap[contact.id]; + + if (comments == null) { + return const Center(child: CircularProgressIndicator()); + } + + if (comments.isEmpty) { + return Center( + child: + MyText.bodyLarge("No comments yet.", color: Colors.grey), + ); + } + + return ListView.separated( + padding: MySpacing.xy(12, 16), + itemCount: comments.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (_, index) { + final comment = comments[index]; + final initials = comment.createdBy.firstName.isNotEmpty + ? comment.createdBy.firstName[0].toUpperCase() + : "?"; + + return Container( + padding: MySpacing.xy(14, 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, 2), + ) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar + By + Date Row at top + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Avatar( + firstName: initials, + lastName: '', + size: 31, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall( + "By: ${comment.createdBy.firstName}", + fontWeight: 600, + color: Colors.indigo[700], + ), + const SizedBox(height: 2), + MyText.bodySmall( + DateFormat('dd MMM yyyy, hh:mm a') + .format(comment.createdAt), + fontWeight: 500, + color: Colors.grey[600], + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.more_vert, + size: 20, color: Colors.grey), + onPressed: () {}, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + + const SizedBox(height: 10), + + // Comment content + Html( + data: comment.note, + style: { + "body": Style( + margin: Margins.all(0), + padding: HtmlPaddings.all(0), + fontSize: FontSize.medium, + color: Colors.black87, + ), + "pre": Style( + padding: HtmlPaddings.all(8), + fontSize: FontSize.small, + fontFamily: 'monospace', + backgroundColor: const Color(0xFFF1F1F1), + border: Border.all(color: Colors.grey.shade300), + ), + "h3": Style( + fontSize: FontSize.large, + fontWeight: FontWeight.bold, + color: Colors.indigo[700], + ), + "strong": Style( + fontWeight: FontWeight.w700, + ), + "p": Style( + margin: Margins.only(bottom: 8), + ), + }, + ), + ], + ), + ); + }, + ); + }) + ], + ), + ), + ); + } + + Widget _iconInfoRow(IconData icon, String label, String value, + {VoidCallback? onTap, VoidCallback? onLongPress}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 22, color: Colors.indigo), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall(label, + fontWeight: 600, color: Colors.black87), + const SizedBox(height: 2), + MyText.bodyMedium(value, color: Colors.grey[800]), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _infoCard(String title, List children) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 2, + margin: const EdgeInsets.only(bottom: 10), + child: Padding( + padding: MySpacing.xy(16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall(title, + fontWeight: 700, color: Colors.indigo[700]), + const SizedBox(height: 8), + ...children, + ], + ), + ), + ); + } +} diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart new file mode 100644 index 0000000..3ab2d8e --- /dev/null +++ b/lib/view/directory/directory_main_screen.dart @@ -0,0 +1,524 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/directory/directory_controller.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/my_card.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/utils/my_shadow.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/model/directory/directory_filter_bottom_sheet.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:marco/helpers/utils/launcher_utils.dart'; +import 'package:marco/view/directory/contact_detail_screen.dart'; +import 'package:marco/model/directory/add_contact_bottom_sheet.dart'; + +class DirectoryMainScreen extends StatelessWidget { + DirectoryMainScreen({super.key}); + + final DirectoryController controller = Get.put(DirectoryController()); + final TextEditingController searchController = TextEditingController(); + Future _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: const Color(0xFFF5F5F5), + appBar: PreferredSize( + preferredSize: const Size.fromHeight(80), + child: AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + foregroundColor: Colors.black, + 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( + padding: const EdgeInsets.only(top: 15.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MyText.titleLarge( + 'Directory', + fontWeight: 700, + color: Colors.black, + ), + const SizedBox(height: 2), + GetBuilder( + builder: (projectController) { + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; + return MyText.bodySmall( + projectName, + fontWeight: 600, + maxLines: 1, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ); + }, + ), + ], + ), + ), + ), + ), + floatingActionButton: FloatingActionButton( + backgroundColor: Colors.indigo, + 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: SafeArea( + child: Column( + children: [ + // Search + Filter + Toggle + Padding( + padding: MySpacing.xy(8, 10), + child: Row( + children: [ + // Compact Search Field + Expanded( + child: SizedBox( + height: 42, + 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: MouseRegion( + cursor: SystemMouseCursors.click, + child: const Padding( + padding: EdgeInsets.all(0), + child: Icon( + Icons.refresh, + color: Colors.green, + size: 28, + ), + ), + ), + ), + ), + MySpacing.width(8), + // Filter Icon with optional red dot + Obx(() { + final isFilterActive = controller.hasActiveFilters(); + return Stack( + children: [ + Container( + height: 38, + width: 38, + 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), + + // 3-dot Popup Menu with Toggle + Container( + height: 38, + width: 38, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(10), + ), + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.more_vert, + size: 20, color: Colors.black87), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + itemBuilder: (context) => [ + PopupMenuItem( + 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); + }, + ), + ], + )), + ), + ], + ), + ), + ], + ), + ), + + // Contacts List + 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 phone = contact.contactPhones.isNotEmpty + ? contact.contactPhones.first.phoneNumber + : '-'; + final email = contact.contactEmails.isNotEmpty + ? contact.contactEmails.first.emailAddress + : '-'; + 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 MyCard.bordered( + margin: MySpacing.only(bottom: 2), + paddingAll: 8, + borderRadiusAll: 8, + shadow: MyShadow( + elevation: 1.5, + position: MyShadowPosition.bottom, + ), + onTap: () { + Get.to(() => ContactDetailScreen(contact: contact)); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Avatar( + firstName: firstName, + lastName: lastName, + size: 31, + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall( + contact.name, + fontWeight: 700, + color: Colors.black87, + ), + MyText.bodySmall( + contact.organization, + fontWeight: 500, + ), + ], + ), + ), + GestureDetector( + onTap: () { + Get.to(() => + ContactDetailScreen(contact: contact)); + }, + child: const Icon(Icons.arrow_forward_ios, + color: Colors.black, size: 15), + ), + MySpacing.width(4), + ], + ), + const Divider(), + if (contact.contactEmails.isNotEmpty || + contact.contactPhones.isNotEmpty) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Email Row + if (contact.contactEmails.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Row( + children: [ + GestureDetector( + onTap: () => + LauncherUtils.launchEmail(email), + child: const Padding( + padding: + EdgeInsets.only(right: 8.0), + child: Icon(Icons.email_outlined, + color: Colors.blue, size: 20), + ), + ), + Expanded( + child: GestureDetector( + onTap: () => + LauncherUtils.launchEmail( + email), + onLongPress: () => + LauncherUtils.copyToClipboard( + email, + typeLabel: 'Email'), + child: MyText.bodySmall( + email, + maxLines: 1, + overflow: TextOverflow.ellipsis, + color: Colors.blue, + fontWeight: 600, + textAlign: TextAlign.start, + decoration: + TextDecoration.underline, + ), + ), + ), + ], + ), + ), + + // Phone Row with icons at the end + if (contact.contactPhones.isNotEmpty) + Row( + children: [ + // Phone Icon + Padding( + padding: + const EdgeInsets.only(right: 6.0), + child: GestureDetector( + onTap: () => + LauncherUtils.launchPhone(phone), + child: const Icon( + Icons.phone_outlined, + color: Colors.blue, + size: 20), + ), + ), + + // Phone number text + Expanded( + child: GestureDetector( + onTap: () => + LauncherUtils.launchPhone(phone), + onLongPress: () => + LauncherUtils.copyToClipboard( + phone, + typeLabel: 'Phone number'), + child: MyText.bodySmall( + phone, + maxLines: 1, + overflow: TextOverflow.ellipsis, + color: Colors.blue, + fontWeight: 600, + textAlign: TextAlign.start, + decoration: + TextDecoration.underline, + ), + ), + ), + + // WhatsApp Icon + Padding( + padding: + const EdgeInsets.only(right: 6.0), + child: GestureDetector( + onTap: () => + LauncherUtils.launchWhatsApp( + phone), + child: const FaIcon( + FontAwesomeIcons.whatsapp, + color: Colors.green, + size: 18), + ), + ), + ], + ), + ], + ), + + MySpacing.height(8), + // Tags Section + if (tags.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: + true, // ensures scroll starts from right + child: Row( + children: tags.map((name) { + return Container( + margin: + const EdgeInsets.only(left: 6), + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + padding: + const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + backgroundColor: + const Color.fromARGB( + 255, 179, 207, 246), + tapTargetSize: + MaterialTapTargetSize + .shrinkWrap, + minimumSize: Size.zero, + visualDensity: + VisualDensity.standard, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(5), + ), + ), + child: Text( + name, + style: const TextStyle( + fontSize: 10, + color: Color.fromARGB( + 255, 0, 0, 0), + height: 1.2, + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + }), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 371d19d..5ed79e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,8 @@ dependencies: flutter_contacts: ^1.1.9+2 photo_view: ^0.15.0 jwt_decoder: ^2.0.1 + font_awesome_flutter: ^10.8.0 + flutter_html: ^3.0.0 dev_dependencies: flutter_test: sdk: flutter -- 2.43.0 From 83ad10ffb4f42653554fc666d47fdbd07d556265 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 3 Jul 2025 13:22:04 +0530 Subject: [PATCH 03/39] feat: update UI components for improved consistency and add tab indicator styling --- lib/helpers/utils/launcher_utils.dart | 41 +- .../Attendence/attendance_screen.dart | 80 +-- lib/view/directory/contact_detail_screen.dart | 569 +++++++++--------- lib/view/directory/directory_main_screen.dart | 90 +-- lib/view/employees/employees_screen.dart | 83 +-- lib/view/taskPlaning/daily_progress.dart | 80 +-- lib/view/taskPlaning/daily_task_planing.dart | 80 +-- pubspec.yaml | 1 + 8 files changed, 551 insertions(+), 473 deletions(-) diff --git a/lib/helpers/utils/launcher_utils.dart b/lib/helpers/utils/launcher_utils.dart index daaf082..8ac99d8 100644 --- a/lib/helpers/utils/launcher_utils.dart +++ b/lib/helpers/utils/launcher_utils.dart @@ -4,6 +4,7 @@ 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 launchPhone(String phoneNumber) async { logSafe('Attempting to launch phone: $phoneNumber', sensitive: true); @@ -11,6 +12,7 @@ class LauncherUtils { await _tryLaunch(url, 'Could not launch phone'); } + /// Launches the email app with the provided email address static Future launchEmail(String email) async { logSafe('Attempting to launch email: $email', sensitive: true); @@ -18,9 +20,9 @@ class LauncherUtils { await _tryLaunch(url, 'Could not launch email'); } + /// Launches WhatsApp with the provided phone number static Future launchWhatsApp(String phoneNumber) async { - logSafe('Attempting to launch WhatsApp with: $phoneNumber', - sensitive: true); + logSafe('Attempting to launch WhatsApp with: $phoneNumber', sensitive: true); String normalized = phoneNumber.replaceAll(RegExp(r'\D'), ''); if (!normalized.startsWith('91')) { @@ -43,8 +45,8 @@ class LauncherUtils { await _tryLaunch(url, 'Could not open WhatsApp'); } - static Future copyToClipboard(String text, - {required String typeLabel}) async { + /// Copies text to clipboard with feedback + static Future copyToClipboard(String text, {required String typeLabel}) async { try { logSafe('Copying "$typeLabel" to clipboard'); @@ -56,9 +58,12 @@ class LauncherUtils { type: SnackbarType.success, ); } catch (e, st) { - logSafe('Failed to copy $typeLabel to clipboard: $e', - stackTrace: st, level: LogLevel.error, sensitive: true); - + logSafe( + 'Failed to copy $typeLabel to clipboard: $e', + stackTrace: st, + level: LogLevel.error, + sensitive: true, + ); showAppSnackbar( title: 'Error', message: 'Failed to copy $typeLabel', @@ -67,17 +72,23 @@ class LauncherUtils { } } + /// Internal function to launch a URL and show error if failed static Future _tryLaunch(Uri url, String errorMsg) async { try { logSafe('Trying to launch URL: ${url.toString()}'); - if (await canLaunchUrl(url)) { - await launchUrl(url, mode: LaunchMode.externalApplication); + final bool launched = await launchUrl( + url, + mode: LaunchMode.externalApplication, + ); + + if (launched) { logSafe('URL launched successfully: ${url.toString()}'); } else { logSafe( - 'Launch failed - canLaunchUrl returned false: ${url.toString()}', - level: LogLevel.warning); + 'launchUrl returned false: ${url.toString()}', + level: LogLevel.warning, + ); showAppSnackbar( title: 'Error', message: errorMsg, @@ -85,9 +96,11 @@ class LauncherUtils { ); } } catch (e, st) { - logSafe('Exception during launch of ${url.toString()}: $e', - stackTrace: st, level: LogLevel.error); - + logSafe( + 'Exception during launch of ${url.toString()}: $e', + stackTrace: st, + level: LogLevel.error, + ); showAppSnackbar( title: 'Error', message: '$errorMsg: $e', diff --git a/lib/view/dashboard/Attendence/attendance_screen.dart b/lib/view/dashboard/Attendence/attendance_screen.dart index 3974484..12d8e34 100644 --- a/lib/view/dashboard/Attendence/attendance_screen.dart +++ b/lib/view/dashboard/Attendence/attendance_screen.dart @@ -71,48 +71,58 @@ class _AttendanceScreenState extends State with UIMixin { Widget build(BuildContext context) { return Scaffold( appBar: PreferredSize( - preferredSize: const Size.fromHeight(80), + preferredSize: const Size.fromHeight(72), child: AppBar( backgroundColor: const Color(0xFFF5F5F5), elevation: 0.5, - foregroundColor: Colors.black, + automaticallyImplyLeading: false, titleSpacing: 0, - centerTitle: false, - leading: Padding( - padding: const EdgeInsets.only(top: 15.0), - child: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () { - Get.offNamed('/dashboard'); - }, - ), - ), title: Padding( - padding: const EdgeInsets.only(top: 15.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleLarge( - 'Attendance', - fontWeight: 700, - color: Colors.black, + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), ), - const SizedBox(height: 2), - GetBuilder( - builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return MyText.bodySmall( - projectName, - fontWeight: 600, - maxLines: 1, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ); - }, + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Attendance', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + 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], + ), + ), + ], + ); + }, + ), + ], + ), ), ], ), diff --git a/lib/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart index 7ffa1eb..3ded565 100644 --- a/lib/view/directory/contact_detail_screen.dart +++ b/lib/view/directory/contact_detail_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; +import 'package:flutter_html/flutter_html.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/directory/directory_controller.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; @@ -8,34 +9,179 @@ 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:flutter_html/flutter_html.dart'; +import 'package:tab_indicator_styler/tab_indicator_styler.dart'; + class ContactDetailScreen extends StatelessWidget { final ContactModel contact; + const ContactDetailScreen({super.key, required this.contact}); + @override Widget build(BuildContext context) { final directoryController = Get.find(); final projectController = Get.find(); + Future.microtask(() { if (!directoryController.contactCommentsMap.containsKey(contact.id)) { directoryController.fetchCommentsForContact(contact.id); } }); + + return DefaultTabController( + length: 2, + child: Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: _buildMainAppBar(projectController), + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSubHeader(), + Expanded( + child: TabBarView( + children: [ + _buildDetailsTab(directoryController, projectController), + _buildCommentsTab(directoryController), + ], + ), + ), + ], + ), + ), + ), + ); + } + + PreferredSizeWidget _buildMainAppBar(ProjectController projectController) { + 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( + 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: contact.name.split(" ").first, + lastName: contact.name.split(" ").length > 1 + ? contact.name.split(" ").last + : "", + size: 35, + backgroundColor: Colors.indigo, + ), + MySpacing.width(12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall(contact.name, + fontWeight: 600, color: Colors.black), + MySpacing.height(2), + MyText.bodySmall(contact.organization, + fontWeight: 500, color: Colors.grey[700]), + ], + ), + ], + ), + TabBar( + labelColor: Colors.indigo, + unselectedLabelColor: Colors.grey, + indicator: MaterialIndicator( + color: Colors.indigo, + height: 4, + topLeftRadius: 8, + topRightRadius: 8, + bottomLeftRadius: 8, + bottomRightRadius: 8, + ), + tabs: const [ + Tab(text: "Details"), + Tab(text: "Comments"), + ], + ), + ], + ), + ); + } + + Widget _buildDetailsTab(DirectoryController directoryController, + ProjectController projectController) { final email = contact.contactEmails.isNotEmpty ? contact.contactEmails.first.emailAddress : "-"; + final phone = contact.contactPhones.isNotEmpty ? contact.contactPhones.first.phoneNumber : "-"; + final createdDate = DateTime.now(); final formattedDate = DateFormat('MMMM dd, yyyy').format(createdDate); final tags = contact.tags.map((e) => e.name).join(", "); + final bucketNames = contact.bucketIds .map((id) => directoryController.contactBuckets .firstWhereOrNull((b) => b.id == id) ?.name) .whereType() .join(", "); + final projectNames = contact.projectIds ?.map((id) => projectController.projects .firstWhereOrNull((p) => p.id == id) @@ -43,292 +189,159 @@ class ContactDetailScreen extends StatelessWidget { .whereType() .join(", ") ?? "-"; + final category = contact.contactCategory?.name ?? "-"; - return DefaultTabController( - length: 2, - child: Scaffold( - backgroundColor: const Color(0xFFF5F5F5), - appBar: PreferredSize( - preferredSize: const Size.fromHeight(170), - child: AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - flexibleSpace: SafeArea( - child: Padding( - padding: MySpacing.xy(10, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return SingleChildScrollView( + padding: MySpacing.xy(8, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _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.calendar_today, "Created", formattedDate), + _iconInfoRow(Icons.location_on, "Address", contact.address), + ]), + _infoCard("Organization", [ + _iconInfoRow(Icons.business, "Organization", 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( + contact.description, + color: Colors.grey[800], + maxLines: 10, + textAlign: TextAlign.left, + ), + ), + ]) + ], + ), + ); + } + + Widget _buildCommentsTab(DirectoryController directoryController) { + return Obx(() { + final comments = directoryController.contactCommentsMap[contact.id]; + + if (comments == null) { + return const Center(child: CircularProgressIndicator()); + } + + if (comments.isEmpty) { + return Center( + child: MyText.bodyLarge("No comments yet.", color: Colors.grey)); + } + + return ListView.separated( + padding: MySpacing.xy(8, 8), + itemCount: comments.length, + separatorBuilder: (_, __) => MySpacing.height(12), + itemBuilder: (_, index) { + final comment = comments[index]; + final initials = comment.createdBy.firstName.isNotEmpty + ? comment.createdBy.firstName[0].toUpperCase() + : "?"; + + return Container( + padding: MySpacing.xy(14, 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, 2), + ) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - // Back button and title - Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.back(), - ), - const SizedBox(width: 4), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleLarge('Contact Profile', - fontWeight: 700, color: Colors.black), - const SizedBox(height: 2), - GetBuilder( - builder: (_) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return MyText.bodySmall( - projectName, - fontWeight: 600, - color: Colors.grey[700], - ); - }, - ), - ], - ), - ], + Avatar(firstName: initials, lastName: '', size: 31), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall("By: ${comment.createdBy.firstName}", + fontWeight: 600, color: Colors.indigo[700]), + MySpacing.height(2), + MyText.bodySmall( + DateFormat('dd MMM yyyy, hh:mm a') + .format(comment.createdAt), + fontWeight: 500, + color: Colors.grey[600], + ), + ], + ), ), - const SizedBox(height: 12), - // Avatar + name + org - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Avatar( - firstName: contact.name.split(" ").first, - lastName: contact.name.split(" ").length > 1 - ? contact.name.split(" ").last - : "", - size: 35, - backgroundColor: Colors.indigo, - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall( - contact.name, - fontWeight: 600, - color: Colors.black, - ), - const SizedBox(height: 2), - MyText.titleSmall( - contact.organization, - fontWeight: 500, - color: Colors.grey[700], - ), - ], - ), - ], - ), - - const SizedBox(height: 6), - - // Tab Bar - const TabBar( - indicatorColor: Colors.indigo, - labelColor: Colors.indigo, - unselectedLabelColor: Colors.grey, - tabs: [ - Tab(text: "Details"), - Tab(text: "Comments"), - ], + IconButton( + icon: const Icon(Icons.more_vert, + size: 20, color: Colors.grey), + onPressed: () {}, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), ], ), - ), + MySpacing.height(10), + Html( + data: comment.note, + style: { + "body": Style( + margin: Margins.all(0), + padding: HtmlPaddings.all(0), + fontSize: FontSize.medium, + color: Colors.black87, + ), + "pre": Style( + padding: HtmlPaddings.all(8), + fontSize: FontSize.small, + fontFamily: 'monospace', + backgroundColor: const Color(0xFFF1F1F1), + border: Border.all(color: Colors.grey.shade300), + ), + "h3": Style( + fontSize: FontSize.large, + fontWeight: FontWeight.bold, + color: Colors.indigo[700], + ), + "strong": Style(fontWeight: FontWeight.w700), + "p": Style(margin: Margins.only(bottom: 8)), + }, + ), + ], ), - ), - ), - body: TabBarView( - children: [ - // Details Tab - SingleChildScrollView( - padding: MySpacing.xy(9, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _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.calendar_today, "Created", formattedDate), - _iconInfoRow(Icons.location_on, "Address", contact.address), - ]), - _infoCard("Organization", [ - _iconInfoRow( - Icons.business, "Organization", contact.organization), - _iconInfoRow(Icons.category, "Category", category), - ]), - _infoCard("Meta Info", [ - _iconInfoRow( - Icons.label, "Tags", tags.isNotEmpty ? tags : "-"), - _iconInfoRow(Icons.folder_shared, "Contat Buckets", - bucketNames.isNotEmpty ? bucketNames : "-"), - _iconInfoRow(Icons.work_outline, "Projects", projectNames), - ]), - _infoCard("Description", [ - const SizedBox(height: 6), - SizedBox( - width: double.infinity, - child: MyText.bodyMedium( - contact.description, - color: Colors.grey[800], - maxLines: 10, - ), - ), - ]), - ], - ), - ), - - // Comments Tab - // Improved Comments Tab - Obx(() { - final comments = - directoryController.contactCommentsMap[contact.id]; - - if (comments == null) { - return const Center(child: CircularProgressIndicator()); - } - - if (comments.isEmpty) { - return Center( - child: - MyText.bodyLarge("No comments yet.", color: Colors.grey), - ); - } - - return ListView.separated( - padding: MySpacing.xy(12, 16), - itemCount: comments.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemBuilder: (_, index) { - final comment = comments[index]; - final initials = comment.createdBy.firstName.isNotEmpty - ? comment.createdBy.firstName[0].toUpperCase() - : "?"; - - return Container( - padding: MySpacing.xy(14, 12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 4, - offset: Offset(0, 2), - ) - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Avatar + By + Date Row at top - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Avatar( - firstName: initials, - lastName: '', - size: 31, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodySmall( - "By: ${comment.createdBy.firstName}", - fontWeight: 600, - color: Colors.indigo[700], - ), - const SizedBox(height: 2), - MyText.bodySmall( - DateFormat('dd MMM yyyy, hh:mm a') - .format(comment.createdAt), - fontWeight: 500, - color: Colors.grey[600], - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.more_vert, - size: 20, color: Colors.grey), - onPressed: () {}, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - - const SizedBox(height: 10), - - // Comment content - Html( - data: comment.note, - style: { - "body": Style( - margin: Margins.all(0), - padding: HtmlPaddings.all(0), - fontSize: FontSize.medium, - color: Colors.black87, - ), - "pre": Style( - padding: HtmlPaddings.all(8), - fontSize: FontSize.small, - fontFamily: 'monospace', - backgroundColor: const Color(0xFFF1F1F1), - border: Border.all(color: Colors.grey.shade300), - ), - "h3": Style( - fontSize: FontSize.large, - fontWeight: FontWeight.bold, - color: Colors.indigo[700], - ), - "strong": Style( - fontWeight: FontWeight.w700, - ), - "p": Style( - margin: Margins.only(bottom: 8), - ), - }, - ), - ], - ), - ); - }, - ); - }) - ], - ), - ), - ); + ); + }, + ); + }); } Widget _iconInfoRow(IconData icon, String label, String value, {VoidCallback? onTap, VoidCallback? onLongPress}) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: MySpacing.y(8), child: GestureDetector( onTap: onTap, onLongPress: onLongPress, @@ -336,14 +349,14 @@ class ContactDetailScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, size: 22, color: Colors.indigo), - const SizedBox(width: 12), + MySpacing.width(12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall(label, fontWeight: 600, color: Colors.black87), - const SizedBox(height: 2), + MySpacing.height(2), MyText.bodyMedium(value, color: Colors.grey[800]), ], ), @@ -358,7 +371,7 @@ class ContactDetailScreen extends StatelessWidget { return Card( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), elevation: 2, - margin: const EdgeInsets.only(bottom: 10), + margin: MySpacing.bottom(12), child: Padding( padding: MySpacing.xy(16, 16), child: Column( @@ -366,7 +379,7 @@ class ContactDetailScreen extends StatelessWidget { children: [ MyText.titleSmall(title, fontWeight: 700, color: Colors.indigo[700]), - const SizedBox(height: 8), + MySpacing.height(8), ...children, ], ), diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart index 3ab2d8e..aa268f4 100644 --- a/lib/view/directory/directory_main_screen.dart +++ b/lib/view/directory/directory_main_screen.dart @@ -33,48 +33,58 @@ class DirectoryMainScreen extends StatelessWidget { return Scaffold( backgroundColor: const Color(0xFFF5F5F5), appBar: PreferredSize( - preferredSize: const Size.fromHeight(80), + preferredSize: const Size.fromHeight(72), child: AppBar( backgroundColor: const Color(0xFFF5F5F5), elevation: 0.5, - foregroundColor: Colors.black, + automaticallyImplyLeading: false, titleSpacing: 0, - centerTitle: false, - leading: Padding( - padding: const EdgeInsets.only(top: 15.0), - child: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () { - Get.offNamed('/dashboard'); - }, - ), - ), title: Padding( - padding: const EdgeInsets.only(top: 15.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleLarge( - 'Directory', - fontWeight: 700, - color: Colors.black, + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), ), - const SizedBox(height: 2), - GetBuilder( - builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return MyText.bodySmall( - projectName, - fontWeight: 600, - maxLines: 1, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ); - }, + 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( + 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], + ), + ), + ], + ); + }, + ), + ], + ), ), ], ), @@ -366,7 +376,7 @@ class DirectoryMainScreen extends StatelessWidget { padding: EdgeInsets.only(right: 8.0), child: Icon(Icons.email_outlined, - color: Colors.blue, size: 20), + color: Colors.blue, size: 25), ), ), Expanded( @@ -378,7 +388,7 @@ class DirectoryMainScreen extends StatelessWidget { LauncherUtils.copyToClipboard( email, typeLabel: 'Email'), - child: MyText.bodySmall( + child: MyText.bodyMedium( email, maxLines: 1, overflow: TextOverflow.ellipsis, @@ -408,7 +418,7 @@ class DirectoryMainScreen extends StatelessWidget { child: const Icon( Icons.phone_outlined, color: Colors.blue, - size: 20), + size: 25), ), ), @@ -421,7 +431,7 @@ class DirectoryMainScreen extends StatelessWidget { LauncherUtils.copyToClipboard( phone, typeLabel: 'Phone number'), - child: MyText.bodySmall( + child: MyText.bodyMedium( phone, maxLines: 1, overflow: TextOverflow.ellipsis, @@ -445,7 +455,7 @@ class DirectoryMainScreen extends StatelessWidget { child: const FaIcon( FontAwesomeIcons.whatsapp, color: Colors.green, - size: 18), + size: 25), ), ), ], diff --git a/lib/view/employees/employees_screen.dart b/lib/view/employees/employees_screen.dart index 37bbc28..3e3dbef 100644 --- a/lib/view/employees/employees_screen.dart +++ b/lib/view/employees/employees_screen.dart @@ -15,6 +15,7 @@ import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/model/employees/employee_detail_bottom_sheet.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; + class EmployeesScreen extends StatefulWidget { const EmployeesScreen({super.key}); @@ -73,49 +74,59 @@ class _EmployeesScreenState extends State with UIMixin { Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F5F5), - appBar: PreferredSize( - preferredSize: const Size.fromHeight(80), + appBar: PreferredSize( + preferredSize: const Size.fromHeight(72), child: AppBar( backgroundColor: const Color(0xFFF5F5F5), elevation: 0.5, - foregroundColor: Colors.black, + automaticallyImplyLeading: false, titleSpacing: 0, - centerTitle: false, - leading: Padding( - padding: const EdgeInsets.only(top: 15.0), // Aligns with title - child: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () { - Get.offNamed('/dashboard'); - }, - ), - ), title: Padding( - padding: const EdgeInsets.only(top: 15.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleLarge( - 'Employees', - fontWeight: 700, - color: Colors.black, + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), ), - const SizedBox(height: 2), - GetBuilder( - builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return MyText.bodySmall( - projectName, - fontWeight: 600, - maxLines: 1, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ); - }, + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Employees', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + 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], + ), + ), + ], + ); + }, + ), + ], + ), ), ], ), diff --git a/lib/view/taskPlaning/daily_progress.dart b/lib/view/taskPlaning/daily_progress.dart index 8a7e8a4..21d704a 100644 --- a/lib/view/taskPlaning/daily_progress.dart +++ b/lib/view/taskPlaning/daily_progress.dart @@ -67,48 +67,58 @@ class _DailyProgressReportScreenState extends State Widget build(BuildContext context) { return Scaffold( appBar: PreferredSize( - preferredSize: const Size.fromHeight(80), + preferredSize: const Size.fromHeight(72), child: AppBar( backgroundColor: const Color(0xFFF5F5F5), elevation: 0.5, - foregroundColor: Colors.black, + automaticallyImplyLeading: false, titleSpacing: 0, - centerTitle: false, - leading: Padding( - padding: const EdgeInsets.only(top: 15.0), // Aligns with title - child: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () { - Get.offNamed('/dashboard'); - }, - ), - ), title: Padding( - padding: const EdgeInsets.only(top: 15.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleLarge( - 'Daily Task Progress', - fontWeight: 700, - color: Colors.black, + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), ), - const SizedBox(height: 2), - GetBuilder( - builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return MyText.bodySmall( - projectName, - fontWeight: 600, - maxLines: 1, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ); - }, + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Daily Task Progress', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + 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], + ), + ), + ], + ); + }, + ), + ], + ), ), ], ), diff --git a/lib/view/taskPlaning/daily_task_planing.dart b/lib/view/taskPlaning/daily_task_planing.dart index 0e96a60..d734b14 100644 --- a/lib/view/taskPlaning/daily_task_planing.dart +++ b/lib/view/taskPlaning/daily_task_planing.dart @@ -53,48 +53,58 @@ class _DailyTaskPlaningScreenState extends State Widget build(BuildContext context) { return Scaffold( appBar: PreferredSize( - preferredSize: const Size.fromHeight(80), + preferredSize: const Size.fromHeight(72), child: AppBar( backgroundColor: const Color(0xFFF5F5F5), elevation: 0.5, - foregroundColor: Colors.black, + automaticallyImplyLeading: false, titleSpacing: 0, - centerTitle: false, - leading: Padding( - padding: const EdgeInsets.only(top: 15.0), // Aligns with title - child: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () { - Get.offNamed('/dashboard'); - }, - ), - ), title: Padding( - padding: const EdgeInsets.only(top: 15.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleLarge( - 'Daily Task Planning', - fontWeight: 700, - color: Colors.black, + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), ), - const SizedBox(height: 2), - GetBuilder( - builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return MyText.bodySmall( - projectName, - fontWeight: 600, - maxLines: 1, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ); - }, + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Daily Task Planing', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + 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], + ), + ), + ], + ); + }, + ), + ], + ), ), ], ), diff --git a/pubspec.yaml b/pubspec.yaml index 5ed79e3..1849534 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -73,6 +73,7 @@ dependencies: jwt_decoder: ^2.0.1 font_awesome_flutter: ^10.8.0 flutter_html: ^3.0.0 + tab_indicator_styler: ^2.0.0 dev_dependencies: flutter_test: sdk: flutter -- 2.43.0 From be71544ae4d96f1f41ccd2792a89bd56a7dfcb96 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 4 Jul 2025 15:09:49 +0530 Subject: [PATCH 04/39] feat(directory): implement comment editing functionality and enhance comment model --- .../directory/directory_controller.dart | 119 ++++++--- lib/helpers/services/api_endpoints.dart | 1 + lib/helpers/services/api_service.dart | 112 ++++++++- .../Directory/comment_editor_card.dart | 120 +++++++++ .../directory/directory_comment_model.dart | 44 ++++ lib/view/directory/contact_detail_screen.dart | 236 ++++++++++++------ pubspec.yaml | 5 +- 7 files changed, 523 insertions(+), 114 deletions(-) create mode 100644 lib/helpers/widgets/Directory/comment_editor_card.dart diff --git a/lib/controller/directory/directory_controller.dart b/lib/controller/directory/directory_controller.dart index 75f0974..72adfbd 100644 --- a/lib/controller/directory/directory_controller.dart +++ b/lib/controller/directory/directory_controller.dart @@ -4,7 +4,7 @@ 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 allContacts = [].obs; RxList filteredContacts = [].obs; @@ -16,8 +16,15 @@ class DirectoryController extends GetxController { RxList contactBuckets = [].obs; RxString searchQuery = ''.obs; RxBool showFabMenu = false.obs; - RxMap> contactCommentsMap = - >{}.obs; + final RxBool showFullEditorToolbar = false.obs; + final RxBool isEditorFocused = false.obs; + + final Map> contactCommentsMap = {}; + RxList getCommentsForContact(String contactId) { + return contactCommentsMap[contactId] ?? [].obs; + } + + final editingCommentId = Rxn(); @override void onInit() { @@ -25,41 +32,79 @@ class DirectoryController extends GetxController { fetchContacts(); fetchBuckets(); } +// inside DirectoryController - void extractCategoriesFromContacts() { - final uniqueCategories = {}; + Future updateComment(DirectoryComment comment) async { + try { + logSafe( + "Attempting to update comment. id: ${comment.id}, contactId: ${comment.contactId}"); - for (final contact in allContacts) { - final category = contact.contactCategory; - if (category != null && !uniqueCategories.containsKey(category.id)) { - uniqueCategories[category.id] = category; + 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}"); } - } - contactCategories.value = uniqueCategories.values.toList(); + if (oldComment != null && oldComment.note.trim() == comment.note.trim()) { + logSafe("No changes detected in comment. id: ${comment.id}"); + 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); + } 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 fetchCommentsForContact(String contactId) async { - try { - final data = await ApiService.getDirectoryComments(contactId); - logSafe("Fetched comments for contact $contactId: $data"); + try { + final data = await ApiService.getDirectoryComments(contactId); + logSafe("Fetched comments for contact $contactId: $data"); - if (data != null ) { - final comments = data.map((e) => DirectoryComment.fromJson(e)).toList(); - contactCommentsMap[contactId] = comments; - } else { - contactCommentsMap[contactId] = []; + final comments = + data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? []; + + if (!contactCommentsMap.containsKey(contactId)) { + contactCommentsMap[contactId] = [].obs; + } + + contactCommentsMap[contactId]!.assignAll(comments); + contactCommentsMap[contactId]?.refresh(); + } catch (e) { + logSafe("Error fetching comments for contact $contactId: $e", + level: LogLevel.error); + + contactCommentsMap[contactId] ??= [].obs; + contactCommentsMap[contactId]!.clear(); } - - contactCommentsMap.refresh(); - } catch (e) { - logSafe("Error fetching comments for contact $contactId: $e", - level: LogLevel.error); - contactCommentsMap[contactId] = []; - contactCommentsMap.refresh(); } -} - Future fetchBuckets() async { try { @@ -86,9 +131,7 @@ class DirectoryController extends GetxController { if (response != null) { final contacts = response.map((e) => ContactModel.fromJson(e)).toList(); allContacts.assignAll(contacts); - extractCategoriesFromContacts(); - applyFilters(); } else { allContacts.clear(); @@ -101,20 +144,30 @@ class DirectoryController extends GetxController { } } + void extractCategoriesFromContacts() { + final uniqueCategories = {}; + + 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) { - // 1. Category filter final categoryMatch = selectedCategories.isEmpty || (contact.contactCategory != null && selectedCategories.contains(contact.contactCategory!.id)); - // 2. Bucket filter final bucketMatch = selectedBuckets.isEmpty || contact.bucketIds.any((id) => selectedBuckets.contains(id)); - // 3. Search filter: match name, organization, email, or tags final nameMatch = contact.name.toLowerCase().contains(query); final orgMatch = contact.organization.toLowerCase().contains(query); final emailMatch = contact.contactEmails diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 18c70fe..cfda4f8 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -41,4 +41,5 @@ class ApiEndpoints { static const String getDirectoryOrganization = "/directory/organization"; static const String createContact = "/directory"; static const String getDirectoryNotes = "/directory/notes"; + static const String updateDirectoryNotes = "/directory/note"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index cd63e6c..b5cb819 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -162,6 +162,49 @@ class ApiService { } } + static Future _putRequest( + String endpoint, + dynamic body, { + Map? 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", sensitive: true); + + try { + final response = await http + .put(uri, headers: headers, body: jsonEncode(body)) + .timeout(customTimeout); + + if (response.statusCode == 401 && !hasRetried) { + logSafe("Unauthorized PUT. Attempting token refresh..."); + if (await AuthService.refreshToken()) { + return await _putRequest(endpoint, body, + additionalHeaders: additionalHeaders, + customTimeout: customTimeout, + hasRetried: true); + } + } + + return response; + } catch (e) { + logSafe("HTTP PUT Exception: $e", level: LogLevel.error); + return null; + } + } + // === Dashboard Endpoints === static Future?> getDashboardAttendanceOverview( @@ -177,16 +220,67 @@ class ApiService { : null); } - /// Directly calling the API -static Future?> getDirectoryComments(String contactId) async { - final url = "${ApiEndpoints.getDirectoryNotes}/$contactId"; - final response = await _getRequest(url); - final data = response != null - ? _parseResponse(response, label: 'Directory Comments') - : null; + /// Directory calling the API + static Future updateContactComment( + String commentId, String note, String contactId) async { + final payload = { + "id": commentId, + "contactId": contactId, + "note": note, + }; - return data is List ? data : null; -} + 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?> 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 createContact(Map payload) async { try { diff --git a/lib/helpers/widgets/Directory/comment_editor_card.dart b/lib/helpers/widgets/Directory/comment_editor_card.dart new file mode 100644 index 0000000..4c26dbf --- /dev/null +++ b/lib/helpers/widgets/Directory/comment_editor_card.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; +import 'package:get/get.dart'; + +class CommentEditorCard extends StatelessWidget { + final quill.QuillController controller; + final VoidCallback onCancel; + final Future Function(quill.QuillController controller) onSave; + + const CommentEditorCard({ + super.key, + required this.controller, + required this.onCancel, + required this.onSave, + }); + + @override + Widget build(BuildContext context) { + final RxBool _showFullToolbar = false.obs; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx(() { + final showFull = _showFullToolbar.value; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + quill.QuillSimpleToolbar( + controller: controller, + configurations: quill.QuillSimpleToolbarConfigurations( + showBoldButton: true, + showItalicButton: true, + showUnderLineButton: showFull, + showListBullets: true, + showListNumbers: true, + showAlignmentButtons: showFull, + showLink: true, + showFontSize: showFull, + showFontFamily: showFull, + showColorButton: showFull, + showBackgroundColorButton: showFull, + showUndo: false, + showRedo: false, + showCodeBlock: showFull, + showQuote: showFull, + showSuperscript: false, + showSubscript: false, + showInlineCode: false, + showDirection: false, + showListCheck: false, + showStrikeThrough: false, + showClearFormat: showFull, + showDividers: false, + showHeaderStyle: showFull, + multiRowsDisplay: false, + ), + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => _showFullToolbar.toggle(), + child: Text( + showFull ? "Hide Formatting" : "More Formatting", + style: const TextStyle(color: Colors.indigo), + ), + ), + ) + ], + ); + }), + const SizedBox(height: 8), + Container( + height: 120, + 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, + ), + ), + ], + ), + ) + ], + ); + } +} diff --git a/lib/model/directory/directory_comment_model.dart b/lib/model/directory/directory_comment_model.dart index af6741f..562e6dc 100644 --- a/lib/model/directory/directory_comment_model.dart +++ b/lib/model/directory/directory_comment_model.dart @@ -69,6 +69,32 @@ class DirectoryComment { 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 { @@ -98,4 +124,22 @@ class CommentUser { 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, + ); + } } diff --git a/lib/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart index 3ded565..71e193c 100644 --- a/lib/view/directory/contact_detail_screen.dart +++ b/lib/view/directory/contact_detail_screen.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; -import 'package:flutter_html/flutter_html.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'; @@ -10,28 +11,92 @@ 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'; -class ContactDetailScreen extends StatelessWidget { +class ContactDetailScreen extends StatefulWidget { final ContactModel contact; const ContactDetailScreen({super.key, required this.contact}); @override - Widget build(BuildContext context) { - final directoryController = Get.find(); - final projectController = Get.find(); + State createState() => _ContactDetailScreenState(); +} - Future.microtask(() { - if (!directoryController.contactCommentsMap.containsKey(contact.id)) { - directoryController.fetchCommentsForContact(contact.id); +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('
    '); + inList = true; + } + + // Close list if we are not in list mode anymore + if (!isListItem && inList) { + buffer.write('
'); + inList = false; + } + + if (isListItem) buffer.write('
  • '); + + // Apply inline styles + if (attr.containsKey('bold')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('link')) buffer.write(''); + + buffer.write(data.replaceAll('\n', '')); + + if (attr.containsKey('link')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('bold')) buffer.write(''); + + if (isListItem) buffer.write('
  • '); + else if (data.contains('\n')) buffer.write('
    '); + } + + if (inList) buffer.write(''); + + return buffer.toString(); +} + + +class _ContactDetailScreenState extends State { + late final DirectoryController directoryController; + late final ProjectController projectController; + + @override + void initState() { + super.initState(); + directoryController = Get.find(); + projectController = Get.find(); + + 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(projectController), + appBar: _buildMainAppBar(), body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -40,8 +105,8 @@ class ContactDetailScreen extends StatelessWidget { Expanded( child: TabBarView( children: [ - _buildDetailsTab(directoryController, projectController), - _buildCommentsTab(directoryController), + _buildDetailsTab(), + _buildCommentsTab(context), ], ), ), @@ -52,7 +117,7 @@ class ContactDetailScreen extends StatelessWidget { ); } - PreferredSizeWidget _buildMainAppBar(ProjectController projectController) { + PreferredSizeWidget _buildMainAppBar() { return AppBar( backgroundColor: const Color(0xFFF5F5F5), elevation: 0.2, @@ -74,11 +139,8 @@ class ContactDetailScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - MyText.titleLarge( - 'Contact Profile', - fontWeight: 700, - color: Colors.black, - ), + MyText.titleLarge('Contact Profile', + fontWeight: 700, color: Colors.black), MySpacing.height(2), GetBuilder( builder: (projectController) { @@ -120,9 +182,9 @@ class ContactDetailScreen extends StatelessWidget { Row( children: [ Avatar( - firstName: contact.name.split(" ").first, - lastName: contact.name.split(" ").length > 1 - ? contact.name.split(" ").last + firstName: widget.contact.name.split(" ").first, + lastName: widget.contact.name.split(" ").length > 1 + ? widget.contact.name.split(" ").last : "", size: 35, backgroundColor: Colors.indigo, @@ -131,10 +193,10 @@ class ContactDetailScreen extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.titleSmall(contact.name, + MyText.titleSmall(widget.contact.name, fontWeight: 600, color: Colors.black), MySpacing.height(2), - MyText.bodySmall(contact.organization, + MyText.bodySmall(widget.contact.organization, fontWeight: 500, color: Colors.grey[700]), ], ), @@ -161,28 +223,28 @@ class ContactDetailScreen extends StatelessWidget { ); } - Widget _buildDetailsTab(DirectoryController directoryController, - ProjectController projectController) { - final email = contact.contactEmails.isNotEmpty - ? contact.contactEmails.first.emailAddress + Widget _buildDetailsTab() { + final email = widget.contact.contactEmails.isNotEmpty + ? widget.contact.contactEmails.first.emailAddress : "-"; - final phone = contact.contactPhones.isNotEmpty - ? contact.contactPhones.first.phoneNumber + final phone = widget.contact.contactPhones.isNotEmpty + ? widget.contact.contactPhones.first.phoneNumber : "-"; - final createdDate = DateTime.now(); + final createdDate = + DateTime.now(); // TODO: Replace with actual creation date if available final formattedDate = DateFormat('MMMM dd, yyyy').format(createdDate); - final tags = contact.tags.map((e) => e.name).join(", "); + final tags = widget.contact.tags.map((e) => e.name).join(", "); - final bucketNames = contact.bucketIds + final bucketNames = widget.contact.bucketIds .map((id) => directoryController.contactBuckets .firstWhereOrNull((b) => b.id == id) ?.name) .whereType() .join(", "); - final projectNames = contact.projectIds + final projectNames = widget.contact.projectIds ?.map((id) => projectController.projects .firstWhereOrNull((p) => p.id == id) ?.name) @@ -190,7 +252,7 @@ class ContactDetailScreen extends StatelessWidget { .join(", ") ?? "-"; - final category = contact.contactCategory?.name ?? "-"; + final category = widget.contact.contactCategory?.name ?? "-"; return SingleChildScrollView( padding: MySpacing.xy(8, 8), @@ -207,10 +269,11 @@ class ContactDetailScreen extends StatelessWidget { onLongPress: () => LauncherUtils.copyToClipboard(phone, typeLabel: "Phone")), _iconInfoRow(Icons.calendar_today, "Created", formattedDate), - _iconInfoRow(Icons.location_on, "Address", contact.address), + _iconInfoRow(Icons.location_on, "Address", widget.contact.address), ]), _infoCard("Organization", [ - _iconInfoRow(Icons.business, "Organization", contact.organization), + _iconInfoRow( + Icons.business, "Organization", widget.contact.organization), _iconInfoRow(Icons.category, "Category", category), ]), _infoCard("Meta Info", [ @@ -224,7 +287,7 @@ class ContactDetailScreen extends StatelessWidget { Align( alignment: Alignment.topLeft, child: MyText.bodyMedium( - contact.description, + widget.contact.description, color: Colors.grey[800], maxLines: 10, textAlign: TextAlign.left, @@ -236,17 +299,21 @@ class ContactDetailScreen extends StatelessWidget { ); } - Widget _buildCommentsTab(DirectoryController directoryController) { + Widget _buildCommentsTab(BuildContext context) { return Obx(() { - final comments = directoryController.contactCommentsMap[contact.id]; - - if (comments == null) { + if (!directoryController.contactCommentsMap + .containsKey(widget.contact.id)) { return const Center(child: CircularProgressIndicator()); } + final comments = + directoryController.getCommentsForContact(widget.contact.id); + final editingId = directoryController.editingCommentId.value; + if (comments.isEmpty) { return Center( - child: MyText.bodyLarge("No comments yet.", color: Colors.grey)); + child: MyText.bodyLarge("No comments yet.", color: Colors.grey), + ); } return ListView.separated( @@ -255,10 +322,22 @@ class ContactDetailScreen extends StatelessWidget { separatorBuilder: (_, __) => MySpacing.height(12), 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 Container( padding: MySpacing.xy(14, 12), decoration: BoxDecoration( @@ -296,40 +375,55 @@ class ContactDetailScreen extends StatelessWidget { ), ), IconButton( - icon: const Icon(Icons.more_vert, - size: 20, color: Colors.grey), - onPressed: () {}, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), + icon: Icon(isEditing ? Icons.close : Icons.edit, + size: 20, color: Colors.grey[700]), + onPressed: () { + directoryController.editingCommentId.value = + isEditing ? null : comment.id; + }, ), ], ), MySpacing.height(10), - Html( - data: comment.note, - style: { - "body": Style( - margin: Margins.all(0), - padding: HtmlPaddings.all(0), - fontSize: FontSize.medium, - color: Colors.black87, - ), - "pre": Style( - padding: HtmlPaddings.all(8), - fontSize: FontSize.small, - fontFamily: 'monospace', - backgroundColor: const Color(0xFFF1F1F1), - border: Border.all(color: Colors.grey.shade300), - ), - "h3": Style( - fontSize: FontSize.large, - fontWeight: FontWeight.bold, - color: Colors.indigo[700], - ), - "strong": Style(fontWeight: FontWeight.w700), - "p": Style(margin: Margins.only(bottom: 8)), - }, - ), + 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.all(0), + padding: html.HtmlPaddings.all(0), + fontSize: html.FontSize.medium, + color: Colors.black87, + ), + "pre": html.Style( + padding: html.HtmlPaddings.all(8), + fontSize: html.FontSize.small, + fontFamily: 'monospace', + backgroundColor: const Color(0xFFF1F1F1), + border: Border.all(color: Colors.grey.shade300), + ), + "h3": html.Style( + fontSize: html.FontSize.large, + fontWeight: FontWeight.bold, + color: Colors.indigo[700], + ), + "strong": html.Style(fontWeight: FontWeight.w700), + "p": html.Style(margin: html.Margins.only(bottom: 8)), + }, + ), ], ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 1849534..fef9493 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,7 +52,7 @@ dependencies: intl: ^0.19.0 syncfusion_flutter_core: ^28.1.33 syncfusion_flutter_sliders: ^28.1.33 - file_picker: ^8.1.5 + file_picker: ^9.2.3 timelines_plus: ^1.0.4 syncfusion_flutter_charts: ^28.1.33 appflowy_board: ^0.1.2 @@ -74,6 +74,9 @@ dependencies: font_awesome_flutter: ^10.8.0 flutter_html: ^3.0.0 tab_indicator_styler: ^2.0.0 + html_editor_enhanced: ^2.7.0 + flutter_quill_delta_from_html: ^1.5.2 + quill_delta: ^3.0.0-nullsafety.2 dev_dependencies: flutter_test: sdk: flutter -- 2.43.0 From 549d8cce3cf366966b12f296ced3035285e1cfa3 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 4 Jul 2025 16:55:50 +0530 Subject: [PATCH 05/39] feat(directory): add comment submission functionality and UI components --- .../directory/add_comment_controller.dart | 74 +++++ lib/helpers/services/api_service.dart | 40 +++ .../Directory/comment_editor_card.dart | 82 ++---- .../directory/add_comment_bottom_sheet.dart | 142 +++++++++ lib/view/directory/contact_detail_screen.dart | 278 ++++++++++-------- 5 files changed, 440 insertions(+), 176 deletions(-) create mode 100644 lib/controller/directory/add_comment_controller.dart create mode 100644 lib/model/directory/add_comment_bottom_sheet.dart diff --git a/lib/controller/directory/add_comment_controller.dart b/lib/controller/directory/add_comment_controller.dart new file mode 100644 index 0000000..bc77c50 --- /dev/null +++ b/lib/controller/directory/add_comment_controller.dart @@ -0,0 +1,74 @@ +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 submitComment() async { + if (note.value.trim().isEmpty) { + showAppSnackbar( + title: "Validation", + message: "Comment cannot be empty.", + 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."); + + // Get the directory controller + final directoryController = Get.find(); + + // Fetch latest comments for the contact to refresh UI + await directoryController.fetchCommentsForContact(contactId); + + Get.back(result: true); + + showAppSnackbar( + title: "Success", + message: "Comment added successfully.", + type: SnackbarType.success, + ); + } else { + logSafe("Comment submission failed", level: LogLevel.error); + showAppSnackbar( + title: "Error", + message: "Failed to add comment.", + type: SnackbarType.error, + ); + } + } catch (e) { + logSafe("Error while submitting comment: $e", level: LogLevel.error); + showAppSnackbar( + title: "Error", + message: "Something went wrong.", + type: SnackbarType.error, + ); + } finally { + isSubmitting.value = false; + } + } + + void updateNote(String value) { + note.value = value; + logSafe("Note updated: ${value.trim()}"); + } +} diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index b5cb819..d894c98 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -221,6 +221,46 @@ class ApiService { } /// Directory calling the API + static Future 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 updateContactComment( String commentId, String note, String contactId) async { final payload = { diff --git a/lib/helpers/widgets/Directory/comment_editor_card.dart b/lib/helpers/widgets/Directory/comment_editor_card.dart index 4c26dbf..57c71e3 100644 --- a/lib/helpers/widgets/Directory/comment_editor_card.dart +++ b/lib/helpers/widgets/Directory/comment_editor_card.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; -import 'package:get/get.dart'; class CommentEditorCard extends StatelessWidget { final quill.QuillController controller; @@ -16,60 +15,39 @@ class CommentEditorCard extends StatelessWidget { @override Widget build(BuildContext context) { - final RxBool _showFullToolbar = false.obs; - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Obx(() { - final showFull = _showFullToolbar.value; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - quill.QuillSimpleToolbar( - controller: controller, - configurations: quill.QuillSimpleToolbarConfigurations( - showBoldButton: true, - showItalicButton: true, - showUnderLineButton: showFull, - showListBullets: true, - showListNumbers: true, - showAlignmentButtons: showFull, - showLink: true, - showFontSize: showFull, - showFontFamily: showFull, - showColorButton: showFull, - showBackgroundColorButton: showFull, - showUndo: false, - showRedo: false, - showCodeBlock: showFull, - showQuote: showFull, - showSuperscript: false, - showSubscript: false, - showInlineCode: false, - showDirection: false, - showListCheck: false, - showStrikeThrough: false, - showClearFormat: showFull, - showDividers: false, - showHeaderStyle: showFull, - multiRowsDisplay: false, - ), - ), - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () => _showFullToolbar.toggle(), - child: Text( - showFull ? "Hide Formatting" : "More Formatting", - style: const TextStyle(color: Colors.indigo), - ), - ), - ) - ], - ); - }), + quill.QuillSimpleToolbar( + controller: controller, + configurations: const quill.QuillSimpleToolbarConfigurations( + showBoldButton: true, + showItalicButton: true, + showUnderLineButton: true, + showListBullets: true, + showListNumbers: true, + 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: 8), Container( height: 120, diff --git a/lib/model/directory/add_comment_bottom_sheet.dart b/lib/model/directory/add_comment_bottom_sheet.dart new file mode 100644 index 0000000..e06b47e --- /dev/null +++ b/lib/model/directory/add_comment_bottom_sheet.dart @@ -0,0 +1,142 @@ +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 createState() => _AddCommentBottomSheetState(); +} + +class _AddCommentBottomSheetState extends State { + late final AddCommentController controller; + late final quill.QuillController quillController; + + @override + void initState() { + super.initState(); + controller = Get.put(AddCommentController(contactId: widget.contactId)); + // Initialize empty editor for new comment + quillController = quill.QuillController.basic(); + } + + @override + void dispose() { + quillController.dispose(); + Get.delete(); + 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: (controller) async { + final delta = controller.document.toDelta(); + final htmlOutput = _convertDeltaToHtml(delta); + this.controller.updateNote(htmlOutput); + await this.controller.submitComment(); + if (mounted) Get.back(); + }, + ), + ], + ), + ), + ), + ); + } +} + +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
      if list item starts + if (isListItem && !inList) { + buffer.write('
        '); + inList = true; + } + + // Close
          if list ended + if (!isListItem && inList) { + buffer.write('
        '); + inList = false; + } + + // Skip empty list items + final trimmedData = data.trim(); + if (isListItem && trimmedData.isEmpty) { + // don't write empty
      • + continue; + } + + if (isListItem) buffer.write('
      • '); + + if (attr.containsKey('bold')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('link')) buffer.write(''); + + // Use trimmedData instead of raw data (removes trailing/leading spaces/newlines) + buffer.write(trimmedData.replaceAll('\n', '')); + + if (attr.containsKey('link')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('bold')) buffer.write(''); + + if (isListItem) { + buffer.write('
      • '); + } else if (data.contains('\n')) { + buffer.write('
        '); + } + } + + if (inList) buffer.write('
      '); + + return buffer.toString(); +} diff --git a/lib/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart index 71e193c..a84ad40 100644 --- a/lib/view/directory/contact_detail_screen.dart +++ b/lib/view/directory/contact_detail_screen.dart @@ -13,6 +13,7 @@ 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'; class ContactDetailScreen extends StatefulWidget { final ContactModel contact; @@ -62,7 +63,8 @@ String _convertDeltaToHtml(dynamic delta) { if (attr.containsKey('italic')) buffer.write(''); if (attr.containsKey('bold')) buffer.write(''); - if (isListItem) buffer.write(''); + if (isListItem) + buffer.write(''); else if (data.contains('\n')) buffer.write('
      '); } @@ -71,7 +73,6 @@ String _convertDeltaToHtml(dynamic delta) { return buffer.toString(); } - class _ContactDetailScreenState extends State { late final DirectoryController directoryController; late final ProjectController projectController; @@ -203,10 +204,10 @@ class _ContactDetailScreenState extends State { ], ), TabBar( - labelColor: Colors.indigo, - unselectedLabelColor: Colors.grey, + labelColor: Colors.red, + unselectedLabelColor: Colors.black, indicator: MaterialIndicator( - color: Colors.indigo, + color: Colors.red, height: 4, topLeftRadius: 8, topRightRadius: 8, @@ -232,8 +233,7 @@ class _ContactDetailScreenState extends State { ? widget.contact.contactPhones.first.phoneNumber : "-"; - final createdDate = - DateTime.now(); // TODO: Replace with actual creation date if available + final createdDate = DateTime.now(); final formattedDate = DateFormat('MMMM dd, yyyy').format(createdDate); final tags = widget.contact.tags.map((e) => e.name).join(", "); @@ -301,133 +301,163 @@ class _ContactDetailScreenState extends State { Widget _buildCommentsTab(BuildContext context) { return Obx(() { - if (!directoryController.contactCommentsMap - .containsKey(widget.contact.id)) { + final contactId = widget.contact.id; + + if (!directoryController.contactCommentsMap.containsKey(contactId)) { return const Center(child: CircularProgressIndicator()); } - final comments = - directoryController.getCommentsForContact(widget.contact.id); + final comments = directoryController + .getCommentsForContact(contactId) + .reversed + .toList(); + final editingId = directoryController.editingCommentId.value; - if (comments.isEmpty) { - return Center( - child: MyText.bodyLarge("No comments yet.", color: Colors.grey), - ); - } - - return ListView.separated( - padding: MySpacing.xy(8, 8), - itemCount: comments.length, - separatorBuilder: (_, __) => MySpacing.height(12), - 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), + return Stack( + children: [ + comments.isEmpty + ? Center( + child: + MyText.bodyLarge("No comments yet.", color: Colors.grey), ) - : null; + : Padding( + padding: MySpacing.xy(8, 8), // Same padding as Details tab + child: ListView.separated( + padding: EdgeInsets.only( + bottom: 80, // Extra bottom padding to avoid FAB overlap + ), + itemCount: comments.length, + separatorBuilder: (_, __) => MySpacing.height(12), + itemBuilder: (_, index) { + final comment = comments[index]; + final isEditing = editingId == comment.id; - return Container( - padding: MySpacing.xy(14, 12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 4, - offset: Offset(0, 2), - ) - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Avatar(firstName: initials, lastName: '', size: 31), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodySmall("By: ${comment.createdBy.firstName}", - fontWeight: 600, color: Colors.indigo[700]), - MySpacing.height(2), - MyText.bodySmall( - DateFormat('dd MMM yyyy, hh:mm a') - .format(comment.createdAt), - fontWeight: 500, - color: Colors.grey[600], - ), - ], - ), - ), - IconButton( - icon: Icon(isEditing ? Icons.close : Icons.edit, - size: 20, color: Colors.grey[700]), - onPressed: () { - directoryController.editingCommentId.value = - isEditing ? null : comment.id; - }, - ), - ], - ), - MySpacing.height(10), - 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.all(0), - padding: html.HtmlPaddings.all(0), - fontSize: html.FontSize.medium, - color: Colors.black87, - ), - "pre": html.Style( - padding: html.HtmlPaddings.all(8), - fontSize: html.FontSize.small, - fontFamily: 'monospace', - backgroundColor: const Color(0xFFF1F1F1), - border: Border.all(color: Colors.grey.shade300), - ), - "h3": html.Style( - fontSize: html.FontSize.large, - fontWeight: FontWeight.bold, - color: Colors.indigo[700], - ), - "strong": html.Style(fontWeight: FontWeight.w700), - "p": html.Style(margin: html.Margins.only(bottom: 8)), + 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 Container( + padding: MySpacing.xy(14, 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, 2), + ) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Avatar( + firstName: initials, + lastName: '', + size: 31), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + MyText.bodySmall( + "By: ${comment.createdBy.firstName}", + fontWeight: 600, + color: Colors.indigo[700]), + MySpacing.height(2), + MyText.bodySmall( + DateFormat('dd MMM yyyy, hh:mm a') + .format(comment.createdAt), + fontWeight: 500, + color: Colors.grey[600], + ), + ], + ), + ), + IconButton( + icon: Icon( + isEditing ? Icons.close : Icons.edit, + size: 20, + color: Colors.grey[700]), + onPressed: () { + directoryController.editingCommentId.value = + isEditing ? null : comment.id; + }, + ), + ], + ), + MySpacing.height(10), + 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.all(0), + padding: html.HtmlPaddings.all(0), + fontSize: html.FontSize.medium, + color: Colors.black87, + ), + }, + ), + ], + ), + ); }, ), - ], + ), + + // Floating Action Button to Add Comment + if (directoryController.editingCommentId.value == null) + Positioned( + bottom: 16, + right: 16, + 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)), + ), ), - ); - }, + ], ); }); } -- 2.43.0 From becdec1a79876c2cba13d771ef9e8f4fdc2e49eb Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 4 Jul 2025 17:29:26 +0530 Subject: [PATCH 06/39] feat(directory): enhance contact card UI with improved layout and interaction elements --- lib/view/directory/directory_main_screen.dart | 311 +++++++----------- 1 file changed, 118 insertions(+), 193 deletions(-) diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart index aa268f4..e17abb1 100644 --- a/lib/view/directory/directory_main_screen.dart +++ b/lib/view/directory/directory_main_screen.dart @@ -2,10 +2,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/directory/directory_controller.dart'; import 'package:marco/controller/project_controller.dart'; -import 'package:marco/helpers/widgets/my_card.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/helpers/utils/my_shadow.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/model/directory/directory_filter_bottom_sheet.dart'; @@ -31,7 +29,7 @@ class DirectoryMainScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF5F5F5), + backgroundColor: Colors.white, appBar: PreferredSize( preferredSize: const Size.fromHeight(72), child: AppBar( @@ -53,7 +51,7 @@ class DirectoryMainScreen extends StatelessWidget { Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.min, children: [ MyText.titleLarge( 'Directory', @@ -308,218 +306,145 @@ class DirectoryMainScreen extends StatelessWidget { final firstName = nameParts.first; final lastName = nameParts.length > 1 ? nameParts.last : ""; final tags = contact.tags.map((tag) => tag.name).toList(); - return MyCard.bordered( - margin: MySpacing.only(bottom: 2), - paddingAll: 8, - borderRadiusAll: 8, - shadow: MyShadow( - elevation: 1.5, - position: MyShadowPosition.bottom, - ), + return InkWell( onTap: () { Get.to(() => ContactDetailScreen(contact: contact)); }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Avatar( - firstName: firstName, - lastName: lastName, - size: 31, - ), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall( - contact.name, - fontWeight: 700, - color: Colors.black87, - ), - MyText.bodySmall( - contact.organization, - fontWeight: 500, - ), - ], - ), - ), - GestureDetector( - onTap: () { - Get.to(() => - ContactDetailScreen(contact: contact)); - }, - child: const Icon(Icons.arrow_forward_ios, - color: Colors.black, size: 15), - ), - MySpacing.width(4), - ], - ), - const Divider(), - if (contact.contactEmails.isNotEmpty || - contact.contactPhones.isNotEmpty) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Email Row - if (contact.contactEmails.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 5), - child: Row( - children: [ + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Leading Icon + Avatar( + firstName: firstName, + lastName: lastName, + size: 45, + ), + MySpacing.width(12), + + // Middle Content + 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(6), + + // Launcher Row + Wrap( + spacing: 12, + runSpacing: 6, + children: [ + if (email != '-') GestureDetector( onTap: () => LauncherUtils.launchEmail(email), - child: const Padding( - padding: - EdgeInsets.only(right: 8.0), - child: Icon(Icons.email_outlined, - color: Colors.blue, size: 25), + onLongPress: () => + LauncherUtils.copyToClipboard( + email, + typeLabel: 'Email'), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.email_outlined, + size: 16, + color: Colors.indigo), + MySpacing.width(4), + ConstrainedBox( + constraints: + const BoxConstraints( + maxWidth: 120), + child: MyText.labelSmall( + email, + overflow: + TextOverflow.ellipsis, + color: Colors.indigo, + decoration: + TextDecoration.underline, + ), + ), + ], ), ), - Expanded( - child: GestureDetector( - onTap: () => - LauncherUtils.launchEmail( - email), - onLongPress: () => - LauncherUtils.copyToClipboard( - email, - typeLabel: 'Email'), - child: MyText.bodyMedium( - email, - maxLines: 1, - overflow: TextOverflow.ellipsis, - color: Colors.blue, - fontWeight: 600, - textAlign: TextAlign.start, - decoration: - TextDecoration.underline, - ), - ), - ), - ], - ), - ), - - // Phone Row with icons at the end - if (contact.contactPhones.isNotEmpty) - Row( - children: [ - // Phone Icon - Padding( - padding: - const EdgeInsets.only(right: 6.0), - child: GestureDetector( - onTap: () => - LauncherUtils.launchPhone(phone), - child: const Icon( - Icons.phone_outlined, - color: Colors.blue, - size: 25), - ), - ), - - // Phone number text - Expanded( - child: GestureDetector( + if (phone != '-') + GestureDetector( onTap: () => LauncherUtils.launchPhone(phone), onLongPress: () => LauncherUtils.copyToClipboard( phone, typeLabel: 'Phone number'), - child: MyText.bodyMedium( - phone, - maxLines: 1, - overflow: TextOverflow.ellipsis, - color: Colors.blue, - fontWeight: 600, - textAlign: TextAlign.start, - decoration: - TextDecoration.underline, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.phone_outlined, + size: 16, + color: Colors.indigo), + MySpacing.width(4), + ConstrainedBox( + constraints: + const BoxConstraints( + maxWidth: 100), + child: MyText.labelSmall( + phone, + overflow: + TextOverflow.ellipsis, + color: Colors.indigo, + decoration: + TextDecoration.underline, + ), + ), + ], ), ), - ), - - // WhatsApp Icon - Padding( - padding: - const EdgeInsets.only(right: 6.0), - child: GestureDetector( - onTap: () => - LauncherUtils.launchWhatsApp( - phone), - child: const FaIcon( - FontAwesomeIcons.whatsapp, - color: Colors.green, - size: 25), - ), - ), ], ), - ], - ), - MySpacing.height(8), - // Tags Section - if (tags.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - SingleChildScrollView( - scrollDirection: Axis.horizontal, - reverse: - true, // ensures scroll starts from right - child: Row( - children: tags.map((name) { - return Container( - margin: - const EdgeInsets.only(left: 6), - child: TextButton( - onPressed: () {}, - style: TextButton.styleFrom( - padding: - const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - backgroundColor: - const Color.fromARGB( - 255, 179, 207, 246), - tapTargetSize: - MaterialTapTargetSize - .shrinkWrap, - minimumSize: Size.zero, - visualDensity: - VisualDensity.standard, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(5), - ), - ), - child: Text( - name, - style: const TextStyle( - fontSize: 10, - color: Color.fromARGB( - 255, 0, 0, 0), - height: 1.2, - ), - ), - ), - ); - }).toList(), + if (tags.isNotEmpty) ...[ + MySpacing.height(4), + MyText.labelSmall( + tags.join(', '), + color: Colors.grey[500], + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), + ], ], ), ), - ], + + // WhatsApp launcher icon + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.arrow_forward_ios, + color: Colors.grey, size: 16), + MySpacing.height(12), + if (phone != '-') + GestureDetector( + onTap: () => + LauncherUtils.launchWhatsApp(phone), + child: const FaIcon( + FontAwesomeIcons.whatsapp, + color: Colors.green, + size: 20, + ), + ), + ], + ), + ], + ), ), ); }, -- 2.43.0 From eabd988b322ee573fa3d3b569c2212550d1c4a98 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 4 Jul 2025 17:30:26 +0530 Subject: [PATCH 07/39] feat(directory): change floating action button color to red for better visibility --- lib/view/directory/directory_main_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart index e17abb1..cf99e07 100644 --- a/lib/view/directory/directory_main_screen.dart +++ b/lib/view/directory/directory_main_screen.dart @@ -90,7 +90,7 @@ class DirectoryMainScreen extends StatelessWidget { ), ), floatingActionButton: FloatingActionButton( - backgroundColor: Colors.indigo, + backgroundColor: Colors.red, onPressed: () async { final result = await Get.bottomSheet( AddContactBottomSheet(), -- 2.43.0 From b187f1843abe7b7a8eb504e9a0b3a3095c6143b4 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 5 Jul 2025 11:30:13 +0530 Subject: [PATCH 08/39] chore: update gradle properties for improved performance and memory management --- android/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/gradle.properties b/android/gradle.properties index 2597170..84044a9 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,3 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true +android.enableJetifier=false -- 2.43.0 From 62c49b5429db9364879723e5933685d0be631d15 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 5 Jul 2025 11:57:15 +0530 Subject: [PATCH 09/39] feat(directory): add form reset functionality in AddContactController and initialize fields in AddContactBottomSheet --- .../directory/add_contact_controller.dart | 9 +++ .../directory/add_contact_bottom_sheet.dart | 80 ++++++++----------- 2 files changed, 44 insertions(+), 45 deletions(-) diff --git a/lib/controller/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart index 1c2bace..b3f3b43 100644 --- a/lib/controller/directory/add_contact_controller.dart +++ b/lib/controller/directory/add_contact_controller.dart @@ -41,6 +41,15 @@ class AddContactController extends GetxController { ]); } + void resetForm() { + selectedCategory.value = ''; + selectedProject.value = ''; + selectedBucket.value = ''; + enteredTags.clear(); + filteredSuggestions.clear(); + filteredOrgSuggestions.clear(); + } + Future fetchBuckets() async { try { final response = await ApiService.getContactBucketList(); diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart index 5c26fd9..de2ff9e 100644 --- a/lib/model/directory/add_contact_bottom_sheet.dart +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -1,4 +1,3 @@ -// unchanged imports import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/directory/add_contact_controller.dart'; @@ -7,7 +6,20 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; class AddContactBottomSheet extends StatelessWidget { - AddContactBottomSheet({super.key}); + AddContactBottomSheet({super.key}) { + controller.resetForm(); + nameController.clear(); + emailController.clear(); + phoneController.clear(); + orgController.clear(); + tagTextController.clear(); + addressController.clear(); + descriptionController.clear(); + + // Reset labels + emailLabel.value = 'Office'; + phoneLabel.value = 'Work'; + } final controller = Get.put(AddContactController()); final formKey = GlobalKey(); @@ -66,7 +78,7 @@ class AddContactBottomSheet extends StatelessWidget { child: TextFormField( readOnly: true, initialValue: selectedValue.value, - style: const TextStyle(fontSize: 14), + style: const TextStyle(fontSize: 14), decoration: _inputDecoration(hint) .copyWith(suffixIcon: const Icon(Icons.expand_more)), ), @@ -155,8 +167,7 @@ class AddContactBottomSheet extends StatelessWidget { border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(8), boxShadow: const [ - BoxShadow( - color: Colors.black12, blurRadius: 4, offset: Offset(0, 2)), + BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2)), ], ), child: ListView.builder( @@ -197,8 +208,7 @@ class AddContactBottomSheet extends StatelessWidget { color: theme.cardColor, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), boxShadow: const [ - BoxShadow( - color: Colors.black12, blurRadius: 12, offset: Offset(0, -2)) + BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, -2)) ], ), child: Padding( @@ -219,10 +229,7 @@ class AddContactBottomSheet extends StatelessWidget { ), ), MySpacing.height(12), - Center( - child: - MyText.titleMedium("Create New Contact", fontWeight: 700), - ), + Center(child: MyText.titleMedium("Create New Contact", fontWeight: 700)), MySpacing.height(24), _sectionLabel("Basic Info"), MySpacing.height(16), @@ -232,21 +239,11 @@ class AddContactBottomSheet extends StatelessWidget { MySpacing.height(24), _sectionLabel("Contact Info"), MySpacing.height(16), - _buildLabeledRow( - "Email Label", - emailLabel, - ["Office", "Personal", "Other"], - "Email", - emailController, - TextInputType.emailAddress), + _buildLabeledRow("Email Label", emailLabel, ["Office", "Personal", "Other"], + "Email", emailController, TextInputType.emailAddress), MySpacing.height(16), - _buildLabeledRow( - "Phone Label", - phoneLabel, - ["Work", "Mobile", "Other"], - "Phone", - phoneController, - TextInputType.phone), + _buildLabeledRow("Phone Label", phoneLabel, ["Work", "Mobile", "Other"], + "Phone", phoneController, TextInputType.phone), MySpacing.height(24), _sectionLabel("Other Details"), MySpacing.height(16), @@ -280,8 +277,7 @@ class AddContactBottomSheet extends StatelessWidget { MySpacing.height(16), _buildTextField("Address", addressController, maxLines: 2), MySpacing.height(16), - _buildTextField("Description", descriptionController, - maxLines: 2), + _buildTextField("Description", descriptionController, maxLines: 2), MySpacing.height(24), _buildActionButtons(), ], @@ -303,9 +299,8 @@ class AddContactBottomSheet extends StatelessWidget { controller: controller, maxLines: maxLines, decoration: _inputDecoration("Enter $label"), - validator: (value) => (value == null || value.trim().isEmpty) - ? "$label is required" - : null, + validator: (value) => + (value == null || value.trim().isEmpty) ? "$label is required" : null, ), ], ); @@ -338,7 +333,7 @@ class AddContactBottomSheet extends StatelessWidget { }, ); }, - )) + )), ], ); } @@ -359,11 +354,7 @@ class AddContactBottomSheet extends StatelessWidget { children: [ MyText.labelMedium(label), MySpacing.height(8), - _popupSelector( - hint: "Label", - selectedValue: selectedLabel, - options: options, - ), + _popupSelector(hint: "Label", selectedValue: selectedLabel, options: options), ], ), ), @@ -398,14 +389,15 @@ class AddContactBottomSheet extends StatelessWidget { children: [ Expanded( child: OutlinedButton.icon( - onPressed: () => Get.back(), + onPressed: () { + Get.back(); + Get.delete(); // cleanup + }, icon: const Icon(Icons.close, color: Colors.red), - label: - MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), + label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), style: OutlinedButton.styleFrom( side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), ), ), @@ -428,12 +420,10 @@ class AddContactBottomSheet extends StatelessWidget { } }, icon: const Icon(Icons.check_circle_outline, color: Colors.white), - label: - MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600), + label: MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600), style: ElevatedButton.styleFrom( backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), ), ), -- 2.43.0 From e7940941edfc7016e82a4618be7d66d84077e161 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 5 Jul 2025 13:19:53 +0530 Subject: [PATCH 10/39] feat(directory): enhance AddContact functionality to support multiple emails and phones, improve logging, and refactor contact detail display --- .../directory/add_contact_controller.dart | 69 ++--- lib/helpers/services/api_service.dart | 49 ++- lib/helpers/utils/date_time_utils.dart | 38 +++ .../directory/add_contact_bottom_sheet.dart | 287 +++++++++++------- lib/view/directory/contact_detail_screen.dart | 12 +- lib/view/directory/directory_main_screen.dart | 144 ++++----- 6 files changed, 369 insertions(+), 230 deletions(-) create mode 100644 lib/helpers/utils/date_time_utils.dart diff --git a/lib/controller/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart index b3f3b43..7a78231 100644 --- a/lib/controller/directory/add_contact_controller.dart +++ b/lib/controller/directory/add_contact_controller.dart @@ -1,3 +1,5 @@ +// 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'; @@ -62,10 +64,10 @@ class AddContactController extends GetxController { } } buckets.assignAll(names); - logSafe("Fetched ${names.length} buckets"); + logSafe("Fetched \${names.length} buckets"); } } catch (e) { - logSafe("Failed to fetch buckets: $e", level: LogLevel.error); + logSafe("Failed to fetch buckets: \$e", level: LogLevel.error); } } @@ -73,19 +75,17 @@ class AddContactController extends GetxController { try { final orgs = await ApiService.getOrganizationList(); organizationNames.assignAll(orgs); - logSafe("Fetched ${orgs.length} organization names"); + logSafe("Fetched \${orgs.length} organization names"); } catch (e) { - logSafe("Failed to load organization names: $e", level: LogLevel.error); + logSafe("Failed to load organization names: \$e", level: LogLevel.error); } } Future submitContact({ required String name, required String organization, - required String email, - required String emailLabel, - required String phone, - required String phoneLabel, + required List> emails, + required List> phones, required String address, required String description, }) async { @@ -96,9 +96,7 @@ class AddContactController extends GetxController { final tagObjects = enteredTags.map((tagName) { final tagId = tagsMap[tagName]; - return tagId != null - ? {"id": tagId, "name": tagName} - : {"name": tagName}; + return tagId != null ? {"id": tagId, "name": tagName} : {"name": tagName}; }).toList(); final body = { @@ -108,18 +106,8 @@ class AddContactController extends GetxController { "projectIds": projectId != null ? [projectId] : [], "bucketIds": bucketId != null ? [bucketId] : [], "tags": tagObjects, - "contactEmails": [ - { - "label": emailLabel, - "emailAddress": email, - } - ], - "contactPhones": [ - { - "label": phoneLabel, - "phoneNumber": phone, - } - ], + "contactEmails": emails, + "contactPhones": phones, "address": address, "description": description, }; @@ -129,10 +117,7 @@ class AddContactController extends GetxController { final response = await ApiService.createContact(body); if (response == true) { logSafe("Contact creation succeeded"); - - // Send result back to previous screen Get.back(result: true); - showAppSnackbar( title: "Success", message: "Contact created successfully", @@ -147,7 +132,7 @@ class AddContactController extends GetxController { ); } } catch (e) { - logSafe("Contact creation error: $e", level: LogLevel.error); + logSafe("Contact creation error: \$e", level: LogLevel.error); showAppSnackbar( title: "Error", message: "Something went wrong", @@ -164,12 +149,9 @@ class AddContactController extends GetxController { final lower = query.toLowerCase(); filteredOrgSuggestions.assignAll( - organizationNames - .where((name) => name.toLowerCase().contains(lower)) - .toList(), + organizationNames.where((name) => name.toLowerCase().contains(lower)).toList(), ); - logSafe("Filtered organization suggestions for: $query", - level: LogLevel.debug); + logSafe("Filtered organization suggestions for: \$query", level: LogLevel.debug); } Future fetchGlobalProjects() async { @@ -186,10 +168,10 @@ class AddContactController extends GetxController { } } globalProjects.assignAll(names); - logSafe("Fetched ${names.length} global projects"); + logSafe("Fetched \${names.length} global projects"); } } catch (e) { - logSafe("Failed to fetch global projects: $e", level: LogLevel.error); + logSafe("Failed to fetch global projects: \$e", level: LogLevel.error); } } @@ -200,10 +182,10 @@ class AddContactController extends GetxController { tags.assignAll(List.from( response['data'].map((e) => e['name'] ?? '').where((e) => e != ''), )); - logSafe("Fetched ${tags.length} tags"); + logSafe("Fetched \${tags.length} tags"); } } catch (e) { - logSafe("Failed to fetch tags: $e", level: LogLevel.error); + logSafe("Failed to fetch tags: \$e", level: LogLevel.error); } } @@ -215,12 +197,9 @@ class AddContactController extends GetxController { final lower = query.toLowerCase(); filteredSuggestions.assignAll( - tags - .where((tag) => - tag.toLowerCase().contains(lower) && !enteredTags.contains(tag)) - .toList(), + tags.where((tag) => tag.toLowerCase().contains(lower) && !enteredTags.contains(tag)).toList(), ); - logSafe("Filtered tag suggestions for: $query", level: LogLevel.debug); + logSafe("Filtered tag suggestions for: \$query", level: LogLevel.debug); } void clearSuggestions() { @@ -242,22 +221,22 @@ class AddContactController extends GetxController { } } categories.assignAll(names); - logSafe("Fetched ${names.length} contact categories"); + logSafe("Fetched \${names.length} contact categories"); } } catch (e) { - logSafe("Failed to fetch categories: $e", level: LogLevel.error); + 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); + logSafe("Added tag: \$tag", level: LogLevel.debug); } } void removeEnteredTag(String tag) { enteredTags.remove(tag); - logSafe("Removed tag: $tag", level: LogLevel.debug); + logSafe("Removed tag: \$tag", level: LogLevel.debug); } } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index d894c98..3361e9e 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -106,23 +106,45 @@ class ApiService { bool hasRetried = false, }) async { String? token = await _getToken(); - if (token == null) return null; + if (token == null) { + logSafe("Token is null. Cannot proceed with GET request.", + level: LogLevel.error); + return null; + } final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint") .replace(queryParameters: queryParams); - logSafe("GET $uri"); + + logSafe("Initiating GET request", level: LogLevel.debug); + logSafe("URL: $uri", level: LogLevel.debug); + logSafe("Query Parameters: ${queryParams ?? {}}", level: LogLevel.debug); + logSafe("Headers: ${_headers(token)}", level: LogLevel.debug); try { final response = await http.get(uri, headers: _headers(token)).timeout(timeout); + + logSafe("Response Status: ${response.statusCode}", level: LogLevel.debug); + logSafe("Response Body: ${response.body}", level: LogLevel.debug); + if (response.statusCode == 401 && !hasRetried) { - logSafe("Unauthorized. Attempting token refresh..."); + logSafe("Unauthorized (401). Attempting token refresh...", + level: LogLevel.warning); + if (await AuthService.refreshToken()) { - return await _getRequest(endpoint, - queryParams: queryParams, hasRetried: true); + logSafe("Token refresh succeeded. Retrying request...", + level: LogLevel.info); + return await _getRequest( + endpoint, + queryParams: queryParams, + hasRetried: true, + ); } - logSafe("Token refresh failed."); + + logSafe("Token refresh failed. Aborting request.", + level: LogLevel.error); } + return response; } catch (e) { logSafe("HTTP GET Exception: $e", level: LogLevel.error); @@ -324,7 +346,7 @@ class ApiService { static Future createContact(Map payload) async { try { - logSafe("Submitting contact payload: $payload", sensitive: true); + logSafe("Submitting contact payload: $payload"); final response = await _postRequest(ApiEndpoints.createContact, payload); if (response != null) { @@ -345,15 +367,24 @@ class ApiService { static Future> getOrganizationList() async { try { - final response = await _getRequest(ApiEndpoints.getDirectoryOrganization); + 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.from(body['data']); } } - } catch (e) { + } catch (e, stackTrace) { logSafe("Failed to fetch organization names: $e", level: LogLevel.error); + logSafe("Stack trace: $stackTrace", level: LogLevel.debug); } return []; } diff --git a/lib/helpers/utils/date_time_utils.dart b/lib/helpers/utils/date_time_utils.dart new file mode 100644 index 0000000..2c6a732 --- /dev/null +++ b/lib/helpers/utils/date_time_utils.dart @@ -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); + } +} diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart index de2ff9e..4c0337c 100644 --- a/lib/model/directory/add_contact_bottom_sheet.dart +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -7,34 +7,38 @@ import 'package:marco/helpers/widgets/my_text_style.dart'; class AddContactBottomSheet extends StatelessWidget { AddContactBottomSheet({super.key}) { - controller.resetForm(); + controller.resetForm(); + nameController.clear(); - emailController.clear(); - phoneController.clear(); orgController.clear(); tagTextController.clear(); addressController.clear(); descriptionController.clear(); - // Reset labels - emailLabel.value = 'Office'; - phoneLabel.value = 'Work'; + emailControllers.add(TextEditingController()); + emailLabels.add('Office'.obs); + + phoneControllers.add(TextEditingController()); + phoneLabels.add('Work'.obs); } final controller = Get.put(AddContactController()); final formKey = GlobalKey(); - final emailLabel = 'Office'.obs; - final phoneLabel = 'Work'.obs; - final nameController = TextEditingController(); - final emailController = TextEditingController(); - final phoneController = TextEditingController(); final orgController = TextEditingController(); final tagTextController = TextEditingController(); final addressController = TextEditingController(); final descriptionController = TextEditingController(); + final RxList emailControllers = + [].obs; + final RxList emailLabels = [].obs; + + final RxList phoneControllers = + [].obs; + final RxList phoneLabels = [].obs; + InputDecoration _inputDecoration(String hint) => InputDecoration( hintText: hint, hintStyle: MyTextStyle.bodySmall(xMuted: true), @@ -52,7 +56,8 @@ class AddContactBottomSheet extends StatelessWidget { borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), isDense: true, ); @@ -67,7 +72,7 @@ class AddContactBottomSheet extends StatelessWidget { context: Navigator.of(Get.context!).overlay!.context, position: const RelativeRect.fromLTRB(100, 300, 100, 0), items: options - .map((e) => PopupMenuItem(value: e, child: Text(e))) + .map((e) => PopupMenuItem(value: e, child: Text(e))) .toList(), ); if (selected != null) selectedValue.value = selected; @@ -79,14 +84,117 @@ class AddContactBottomSheet extends StatelessWidget { readOnly: true, initialValue: selectedValue.value, style: const TextStyle(fontSize: 14), - decoration: _inputDecoration(hint) - .copyWith(suffixIcon: const Icon(Icons.expand_more)), + decoration: _inputDecoration(hint).copyWith( + suffixIcon: const Icon(Icons.expand_more), + ), ), ), ), )); } + Widget _buildLabeledRow( + String label, + RxString selectedLabel, + List options, + String inputLabel, + TextEditingController controller, + TextInputType inputType, { + VoidCallback? onRemove, + }) { + return Row( + 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), + SizedBox( + height: 48, + child: TextFormField( + controller: controller, + keyboardType: inputType, + decoration: _inputDecoration("Enter $inputLabel"), + validator: (value) => value == null || value.trim().isEmpty + ? "$inputLabel is required" + : null, + ), + ), + ], + ), + ), + if (onRemove != null) + IconButton( + icon: const Icon(Icons.remove_circle_outline, color: Colors.red), + onPressed: onRemove, + ), + ], + ); + } + + Widget _buildEmailList() { + return 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() { + return 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 _dropdownField({ required String label, required RxString selectedValue, @@ -166,9 +274,7 @@ class AddContactBottomSheet extends StatelessWidget { color: Colors.white, border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(8), - boxShadow: const [ - BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2)), - ], + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4)], ), child: ListView.builder( shrinkWrap: true, @@ -199,17 +305,12 @@ class AddContactBottomSheet extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return SingleChildScrollView( padding: MediaQuery.of(context).viewInsets, child: Container( decoration: BoxDecoration( - color: theme.cardColor, + 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), @@ -219,17 +320,8 @@ class AddContactBottomSheet extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, 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("Create New Contact", fontWeight: 700)), + child: MyText.titleMedium("Create New Contact", + fontWeight: 700)), MySpacing.height(24), _sectionLabel("Basic Info"), MySpacing.height(16), @@ -239,11 +331,24 @@ class AddContactBottomSheet extends StatelessWidget { MySpacing.height(24), _sectionLabel("Contact Info"), MySpacing.height(16), - _buildLabeledRow("Email Label", emailLabel, ["Office", "Personal", "Other"], - "Email", emailController, TextInputType.emailAddress), - MySpacing.height(16), - _buildLabeledRow("Phone Label", phoneLabel, ["Work", "Mobile", "Other"], - "Phone", phoneController, TextInputType.phone), + 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), @@ -263,10 +368,6 @@ class AddContactBottomSheet extends StatelessWidget { options: controller.globalProjects, ), MySpacing.height(16), - MyText.labelMedium("Tags"), - MySpacing.height(8), - _tagInputSection(), - MySpacing.height(16), MyText.labelMedium("Select Bucket"), MySpacing.height(8), _dropdownField( @@ -275,9 +376,14 @@ class AddContactBottomSheet extends StatelessWidget { 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), + _buildTextField("Description", descriptionController, + maxLines: 2), MySpacing.height(24), _buildActionButtons(), ], @@ -299,8 +405,9 @@ class AddContactBottomSheet extends StatelessWidget { controller: controller, maxLines: maxLines, decoration: _inputDecoration("Enter $label"), - validator: (value) => - (value == null || value.trim().isEmpty) ? "$label is required" : null, + validator: (value) => value == null || value.trim().isEmpty + ? "$label is required" + : null, ), ], ); @@ -338,52 +445,6 @@ class AddContactBottomSheet extends StatelessWidget { ); } - Widget _buildLabeledRow( - String label, - RxString selectedLabel, - List options, - String inputLabel, - TextEditingController controller, - TextInputType inputType, - ) { - return Row( - 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), - SizedBox( - height: 48, - child: TextFormField( - controller: controller, - keyboardType: inputType, - decoration: _inputDecoration("Enter $inputLabel"), - validator: (value) => - (value == null || value.trim().isEmpty) - ? "$inputLabel is required" - : null, - ), - ), - ], - ), - ), - ], - ); - } - Widget _buildActionButtons() { return Row( children: [ @@ -391,13 +452,15 @@ class AddContactBottomSheet extends StatelessWidget { child: OutlinedButton.icon( onPressed: () { Get.back(); - Get.delete(); // cleanup + Get.delete(); }, icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), + label: + MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), style: OutlinedButton.styleFrom( side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), ), ), @@ -407,23 +470,43 @@ class AddContactBottomSheet extends StatelessWidget { 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( name: nameController.text.trim(), organization: orgController.text.trim(), - email: emailController.text.trim(), - emailLabel: emailLabel.value, - phone: phoneController.text.trim(), - phoneLabel: phoneLabel.value, + 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), + label: + MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600), style: ElevatedButton.styleFrom( backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), ), ), diff --git a/lib/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart index a84ad40..a507237 100644 --- a/lib/view/directory/contact_detail_screen.dart +++ b/lib/view/directory/contact_detail_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:intl/intl.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'; @@ -14,6 +13,7 @@ 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'; class ContactDetailScreen extends StatefulWidget { final ContactModel contact; @@ -233,8 +233,6 @@ class _ContactDetailScreenState extends State { ? widget.contact.contactPhones.first.phoneNumber : "-"; - final createdDate = DateTime.now(); - final formattedDate = DateFormat('MMMM dd, yyyy').format(createdDate); final tags = widget.contact.tags.map((e) => e.name).join(", "); final bucketNames = widget.contact.bucketIds @@ -268,7 +266,6 @@ class _ContactDetailScreenState extends State { onTap: () => LauncherUtils.launchPhone(phone), onLongPress: () => LauncherUtils.copyToClipboard(phone, typeLabel: "Phone")), - _iconInfoRow(Icons.calendar_today, "Created", formattedDate), _iconInfoRow(Icons.location_on, "Address", widget.contact.address), ]), _infoCard("Organization", [ @@ -381,8 +378,11 @@ class _ContactDetailScreenState extends State { color: Colors.indigo[700]), MySpacing.height(2), MyText.bodySmall( - DateFormat('dd MMM yyyy, hh:mm a') - .format(comment.createdAt), + DateTimeUtils.convertUtcToLocal( + comment.createdAt + .toString(), // pass as String + format: 'dd MMM yyyy, hh:mm a', + ), fontWeight: 500, color: Colors.grey[600], ), diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart index cf99e07..fb3956b 100644 --- a/lib/view/directory/directory_main_screen.dart +++ b/lib/view/directory/directory_main_screen.dart @@ -29,7 +29,7 @@ class DirectoryMainScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Colors.white, + backgroundColor: Colors.white, appBar: PreferredSize( preferredSize: const Size.fromHeight(72), child: AppBar( @@ -299,9 +299,6 @@ class DirectoryMainScreen extends StatelessWidget { final phone = contact.contactPhones.isNotEmpty ? contact.contactPhones.first.phoneNumber : '-'; - final email = contact.contactEmails.isNotEmpty - ? contact.contactEmails.first.emailAddress - : '-'; final nameParts = contact.name.trim().split(" "); final firstName = nameParts.first; final lastName = nameParts.length > 1 ? nameParts.last : ""; @@ -342,72 +339,84 @@ class DirectoryMainScreen extends StatelessWidget { MySpacing.height(6), // Launcher Row - Wrap( - spacing: 12, - runSpacing: 6, + Column( + crossAxisAlignment: + CrossAxisAlignment.start, children: [ - if (email != '-') - GestureDetector( - onTap: () => - LauncherUtils.launchEmail(email), - onLongPress: () => - LauncherUtils.copyToClipboard( - email, - typeLabel: 'Email'), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.email_outlined, - size: 16, - color: Colors.indigo), - MySpacing.width(4), - ConstrainedBox( - constraints: - const BoxConstraints( - maxWidth: 120), - child: MyText.labelSmall( - email, - overflow: - TextOverflow.ellipsis, - color: Colors.indigo, - decoration: - TextDecoration.underline, - ), + ...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, + ), + ), + ], ), - ], - ), - ), - if (phone != '-') - GestureDetector( - onTap: () => - LauncherUtils.launchPhone(phone), - onLongPress: () => - LauncherUtils.copyToClipboard( - phone, - 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: 100), - child: MyText.labelSmall( - phone, - overflow: - TextOverflow.ellipsis, - color: Colors.indigo, - decoration: - TextDecoration.underline, - ), + ), + )), + ...contact.contactPhones.map((p) => + GestureDetector( + onTap: () => + LauncherUtils.launchPhone( + p.phoneNumber), + onLongPress: () => + LauncherUtils.copyToClipboard( + p.phoneNumber, + typeLabel: 'Phone number'), + child: Padding( + padding: const EdgeInsets.only( + bottom: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.phone_outlined, + size: 16, + color: Colors.indigo), + MySpacing.width(4), + ConstrainedBox( + constraints: + const BoxConstraints( + maxWidth: 160), + child: MyText.labelSmall( + p.phoneNumber, + overflow: + TextOverflow.ellipsis, + color: Colors.indigo, + decoration: TextDecoration + .underline, + ), + ), + ], ), - ], - ), - ), + ), + )), ], ), @@ -423,7 +432,6 @@ class DirectoryMainScreen extends StatelessWidget { ], ), ), - // WhatsApp launcher icon Column( mainAxisAlignment: MainAxisAlignment.center, -- 2.43.0 From 606c5e5971489c9dea47e880d9fbf6f5361b7195 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 5 Jul 2025 17:29:35 +0530 Subject: [PATCH 11/39] feat(directory): refactor contact card layout for improved readability and interaction --- lib/view/directory/directory_main_screen.dart | 193 ++++++++++-------- 1 file changed, 105 insertions(+), 88 deletions(-) diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart index fb3956b..547f627 100644 --- a/lib/view/directory/directory_main_screen.dart +++ b/lib/view/directory/directory_main_screen.dart @@ -303,6 +303,7 @@ class DirectoryMainScreen extends StatelessWidget { 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)); @@ -310,63 +311,56 @@ class DirectoryMainScreen extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12.0, vertical: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Leading Icon - Avatar( - firstName: firstName, - lastName: lastName, - size: 45, - ), - MySpacing.width(12), + // Top Row: Avatar, Info, Icons + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar + Avatar( + firstName: firstName, + lastName: lastName, + size: 35), + MySpacing.width(12), - // Middle Content - 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(6), - - // Launcher Row - Column( + // Middle: Contact Info (wrap with Flexible instead of Expanded) + Flexible( + fit: FlexFit.tight, + 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(6), + + // Emails ...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), + Padding( + padding: const EdgeInsets.only( + bottom: 4), + child: GestureDetector( + onTap: () => + LauncherUtils.launchEmail( + e.emailAddress), + onLongPress: () => + LauncherUtils.copyToClipboard( + e.emailAddress, + typeLabel: 'Email'), 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), + Flexible( child: MyText.labelSmall( e.emailAddress, overflow: @@ -380,30 +374,29 @@ class DirectoryMainScreen extends StatelessWidget { ), ), )), + + // Phones ...contact.contactPhones.map((p) => - GestureDetector( - onTap: () => - LauncherUtils.launchPhone( - p.phoneNumber), - onLongPress: () => - LauncherUtils.copyToClipboard( - p.phoneNumber, - typeLabel: 'Phone number'), - child: Padding( - padding: const EdgeInsets.only( - bottom: 4), + Padding( + padding: const EdgeInsets.only( + bottom: 4), + child: 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: 160), + Flexible( child: MyText.labelSmall( p.phoneNumber, overflow: @@ -419,38 +412,62 @@ class DirectoryMainScreen extends StatelessWidget { )), ], ), + ), - if (tags.isNotEmpty) ...[ - MySpacing.height(4), - MyText.labelSmall( - tags.join(', '), - color: Colors.grey[500], - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + // Right: Arrow + WhatsApp + MySpacing.width( + 8), // spacing between content and right column + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Icon(Icons.arrow_forward_ios, + color: Colors.grey, size: 16), + MySpacing.height(12), + if (phone != '-') + GestureDetector( + onTap: () => + LauncherUtils.launchWhatsApp(phone), + child: const FaIcon( + FontAwesomeIcons.whatsapp, + color: Colors.green, + size: 20), + ), ], - ], - ), - ), - // WhatsApp launcher icon - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.arrow_forward_ios, - color: Colors.grey, size: 16), - MySpacing.height(12), - if (phone != '-') - GestureDetector( - onTap: () => - LauncherUtils.launchWhatsApp(phone), - child: const FaIcon( - FontAwesomeIcons.whatsapp, - color: Colors.green, - size: 20, - ), - ), + ), ], ), + + // Bottom Row: Tags as chips aligned to right + if (tags.isNotEmpty) ...[ + MySpacing.height(5), + Align( + alignment: Alignment.centerRight, + child: Wrap( + spacing: 8, + runSpacing: 6, + alignment: WrapAlignment.end, + children: tags + .map( + (tag) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.indigo.shade50, + borderRadius: + BorderRadius.circular(8), + ), + child: MyText.labelSmall( + tag, + fontWeight: 600, + color: Colors.indigo, + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), + ), + ), + ], ], ), ), -- 2.43.0 From 087c77bbd2cda068f74d902add4440a56c8bd6ec Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 5 Jul 2025 17:38:26 +0530 Subject: [PATCH 12/39] feat(directory): enhance input validation and layout in AddContactBottomSheet --- .../directory/add_contact_bottom_sheet.dart | 64 ++++++++++++++----- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart index 4c0337c..3cbae01 100644 --- a/lib/model/directory/add_contact_bottom_sheet.dart +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -4,6 +4,7 @@ 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:flutter/services.dart'; class AddContactBottomSheet extends StatelessWidget { AddContactBottomSheet({super.key}) { @@ -103,6 +104,7 @@ class AddContactBottomSheet extends StatelessWidget { VoidCallback? onRemove, }) { return Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( @@ -111,9 +113,10 @@ class AddContactBottomSheet extends StatelessWidget { MyText.labelMedium(label), MySpacing.height(8), _popupSelector( - hint: "Label", - selectedValue: selectedLabel, - options: options), + hint: "Label", + selectedValue: selectedLabel, + options: options, + ), ], ), ), @@ -124,24 +127,51 @@ class AddContactBottomSheet extends StatelessWidget { children: [ MyText.labelMedium(inputLabel), MySpacing.height(8), - SizedBox( - height: 48, - child: TextFormField( - controller: controller, - keyboardType: inputType, - decoration: _inputDecoration("Enter $inputLabel"), - validator: (value) => value == null || value.trim().isEmpty - ? "$inputLabel is required" - : null, - ), - ), + TextFormField( + controller: controller, + keyboardType: inputType, + maxLength: inputType == TextInputType.phone ? 10 : null, + inputFormatters: inputType == TextInputType.phone + ? [FilteringTextInputFormatter.digitsOnly] + : [], + decoration: _inputDecoration("Enter $inputLabel").copyWith( + counterText: "", // hides length indicator + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return "$inputLabel is required"; + } + + final trimmed = value.trim(); + + if (inputType == TextInputType.phone) { + if (!RegExp(r'^\d{10}$').hasMatch(trimmed)) { + return "Enter a valid 10-digit phone number"; + } + if (RegExp(r'^0+$').hasMatch(trimmed)) { + return "Phone number cannot be all zeroes"; + } + } + + if (inputType == TextInputType.emailAddress && + !RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(trimmed)) { + return "Enter a valid email address"; + } + + return null; + }, +), + ], ), ), if (onRemove != null) - IconButton( - icon: const Icon(Icons.remove_circle_outline, color: Colors.red), - onPressed: onRemove, + Padding( + padding: const EdgeInsets.only(top: 24), + child: IconButton( + icon: const Icon(Icons.remove_circle_outline, color: Colors.red), + onPressed: onRemove, + ), ), ], ); -- 2.43.0 From 56b493c909b92c8177da6a6e76324d19bb6d93bb Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 7 Jul 2025 10:13:15 +0530 Subject: [PATCH 13/39] feat(directory): refactor contact card layout for improved structure and readability --- lib/view/directory/directory_main_screen.dart | 192 ++++++++---------- 1 file changed, 88 insertions(+), 104 deletions(-) diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart index 547f627..453c1f4 100644 --- a/lib/view/directory/directory_main_screen.dart +++ b/lib/view/directory/directory_main_screen.dart @@ -311,56 +311,63 @@ class DirectoryMainScreen extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12.0, vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Top Row: Avatar, Info, Icons - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Avatar - Avatar( - firstName: firstName, - lastName: lastName, - size: 35), - MySpacing.width(12), + // Leading Icon + Avatar( + firstName: firstName, + lastName: lastName, + size: 45, + ), + MySpacing.width(12), - // Middle: Contact Info (wrap with Flexible instead of Expanded) - Flexible( - fit: FlexFit.tight, - child: Column( + // Middle Content + 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(6), + + // Launcher Row + 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(6), - - // Emails ...contact.contactEmails.map((e) => - Padding( - padding: const EdgeInsets.only( - bottom: 4), - child: GestureDetector( - onTap: () => - LauncherUtils.launchEmail( - e.emailAddress), - onLongPress: () => - LauncherUtils.copyToClipboard( - e.emailAddress, - typeLabel: 'Email'), + 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), - Flexible( + ConstrainedBox( + constraints: + const BoxConstraints( + maxWidth: 180), child: MyText.labelSmall( e.emailAddress, overflow: @@ -374,29 +381,30 @@ class DirectoryMainScreen extends StatelessWidget { ), ), )), - - // Phones ...contact.contactPhones.map((p) => - Padding( - padding: const EdgeInsets.only( - bottom: 4), - child: GestureDetector( - onTap: () => - LauncherUtils.launchPhone( - p.phoneNumber), - onLongPress: () => - LauncherUtils.copyToClipboard( - p.phoneNumber, - typeLabel: - 'Phone number'), + GestureDetector( + onTap: () => + LauncherUtils.launchPhone( + p.phoneNumber), + onLongPress: () => + LauncherUtils.copyToClipboard( + p.phoneNumber, + typeLabel: 'Phone number'), + child: Padding( + padding: const EdgeInsets.only( + bottom: 4), child: Row( + mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.phone_outlined, size: 16, color: Colors.indigo), MySpacing.width(4), - Flexible( + ConstrainedBox( + constraints: + const BoxConstraints( + maxWidth: 160), child: MyText.labelSmall( p.phoneNumber, overflow: @@ -412,62 +420,38 @@ class DirectoryMainScreen extends StatelessWidget { )), ], ), - ), - // Right: Arrow + WhatsApp - MySpacing.width( - 8), // spacing between content and right column - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - const Icon(Icons.arrow_forward_ios, - color: Colors.grey, size: 16), - MySpacing.height(12), - if (phone != '-') - GestureDetector( - onTap: () => - LauncherUtils.launchWhatsApp(phone), - child: const FaIcon( - FontAwesomeIcons.whatsapp, - color: Colors.green, - size: 20), - ), + if (tags.isNotEmpty) ...[ + MySpacing.height(4), + MyText.labelSmall( + tags.join(', '), + color: Colors.grey[500], + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ], - ), + ], + ), + ), + // WhatsApp launcher icon + Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Icon(Icons.arrow_forward_ios, + color: Colors.grey, size: 16), + MySpacing.height(12), + if (phone != '-') + GestureDetector( + onTap: () => + LauncherUtils.launchWhatsApp(phone), + child: const FaIcon( + FontAwesomeIcons.whatsapp, + color: Colors.green, + size: 20, + ), + ), ], ), - - // Bottom Row: Tags as chips aligned to right - if (tags.isNotEmpty) ...[ - MySpacing.height(5), - Align( - alignment: Alignment.centerRight, - child: Wrap( - spacing: 8, - runSpacing: 6, - alignment: WrapAlignment.end, - children: tags - .map( - (tag) => Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.indigo.shade50, - borderRadius: - BorderRadius.circular(8), - ), - child: MyText.labelSmall( - tag, - fontWeight: 600, - color: Colors.indigo, - overflow: TextOverflow.ellipsis, - ), - ), - ) - .toList(), - ), - ), - ], ], ), ), -- 2.43.0 From 7a2798401ab38c12c7357cfa4d9da99524510a77 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 7 Jul 2025 12:55:52 +0530 Subject: [PATCH 14/39] 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. --- .../directory/directory_controller.dart | 1 + .../directory/notes_controller.dart | 103 +++++ lib/helpers/services/api_service.dart | 21 + .../directory/note_list_response_model.dart | 142 ++++++ lib/view/directory/contact_detail_screen.dart | 102 +++-- lib/view/directory/directory_main_screen.dart | 417 ++---------------- lib/view/directory/directory_view.dart | 344 +++++++++++++++ lib/view/directory/notes_view.dart | 225 ++++++++++ 8 files changed, 928 insertions(+), 427 deletions(-) create mode 100644 lib/controller/directory/notes_controller.dart create mode 100644 lib/model/directory/note_list_response_model.dart create mode 100644 lib/view/directory/directory_view.dart create mode 100644 lib/view/directory/notes_view.dart diff --git a/lib/controller/directory/directory_controller.dart b/lib/controller/directory/directory_controller.dart index 72adfbd..f078227 100644 --- a/lib/controller/directory/directory_controller.dart +++ b/lib/controller/directory/directory_controller.dart @@ -18,6 +18,7 @@ class DirectoryController extends GetxController { RxBool showFabMenu = false.obs; final RxBool showFullEditorToolbar = false.obs; final RxBool isEditorFocused = false.obs; + RxBool isNotesView = false.obs; final Map> contactCommentsMap = {}; RxList getCommentsForContact(String contactId) { diff --git a/lib/controller/directory/notes_controller.dart b/lib/controller/directory/notes_controller.dart new file mode 100644 index 0000000..462d81a --- /dev/null +++ b/lib/controller/directory/notes_controller.dart @@ -0,0 +1,103 @@ +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 notesList = [].obs; + RxBool isLoading = false.obs; + RxnString editingNoteId = RxnString(); + + @override + void onInit() { + super.onInit(); + fetchNotes(); + } + + Future 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 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}"); + 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(); + } + } 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"); + } +} diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 3361e9e..e9fcc82 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -243,6 +243,27 @@ class ApiService { } /// Directory calling the API + static Future?> 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 ? data : null; + } + static Future addContactComment(String note, String contactId) async { final payload = { "note": note, diff --git a/lib/model/directory/note_list_response_model.dart b/lib/model/directory/note_list_response_model.dart new file mode 100644 index 0000000..e92283c --- /dev/null +++ b/lib/model/directory/note_list_response_model.dart @@ -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 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 data; + + NotePaginationData({ + required this.currentPage, + required this.pageSize, + required this.totalPages, + required this.totalRecords, + required this.data, + }); + + factory NotePaginationData.fromJson(Map json) { + return NotePaginationData( + currentPage: json['currentPage'] ?? 0, + pageSize: json['pageSize'] ?? 0, + totalPages: json['totalPages'] ?? 0, + totalRecords: json['totalRecords'] ?? 0, + data: List.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 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 json) { + return UserModel( + id: json['id'] ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'] ?? '', + photo: json['photo'], + jobRoleId: json['jobRoleId'] ?? '', + jobRoleName: json['jobRoleName'] ?? '', + ); + } +} diff --git a/lib/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart index a507237..a8e9b4d 100644 --- a/lib/view/directory/contact_detail_screen.dart +++ b/lib/view/directory/contact_detail_screen.dart @@ -319,13 +319,11 @@ class _ContactDetailScreenState extends State { MyText.bodyLarge("No comments yet.", color: Colors.grey), ) : Padding( - padding: MySpacing.xy(8, 8), // Same padding as Details tab + padding: MySpacing.xy(12, 12), child: ListView.separated( - padding: EdgeInsets.only( - bottom: 80, // Extra bottom padding to avoid FAB overlap - ), + padding: const EdgeInsets.only(bottom: 100), itemCount: comments.length, - separatorBuilder: (_, __) => MySpacing.height(12), + separatorBuilder: (_, __) => MySpacing.height(14), itemBuilder: (_, index) { final comment = comments[index]; final isEditing = editingId == comment.id; @@ -344,11 +342,18 @@ class _ContactDetailScreenState extends State { ) : null; - return Container( - padding: MySpacing.xy(14, 12), + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: MySpacing.xy(8, 7), decoration: BoxDecoration( - color: Colors.white, + 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, @@ -360,30 +365,31 @@ class _ContactDetailScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Header Row Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Avatar( firstName: initials, lastName: '', - size: 31), - MySpacing.width(8), + size: 36), + MySpacing.width(12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.bodySmall( - "By: ${comment.createdBy.firstName}", - fontWeight: 600, - color: Colors.indigo[700]), - MySpacing.height(2), + MyText.bodyMedium( + "By: ${comment.createdBy.firstName}", + fontWeight: 600, + color: Colors.indigo[800], + ), + MySpacing.height(4), MyText.bodySmall( DateTimeUtils.convertUtcToLocal( - comment.createdAt - .toString(), // pass as String + comment.createdAt.toString(), format: 'dd MMM yyyy, hh:mm a', ), - fontWeight: 500, color: Colors.grey[600], ), ], @@ -391,9 +397,10 @@ class _ContactDetailScreenState extends State { ), IconButton( icon: Icon( - isEditing ? Icons.close : Icons.edit, - size: 20, - color: Colors.grey[700]), + isEditing ? Icons.close : Icons.edit, + size: 20, + color: Colors.indigo, + ), onPressed: () { directoryController.editingCommentId.value = isEditing ? null : comment.id; @@ -401,32 +408,33 @@ class _ContactDetailScreenState extends State { ), ], ), - MySpacing.height(10), + MySpacing.height(12), + // 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; - }) + 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.all(0), - padding: html.HtmlPaddings.all(0), + margin: html.Margins.zero, + padding: html.HtmlPaddings.zero, fontSize: html.FontSize.medium, color: Colors.black87, ), @@ -439,13 +447,13 @@ class _ContactDetailScreenState extends State { ), ), - // Floating Action Button to Add Comment + // Floating Action Button if (directoryController.editingCommentId.value == null) Positioned( - bottom: 16, - right: 16, + bottom: 20, + right: 20, child: FloatingActionButton.extended( - backgroundColor: Colors.red, + backgroundColor: Colors.indigo, onPressed: () { Get.bottomSheet( AddCommentBottomSheet(contactId: contactId), @@ -453,8 +461,10 @@ class _ContactDetailScreenState extends State { ); }, icon: const Icon(Icons.add_comment, color: Colors.white), - label: const Text("Add Comment", - style: TextStyle(color: Colors.white)), + label: const Text( + "Add Comment", + style: TextStyle(color: Colors.white), + ), ), ), ], diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart index 453c1f4..527c6cc 100644 --- a/lib/view/directory/directory_main_screen.dart +++ b/lib/view/directory/directory_main_screen.dart @@ -1,30 +1,18 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; + import 'package:marco/controller/directory/directory_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/helpers/widgets/avatar.dart'; -import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; -import 'package:marco/model/directory/directory_filter_bottom_sheet.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:marco/helpers/utils/launcher_utils.dart'; -import 'package:marco/view/directory/contact_detail_screen.dart'; -import 'package:marco/model/directory/add_contact_bottom_sheet.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 TextEditingController searchController = TextEditingController(); - Future _refreshDirectory() async { - try { - await controller.fetchContacts(); - } catch (e, stackTrace) { - debugPrint('Error refreshing directory data: ${e.toString()}'); - debugPrintStack(stackTrace: stackTrace); - } - } @override Widget build(BuildContext context) { @@ -61,9 +49,9 @@ class DirectoryMainScreen extends StatelessWidget { MySpacing.height(2), GetBuilder( builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; + final projectName = projectController + .selectedProject?.name ?? + 'Select Project'; return Row( children: [ const Icon(Icons.work_outline, @@ -89,377 +77,44 @@ class DirectoryMainScreen extends StatelessWidget { ), ), ), - 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: SafeArea( child: Column( children: [ - // Search + Filter + Toggle + // Toggle between Directory and Notes Padding( - padding: MySpacing.xy(8, 10), - child: Row( - children: [ - // Compact Search Field - Expanded( - child: SizedBox( - height: 42, - 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: MouseRegion( - cursor: SystemMouseCursors.click, - child: const Padding( - padding: EdgeInsets.all(0), - child: Icon( - Icons.refresh, - color: Colors.green, - size: 28, - ), - ), - ), - ), - ), - MySpacing.width(8), - // Filter Icon with optional red dot - Obx(() { - final isFilterActive = controller.hasActiveFilters(); - return Stack( - children: [ - Container( - height: 38, - width: 38, - 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), - - // 3-dot Popup Menu with Toggle - Container( - height: 38, - width: 38, - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(10), - ), - child: PopupMenuButton( - padding: EdgeInsets.zero, - icon: const Icon(Icons.more_vert, - size: 20, color: Colors.black87), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - itemBuilder: (context) => [ - PopupMenuItem( - 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); - }, - ), - ], - )), - ), - ], - ), - ), - ], - ), - ), - - // Contacts List - Expanded( + padding: MySpacing.xy(16, 10), 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 phone = contact.contactPhones.isNotEmpty - ? contact.contactPhones.first.phoneNumber - : '-'; - 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.symmetric( - horizontal: 12.0, vertical: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Leading Icon - Avatar( - firstName: firstName, - lastName: lastName, - size: 45, - ), - MySpacing.width(12), - - // Middle Content - 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(6), - - // Launcher Row - 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) => - GestureDetector( - onTap: () => - LauncherUtils.launchPhone( - p.phoneNumber), - onLongPress: () => - LauncherUtils.copyToClipboard( - p.phoneNumber, - typeLabel: 'Phone number'), - child: Padding( - padding: const EdgeInsets.only( - bottom: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.phone_outlined, - size: 16, - color: Colors.indigo), - MySpacing.width(4), - ConstrainedBox( - constraints: - const BoxConstraints( - maxWidth: 160), - child: MyText.labelSmall( - p.phoneNumber, - overflow: - TextOverflow.ellipsis, - color: Colors.indigo, - decoration: TextDecoration - .underline, - ), - ), - ], - ), - ), - )), - ], - ), - - if (tags.isNotEmpty) ...[ - MySpacing.height(4), - MyText.labelSmall( - tags.join(', '), - color: Colors.grey[500], - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - // WhatsApp launcher icon - Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const Icon(Icons.arrow_forward_ios, - color: Colors.grey, size: 16), - MySpacing.height(12), - if (phone != '-') - GestureDetector( - onTap: () => - LauncherUtils.launchWhatsApp(phone), - child: const FaIcon( - FontAwesomeIcons.whatsapp, - color: Colors.green, - size: 20, - ), - ), - ], - ), - ], - ), + final isNotesView = controller.isNotesView.value; + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + tooltip: 'Directory View', + onPressed: () => controller.isNotesView.value = false, + icon: Icon( + Icons.contacts, + color: !isNotesView ? Colors.indigo : Colors.grey, ), - ); - }, + ), + IconButton( + tooltip: 'Notes View', + onPressed: () => controller.isNotesView.value = true, + icon: Icon( + Icons.notes, + color: isNotesView ? Colors.indigo : Colors.grey, + ), + ), + ], ); }), ), + + // Main View + Expanded( + child: Obx(() => controller.isNotesView.value + ? NotesView() + : DirectoryView()), + ), ], ), ), diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart new file mode 100644 index 0000000..14d6931 --- /dev/null +++ b/lib/view/directory/directory_view.dart @@ -0,0 +1,344 @@ +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 _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 Column( + children: [ + // Search + Filter + Toggle + Padding( + padding: MySpacing.xy(8, 10), + child: Row( + children: [ + // Search Field + Expanded( + child: SizedBox( + height: 42, + 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: MouseRegion( + cursor: SystemMouseCursors.click, + child: const Padding( + padding: EdgeInsets.all(0), + child: Icon(Icons.refresh, color: Colors.green, size: 28), + ), + ), + ), + ), + MySpacing.width(8), + // Filter Icon with red dot + Obx(() { + final isFilterActive = controller.hasActiveFilters(); + return Stack( + children: [ + Container( + height: 38, + width: 38, + 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), + // 3-dot Popup with toggle + Container( + height: 38, + width: 38, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(10), + ), + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.more_vert, size: 20, color: Colors.black87), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + itemBuilder: (context) => [ + PopupMenuItem( + 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); + }, + ), + ], + )), + ), + ], + ), + ), + ], + ), + ), + + // Contact List + 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 phone = contact.contactPhones.isNotEmpty + ? contact.contactPhones.first.phoneNumber + : '-'; + 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.symmetric(horizontal: 12.0, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Avatar(firstName: firstName, lastName: lastName, size: 45), + 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(6), + 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) => GestureDetector( + onTap: () => + LauncherUtils.launchPhone(p.phoneNumber), + onLongPress: () => + LauncherUtils.copyToClipboard(p.phoneNumber, + typeLabel: 'Phone number'), + child: Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.phone_outlined, + size: 16, color: Colors.indigo), + MySpacing.width(4), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 160), + child: MyText.labelSmall( + p.phoneNumber, + overflow: TextOverflow.ellipsis, + color: Colors.indigo, + decoration: TextDecoration.underline, + ), + ), + ], + ), + ), + )), + ], + ), + if (tags.isNotEmpty) ...[ + MySpacing.height(4), + 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(12), + if (phone != '-') + GestureDetector( + onTap: () => LauncherUtils.launchWhatsApp(phone), + child: const FaIcon(FontAwesomeIcons.whatsapp, + color: Colors.green, size: 20), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }), + ), + + // Floating action button (moved here so it doesn't appear in NotesView) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Align( + alignment: Alignment.bottomRight, + child: 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), + ), + ), + ), + ], + ); + } +} diff --git a/lib/view/directory/notes_view.dart b/lib/view/directory/notes_view.dart new file mode 100644 index 0000000..05f59aa --- /dev/null +++ b/lib/view/directory/notes_view.dart @@ -0,0 +1,225 @@ +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; + + NotesView({super.key}) : controller = _initController(); + + static NotesController _initController() { + if (!Get.isRegistered()) { + return Get.put(NotesController()); + } + return Get.find(); + } + + 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('
        '); + inList = true; + } + if (!isListItem && inList) { + buffer.write('
      '); + inList = false; + } + + if (isListItem) buffer.write('
    • '); + + if (attr.containsKey('bold')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('link')) buffer.write(''); + + buffer.write(data.replaceAll('\n', '')); + + if (attr.containsKey('link')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('bold')) buffer.write(''); + + if (isListItem) + buffer.write('
    • '); + else if (data.contains('\n')) buffer.write('
      '); + } + + if (inList) buffer.write('
    '); + + return buffer.toString(); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.notesList.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.note_alt_outlined, size: 60, color: Colors.grey), + MySpacing.height(12), + MyText.bodyMedium('No notes available.', fontWeight: 500), + ], + ), + ); + } + + return Padding( + padding: MySpacing.xy(16, 16), + child: ListView.separated( + itemCount: controller.notesList.length, + separatorBuilder: (_, __) => MySpacing.height(16), + itemBuilder: (_, index) { + final note = controller.notesList[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: 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( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar(firstName: initials, lastName: '', size: 36), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium( + "${note.contactName} (${note.organizationName})", + fontWeight: 600, + color: Colors.indigo[800], + ), + MySpacing.height(4), + MyText.bodySmall( + "by ${note.createdBy.firstName} β€’ $createdDate, $createdTime", + color: Colors.grey[600], + ), + ], + ), + ), + IconButton( + icon: Icon( + isEditing ? Icons.close : Icons.edit, + size: 20, + color: Colors.indigo, + ), + onPressed: () { + controller.editingNoteId.value = + isEditing ? null : note.id; + }, + ), + ], + ), + MySpacing.height(12), + // Note 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, + ), + }, + ), + ], + ), + ); + }); + }, + ), + ); + }); + } +} -- 2.43.0 From 45ce53539c28b97be50d5b0e8abeb0601960ecc2 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 7 Jul 2025 13:41:46 +0530 Subject: [PATCH 15/39] feat(directory): enhance search functionality in Directory and Notes views --- .../directory/directory_controller.dart | 29 +- .../directory/notes_controller.dart | 13 + lib/view/directory/directory_main_screen.dart | 6 +- lib/view/directory/directory_view.dart | 98 ++++-- lib/view/directory/notes_view.dart | 325 ++++++++++-------- 5 files changed, 286 insertions(+), 185 deletions(-) diff --git a/lib/controller/directory/directory_controller.dart b/lib/controller/directory/directory_controller.dart index f078227..86e9be9 100644 --- a/lib/controller/directory/directory_controller.dart +++ b/lib/controller/directory/directory_controller.dart @@ -5,6 +5,7 @@ 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 allContacts = [].obs; RxList filteredContacts = [].obs; @@ -169,15 +170,39 @@ class DirectoryController extends GetxController { 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 searchMatch = - query.isEmpty || nameMatch || orgMatch || emailMatch || tagMatch; + 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(); diff --git a/lib/controller/directory/notes_controller.dart b/lib/controller/directory/notes_controller.dart index 462d81a..dca0201 100644 --- a/lib/controller/directory/notes_controller.dart +++ b/lib/controller/directory/notes_controller.dart @@ -8,6 +8,19 @@ class NotesController extends GetxController { RxList notesList = [].obs; RxBool isLoading = false.obs; RxnString editingNoteId = RxnString(); + RxString searchQuery = ''.obs; + + List 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() { diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart index 527c6cc..04c0a0e 100644 --- a/lib/view/directory/directory_main_screen.dart +++ b/lib/view/directory/directory_main_screen.dart @@ -82,7 +82,7 @@ class DirectoryMainScreen extends StatelessWidget { children: [ // Toggle between Directory and Notes Padding( - padding: MySpacing.xy(16, 10), + padding: MySpacing.xy(8, 5), child: Obx(() { final isNotesView = controller.isNotesView.value; return Row( @@ -93,7 +93,7 @@ class DirectoryMainScreen extends StatelessWidget { onPressed: () => controller.isNotesView.value = false, icon: Icon( Icons.contacts, - color: !isNotesView ? Colors.indigo : Colors.grey, + color: !isNotesView ? Colors.red : Colors.grey, ), ), IconButton( @@ -101,7 +101,7 @@ class DirectoryMainScreen extends StatelessWidget { onPressed: () => controller.isNotesView.value = true, icon: Icon( Icons.notes, - color: isNotesView ? Colors.indigo : Colors.grey, + color: isNotesView ? Colors.red : Colors.grey, ), ), ], diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index 14d6931..d52a465 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -37,7 +37,7 @@ class DirectoryView extends StatelessWidget { // Search Field Expanded( child: SizedBox( - height: 42, + height: 35, child: TextField( controller: searchController, onChanged: (value) { @@ -45,8 +45,10 @@ class DirectoryView extends StatelessWidget { controller.applyFilters(); }, decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: const Icon(Icons.search, + size: 20, color: Colors.grey), hintText: 'Search contacts...', filled: true, fillColor: Colors.white, @@ -84,8 +86,8 @@ class DirectoryView extends StatelessWidget { return Stack( children: [ Container( - height: 38, - width: 38, + height: 35, + width: 35, decoration: BoxDecoration( color: Colors.white, border: Border.all(color: Colors.grey.shade300), @@ -94,13 +96,16 @@ class DirectoryView extends StatelessWidget { child: IconButton( icon: Icon(Icons.filter_alt_outlined, size: 20, - color: isFilterActive ? Colors.indigo : Colors.black87), + color: isFilterActive + ? Colors.indigo + : Colors.black87), onPressed: () { showModalBottomSheet( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + borderRadius: BorderRadius.vertical( + top: Radius.circular(20)), ), builder: (_) => const DirectoryFilterBottomSheet(), ); @@ -126,8 +131,8 @@ class DirectoryView extends StatelessWidget { MySpacing.width(10), // 3-dot Popup with toggle Container( - height: 38, - width: 38, + height: 35, + width: 35, decoration: BoxDecoration( color: Colors.white, border: Border.all(color: Colors.grey.shade300), @@ -135,7 +140,8 @@ class DirectoryView extends StatelessWidget { ), child: PopupMenuButton( padding: EdgeInsets.zero, - icon: const Icon(Icons.more_vert, size: 20, color: Colors.black87), + icon: const Icon(Icons.more_vert, + size: 20, color: Colors.black87), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), @@ -146,7 +152,8 @@ class DirectoryView extends StatelessWidget { child: Obx(() => Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - MyText.bodySmall('Show Inactive', fontWeight: 600), + MyText.bodySmall('Show Inactive', + fontWeight: 600), Switch.adaptive( value: !controller.isActive.value, activeColor: Colors.indigo, @@ -182,7 +189,8 @@ class DirectoryView extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.contact_page_outlined, size: 60, color: Colors.grey), + const Icon(Icons.contact_page_outlined, + size: 60, color: Colors.grey), const SizedBox(height: 12), MyText.bodyMedium('No contacts found.', fontWeight: 500), ], @@ -209,18 +217,21 @@ class DirectoryView extends StatelessWidget { Get.to(() => ContactDetailScreen(contact: contact)); }, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12.0, vertical: 10), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Avatar(firstName: firstName, lastName: lastName, size: 45), + Avatar( + firstName: firstName, lastName: lastName, size: 45), MySpacing.width(12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.titleSmall(contact.name, - fontWeight: 600, overflow: TextOverflow.ellipsis), + fontWeight: 600, + overflow: TextOverflow.ellipsis), MyText.bodySmall(contact.organization, color: Colors.grey[700], overflow: TextOverflow.ellipsis), @@ -228,54 +239,70 @@ class DirectoryView extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ...contact.contactEmails.map((e) => GestureDetector( - onTap: () => - LauncherUtils.launchEmail(e.emailAddress), + ...contact.contactEmails.map((e) => + GestureDetector( + onTap: () => LauncherUtils.launchEmail( + e.emailAddress), onLongPress: () => - LauncherUtils.copyToClipboard(e.emailAddress, + LauncherUtils.copyToClipboard( + e.emailAddress, typeLabel: 'Email'), child: Padding( - padding: const EdgeInsets.only(bottom: 4), + padding: + const EdgeInsets.only(bottom: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.email_outlined, - size: 16, color: Colors.indigo), + size: 16, + color: Colors.indigo), MySpacing.width(4), ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 180), + constraints: + const BoxConstraints( + maxWidth: 180), child: MyText.labelSmall( e.emailAddress, - overflow: TextOverflow.ellipsis, + overflow: + TextOverflow.ellipsis, color: Colors.indigo, - decoration: TextDecoration.underline, + decoration: + TextDecoration.underline, ), ), ], ), ), )), - ...contact.contactPhones.map((p) => GestureDetector( - onTap: () => - LauncherUtils.launchPhone(p.phoneNumber), + ...contact.contactPhones.map((p) => + GestureDetector( + onTap: () => LauncherUtils.launchPhone( + p.phoneNumber), onLongPress: () => - LauncherUtils.copyToClipboard(p.phoneNumber, + LauncherUtils.copyToClipboard( + p.phoneNumber, typeLabel: 'Phone number'), child: Padding( - padding: const EdgeInsets.only(bottom: 4), + padding: + const EdgeInsets.only(bottom: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.phone_outlined, - size: 16, color: Colors.indigo), + size: 16, + color: Colors.indigo), MySpacing.width(4), ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 160), + constraints: + const BoxConstraints( + maxWidth: 160), child: MyText.labelSmall( p.phoneNumber, - overflow: TextOverflow.ellipsis, + overflow: + TextOverflow.ellipsis, color: Colors.indigo, - decoration: TextDecoration.underline, + decoration: + TextDecoration.underline, ), ), ], @@ -302,7 +329,8 @@ class DirectoryView extends StatelessWidget { MySpacing.height(12), if (phone != '-') GestureDetector( - onTap: () => LauncherUtils.launchWhatsApp(phone), + onTap: () => + LauncherUtils.launchWhatsApp(phone), child: const FaIcon(FontAwesomeIcons.whatsapp, color: Colors.green, size: 20), ), @@ -319,7 +347,7 @@ class DirectoryView extends StatelessWidget { // Floating action button (moved here so it doesn't appear in NotesView) Padding( - padding: const EdgeInsets.only(bottom: 16.0), + padding: const EdgeInsets.only(right: 16.0, bottom: 16.0), child: Align( alignment: Alignment.bottomRight, child: FloatingActionButton( diff --git a/lib/view/directory/notes_view.dart b/lib/view/directory/notes_view.dart index 05f59aa..8745312 100644 --- a/lib/view/directory/notes_view.dart +++ b/lib/view/directory/notes_view.dart @@ -12,15 +12,18 @@ 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; + final NotesController controller = Get.find(); + final TextEditingController searchController = TextEditingController(); - NotesView({super.key}) : controller = _initController(); + NotesView({super.key}); - static NotesController _initController() { - if (!Get.isRegistered()) { - return Get.put(NotesController()); + Future _refreshNotes() async { + try { + await controller.fetchNotes(); + } catch (e, st) { + debugPrint('Error refreshing notes: $e'); + debugPrintStack(stackTrace: st); } - return Get.find(); } String _convertDeltaToHtml(dynamic delta) { @@ -69,157 +72,189 @@ class NotesView extends StatelessWidget { @override Widget build(BuildContext context) { - return Obx(() { - if (controller.isLoading.value) { - return const Center(child: CircularProgressIndicator()); - } - - if (controller.notesList.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + return Column( + children: [ + /// πŸ” Search + Refresh (Top Row) + Padding( + padding: MySpacing.xy(8, 10), + child: Row( children: [ - const Icon(Icons.note_alt_outlined, size: 60, color: Colors.grey), - MySpacing.height(12), - MyText.bodyMedium('No notes available.', fontWeight: 500), + 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), + ), + ), + ), + ), ], ), - ); - } + ), - return Padding( - padding: MySpacing.xy(16, 16), - child: ListView.separated( - itemCount: controller.notesList.length, - separatorBuilder: (_, __) => MySpacing.height(16), - itemBuilder: (_, index) { - final note = controller.notesList[index]; + /// πŸ“„ Notes List View + Expanded( + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } - return Obx(() { - final isEditing = controller.editingNoteId.value == note.id; + final notes = controller.filteredNotesList; - 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: 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), - ) + 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), ], ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Avatar(firstName: initials, lastName: '', size: 36), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodyMedium( - "${note.contactName} (${note.organizationName})", - fontWeight: 600, - color: Colors.indigo[800], - ), - MySpacing.height(4), - MyText.bodySmall( - "by ${note.createdBy.firstName} β€’ $createdDate, $createdTime", - color: Colors.grey[600], - ), - ], + ); + } + + 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]; + 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, - size: 20, - color: Colors.indigo, + IconButton( + icon: Icon( + isEditing ? Icons.close : Icons.edit, + color: Colors.indigo, + size: 20, + ), + onPressed: () { + controller.editingNoteId.value = isEditing ? null : note.id; + }, ), - 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, + ), }, ), - ], - ), - MySpacing.height(12), - // Note 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, - ), - }, - ), - ], - ), - ); - }); - }, + ], + ), + ); + }, + ); + }), ), - ); - }); + ], + ); } } -- 2.43.0 From 5e8158a410cd984cd8e521829770dced36f61a36 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 7 Jul 2025 13:48:57 +0530 Subject: [PATCH 16/39] feat(directory): center align buttons in DirectoryMainScreen and adjust padding in Directory and Notes views --- lib/view/directory/directory_main_screen.dart | 2 +- lib/view/directory/directory_view.dart | 2 +- lib/view/directory/notes_view.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart index 04c0a0e..cdc02b7 100644 --- a/lib/view/directory/directory_main_screen.dart +++ b/lib/view/directory/directory_main_screen.dart @@ -86,7 +86,7 @@ class DirectoryMainScreen extends StatelessWidget { child: Obx(() { final isNotesView = controller.isNotesView.value; return Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( tooltip: 'Directory View', diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index d52a465..0f8430d 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -31,7 +31,7 @@ class DirectoryView extends StatelessWidget { children: [ // Search + Filter + Toggle Padding( - padding: MySpacing.xy(8, 10), + padding: MySpacing.xy(8, 8), child: Row( children: [ // Search Field diff --git a/lib/view/directory/notes_view.dart b/lib/view/directory/notes_view.dart index 8745312..ffe2997 100644 --- a/lib/view/directory/notes_view.dart +++ b/lib/view/directory/notes_view.dart @@ -76,7 +76,7 @@ class NotesView extends StatelessWidget { children: [ /// πŸ” Search + Refresh (Top Row) Padding( - padding: MySpacing.xy(8, 10), + padding: MySpacing.xy(8, 8), child: Row( children: [ Expanded( -- 2.43.0 From 43aeec4c6fe1d8ea6d1eb87a17a4910cc35ea16e Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 7 Jul 2025 13:57:20 +0530 Subject: [PATCH 17/39] feat(directory): integrate NotesController in DirectoryMainScreen and improve code formatting in NotesView --- lib/view/directory/directory_main_screen.dart | 13 +- lib/view/directory/notes_view.dart | 207 ++++++++++-------- 2 files changed, 121 insertions(+), 99 deletions(-) diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart index cdc02b7..d66f39a 100644 --- a/lib/view/directory/directory_main_screen.dart +++ b/lib/view/directory/directory_main_screen.dart @@ -2,6 +2,7 @@ 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'; @@ -13,6 +14,7 @@ class DirectoryMainScreen extends StatelessWidget { DirectoryMainScreen({super.key}); final DirectoryController controller = Get.put(DirectoryController()); + final NotesController notesController = Get.put(NotesController()); @override Widget build(BuildContext context) { @@ -49,9 +51,9 @@ class DirectoryMainScreen extends StatelessWidget { MySpacing.height(2), GetBuilder( builder: (projectController) { - final projectName = projectController - .selectedProject?.name ?? - 'Select Project'; + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; return Row( children: [ const Icon(Icons.work_outline, @@ -111,9 +113,8 @@ class DirectoryMainScreen extends StatelessWidget { // Main View Expanded( - child: Obx(() => controller.isNotesView.value - ? NotesView() - : DirectoryView()), + child: Obx(() => + controller.isNotesView.value ? NotesView() : DirectoryView()), ), ], ), diff --git a/lib/view/directory/notes_view.dart b/lib/view/directory/notes_view.dart index ffe2997..c4f56c3 100644 --- a/lib/view/directory/notes_view.dart +++ b/lib/view/directory/notes_view.dart @@ -87,7 +87,8 @@ class NotesView extends StatelessWidget { onChanged: (value) => controller.searchQuery.value = value, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 6), - prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey), + prefixIcon: const Icon(Icons.search, + size: 20, color: Colors.grey), hintText: 'Search notes...', filled: true, fillColor: Colors.white, @@ -136,7 +137,8 @@ class NotesView extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.note_alt_outlined, size: 60, color: Colors.grey), + const Icon(Icons.note_alt_outlined, + size: 60, color: Colors.grey), const SizedBox(height: 12), MyText.bodyMedium('No notes found.', fontWeight: 500), ], @@ -150,106 +152,125 @@ class NotesView extends StatelessWidget { separatorBuilder: (_, __) => MySpacing.height(12), itemBuilder: (_, index) { final note = notes[index]; - 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"; + return Obx(() { + final isEditing = controller.editingNoteId.value == note.id; - final createdDate = DateTimeUtils.convertUtcToLocal(note.createdAt.toString(), format: 'dd MMM yyyy'); - final createdTime = DateTimeUtils.convertUtcToLocal(note.createdAt.toString(), format: 'hh:mm a'); + final initials = note.contactName.trim().isNotEmpty + ? note.contactName + .trim() + .split(' ') + .map((e) => e[0]) + .take(2) + .join() + .toUpperCase() + : "NA"; - final decodedDelta = HtmlToDelta().convert(note.note); - final quillController = isEditing - ? quill.QuillController( - document: quill.Document.fromDelta(decodedDelta), - selection: TextSelection.collapsed(offset: decodedDelta.length), - ) - : null; + final createdDate = DateTimeUtils.convertUtcToLocal( + note.createdAt.toString(), + format: 'dd MMM yyyy'); + final createdTime = DateTimeUtils.convertUtcToLocal( + note.createdAt.toString(), + format: 'hh:mm a'); - 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, + 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)), + ], ), - 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], - ), - ], + 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, + IconButton( + icon: Icon( + isEditing ? Icons.close : Icons.edit, + color: Colors.indigo, + size: 20, + ), + onPressed: () { + controller.editingNoteId.value = + isEditing ? null : note.id; + }, ), - 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, + ), }, ), - ], - ), - - 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, - ), - }, - ), - ], - ), - ); + ], + ), + ); + }); }, ); }), -- 2.43.0 From 5fb18a13d2442f651209e1fa83d1250edc798ff2 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 7 Jul 2025 14:11:15 +0530 Subject: [PATCH 18/39] feat(directory): refactor DirectoryView layout for improved structure and readability --- lib/view/directory/directory_view.dart | 628 ++++++++++++------------- 1 file changed, 307 insertions(+), 321 deletions(-) diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index 0f8430d..0b41133 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -27,346 +27,332 @@ class DirectoryView extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - // Search + Filter + Toggle - Padding( - padding: MySpacing.xy(8, 8), - child: Row( - children: [ - // Search Field - 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), + 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: MouseRegion( - cursor: SystemMouseCursors.click, + 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), - // Filter Icon with red dot - 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(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(), + ); + }, ), ), - ], - ); - }), - MySpacing.width(10), - // 3-dot Popup with toggle - Container( - height: 35, - width: 35, - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(10), - ), - child: PopupMenuButton( - padding: EdgeInsets.zero, - icon: const Icon(Icons.more_vert, - size: 20, color: Colors.black87), - shape: RoundedRectangleBorder( + 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), ), - itemBuilder: (context) => [ - PopupMenuItem( - 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); - }, - ), - ], - )), + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.more_vert, + size: 20, color: Colors.black87), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), ), - ], - ), - ), - ], - ), - ), - - // Contact List - 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 phone = contact.contactPhones.isNotEmpty - ? contact.contactPhones.first.phoneNumber - : '-'; - 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.symmetric( - horizontal: 12.0, vertical: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Avatar( - firstName: firstName, lastName: lastName, size: 45), - 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(6), - 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) => - GestureDetector( - onTap: () => LauncherUtils.launchPhone( - p.phoneNumber), - onLongPress: () => - LauncherUtils.copyToClipboard( - p.phoneNumber, - typeLabel: 'Phone number'), - child: Padding( - padding: - const EdgeInsets.only(bottom: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.phone_outlined, - size: 16, - color: Colors.indigo), - MySpacing.width(4), - ConstrainedBox( - constraints: - const BoxConstraints( - maxWidth: 160), - child: MyText.labelSmall( - p.phoneNumber, - overflow: - TextOverflow.ellipsis, - color: Colors.indigo, - decoration: - TextDecoration.underline, - ), - ), - ], - ), - ), - )), - ], - ), - if (tags.isNotEmpty) ...[ - MySpacing.height(4), - MyText.labelSmall(tags.join(', '), - color: Colors.grey[500], - maxLines: 1, - overflow: TextOverflow.ellipsis), + itemBuilder: (context) => [ + PopupMenuItem( + 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); + }, + ), ], - ], - ), - ), - Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const Icon(Icons.arrow_forward_ios, - color: Colors.grey, size: 16), - MySpacing.height(12), - if (phone != '-') - GestureDetector( - onTap: () => - LauncherUtils.launchWhatsApp(phone), - child: const FaIcon(FontAwesomeIcons.whatsapp, - color: Colors.green, size: 20), - ), - ], - ), - ], - ), + )), + ), + ], ), - ); - }, - ); - }), - ), - - // Floating action button (moved here so it doesn't appear in NotesView) - Padding( - padding: const EdgeInsets.only(right: 16.0, bottom: 16.0), - child: Align( - alignment: Alignment.bottomRight, - child: 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), + ), + ], ), ), - ), - ], + 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 phone = contact.contactPhones.isNotEmpty + ? contact.contactPhones.first.phoneNumber + : '-'; + 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.center, + children: [ + Avatar( + firstName: firstName, + lastName: lastName, + size: 45), + 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(6), + 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) => + GestureDetector( + onTap: () => LauncherUtils + .launchPhone(p.phoneNumber), + onLongPress: () => + LauncherUtils.copyToClipboard( + p.phoneNumber, + typeLabel: 'Phone number'), + child: Padding( + padding: + const EdgeInsets.only(bottom: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.phone_outlined, + size: 16, + color: Colors.indigo), + MySpacing.width(4), + ConstrainedBox( + constraints: + const BoxConstraints( + maxWidth: 160), + child: MyText.labelSmall( + p.phoneNumber, + overflow: + TextOverflow.ellipsis, + color: Colors.indigo, + decoration: TextDecoration + .underline, + ), + ), + ], + ), + ), + )), + ], + ), + if (tags.isNotEmpty) ...[ + MySpacing.height(4), + 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(12), + if (phone != '-') + GestureDetector( + onTap: () => + LauncherUtils.launchWhatsApp(phone), + child: const FaIcon(FontAwesomeIcons.whatsapp, + color: Colors.green, size: 20), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }), + ), + ], + ), ); } } -- 2.43.0 From 445cd75e0347816157680ac7b652a15b6dddbbc3 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 7 Jul 2025 15:34:07 +0530 Subject: [PATCH 19/39] feat(contact): implement contact editing functionality and update API integration --- .../directory/add_contact_controller.dart | 35 ++- lib/helpers/services/api_endpoints.dart | 1 + lib/helpers/services/api_service.dart | 24 ++ .../directory/add_contact_bottom_sheet.dart | 244 +++++++++++------- lib/view/directory/contact_detail_screen.dart | 109 +++++--- 5 files changed, 263 insertions(+), 150 deletions(-) diff --git a/lib/controller/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart index 7a78231..aa3f79c 100644 --- a/lib/controller/directory/add_contact_controller.dart +++ b/lib/controller/directory/add_contact_controller.dart @@ -82,6 +82,7 @@ class AddContactController extends GetxController { } Future submitContact({ + String? id, required String name, required String organization, required List> emails, @@ -96,10 +97,13 @@ class AddContactController extends GetxController { final tagObjects = enteredTags.map((tagName) { final tagId = tagsMap[tagName]; - return tagId != null ? {"id": tagId, "name": tagName} : {"name": tagName}; + return tagId != null + ? {"id": tagId, "name": tagName} + : {"name": tagName}; }).toList(); final body = { + if (id != null) "id": id, "name": name, "organization": organization, "contactCategoryId": categoryId, @@ -112,27 +116,30 @@ class AddContactController extends GetxController { "description": description, }; - logSafe("Submitting contact", sensitive: true); + logSafe("${id != null ? 'Updating' : 'Creating'} contact"); + + final response = id != null + ? await ApiService.updateContact(id, body) + : await ApiService.createContact(body); - final response = await ApiService.createContact(body); if (response == true) { - logSafe("Contact creation succeeded"); Get.back(result: true); showAppSnackbar( title: "Success", - message: "Contact created successfully", + message: id != null + ? "Contact updated successfully" + : "Contact created successfully", type: SnackbarType.success, ); } else { - logSafe("Contact creation failed", level: LogLevel.error); showAppSnackbar( title: "Error", - message: "Failed to create contact", + message: "Failed to ${id != null ? 'update' : 'create'} contact", type: SnackbarType.error, ); } } catch (e) { - logSafe("Contact creation error: \$e", level: LogLevel.error); + logSafe("Submit contact error: $e", level: LogLevel.error); showAppSnackbar( title: "Error", message: "Something went wrong", @@ -149,9 +156,12 @@ class AddContactController extends GetxController { final lower = query.toLowerCase(); filteredOrgSuggestions.assignAll( - organizationNames.where((name) => name.toLowerCase().contains(lower)).toList(), + organizationNames + .where((name) => name.toLowerCase().contains(lower)) + .toList(), ); - logSafe("Filtered organization suggestions for: \$query", level: LogLevel.debug); + logSafe("Filtered organization suggestions for: \$query", + level: LogLevel.debug); } Future fetchGlobalProjects() async { @@ -197,7 +207,10 @@ class AddContactController extends GetxController { final lower = query.toLowerCase(); filteredSuggestions.assignAll( - tags.where((tag) => tag.toLowerCase().contains(lower) && !enteredTags.contains(tag)).toList(), + tags + .where((tag) => + tag.toLowerCase().contains(lower) && !enteredTags.contains(tag)) + .toList(), ); logSafe("Filtered tag suggestions for: \$query", level: LogLevel.debug); } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index cfda4f8..f0de389 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -40,6 +40,7 @@ class ApiEndpoints { 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"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index e9fcc82..f90df9f 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -365,6 +365,30 @@ class ApiService { return data is List ? data : null; } + static Future updateContact( + String contactId, Map 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 createContact(Map payload) async { try { logSafe("Submitting contact payload: $payload"); diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart index 3cbae01..92d81e2 100644 --- a/lib/model/directory/add_contact_bottom_sheet.dart +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -1,26 +1,80 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:flutter/services.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:flutter/services.dart'; +import 'package:marco/model/directory/contact_model.dart'; class AddContactBottomSheet extends StatelessWidget { - AddContactBottomSheet({super.key}) { + final ContactModel? existingContact; + + AddContactBottomSheet({super.key, this.existingContact}) { controller.resetForm(); - nameController.clear(); - orgController.clear(); + nameController.text = existingContact?.name ?? ''; + orgController.text = existingContact?.organization ?? ''; tagTextController.clear(); - addressController.clear(); - descriptionController.clear(); + addressController.text = existingContact?.address ?? ''; + descriptionController.text = existingContact?.description ?? ''; - emailControllers.add(TextEditingController()); - emailLabels.add('Office'.obs); + if (existingContact != null) { + emailControllers.clear(); + emailLabels.clear(); + for (var email in existingContact!.contactEmails) { + emailControllers.add(TextEditingController(text: email.emailAddress)); + emailLabels.add((email.label ?? 'Office').obs); + } - phoneControllers.add(TextEditingController()); - phoneLabels.add('Work'.obs); + if (emailControllers.isEmpty) { + emailControllers.add(TextEditingController()); + emailLabels.add('Office'.obs); + } + + phoneControllers.clear(); + phoneLabels.clear(); + for (var phone in existingContact!.contactPhones) { + phoneControllers.add(TextEditingController(text: phone.phoneNumber)); + phoneLabels.add((phone.label ?? 'Work').obs); + } + + if (phoneControllers.isEmpty) { + phoneControllers.add(TextEditingController()); + phoneLabels.add('Work'.obs); + } + + controller.selectedCategory.value = + existingContact!.contactCategory?.name ?? ''; + + if (existingContact!.projectIds?.isNotEmpty == true) { + controller.selectedProject.value = controller.globalProjects + .firstWhereOrNull( + (e) => e == existingContact!.projectIds!.first, + ) + ?.toString() ?? + ''; + } + + if (existingContact!.bucketIds.isNotEmpty) { + controller.selectedBucket.value = controller.buckets + .firstWhereOrNull( + (b) => b == existingContact!.bucketIds.first, + ) + ?.toString() ?? + ''; + } + + controller.enteredTags.assignAll( + existingContact!.tags.map((tag) => tag.name).toList(), + ); + } else { + emailControllers.add(TextEditingController()); + emailLabels.add('Office'.obs); + + phoneControllers.add(TextEditingController()); + phoneLabels.add('Work'.obs); + } } final controller = Get.put(AddContactController()); @@ -113,10 +167,9 @@ class AddContactBottomSheet extends StatelessWidget { MyText.labelMedium(label), MySpacing.height(8), _popupSelector( - hint: "Label", - selectedValue: selectedLabel, - options: options, - ), + hint: "Label", + selectedValue: selectedLabel, + options: options), ], ), ), @@ -128,40 +181,35 @@ class AddContactBottomSheet extends StatelessWidget { 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: "", // hides length indicator - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return "$inputLabel is required"; - } - - final trimmed = value.trim(); - - if (inputType == TextInputType.phone) { - if (!RegExp(r'^\d{10}$').hasMatch(trimmed)) { - return "Enter a valid 10-digit phone number"; - } - if (RegExp(r'^0+$').hasMatch(trimmed)) { - return "Phone number cannot be all zeroes"; - } - } - - if (inputType == TextInputType.emailAddress && - !RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(trimmed)) { - return "Enter a valid email address"; - } - - return null; - }, -), - + 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'^\d{10}$').hasMatch(trimmed)) { + return "Enter a valid 10-digit phone number"; + } + if (RegExp(r'^0+$').hasMatch(trimmed)) { + return "Phone number cannot be all zeroes"; + } + } + if (inputType == TextInputType.emailAddress && + !RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$') + .hasMatch(trimmed)) { + return "Enter a valid email address"; + } + return null; + }, + ), ], ), ), @@ -177,53 +225,49 @@ class AddContactBottomSheet extends StatelessWidget { ); } - Widget _buildEmailList() { - return 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 _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() { - return 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 _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 _dropdownField({ required String label, @@ -350,8 +394,13 @@ class AddContactBottomSheet extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( - child: MyText.titleMedium("Create New Contact", - fontWeight: 700)), + child: MyText.titleMedium( + existingContact != null + ? "Edit Contact" + : "Create New Contact", + fontWeight: 700, + ), + ), MySpacing.height(24), _sectionLabel("Basic Info"), MySpacing.height(16), @@ -519,8 +568,9 @@ class AddContactBottomSheet extends StatelessWidget { "phoneNumber": entry.value.text.trim(), }) .toList(); - + print("Submitting contact payload , id: ${existingContact?.id}"); controller.submitContact( + id: existingContact?.id, name: nameController.text.trim(), organization: orgController.text.trim(), emails: emails, diff --git a/lib/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart index a8e9b4d..f6e5962 100644 --- a/lib/view/directory/contact_detail_screen.dart +++ b/lib/view/directory/contact_detail_screen.dart @@ -14,6 +14,7 @@ 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; @@ -252,47 +253,72 @@ class _ContactDetailScreenState extends State { final category = widget.contact.contactCategory?.name ?? "-"; - return SingleChildScrollView( - padding: MySpacing.xy(8, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _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, - ), + 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), ), - ]) - ], - ), + ), + ), + ], ); } @@ -408,7 +434,6 @@ class _ContactDetailScreenState extends State { ), ], ), - MySpacing.height(12), // Comment Content if (isEditing && quillController != null) CommentEditorCard( @@ -453,7 +478,7 @@ class _ContactDetailScreenState extends State { bottom: 20, right: 20, child: FloatingActionButton.extended( - backgroundColor: Colors.indigo, + backgroundColor: Colors.red, onPressed: () { Get.bottomSheet( AddCommentBottomSheet(contactId: contactId), -- 2.43.0 From 77e27ff98e23fcaa4359e1d7d63659abb07101fb Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 8 Jul 2025 13:10:15 +0530 Subject: [PATCH 20/39] feat(contact): enhance AddContact functionality with validation and initialization state --- .../directory/add_contact_controller.dart | 116 +++- .../directory/add_contact_bottom_sheet.dart | 564 +++++++++--------- 2 files changed, 387 insertions(+), 293 deletions(-) diff --git a/lib/controller/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart index aa3f79c..63c29bd 100644 --- a/lib/controller/directory/add_contact_controller.dart +++ b/lib/controller/directory/add_contact_controller.dart @@ -24,6 +24,7 @@ class AddContactController extends GetxController { final RxMap bucketsMap = {}.obs; final RxMap projectsMap = {}.obs; final RxMap tagsMap = {}.obs; + final RxBool isInitialized = false.obs; @override void onInit() { @@ -41,6 +42,9 @@ class AddContactController extends GetxController { fetchCategories(), fetchOrganizationNames(), ]); + + // βœ… Mark initialization as done + isInitialized.value = true; } void resetForm() { @@ -90,11 +94,103 @@ class AddContactController extends GetxController { required String address, required String description, }) async { - try { - final categoryId = categoriesMap[selectedCategory.value]; - final bucketId = bucketsMap[selectedBucket.value]; - final projectId = projectsMap[selectedProject.value]; + final categoryId = categoriesMap[selectedCategory.value]; + final bucketId = bucketsMap[selectedBucket.value]; + final projectId = projectsMap[selectedProject.value]; + // === 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 (selectedProject.value.trim().isEmpty || projectId == null) { + showAppSnackbar( + title: "Missing Project", + message: "Please select a 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 @@ -104,16 +200,16 @@ class AddContactController extends GetxController { final body = { if (id != null) "id": id, - "name": name, - "organization": organization, + "name": name.trim(), + "organization": organization.trim(), "contactCategoryId": categoryId, - "projectIds": projectId != null ? [projectId] : [], - "bucketIds": bucketId != null ? [bucketId] : [], + "projectIds": [projectId], + "bucketIds": [bucketId], "tags": tagObjects, "contactEmails": emails, "contactPhones": phones, - "address": address, - "description": description, + "address": address.trim(), + "description": description.trim(), }; logSafe("${id != null ? 'Updating' : 'Creating'} contact"); diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart index 92d81e2..c509df4 100644 --- a/lib/model/directory/add_contact_bottom_sheet.dart +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -1,90 +1,30 @@ 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 StatelessWidget { +class AddContactBottomSheet extends StatefulWidget { final ContactModel? existingContact; + const AddContactBottomSheet({super.key, this.existingContact}); - AddContactBottomSheet({super.key, this.existingContact}) { - controller.resetForm(); - - nameController.text = existingContact?.name ?? ''; - orgController.text = existingContact?.organization ?? ''; - tagTextController.clear(); - addressController.text = existingContact?.address ?? ''; - descriptionController.text = existingContact?.description ?? ''; - - if (existingContact != null) { - emailControllers.clear(); - emailLabels.clear(); - for (var email in existingContact!.contactEmails) { - emailControllers.add(TextEditingController(text: email.emailAddress)); - emailLabels.add((email.label ?? 'Office').obs); - } - - if (emailControllers.isEmpty) { - emailControllers.add(TextEditingController()); - emailLabels.add('Office'.obs); - } - - phoneControllers.clear(); - phoneLabels.clear(); - for (var phone in existingContact!.contactPhones) { - phoneControllers.add(TextEditingController(text: phone.phoneNumber)); - phoneLabels.add((phone.label ?? 'Work').obs); - } - - if (phoneControllers.isEmpty) { - phoneControllers.add(TextEditingController()); - phoneLabels.add('Work'.obs); - } - - controller.selectedCategory.value = - existingContact!.contactCategory?.name ?? ''; - - if (existingContact!.projectIds?.isNotEmpty == true) { - controller.selectedProject.value = controller.globalProjects - .firstWhereOrNull( - (e) => e == existingContact!.projectIds!.first, - ) - ?.toString() ?? - ''; - } - - if (existingContact!.bucketIds.isNotEmpty) { - controller.selectedBucket.value = controller.buckets - .firstWhereOrNull( - (b) => b == existingContact!.bucketIds.first, - ) - ?.toString() ?? - ''; - } - - controller.enteredTags.assignAll( - existingContact!.tags.map((tag) => tag.name).toList(), - ); - } else { - emailControllers.add(TextEditingController()); - emailLabels.add('Office'.obs); - - phoneControllers.add(TextEditingController()); - phoneLabels.add('Work'.obs); - } - } + @override + State createState() => _AddContactBottomSheetState(); +} +class _AddContactBottomSheetState extends State { final controller = Get.put(AddContactController()); final formKey = GlobalKey(); final nameController = TextEditingController(); final orgController = TextEditingController(); - final tagTextController = TextEditingController(); final addressController = TextEditingController(); final descriptionController = TextEditingController(); + final tagTextController = TextEditingController(); final RxList emailControllers = [].obs; @@ -94,6 +34,94 @@ class AddContactBottomSheet extends StatelessWidget { [].obs; final RxList phoneLabels = [].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 projectId = widget.existingContact!.projectIds?.firstOrNull; + final bucketId = widget.existingContact!.bucketIds.firstOrNull; + final categoryName = widget.existingContact!.contactCategory?.name; + + if (categoryName != null) { + controller.selectedCategory.value = categoryName; + } + + if (projectId != null) { + final name = controller.projectsMap.entries + .firstWhereOrNull((e) => e.value == projectId) + ?.key; + if (name != null) { + controller.selectedProject.value = name; + } + } + + 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(); + super.dispose(); + } + InputDecoration _inputDecoration(String hint) => InputDecoration( hintText: hint, hintStyle: MyTextStyle.bodySmall(xMuted: true), @@ -116,47 +144,14 @@ class AddContactBottomSheet extends StatelessWidget { isDense: true, ); - Widget _popupSelector({ - required String hint, - required RxString selectedValue, - required List options, - }) { - return Obx(() => GestureDetector( - onTap: () async { - final selected = await showMenu( - context: Navigator.of(Get.context!).overlay!.context, - position: const RelativeRect.fromLTRB(100, 300, 100, 0), - items: options - .map((e) => PopupMenuItem(value: e, child: Text(e))) - .toList(), - ); - if (selected != null) selectedValue.value = selected; - }, - child: AbsorbPointer( - child: SizedBox( - height: 48, - child: TextFormField( - readOnly: true, - initialValue: selectedValue.value, - style: const TextStyle(fontSize: 14), - decoration: _inputDecoration(hint).copyWith( - suffixIcon: const Icon(Icons.expand_more), - ), - ), - ), - ), - )); - } - Widget _buildLabeledRow( - String label, - RxString selectedLabel, - List options, - String inputLabel, - TextEditingController controller, - TextInputType inputType, { - VoidCallback? onRemove, - }) { + String label, + RxString selectedLabel, + List options, + String inputLabel, + TextEditingController controller, + TextInputType inputType, + {VoidCallback? onRemove}) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -190,22 +185,19 @@ class AddContactBottomSheet extends StatelessWidget { decoration: _inputDecoration("Enter $inputLabel") .copyWith(counterText: ""), validator: (value) { - if (value == null || value.trim().isEmpty) { + if (value == null || value.trim().isEmpty) return "$inputLabel is required"; - } final trimmed = value.trim(); if (inputType == TextInputType.phone) { - if (!RegExp(r'^\d{10}$').hasMatch(trimmed)) { - return "Enter a valid 10-digit phone number"; - } - if (RegExp(r'^0+$').hasMatch(trimmed)) { - return "Phone number cannot be all zeroes"; + 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}$') + !RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$') .hasMatch(trimmed)) { - return "Enter a valid email address"; + return "Enter valid email"; } return null; }, @@ -269,45 +261,54 @@ class AddContactBottomSheet extends StatelessWidget { }), ); - Widget _dropdownField({ - required String label, + Widget _popupSelector({ + required String hint, required RxString selectedValue, - required RxList options, + required List options, }) { - return Obx(() => SizedBox( - height: 48, - child: PopupMenuButton( - onSelected: (value) => selectedValue.value = value, - itemBuilder: (_) => options - .map((item) => PopupMenuItem(value: item, child: Text(item))) - .toList(), - padding: EdgeInsets.zero, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - color: Colors.grey.shade100, - ), - alignment: Alignment.centerLeft, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - selectedValue.value.isEmpty ? label : selectedValue.value, - style: const TextStyle(fontSize: 14), - overflow: TextOverflow.ellipsis, - ), - ), - const Icon(Icons.arrow_drop_down), - ], - ), + return Obx(() => GestureDetector( + onTap: () async { + final selected = await showMenu( + context: context, + position: const RelativeRect.fromLTRB(100, 300, 100, 0), + items: options + .map((e) => PopupMenuItem(value: e, child: Text(e))) + .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, @@ -327,7 +328,33 @@ class AddContactBottomSheet extends StatelessWidget { ), Obx(() => controller.filteredSuggestions.isEmpty ? const SizedBox() - : _buildSuggestionsList()), + : 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, @@ -342,137 +369,6 @@ class AddContactBottomSheet extends StatelessWidget { ); } - Widget _buildSuggestionsList() => 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(); - }, - ); - }, - ), - ); - - Widget _sectionLabel(String title) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelLarge(title, fontWeight: 600), - MySpacing.height(4), - Divider(thickness: 1, color: Colors.grey.shade200), - ], - ); - - @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)), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), - child: Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: MyText.titleMedium( - 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), - _dropdownField( - label: "Select Category", - selectedValue: controller.selectedCategory, - options: controller.categories, - ), - MySpacing.height(16), - MyText.labelMedium("Select Projects"), - MySpacing.height(8), - _dropdownField( - label: "Select Project", - selectedValue: controller.selectedProject, - options: controller.globalProjects, - ), - MySpacing.height(16), - MyText.labelMedium("Select Bucket"), - MySpacing.height(8), - _dropdownField( - label: "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(), - ], - ), - ), - ), - ), - ); - } - Widget _buildTextField(String label, TextEditingController controller, {int maxLines = 1}) { return Column( @@ -568,9 +464,9 @@ class AddContactBottomSheet extends StatelessWidget { "phoneNumber": entry.value.text.trim(), }) .toList(); - print("Submitting contact payload , id: ${existingContact?.id}"); + controller.submitContact( - id: existingContact?.id, + id: widget.existingContact?.id, name: nameController.text.trim(), organization: orgController.text.trim(), emails: emails, @@ -594,4 +490,106 @@ class AddContactBottomSheet extends StatelessWidget { ], ); } + + @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), + _popupSelector( + hint: "Select Project", + selectedValue: controller.selectedProject, + options: controller.globalProjects, + ), + 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(), + ], + ), + ), + ), + ), + ); + }); + } } -- 2.43.0 From a8c890a60d437e65311f7bb1190897e07312a15c Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 8 Jul 2025 13:17:21 +0530 Subject: [PATCH 21/39] feat(comment): improve comment submission feedback and validation messages --- .../directory/add_comment_controller.dart | 21 ++-- .../directory/add_comment_bottom_sheet.dart | 110 +++++++++--------- 2 files changed, 62 insertions(+), 69 deletions(-) diff --git a/lib/controller/directory/add_comment_controller.dart b/lib/controller/directory/add_comment_controller.dart index bc77c50..beebff5 100644 --- a/lib/controller/directory/add_comment_controller.dart +++ b/lib/controller/directory/add_comment_controller.dart @@ -15,14 +15,15 @@ class AddCommentController extends GetxController { Future submitComment() async { if (note.value.trim().isEmpty) { showAppSnackbar( - title: "Validation", - message: "Comment cannot be empty.", + title: "Missing Comment", + message: "Please enter a comment before submitting.", type: SnackbarType.warning, ); return; } isSubmitting.value = true; + try { logSafe("Submitting comment for contactId: $contactId"); @@ -34,32 +35,30 @@ class AddCommentController extends GetxController { if (success) { logSafe("Comment added successfully."); - // Get the directory controller + // Refresh UI final directoryController = Get.find(); - - // Fetch latest comments for the contact to refresh UI await directoryController.fetchCommentsForContact(contactId); Get.back(result: true); showAppSnackbar( - title: "Success", - message: "Comment added successfully.", + title: "Comment Added", + message: "Your comment has been successfully added.", type: SnackbarType.success, ); } else { logSafe("Comment submission failed", level: LogLevel.error); showAppSnackbar( - title: "Error", - message: "Failed to add comment.", + 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: "Error", - message: "Something went wrong.", + title: "Unexpected Error", + message: "Something went wrong while adding your comment.", type: SnackbarType.error, ); } finally { diff --git a/lib/model/directory/add_comment_bottom_sheet.dart b/lib/model/directory/add_comment_bottom_sheet.dart index e06b47e..c208d4c 100644 --- a/lib/model/directory/add_comment_bottom_sheet.dart +++ b/lib/model/directory/add_comment_bottom_sheet.dart @@ -23,7 +23,6 @@ class _AddCommentBottomSheetState extends State { void initState() { super.initState(); controller = Get.put(AddCommentController(contactId: widget.contactId)); - // Initialize empty editor for new comment quillController = quill.QuillController.basic(); } @@ -43,7 +42,11 @@ class _AddCommentBottomSheetState extends State { 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)), + BoxShadow( + color: Colors.black12, + blurRadius: 12, + offset: Offset(0, -2), + ), ], ), child: Padding( @@ -67,12 +70,11 @@ class _AddCommentBottomSheetState extends State { CommentEditorCard( controller: quillController, onCancel: () => Get.back(), - onSave: (controller) async { - final delta = controller.document.toDelta(); + onSave: (editorController) async { + final delta = editorController.document.toDelta(); final htmlOutput = _convertDeltaToHtml(delta); - this.controller.updateNote(htmlOutput); - await this.controller.submitComment(); - if (mounted) Get.back(); + controller.updateNote(htmlOutput); + await controller.submitComment(); }, ), ], @@ -81,62 +83,54 @@ class _AddCommentBottomSheetState extends State { ), ); } -} -String _convertDeltaToHtml(dynamic delta) { - final buffer = StringBuffer(); - bool inList = false; + 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 ?? {}; + for (var op in delta.toList()) { + final data = op.data?.toString() ?? ''; + final attr = op.attributes ?? {}; - final isListItem = attr.containsKey('list'); + final isListItem = attr.containsKey('list'); + final trimmedData = data.trim(); - // Start
      if list item starts - if (isListItem && !inList) { - buffer.write('
        '); - inList = true; + if (isListItem && !inList) { + buffer.write('
          '); + inList = true; + } + + if (!isListItem && inList) { + buffer.write('
        '); + inList = false; + } + + if (isListItem && trimmedData.isEmpty) continue; + + if (isListItem) buffer.write('
      • '); + + if (attr.containsKey('bold')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('link')) buffer.write(''); + + buffer.write(trimmedData.replaceAll('\n', '')); + + if (attr.containsKey('link')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('bold')) buffer.write(''); + + if (isListItem) { + buffer.write('
      • '); + } else if (data.contains('\n')) { + buffer.write('
        '); + } } - // Close
          if list ended - if (!isListItem && inList) { - buffer.write('
        '); - inList = false; - } - - // Skip empty list items - final trimmedData = data.trim(); - if (isListItem && trimmedData.isEmpty) { - // don't write empty
      • - continue; - } - - if (isListItem) buffer.write('
      • '); - - if (attr.containsKey('bold')) buffer.write(''); - if (attr.containsKey('italic')) buffer.write(''); - if (attr.containsKey('underline')) buffer.write(''); - if (attr.containsKey('strike')) buffer.write(''); - if (attr.containsKey('link')) buffer.write(''); - - // Use trimmedData instead of raw data (removes trailing/leading spaces/newlines) - buffer.write(trimmedData.replaceAll('\n', '')); - - if (attr.containsKey('link')) buffer.write(''); - if (attr.containsKey('strike')) buffer.write(''); - if (attr.containsKey('underline')) buffer.write(''); - if (attr.containsKey('italic')) buffer.write(''); - if (attr.containsKey('bold')) buffer.write(''); - - if (isListItem) { - buffer.write('
      • '); - } else if (data.contains('\n')) { - buffer.write('
        '); - } + if (inList) buffer.write('
      '); + return buffer.toString(); } - - if (inList) buffer.write('
    '); - - return buffer.toString(); } -- 2.43.0 From 2fef2e508eff5c79f607065ffc37aed1a67a7ccb Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 8 Jul 2025 15:28:20 +0530 Subject: [PATCH 22/39] feat(contact): support multiple project selection in AddContact functionality --- .../directory/add_contact_controller.dart | 15 +- .../directory/add_contact_bottom_sheet.dart | 139 +++++++++++++++--- 2 files changed, 131 insertions(+), 23 deletions(-) diff --git a/lib/controller/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart index 63c29bd..bcc1abc 100644 --- a/lib/controller/directory/add_contact_controller.dart +++ b/lib/controller/directory/add_contact_controller.dart @@ -25,6 +25,7 @@ class AddContactController extends GetxController { final RxMap projectsMap = {}.obs; final RxMap tagsMap = {}.obs; final RxBool isInitialized = false.obs; + final RxList selectedProjects = [].obs; @override void onInit() { @@ -54,6 +55,7 @@ class AddContactController extends GetxController { enteredTags.clear(); filteredSuggestions.clear(); filteredOrgSuggestions.clear(); + selectedProjects.clear(); } Future fetchBuckets() async { @@ -96,7 +98,10 @@ class AddContactController extends GetxController { }) async { final categoryId = categoriesMap[selectedCategory.value]; final bucketId = bucketsMap[selectedBucket.value]; - final projectId = projectsMap[selectedProject.value]; + final projectIds = selectedProjects + .map((name) => projectsMap[name]) + .whereType() + .toList(); // === Per-field Validation with Specific Messages === if (name.trim().isEmpty) { @@ -171,10 +176,10 @@ class AddContactController extends GetxController { return; } - if (selectedProject.value.trim().isEmpty || projectId == null) { + if (selectedProjects.isEmpty || projectIds.isEmpty) { showAppSnackbar( - title: "Missing Project", - message: "Please select a project.", + title: "Missing Projects", + message: "Please select at least one project.", type: SnackbarType.warning, ); return; @@ -203,7 +208,7 @@ class AddContactController extends GetxController { "name": name.trim(), "organization": organization.trim(), "contactCategoryId": categoryId, - "projectIds": [projectId], + "projectIds": projectIds, "bucketIds": [bucketId], "tags": tagObjects, "contactEmails": emails, diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart index c509df4..97d2e95 100644 --- a/lib/model/directory/add_contact_bottom_sheet.dart +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -74,7 +74,7 @@ class _AddContactBottomSheetState extends State { ever(controller.isInitialized, (bool ready) { if (ready) { - final projectId = widget.existingContact!.projectIds?.firstOrNull; + final projectIds = widget.existingContact!.projectIds; final bucketId = widget.existingContact!.bucketIds.firstOrNull; final categoryName = widget.existingContact!.contactCategory?.name; @@ -82,15 +82,17 @@ class _AddContactBottomSheetState extends State { controller.selectedCategory.value = categoryName; } - if (projectId != null) { - final name = controller.projectsMap.entries - .firstWhereOrNull((e) => e.value == projectId) - ?.key; - if (name != null) { - controller.selectedProject.value = name; - } + if (projectIds != null) { + final names = projectIds + .map((id) { + return controller.projectsMap.entries + .firstWhereOrNull((e) => e.value == id) + ?.key; + }) + .whereType() + .toList(); + controller.selectedProjects.assignAll(names); } - if (bucketId != null) { final name = controller.bucketsMap.entries .firstWhereOrNull((e) => e.value == bucketId) @@ -270,12 +272,18 @@ class _AddContactBottomSheetState extends State { onTap: () async { final selected = await showMenu( context: context, - position: const RelativeRect.fromLTRB(100, 300, 100, 0), - items: options - .map((e) => PopupMenuItem(value: e, child: Text(e))) - .toList(), + position: RelativeRect.fromLTRB(100, 300, 100, 0), + items: options.map((option) { + return PopupMenuItem( + value: option, + child: Text(option), + ); + }).toList(), ); - if (selected != null) selectedValue.value = selected; + + if (selected != null) { + selectedValue.value = selected; + } }, child: Container( height: 48, @@ -560,10 +568,105 @@ class _AddContactBottomSheetState extends State { MySpacing.height(16), MyText.labelMedium("Select Projects"), MySpacing.height(8), - _popupSelector( - hint: "Select Project", - selectedValue: controller.selectedProject, - options: controller.globalProjects, + 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((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"), -- 2.43.0 From fb28439d69da4b1714d13a0a76464ac57dafcc2b Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 8 Jul 2025 15:32:20 +0530 Subject: [PATCH 23/39] feat(comment): add feedback messages for comment update actions --- lib/controller/directory/directory_controller.dart | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/controller/directory/directory_controller.dart b/lib/controller/directory/directory_controller.dart index 86e9be9..9e70d51 100644 --- a/lib/controller/directory/directory_controller.dart +++ b/lib/controller/directory/directory_controller.dart @@ -54,6 +54,11 @@ class DirectoryController extends GetxController { 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; } @@ -66,6 +71,13 @@ class DirectoryController extends GetxController { 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( -- 2.43.0 From 1ad880a021ef4542b1ad6b87d56968c4492e2952 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 8 Jul 2025 15:33:35 +0530 Subject: [PATCH 24/39] feat(notes): add feedback messages for note update actions --- lib/controller/directory/notes_controller.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/controller/directory/notes_controller.dart b/lib/controller/directory/notes_controller.dart index dca0201..709b4e0 100644 --- a/lib/controller/directory/notes_controller.dart +++ b/lib/controller/directory/notes_controller.dart @@ -63,6 +63,11 @@ class NotesController extends GetxController { 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; } @@ -79,6 +84,11 @@ class NotesController extends GetxController { notesList[index] = updatedNote; notesList.refresh(); } + showAppSnackbar( + title: "Success", + message: "Note updated successfully.", + type: SnackbarType.success, + ); } else { showAppSnackbar( title: "Error", -- 2.43.0 From df0dd5d560054dbd3834629e55161dc5978a65af Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 8 Jul 2025 15:44:52 +0530 Subject: [PATCH 25/39] feat(directory): adjust avatar size and alignment in contact list --- lib/view/directory/directory_view.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index 0b41133..a61fd3d 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -225,12 +225,12 @@ class DirectoryView extends StatelessWidget { child: Padding( padding: const EdgeInsets.fromLTRB(12, 10, 12, 0), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Avatar( firstName: firstName, lastName: lastName, - size: 45), + size: 35), MySpacing.width(12), Expanded( child: Column( -- 2.43.0 From ae868bb0f6c61954813fad89038e87491fd388d2 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 8 Jul 2025 16:01:10 +0530 Subject: [PATCH 26/39] feat(directory): reduce spacing and padding in category and bucket displays --- .../directory/directory_filter_bottom_sheet.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/model/directory/directory_filter_bottom_sheet.dart b/lib/model/directory/directory_filter_bottom_sheet.dart index 2095ecf..e39f689 100644 --- a/lib/model/directory/directory_filter_bottom_sheet.dart +++ b/lib/model/directory/directory_filter_bottom_sheet.dart @@ -59,8 +59,8 @@ class DirectoryFilterBottomSheet extends StatelessWidget { ), const SizedBox(height: 10), Wrap( - spacing: 8, - runSpacing: 8, + spacing: 2, + runSpacing: 0, children: controller.contactCategories.map((category) { final selected = controller.selectedCategories.contains(category.id); @@ -81,7 +81,7 @@ class DirectoryFilterBottomSheet extends StatelessWidget { ); }).toList(), ), - const SizedBox(height: 24), + const SizedBox(height: 12), ], /// Buckets @@ -94,8 +94,8 @@ class DirectoryFilterBottomSheet extends StatelessWidget { ), const SizedBox(height: 10), Wrap( - spacing: 8, - runSpacing: 8, + spacing: 2, + runSpacing: 0, children: controller.contactBuckets.map((bucket) { final selected = controller.selectedBuckets.contains(bucket.id); @@ -117,7 +117,7 @@ class DirectoryFilterBottomSheet extends StatelessWidget { ), ], - const SizedBox(height: 30), + const SizedBox(height: 12), /// Action Buttons Row( @@ -139,7 +139,7 @@ class DirectoryFilterBottomSheet extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 14), + horizontal: 10, vertical: 7), ), ), ElevatedButton.icon( @@ -155,7 +155,7 @@ class DirectoryFilterBottomSheet extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), padding: const EdgeInsets.symmetric( - horizontal: 28, vertical: 14), + horizontal: 10, vertical: 7), ), ), ], -- 2.43.0 From 6907d176da1f9142be484d2cbb8a0a394f9bfef7 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 8 Jul 2025 16:06:11 +0530 Subject: [PATCH 27/39] feat(directory): enhance toggle UI for Directory and Notes views with improved styling and animations --- lib/view/directory/directory_main_screen.dart | 109 +++++++++++++++--- 1 file changed, 90 insertions(+), 19 deletions(-) diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart index d66f39a..b8b1fdc 100644 --- a/lib/view/directory/directory_main_screen.dart +++ b/lib/view/directory/directory_main_screen.dart @@ -84,29 +84,100 @@ class DirectoryMainScreen extends StatelessWidget { children: [ // Toggle between Directory and Notes Padding( - padding: MySpacing.xy(8, 5), + padding: MySpacing.fromLTRB(8, 12, 8, 5), child: Obx(() { final isNotesView = controller.isNotesView.value; - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - tooltip: 'Directory View', - onPressed: () => controller.isNotesView.value = false, - icon: Icon( - Icons.contacts, - color: !isNotesView ? Colors.red : Colors.grey, + + 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), ), - ), - IconButton( - tooltip: 'Notes View', - onPressed: () => controller.isNotesView.value = true, - icon: Icon( - Icons.notes, - color: isNotesView ? Colors.red : Colors.grey, + ], + ), + 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, + ), + ), + ], + ), + ), + ), + ), + ], + ), ); }), ), -- 2.43.0 From b3b68b62586398d00895b9eb624e101081994712 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 8 Jul 2025 16:33:02 +0530 Subject: [PATCH 28/39] style(directory): format code for improved readability and consistency --- lib/view/directory/directory_view.dart | 130 ++++++++++++++----------- 1 file changed, 71 insertions(+), 59 deletions(-) diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index a61fd3d..135da9f 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -28,7 +28,7 @@ class DirectoryView extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Colors.white, + backgroundColor: Colors.white, floatingActionButton: FloatingActionButton( backgroundColor: Colors.red, onPressed: () async { @@ -106,17 +106,19 @@ class DirectoryView extends StatelessWidget { child: IconButton( icon: Icon(Icons.filter_alt_outlined, size: 20, - color: - isFilterActive ? Colors.indigo : Colors.black87), + color: isFilterActive + ? Colors.indigo + : Colors.black87), onPressed: () { showModalBottomSheet( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( - borderRadius: - BorderRadius.vertical(top: Radius.circular(20)), + borderRadius: BorderRadius.vertical( + top: Radius.circular(20)), ), - builder: (_) => const DirectoryFilterBottomSheet(), + builder: (_) => + const DirectoryFilterBottomSheet(), ); }, ), @@ -160,7 +162,8 @@ class DirectoryView extends StatelessWidget { child: Obx(() => Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - MyText.bodySmall('Show Inactive', fontWeight: 600), + MyText.bodySmall('Show Inactive', + fontWeight: 600), Switch.adaptive( value: !controller.isActive.value, activeColor: Colors.indigo, @@ -209,13 +212,9 @@ class DirectoryView extends StatelessWidget { separatorBuilder: (_, __) => MySpacing.height(12), itemBuilder: (_, index) { final contact = controller.filteredContacts[index]; - final phone = contact.contactPhones.isNotEmpty - ? contact.contactPhones.first.phoneNumber - : '-'; final nameParts = contact.name.trim().split(" "); final firstName = nameParts.first; - final lastName = - nameParts.length > 1 ? nameParts.last : ""; + final lastName = nameParts.length > 1 ? nameParts.last : ""; final tags = contact.tags.map((tag) => tag.name).toList(); return InkWell( @@ -242,21 +241,22 @@ class DirectoryView extends StatelessWidget { MyText.bodySmall(contact.organization, color: Colors.grey[700], overflow: TextOverflow.ellipsis), - MySpacing.height(6), + MySpacing.height(8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ...contact.contactEmails.map((e) => GestureDetector( - onTap: () => LauncherUtils - .launchEmail(e.emailAddress), + onTap: () => + LauncherUtils.launchEmail( + e.emailAddress), onLongPress: () => LauncherUtils.copyToClipboard( e.emailAddress, typeLabel: 'Email'), child: Padding( - padding: - const EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.only( + bottom: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -281,45 +281,64 @@ class DirectoryView extends StatelessWidget { ), ), )), - ...contact.contactPhones.map((p) => - GestureDetector( - onTap: () => LauncherUtils - .launchPhone(p.phoneNumber), - onLongPress: () => - LauncherUtils.copyToClipboard( - p.phoneNumber, - typeLabel: 'Phone number'), - child: Padding( - padding: - const EdgeInsets.only(bottom: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.phone_outlined, - size: 16, - color: Colors.indigo), - MySpacing.width(4), - ConstrainedBox( - constraints: - const BoxConstraints( - maxWidth: 160), - child: MyText.labelSmall( - p.phoneNumber, - 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(4), + MySpacing.height(2), MyText.labelSmall(tags.join(', '), color: Colors.grey[500], maxLines: 1, @@ -333,14 +352,7 @@ class DirectoryView extends StatelessWidget { children: [ const Icon(Icons.arrow_forward_ios, color: Colors.grey, size: 16), - MySpacing.height(12), - if (phone != '-') - GestureDetector( - onTap: () => - LauncherUtils.launchWhatsApp(phone), - child: const FaIcon(FontAwesomeIcons.whatsapp, - color: Colors.green, size: 20), - ), + MySpacing.height(8), ], ), ], -- 2.43.0 From a9067bd407beb5efdc8f5967285b12db102fd4a4 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 8 Jul 2025 17:57:53 +0530 Subject: [PATCH 29/39] feat(comment-editor): disable list buttons and adjust layout for improved usability --- lib/helpers/widgets/Directory/comment_editor_card.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/helpers/widgets/Directory/comment_editor_card.dart b/lib/helpers/widgets/Directory/comment_editor_card.dart index 57c71e3..64a712d 100644 --- a/lib/helpers/widgets/Directory/comment_editor_card.dart +++ b/lib/helpers/widgets/Directory/comment_editor_card.dart @@ -24,8 +24,8 @@ class CommentEditorCard extends StatelessWidget { showBoldButton: true, showItalicButton: true, showUnderLineButton: true, - showListBullets: true, - showListNumbers: true, + showListBullets: false, + showListNumbers: false, showAlignmentButtons: true, showLink: true, showFontSize: false, @@ -48,9 +48,9 @@ class CommentEditorCard extends StatelessWidget { multiRowsDisplay: false, ), ), - const SizedBox(height: 8), + const SizedBox(height: 38), Container( - height: 120, + height: 140, padding: const EdgeInsets.all(8), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), -- 2.43.0 From e059ee71f39f25c41ce9b84ee232274fdc36b9f9 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 8 Jul 2025 17:58:08 +0530 Subject: [PATCH 30/39] feat(user-profile): wrap content in SafeArea for improved layout on different devices --- lib/view/layouts/user_profile_right_bar.dart | 22 +++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/view/layouts/user_profile_right_bar.dart b/lib/view/layouts/user_profile_right_bar.dart index 265a102..8b37bbf 100644 --- a/lib/view/layouts/user_profile_right_bar.dart +++ b/lib/view/layouts/user_profile_right_bar.dart @@ -59,14 +59,20 @@ class _UserProfileBarState extends State width: isCondensed ? 90 : 250, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, - child: Column( - children: [ - userProfileSection(), - MySpacing.height(8), - supportAndSettingsMenu(), - const Spacer(), - logoutButton(), - ], + child: SafeArea( + bottom: true, + top: false, + left: false, + right: false, + child: Column( + children: [ + userProfileSection(), + MySpacing.height(8), + supportAndSettingsMenu(), + const Spacer(), + logoutButton(), + ], + ), ), ), ); -- 2.43.0 From aac65104abab385996943c3301bc0195cf357dea Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 9 Jul 2025 11:35:35 +0530 Subject: [PATCH 31/39] refactor(logging): remove sensitive flag from logSafe calls across multiple controllers and services --- .../auth/forgot_password_controller.dart | 4 +- lib/controller/auth/login_controller.dart | 6 +- lib/controller/auth/mpin_controller.dart | 10 +- lib/controller/auth/otp_controller.dart | 2 +- .../dashboard/dashboard_controller.dart | 4 +- .../employees_screen_controller.dart | 12 +- lib/controller/permission_controller.dart | 4 +- .../task_planing/add_task_controller.dart | 2 +- .../daily_task_planing_controller.dart | 12 +- .../report_task_action_controller.dart | 6 +- .../task_planing/report_task_controller.dart | 6 +- lib/helpers/services/api_service.dart | 4 +- lib/helpers/services/permission_service.dart | 4 +- lib/helpers/utils/launcher_utils.dart | 12 +- lib/helpers/widgets/my_image_compressor.dart | 4 +- lib/view/auth/login_option_screen.dart | 321 +++++++----------- lib/view/my_app.dart | 2 +- 17 files changed, 178 insertions(+), 237 deletions(-) diff --git a/lib/controller/auth/forgot_password_controller.dart b/lib/controller/auth/forgot_password_controller.dart index 527f879..8d544d7 100644 --- a/lib/controller/auth/forgot_password_controller.dart +++ b/lib/controller/auth/forgot_password_controller.dart @@ -32,7 +32,7 @@ class ForgotPasswordController extends MyController { final email = data['email']?.toString() ?? ''; try { - logSafe("Forgot password requested for: $email", sensitive: true); + logSafe("Forgot password requested for: $email", ); final result = await AuthService.forgotPassword(email); @@ -50,7 +50,7 @@ class ForgotPasswordController extends MyController { message: errorMessage, type: SnackbarType.error, ); - logSafe("Failed to send reset password email for $email: $errorMessage", level: LogLevel.warning, sensitive: true); + logSafe("Failed to send reset password email for $email: $errorMessage", level: LogLevel.warning, ); } } catch (e, stacktrace) { logSafe("Error during forgot password", level: LogLevel.error, error: e, stackTrace: stacktrace); diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index af4a62a..cf0aad3 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -55,12 +55,12 @@ class LoginController extends MyController { try { final loginData = basicValidator.getData(); - logSafe("Attempting login for user: ${loginData['username']}", sensitive: true); + logSafe("Attempting login for user: ${loginData['username']}", ); final errors = await AuthService.loginUser(loginData); if (errors != null) { - logSafe("Login failed for user: ${loginData['username']} with errors: $errors", level: LogLevel.warning, sensitive: true); + logSafe("Login failed for user: ${loginData['username']} with errors: $errors", level: LogLevel.warning, ); showAppSnackbar( title: "Login Failed", @@ -73,7 +73,7 @@ class LoginController extends MyController { basicValidator.clearErrors(); } else { await _handleRememberMe(); - logSafe("Login successful for user: ${loginData['username']}", sensitive: true); + logSafe("Login successful for user: ${loginData['username']}", ); Get.toNamed('/home'); } } catch (e, stacktrace) { diff --git a/lib/controller/auth/mpin_controller.dart b/lib/controller/auth/mpin_controller.dart index 31ac669..cde5bc0 100644 --- a/lib/controller/auth/mpin_controller.dart +++ b/lib/controller/auth/mpin_controller.dart @@ -29,7 +29,7 @@ class MPINController extends GetxController { } void onDigitChanged(String value, int index, {bool isRetype = false}) { - logSafe("onDigitChanged -> index: $index, value: $value, isRetype: $isRetype", sensitive: true); + logSafe("onDigitChanged -> index: $index, value: $value, isRetype: $isRetype", ); final nodes = isRetype ? retypeFocusNodes : focusNodes; if (value.isNotEmpty && index < 5) { nodes[index + 1].requestFocus(); @@ -47,7 +47,7 @@ class MPINController extends GetxController { } final enteredMPIN = digitControllers.map((c) => c.text).join(); - logSafe("Entered MPIN: $enteredMPIN", sensitive: true); + logSafe("Entered MPIN: $enteredMPIN", ); if (enteredMPIN.length < 6) { _showError("Please enter all 6 digits."); @@ -56,7 +56,7 @@ class MPINController extends GetxController { if (isNewUser.value) { final retypeMPIN = retypeControllers.map((c) => c.text).join(); - logSafe("Retyped MPIN: $retypeMPIN", sensitive: true); + logSafe("Retyped MPIN: $retypeMPIN", ); if (retypeMPIN.length < 6) { _showError("Please enter all 6 digits in Retype MPIN."); @@ -177,7 +177,7 @@ class MPINController extends GetxController { return false; } - logSafe("Calling AuthService.generateMpin for employeeId: $employeeId", sensitive: true); + logSafe("Calling AuthService.generateMpin for employeeId: $employeeId", ); final response = await AuthService.generateMpin( employeeId: employeeId, @@ -222,7 +222,7 @@ class MPINController extends GetxController { logSafe("verifyMPIN triggered"); final enteredMPIN = digitControllers.map((c) => c.text).join(); - logSafe("Entered MPIN: $enteredMPIN", sensitive: true); + logSafe("Entered MPIN: $enteredMPIN", ); if (enteredMPIN.length < 6) { _showError("Please enter all 6 digits."); diff --git a/lib/controller/auth/otp_controller.dart b/lib/controller/auth/otp_controller.dart index 4f18c1e..06ebf88 100644 --- a/lib/controller/auth/otp_controller.dart +++ b/lib/controller/auth/otp_controller.dart @@ -144,7 +144,7 @@ class OTPController extends GetxController { Get.offAllNamed('/home'); } else { final error = result['error'] ?? "Failed to verify OTP"; - logSafe("[OTPController] OTP verification failed", level: LogLevel.warning, error: error, sensitive: true); + logSafe("[OTPController] OTP verification failed", level: LogLevel.warning, error: error, ); showAppSnackbar( title: "Error", message: error, diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index b22c0bf..2ead7d1 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -20,7 +20,7 @@ class DashboardController extends GetxController { logSafe( 'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}', level: LogLevel.info, - sensitive: true, + ); if (projectController.selectedProjectId.value.isNotEmpty) { @@ -30,7 +30,7 @@ class DashboardController extends GetxController { // React to project change ever(projectController.selectedProjectId, (id) { if (id.isNotEmpty) { - logSafe('Project changed to $id, fetching attendance', level: LogLevel.info, sensitive: true); + logSafe('Project changed to $id, fetching attendance', level: LogLevel.info, ); fetchRoleWiseAttendance(); } }); diff --git a/lib/controller/dashboard/employees_screen_controller.dart b/lib/controller/dashboard/employees_screen_controller.dart index ca91a91..273e080 100644 --- a/lib/controller/dashboard/employees_screen_controller.dart +++ b/lib/controller/dashboard/employees_screen_controller.dart @@ -106,15 +106,15 @@ class EmployeesScreenController extends GetxController { logSafe( "Employees fetched: ${employees.length} for project $projectId", level: LogLevel.info, - sensitive: true, + ); }, onEmpty: () { employees.clear(); - logSafe("No employees found for project $projectId.", level: LogLevel.warning, sensitive: true); + logSafe("No employees found for project $projectId.", level: LogLevel.warning, ); }, onError: (e) { - logSafe("Error fetching employees for project $projectId", level: LogLevel.error, error: e, sensitive: true); + logSafe("Error fetching employees for project $projectId", level: LogLevel.error, error: e, ); }, ); @@ -131,15 +131,15 @@ class EmployeesScreenController extends GetxController { () => ApiService.getEmployeeDetails(employeeId), onSuccess: (data) { selectedEmployeeDetails.value = EmployeeDetailsModel.fromJson(data); - logSafe("Employee details loaded for $employeeId", level: LogLevel.info, sensitive: true); + logSafe("Employee details loaded for $employeeId", level: LogLevel.info, ); }, onEmpty: () { selectedEmployeeDetails.value = null; - logSafe("No employee details found for $employeeId", level: LogLevel.warning, sensitive: true); + logSafe("No employee details found for $employeeId", level: LogLevel.warning, ); }, onError: (e) { selectedEmployeeDetails.value = null; - logSafe("Error fetching employee details for $employeeId", level: LogLevel.error, error: e, sensitive: true); + logSafe("Error fetching employee details for $employeeId", level: LogLevel.error, error: e, ); }, ); diff --git a/lib/controller/permission_controller.dart b/lib/controller/permission_controller.dart index 1eb55d5..166b2f0 100644 --- a/lib/controller/permission_controller.dart +++ b/lib/controller/permission_controller.dart @@ -76,7 +76,7 @@ class PermissionController extends GetxController { employeeInfo.value = userData['employeeInfo']; projectsInfo.assignAll(userData['projects']); - logSafe("State updated with new user data.", sensitive: true); + logSafe("State updated with new user data.", ); } catch (e, stacktrace) { logSafe("Error updating state", level: LogLevel.error, error: e, stackTrace: stacktrace); } @@ -86,7 +86,7 @@ class PermissionController extends GetxController { try { final prefs = await SharedPreferences.getInstance(); final token = prefs.getString('jwt_token'); - logSafe("Auth token retrieved successfully.", sensitive: true); + logSafe("Auth token retrieved successfully.", ); return token; } catch (e, stacktrace) { logSafe("Error retrieving auth token", level: LogLevel.error, error: e, stackTrace: stacktrace); diff --git a/lib/controller/task_planing/add_task_controller.dart b/lib/controller/task_planing/add_task_controller.dart index 4e644ae..8940fcf 100644 --- a/lib/controller/task_planing/add_task_controller.dart +++ b/lib/controller/task_planing/add_task_controller.dart @@ -147,6 +147,6 @@ class AddTaskController extends GetxController { void selectCategory(String id) { selectedCategoryId.value = id; selectedCategoryName.value = categoryIdNameMap[id]; - logSafe("Category selected", level: LogLevel.debug, sensitive: true); + logSafe("Category selected", level: LogLevel.debug, ); } } diff --git a/lib/controller/task_planing/daily_task_planing_controller.dart b/lib/controller/task_planing/daily_task_planing_controller.dart index 32b7e59..2e0a05c 100644 --- a/lib/controller/task_planing/daily_task_planing_controller.dart +++ b/lib/controller/task_planing/daily_task_planing_controller.dart @@ -50,12 +50,12 @@ class DailyTaskPlaningController extends GetxController { .where((e) => uploadingStates[e.id]?.value == true) .toList(); selectedEmployees.value = selected; - logSafe("Updated selected employees", level: LogLevel.debug, sensitive: true); + logSafe("Updated selected employees", level: LogLevel.debug, ); } void onRoleSelected(String? roleId) { selectedRoleId.value = roleId; - logSafe("Role selected", level: LogLevel.info, sensitive: true); + logSafe("Role selected", level: LogLevel.info, ); } Future fetchRoles() async { @@ -137,7 +137,7 @@ class DailyTaskPlaningController extends GetxController { final data = response?['data']; if (data != null) { dailyTasks = [TaskPlanningDetailsModel.fromJson(data)]; - logSafe("Daily task Planning Details fetched", level: LogLevel.info, sensitive: true); + logSafe("Daily task Planning Details fetched", level: LogLevel.info, ); } else { logSafe("Data field is null", level: LogLevel.warning); } @@ -164,14 +164,14 @@ class DailyTaskPlaningController extends GetxController { uploadingStates[emp.id] = false.obs; } logSafe("Employees fetched: ${employees.length} for project $projectId", - level: LogLevel.info, sensitive: true); + level: LogLevel.info, ); } else { employees = []; - logSafe("No employees found for project $projectId", level: LogLevel.warning, sensitive: true); + logSafe("No employees found for project $projectId", level: LogLevel.warning, ); } } catch (e, stack) { logSafe("Error fetching employees for project $projectId", - level: LogLevel.error, error: e, stackTrace: stack, sensitive: true); + level: LogLevel.error, error: e, stackTrace: stack, ); } finally { isLoading.value = false; update(); diff --git a/lib/controller/task_planing/report_task_action_controller.dart b/lib/controller/task_planing/report_task_action_controller.dart index 96e782d..8329898 100644 --- a/lib/controller/task_planing/report_task_action_controller.dart +++ b/lib/controller/task_planing/report_task_action_controller.dart @@ -272,18 +272,18 @@ class ReportTaskActionController extends MyController { final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75); if (pickedFile != null) { selectedImages.add(File(pickedFile.path)); - logSafe("Image added from camera: ${pickedFile.path}", sensitive: true); + logSafe("Image added from camera: ${pickedFile.path}", ); } } else { final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); - logSafe("${pickedFiles.length} images added from gallery.", sensitive: true); + logSafe("${pickedFiles.length} images added from gallery.", ); } } void removeImageAt(int index) { if (index >= 0 && index < selectedImages.length) { - logSafe("Removing image at index $index", sensitive: true); + logSafe("Removing image at index $index", ); selectedImages.removeAt(index); } } diff --git a/lib/controller/task_planing/report_task_controller.dart b/lib/controller/task_planing/report_task_controller.dart index 35077c7..02e4602 100644 --- a/lib/controller/task_planing/report_task_controller.dart +++ b/lib/controller/task_planing/report_task_controller.dart @@ -83,7 +83,7 @@ class ReportTaskController extends MyController { required DateTime reportedDate, List? images, }) async { - logSafe("Reporting task for projectId", sensitive: true); + logSafe("Reporting task for projectId", ); final completedWork = completedWorkController.text.trim(); if (completedWork.isEmpty || int.tryParse(completedWork) == null || int.parse(completedWork) < 0) { _showError("Completed work must be a positive number."); @@ -138,7 +138,7 @@ class ReportTaskController extends MyController { required String comment, List? images, }) async { - logSafe("Submitting comment for project", sensitive: true); + logSafe("Submitting comment for project", ); final commentField = commentController.text.trim(); if (commentField.isEmpty) { @@ -221,7 +221,7 @@ class ReportTaskController extends MyController { final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); } - logSafe("Images picked: ${selectedImages.length}", sensitive: true); + logSafe("Images picked: ${selectedImages.length}", ); } catch (e) { logSafe("Error picking images", level: LogLevel.warning, error: e); } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index f90df9f..75a3abf 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -163,7 +163,7 @@ class ApiService { final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); logSafe("POST $uri\nHeaders: ${_headers(token)}\nBody: $body", - sensitive: true); + ); try { final response = await http @@ -203,7 +203,7 @@ class ApiService { if (additionalHeaders != null) ...additionalHeaders, }; - logSafe("PUT $uri\nHeaders: $headers\nBody: $body", sensitive: true); + logSafe("PUT $uri\nHeaders: $headers\nBody: $body", ); try { final response = await http diff --git a/lib/helpers/services/permission_service.dart b/lib/helpers/services/permission_service.dart index aa9d492..ebde963 100644 --- a/lib/helpers/services/permission_service.dart +++ b/lib/helpers/services/permission_service.dart @@ -19,10 +19,10 @@ class PermissionService { String token, { bool hasRetried = false, }) async { - logSafe("Fetching user data...", sensitive: true); + logSafe("Fetching user data...", ); if (_userDataCache.containsKey(token)) { - logSafe("User data cache hit.", sensitive: true); + logSafe("User data cache hit.", ); return _userDataCache[token]!; } diff --git a/lib/helpers/utils/launcher_utils.dart b/lib/helpers/utils/launcher_utils.dart index 8ac99d8..5733bd6 100644 --- a/lib/helpers/utils/launcher_utils.dart +++ b/lib/helpers/utils/launcher_utils.dart @@ -6,7 +6,7 @@ import 'package:marco/helpers/services/app_logger.dart'; class LauncherUtils { /// Launches the phone dialer with the provided phone number static Future launchPhone(String phoneNumber) async { - logSafe('Attempting to launch phone: $phoneNumber', sensitive: true); + logSafe('Attempting to launch phone: $phoneNumber', ); final Uri url = Uri(scheme: 'tel', path: phoneNumber); await _tryLaunch(url, 'Could not launch phone'); @@ -14,7 +14,7 @@ class LauncherUtils { /// Launches the email app with the provided email address static Future launchEmail(String email) async { - logSafe('Attempting to launch email: $email', sensitive: true); + logSafe('Attempting to launch email: $email', ); final Uri url = Uri(scheme: 'mailto', path: email); await _tryLaunch(url, 'Could not launch email'); @@ -22,17 +22,17 @@ class LauncherUtils { /// Launches WhatsApp with the provided phone number static Future launchWhatsApp(String phoneNumber) async { - logSafe('Attempting to launch WhatsApp with: $phoneNumber', sensitive: true); + 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', sensitive: true); + logSafe('Normalized WhatsApp number: $normalized', ); if (normalized.length < 12) { - logSafe('Invalid WhatsApp number: $normalized', sensitive: true); + logSafe('Invalid WhatsApp number: $normalized', ); showAppSnackbar( title: 'Error', message: 'Invalid phone number for WhatsApp', @@ -62,7 +62,7 @@ class LauncherUtils { 'Failed to copy $typeLabel to clipboard: $e', stackTrace: st, level: LogLevel.error, - sensitive: true, + ); showAppSnackbar( title: 'Error', diff --git a/lib/helpers/widgets/my_image_compressor.dart b/lib/helpers/widgets/my_image_compressor.dart index a885499..ce8bb2b 100644 --- a/lib/helpers/widgets/my_image_compressor.dart +++ b/lib/helpers/widgets/my_image_compressor.dart @@ -14,7 +14,7 @@ Future compressImageToUnder100KB(File file) async { const int maxWidth = 800; const int maxHeight = 800; - logSafe("Starting image compression...", sensitive: true); + logSafe("Starting image compression...", ); while (quality >= 10) { try { @@ -59,7 +59,7 @@ Future saveCompressedImageToFile(Uint8List bytes) async { final file = File(filePath); final savedFile = await file.writeAsBytes(bytes); - logSafe("Compressed image saved to ${savedFile.path}", sensitive: true); + logSafe("Compressed image saved to ${savedFile.path}", ); return savedFile; } catch (e, stacktrace) { logSafe("Error saving compressed image", level: LogLevel.error, error: e, stackTrace: stacktrace); diff --git a/lib/view/auth/login_option_screen.dart b/lib/view/auth/login_option_screen.dart index c155cb9..00e0135 100644 --- a/lib/view/auth/login_option_screen.dart +++ b/lib/view/auth/login_option_screen.dart @@ -1,225 +1,166 @@ import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/images.dart'; import 'package:marco/view/auth/email_login_form.dart'; import 'package:marco/view/auth/otp_login_form.dart'; import 'package:marco/helpers/services/api_endpoints.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; // Make sure this import is added enum LoginOption { email, otp } -class LoginOptionScreen extends StatefulWidget { +class LoginOptionScreen extends StatelessWidget { const LoginOptionScreen({super.key}); @override - State createState() => _LoginOptionScreenState(); + Widget build(BuildContext context) => const WelcomeScreen(); } -class _LoginOptionScreenState extends State with UIMixin { - LoginOption _selectedOption = LoginOption.email; +class WelcomeScreen extends StatelessWidget { + const WelcomeScreen({super.key}); + bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); + void _showLoginDialog(BuildContext context, LoginOption option) { + showDialog( + context: context, + builder: (_) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + insetPadding: const EdgeInsets.all(24), + child: Padding( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MyText( + option == LoginOption.email ? "Login with Email" : "Login with OTP", + fontSize: 20, + fontWeight: 700, + ), + const SizedBox(height: 20), + option == LoginOption.email ? EmailLoginForm() : const OTPLoginScreen(), + ], + ), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + return Scaffold( - backgroundColor: contentTheme.brandRed, + backgroundColor: const Color(0xFFB71C1C), body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return Column( - children: [ - const SizedBox(height: 24), - _buildHeader(), - const SizedBox(height: 16), - _buildWelcomeTextsAndChips(), - const SizedBox(height: 16), - Expanded( - child: Container( - width: double.infinity, - decoration: const BoxDecoration( + 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: [ + // App Logo + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( color: Colors.white, - borderRadius: - BorderRadius.vertical(top: Radius.circular(32)), + borderRadius: BorderRadius.circular(20), + boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 8)], ), - 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()), - ], - ), - ), + child: Image.asset(Images.logoDark, height: 60), + ), + + const SizedBox(height: 24), + + // Welcome Text + MyText( + "Welcome to Marco", + fontSize: 24, + fontWeight: 800, + color: Colors.white, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + MyText( + "Streamline Project Management\nBoost Productivity with Automation.", + fontSize: 14, + color: Colors.white70, + 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), + + // Login Buttons + _buildLoginButton( + context, + label: "Login with Username", + icon: LucideIcons.mail, + option: LoginOption.email, ), - ), - ], - ); - }, + const SizedBox(height: 16), + _buildLoginButton( + context, + label: "Login with OTP", + icon: LucideIcons.message_square, + option: LoginOption.otp, + ), + + const SizedBox(height: 36), + + // Version Info + MyText( + 'App version 1.0.0', + color: Colors.white60, + 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, + Widget _buildLoginButton(BuildContext context, + {required String label, required IconData icon, required LoginOption option}) { + return SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: Icon(icon, size: 20, color: Colors.white), + label: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: MyText(label, fontSize: 16, fontWeight: 600, color: Colors.white), ), - _buildOptionChip( - title: "OTP", - icon: LucideIcons.message_square, - value: LoginOption.otp, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFB71C1C), + side: const BorderSide(color: Colors.white70), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 4, ), - ], - ); - } - - 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(), - ], + onPressed: () => _showLoginDialog(context, option), ), ); } - - Widget _buildOptionChip({ - required String title, - required IconData icon, - required LoginOption value, - }) { - final bool isSelected = _selectedOption == value; - - final Color selectedTextColor = contentTheme.brandRed; - final Color unselectedTextColor = Colors.white; - final Color selectedBgColor = Colors.grey[100]!; - final Color unselectedBgColor = contentTheme.brandRed; - - return ChoiceChip( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 18, - color: isSelected ? selectedTextColor : unselectedTextColor, - ), - const SizedBox(width: 6), - MyText( - title, - fontSize: 14, - fontWeight: 600, - color: isSelected ? selectedTextColor : unselectedTextColor, - ), - ], - ), - selected: isSelected, - onSelected: (_) => setState(() => _selectedOption = value), - selectedColor: selectedBgColor, - backgroundColor: unselectedBgColor, - side: BorderSide( - color: Colors.white.withOpacity(0.6), - width: 1.2, - ), - elevation: 3, - shadowColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15), - ), - ); - } - - Widget _buildLoginForm() { - switch (_selectedOption) { - case LoginOption.email: - return EmailLoginForm(); - case LoginOption.otp: - return const OTPLoginScreen(); - } - } - - Widget _buildVersionInfo() { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: MyText( - 'App version 1.0.0', - color: Colors.grey.shade500, - fontSize: 12, - ), - ); - } -} +} \ No newline at end of file diff --git a/lib/view/my_app.dart b/lib/view/my_app.dart index 605a78c..a27ae57 100644 --- a/lib/view/my_app.dart +++ b/lib/view/my_app.dart @@ -25,7 +25,7 @@ class MyApp extends StatelessWidget { } final bool hasMpin = LocalStorage.getIsMpin(); - logSafe("MPIN enabled: $hasMpin", sensitive: true); + logSafe("MPIN enabled: $hasMpin", ); if (hasMpin) { await LocalStorage.setBool("mpin_verified", false); -- 2.43.0 From f4135a77d890b86103804b22c962ff108663cc43 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 9 Jul 2025 11:53:30 +0530 Subject: [PATCH 32/39] feat(login): refactor login option screen to include demo request button and improve layout --- lib/view/auth/email_login_form.dart | 19 +----- lib/view/auth/login_option_screen.dart | 87 +++++++++++++++++--------- 2 files changed, 61 insertions(+), 45 deletions(-) diff --git a/lib/view/auth/email_login_form.dart b/lib/view/auth/email_login_form.dart index 03db388..eff0222 100644 --- a/lib/view/auth/email_login_form.dart +++ b/lib/view/auth/email_login_form.dart @@ -8,7 +8,7 @@ import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; -import 'package:marco/view/auth/request_demo_bottom_sheet.dart'; + import 'package:marco/helpers/services/api_endpoints.dart'; class EmailLoginForm extends StatefulWidget { @@ -136,22 +136,7 @@ class _EmailLoginFormState extends State 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, - ), - ), - ), + ], ), ); diff --git a/lib/view/auth/login_option_screen.dart b/lib/view/auth/login_option_screen.dart index 00e0135..9b25cc3 100644 --- a/lib/view/auth/login_option_screen.dart +++ b/lib/view/auth/login_option_screen.dart @@ -5,6 +5,7 @@ import 'package:marco/images.dart'; import 'package:marco/view/auth/email_login_form.dart'; import 'package:marco/view/auth/otp_login_form.dart'; import 'package:marco/helpers/services/api_endpoints.dart'; +import 'package:marco/view/auth/request_demo_bottom_sheet.dart'; enum LoginOption { email, otp } @@ -26,21 +27,25 @@ class WelcomeScreen extends StatelessWidget { builder: (_) => Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), insetPadding: const EdgeInsets.all(24), - child: Padding( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - MyText( - option == LoginOption.email ? "Login with Email" : "Login with OTP", - fontSize: 20, - fontWeight: 700, - ), - const SizedBox(height: 20), - option == LoginOption.email ? EmailLoginForm() : const OTPLoginScreen(), - ], + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MyText( + option == LoginOption.email ? "Login with Email" : "Login with OTP", + fontSize: 20, + fontWeight: 700, + ), + const SizedBox(height: 20), + option == LoginOption.email + ? EmailLoginForm() + : const OTPLoginScreen(), + ], + ), ), ), ), @@ -59,17 +64,21 @@ class WelcomeScreen extends StatelessWidget { child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: screenWidth < 500 ? double.infinity : 420), + constraints: BoxConstraints( + maxWidth: screenWidth < 500 ? double.infinity : 420, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // App Logo + // Logo Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), - boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 8)], + boxShadow: const [ + BoxShadow(color: Colors.black26, blurRadius: 8), + ], ), child: Image.asset(Images.logoDark, height: 60), ), @@ -112,22 +121,27 @@ class WelcomeScreen extends StatelessWidget { const SizedBox(height: 36), // Login Buttons - _buildLoginButton( + _buildActionButton( context, label: "Login with Username", icon: LucideIcons.mail, option: LoginOption.email, ), const SizedBox(height: 16), - _buildLoginButton( + _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), - // Version Info MyText( 'App version 1.0.0', @@ -143,24 +157,41 @@ class WelcomeScreen extends StatelessWidget { ); } - Widget _buildLoginButton(BuildContext context, - {required String label, required IconData icon, required LoginOption option}) { + Widget _buildActionButton( + BuildContext context, { + required String label, + required IconData icon, + LoginOption? option, // nullable for "Request a Demo" + }) { return SizedBox( width: double.infinity, child: ElevatedButton.icon( icon: Icon(icon, size: 20, color: Colors.white), label: Padding( padding: const EdgeInsets.symmetric(vertical: 12), - child: MyText(label, fontSize: 16, fontWeight: 600, color: Colors.white), + child: MyText( + label, + fontSize: 16, + fontWeight: 600, + color: Colors.white, + ), ), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFFB71C1C), side: const BorderSide(color: Colors.white70), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), elevation: 4, ), - onPressed: () => _showLoginDialog(context, option), + onPressed: () { + if (option == null) { + OrganizationFormBottomSheet.show(context); + } else { + _showLoginDialog(context, option); + } + }, ), ); } -} \ No newline at end of file +} -- 2.43.0 From 91e2bb7bc855106318f16546aef548046d77dec4 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 9 Jul 2025 12:27:58 +0530 Subject: [PATCH 33/39] feat(login): enhance WelcomeScreen with animation and improved dialog layout --- lib/view/auth/login_option_screen.dart | 359 +++++++++++++++++-------- 1 file changed, 240 insertions(+), 119 deletions(-) diff --git a/lib/view/auth/login_option_screen.dart b/lib/view/auth/login_option_screen.dart index 9b25cc3..572a402 100644 --- a/lib/view/auth/login_option_screen.dart +++ b/lib/view/auth/login_option_screen.dart @@ -16,143 +16,199 @@ class LoginOptionScreen extends StatelessWidget { Widget build(BuildContext context) => const WelcomeScreen(); } -class WelcomeScreen extends StatelessWidget { +class WelcomeScreen extends StatefulWidget { const WelcomeScreen({super.key}); + @override + State createState() => _WelcomeScreenState(); +} + +class _WelcomeScreenState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _logoAnimation; + bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _logoAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutBack, + ); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + void _showLoginDialog(BuildContext context, LoginOption option) { - showDialog( - context: context, - 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: [ - MyText( - option == LoginOption.email ? "Login with Email" : "Login with OTP", - fontSize: 20, - fontWeight: 700, - ), - const SizedBox(height: 20), - option == LoginOption.email - ? EmailLoginForm() - : const OTPLoginScreen(), - ], - ), + showDialog( + context: context, + barrierDismissible: false, // Prevent dismiss on outside tap + builder: (_) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + insetPadding: const EdgeInsets.all(24), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Row with title and close button + Row( + children: [ + Expanded( + child: MyText( + option == LoginOption.email + ? "Login with Email" + : "Login with OTP", + fontSize: 20, + fontWeight: 700, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 20), + option == LoginOption.email + ? EmailLoginForm() + : const OTPLoginScreen(), + ], ), ), ), ), - ); - } + ), + ); +} @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; return Scaffold( - backgroundColor: const Color(0xFFB71C1C), - body: 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 - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: const [ - BoxShadow(color: Colors.black26, blurRadius: 8), - ], - ), - child: Image.asset(Images.logoDark, height: 60), + 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, ), - - const SizedBox(height: 24), - - // Welcome Text - MyText( - "Welcome to Marco", - fontSize: 24, - fontWeight: 800, - color: Colors.white, - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - MyText( - "Streamline Project Management\nBoost Productivity with Automation.", - fontSize: 14, - color: Colors.white70, - 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: 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), + ), ), - child: MyText( - 'BETA', - color: Colors.white, - fontWeight: 600, + + 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, ), - ), - ], - - const SizedBox(height: 36), - - // Login Buttons - _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), - // Version Info - MyText( - 'App version 1.0.0', - color: Colors.white60, - fontSize: 12, - ), - ], + ), ), ), ), - ), + ], ), ); } @@ -161,14 +217,14 @@ class WelcomeScreen extends StatelessWidget { BuildContext context, { required String label, required IconData icon, - LoginOption? option, // nullable for "Request a Demo" + LoginOption? option, }) { return SizedBox( width: double.infinity, child: ElevatedButton.icon( icon: Icon(icon, size: 20, color: Colors.white), label: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric(vertical: 14), child: MyText( label, fontSize: 16, @@ -177,12 +233,13 @@ class WelcomeScreen extends StatelessWidget { ), ), style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFB71C1C), - side: const BorderSide(color: Colors.white70), + backgroundColor: const Color(0xFFB71C1C), // Red background + foregroundColor: Colors.white, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), elevation: 4, + shadowColor: Colors.black26, ), onPressed: () { if (option == null) { @@ -195,3 +252,67 @@ class WelcomeScreen extends StatelessWidget { ); } } + +/// 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; +} -- 2.43.0 From efd5021ab1a8e65d2a1b60d187f3b8a94d32e01f Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 9 Jul 2025 12:43:19 +0530 Subject: [PATCH 34/39] feat(mpin-auth): enhance MPINAuthScreen with logo animation and improved layout --- lib/view/auth/mpin_auth_screen.dart | 349 ++++++++++++++++------------ 1 file changed, 200 insertions(+), 149 deletions(-) diff --git a/lib/view/auth/mpin_auth_screen.dart b/lib/view/auth/mpin_auth_screen.dart index 3efb225..adbfe6f 100644 --- a/lib/view/auth/mpin_auth_screen.dart +++ b/lib/view/auth/mpin_auth_screen.dart @@ -16,168 +16,171 @@ class MPINAuthScreen extends StatefulWidget { State createState() => _MPINAuthScreenState(); } -class _MPINAuthScreenState extends State with UIMixin { +class _MPINAuthScreenState extends State + with UIMixin, SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _logoAnimation; + bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _logoAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutBack, + ); + _controller.forward(); + } + @override void dispose() { Get.delete(); + _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final MPINController controller = Get.put(MPINController()); + final controller = Get.put(MPINController()); return Scaffold( - backgroundColor: contentTheme.brandRed, - body: SafeArea( - child: LayoutBuilder(builder: (context, constraints) { - return Column( - children: [ - _buildHeader(), - const SizedBox(height: 16), - _buildWelcomeTextsAndChips(), - const SizedBox(height: 16), - Expanded( - child: Container( - width: double.infinity, - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: - BorderRadius.vertical(top: Radius.circular(32)), + body: Stack( + children: [ + const _RedWaveBackground(), + SafeArea( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 24), + // Static Logo (not scrollable) + ScaleTransition( + scale: _logoAnimation, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + padding: const EdgeInsets.all(20), + child: Image.asset(Images.logoDark), + ), ), - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 32), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 120), - child: Obx(() { - final isNewUser = controller.isNewUser.value; - - return IntrinsicHeight( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - MyText.headlineSmall( - isNewUser ? 'Generate MPIN' : 'Enter MPIN', - fontWeight: 700, - color: Colors.black87, - ), - const SizedBox(height: 8), - MyText.bodyMedium( - isNewUser - ? 'Set your 6-digit MPIN for quick login.' - : 'Enter your 6-digit MPIN to continue.', - color: Colors.black54, - fontSize: 16, - textAlign: TextAlign.center, - ), - const SizedBox(height: 32), - _buildMPINForm(controller, isNewUser), - const SizedBox(height: 40), - _buildSubmitButton(controller, isNewUser), - const SizedBox(height: 24), - _buildFooterOptions(controller, isNewUser), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 24), - child: Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: () async { - await LocalStorage.logout(); - }, - icon: const Icon(Icons.arrow_back, - color: Colors.white), - label: MyText.bodyMedium( - 'Back to Home Page', - color: Colors.white, - fontWeight: 600, - fontSize: 14, - ), - style: TextButton.styleFrom( - foregroundColor: Colors.white, - ), - ), + const SizedBox(height: 8), + // Scrollable content below the logo + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + children: [ + const SizedBox(height: 12), + MyText( + "Welcome to Marco", + fontSize: 24, + fontWeight: 800, + color: Colors.black87, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + MyText( + "Streamline Project Management\nBoost Productivity with Automation.", + fontSize: 14, + color: Colors.black54, + textAlign: TextAlign.center, + ), + if (_isBetaEnvironment) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.orangeAccent, + borderRadius: BorderRadius.circular(6), + ), + child: MyText( + 'BETA', + color: Colors.white, + fontWeight: 600, + fontSize: 12, ), ), ], - ), - ); - }), + const SizedBox(height: 36), + _buildMPINCard(controller), + ], + ), + ), ), ), - ), + ], ), - ], - ); - }), + ), + ), + ], ), ); } - Widget _buildWelcomeTextsAndChips() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - MyText.headlineSmall( - "Welcome to Marco", - fontWeight: 700, - color: Colors.white, - textAlign: TextAlign.center, - fontSize: 20, - ), - const SizedBox(height: 4), - MyText.bodyMedium( - "Streamline Project Management and Boost Productivity with Automation.", - color: Colors.white70, - fontSize: 14, - textAlign: TextAlign.center, - ), - if (_isBetaEnvironment) ...[ - const SizedBox(height: 8), - _buildBetaLabel(), + Widget _buildMPINCard(MPINController controller) { + return Obx(() { + final isNewUser = controller.isNewUser.value; + + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, 4), + ), ], - ], - ), - ); - } - - Widget _buildBetaLabel() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.orangeAccent, - borderRadius: BorderRadius.circular(4), - ), - child: MyText.bodySmall( - 'BETA', - color: Colors.white, - fontWeight: 600, - fontSize: 12, - ), - ); - } - - Widget _buildHeader() { - return Container( - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.symmetric(horizontal: 24), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 6, - offset: Offset(0, 3), - ), - ], - ), - child: Image.asset(Images.logoDark, height: 70), - ); + ), + child: Column( + children: [ + MyText( + isNewUser ? 'Generate MPIN' : 'Enter MPIN', + fontSize: 20, + fontWeight: 700, + color: Colors.black87, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + MyText( + isNewUser + ? 'Set your 6-digit MPIN for quick login.' + : 'Enter your 6-digit MPIN to continue.', + fontSize: 14, + color: Colors.black54, + textAlign: TextAlign.center, + ), + const SizedBox(height: 30), + _buildMPINForm(controller, isNewUser), + const SizedBox(height: 32), + _buildSubmitButton(controller, isNewUser), + const SizedBox(height: 20), + _buildFooterOptions(controller, isNewUser), + ], + ), + ); + }); } Widget _buildMPINForm(MPINController controller, bool isNewUser) { @@ -187,8 +190,8 @@ class _MPINAuthScreenState extends State with UIMixin { children: [ _buildDigitRow(controller, isRetype: false), if (isNewUser) ...[ - const SizedBox(height: 24), - MyText.bodyMedium( + const SizedBox(height: 20), + MyText( 'Retype MPIN', fontWeight: 600, color: Colors.black.withOpacity(0.6), @@ -203,8 +206,10 @@ class _MPINAuthScreenState extends State with UIMixin { } Widget _buildDigitRow(MPINController controller, {required bool isRetype}) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, + return Wrap( + alignment: WrapAlignment.center, + spacing: 0, + runSpacing: 12, children: List.generate(6, (index) { return _buildDigitBox(controller, index, isRetype); }), @@ -221,7 +226,7 @@ class _MPINAuthScreenState extends State with UIMixin { return Container( margin: const EdgeInsets.symmetric(horizontal: 6), - width: 40, + width: 30, height: 55, child: TextFormField( controller: textController, @@ -294,10 +299,7 @@ class _MPINAuthScreenState extends State with UIMixin { children: [ if (isNewUser) TextButton.icon( - onPressed: () async { - Get.delete(); - Get.toNamed('/dashboard'); - }, + onPressed: () => Get.toNamed('/dashboard'), icon: const Icon(Icons.arrow_back, size: 18, color: Colors.redAccent), label: MyText.bodyMedium( @@ -310,7 +312,6 @@ class _MPINAuthScreenState extends State with UIMixin { if (showBackToLogin) TextButton.icon( onPressed: () async { - Get.delete(); await LocalStorage.logout(); }, icon: const Icon(Icons.arrow_back, @@ -327,3 +328,53 @@ class _MPINAuthScreenState extends State 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; +} -- 2.43.0 From ffba37b767b6a54cc5709a542e096f21cbe36097 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 9 Jul 2025 13:02:35 +0530 Subject: [PATCH 35/39] feat(forgot-password): enhance ForgotPasswordScreen with logo animation and improved layout --- lib/view/auth/forgot_password_screen.dart | 388 +++++++++++++--------- 1 file changed, 226 insertions(+), 162 deletions(-) diff --git a/lib/view/auth/forgot_password_screen.dart b/lib/view/auth/forgot_password_screen.dart index e6d0c64..76a9330 100644 --- a/lib/view/auth/forgot_password_screen.dart +++ b/lib/view/auth/forgot_password_screen.dart @@ -1,15 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:get/get.dart'; import 'package:marco/controller/auth/forgot_password_controller.dart'; import 'package:marco/helpers/widgets/my_button.dart'; -import 'package:marco/helpers/widgets/my_text_style.dart'; -import 'package:marco/images.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/images.dart'; class ForgotPasswordScreen extends StatefulWidget { const ForgotPasswordScreen({super.key}); @@ -19,208 +18,273 @@ class ForgotPasswordScreen extends StatefulWidget { } class _ForgotPasswordScreenState extends State - with UIMixin { + with UIMixin, SingleTickerProviderStateMixin { final ForgotPasswordController controller = Get.put(ForgotPasswordController()); + late AnimationController _controller; + late Animation _logoAnimation; + bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); bool _isLoading = false; - void _handleForgotPassword() async { - setState(() { - _isLoading = true; - }); + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _logoAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutBack, + ); + _controller.forward(); + } + @override + void dispose() { + _controller.dispose(); + Get.delete(); + super.dispose(); + } + + Future _handleForgotPassword() async { + setState(() => _isLoading = true); await controller.onForgotPassword(); - - setState(() { - _isLoading = false; - }); + setState(() => _isLoading = false); } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: contentTheme.brandRed, - body: SafeArea( - child: LayoutBuilder(builder: (context, constraints) { - return Column( - children: [ - const SizedBox(height: 24), - _buildHeader(), - const SizedBox(height: 16), - _buildWelcomeTextsAndChips(), - const SizedBox(height: 16), - Expanded( - child: Container( - width: double.infinity, - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: - BorderRadius.vertical(top: Radius.circular(32)), - ), - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 32), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 120, + body: Stack( + children: [ + const _RedWaveBackground(), + SafeArea( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 24), + ScaleTransition( + scale: _logoAnimation, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], ), - child: Form( - key: controller.basicValidator.formKey, + padding: const EdgeInsets.all(20), + 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( - crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleLarge( - 'Forgot Password', - fontWeight: 700, + const SizedBox(height: 12), + MyText( + "Welcome to Marco", + fontSize: 24, + fontWeight: 800, color: Colors.black87, + textAlign: TextAlign.center, ), - const SizedBox(height: 8), - MyText.bodyMedium( - "Enter your email and we'll send you instructions to reset your password.", + const SizedBox(height: 10), + MyText( + "Streamline Project Management\nBoost Productivity with Automation.", + fontSize: 14, color: Colors.black54, textAlign: TextAlign.center, ), - const SizedBox(height: 32), - TextFormField( - validator: controller.basicValidator - .getValidation('email'), - controller: controller.basicValidator - .getController('email'), - keyboardType: TextInputType.emailAddress, - style: MyTextStyle.labelMedium(), - decoration: InputDecoration( - labelText: "Email Address", - labelStyle: MyTextStyle.bodySmall(xMuted: true), - filled: true, - fillColor: Colors.grey.shade100, - prefixIcon: - const Icon(LucideIcons.mail, size: 20), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, + 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, ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 16), - floatingLabelBehavior: - FloatingLabelBehavior.auto, ), - ), - const SizedBox(height: 40), - MyButton.rounded( - 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, - ), - ], - ), - ), + ], + const SizedBox(height: 36), + _buildForgotCard(), ], ), ), ), ), - ), + ], ), - ], - ); - }), - ), - ); - } - - 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( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.orangeAccent, - borderRadius: BorderRadius.circular(4), - ), - child: MyText.bodySmall( - 'BETA', - fontWeight: 600, - color: Colors.white, - ), - ); - } - - Widget _buildHeader() { - return Container( - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(20), boxShadow: const [ BoxShadow( color: Colors.black12, - blurRadius: 6, - offset: Offset(0, 3), + blurRadius: 10, + 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; +} -- 2.43.0 From 5b5030ec3635449be90a79fc7a0ff0b3bf9532f1 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 9 Jul 2025 15:48:15 +0530 Subject: [PATCH 36/39] feat(auth): refactor login success flow to inject controllers and load data conditionally --- lib/controller/permission_controller.dart | 102 +++++++++++----------- lib/controller/project_controller.dart | 2 +- lib/helpers/services/app_initializer.dart | 20 +++-- lib/helpers/services/auth_service.dart | 61 ++++++++----- 4 files changed, 106 insertions(+), 79 deletions(-) diff --git a/lib/controller/permission_controller.dart b/lib/controller/permission_controller.dart index 166b2f0..0802f33 100644 --- a/lib/controller/permission_controller.dart +++ b/lib/controller/permission_controller.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:get/get.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/model/user_permission.dart'; import 'package:marco/model/employee_info.dart'; @@ -17,8 +17,51 @@ class PermissionController extends GetxController { @override void onInit() { super.onInit(); - _loadDataFromAPI(); - _startAutoRefresh(); + _initialize(); + } + + Future _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 _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 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 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 _storeData() async { @@ -50,54 +93,15 @@ class PermissionController extends GetxController { } } - Future _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 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 userData) { - try { - permissions.assignAll(userData['permissions']); - employeeInfo.value = userData['employeeInfo']; - projectsInfo.assignAll(userData['projects']); - - logSafe("State updated with new user data.", ); - } catch (e, stacktrace) { - logSafe("Error updating state", level: LogLevel.error, error: e, stackTrace: stacktrace); - } - } - - Future _getAuthToken() async { - try { - final prefs = await SharedPreferences.getInstance(); - final token = prefs.getString('jwt_token'); - logSafe("Auth token retrieved successfully.", ); - return token; - } catch (e, stacktrace) { - logSafe("Error retrieving auth token", level: LogLevel.error, error: e, stackTrace: stacktrace); - return null; - } - } - void _startAutoRefresh() { _refreshTimer = Timer.periodic(Duration(minutes: 30), (timer) async { logSafe("Auto-refresh triggered."); - await _loadDataFromAPI(); + 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 void onClose() { _refreshTimer?.cancel(); - logSafe("PermissionController disposed and timer cancelled."); + logSafe("PermissionController disposed and auto-refresh timer cancelled."); super.onClose(); } } diff --git a/lib/controller/project_controller.dart b/lib/controller/project_controller.dart index 6fed738..a31cfcd 100644 --- a/lib/controller/project_controller.dart +++ b/lib/controller/project_controller.dart @@ -66,7 +66,7 @@ class ProjectController extends GetxController { isProjectSelectionExpanded.value = false; logSafe("Projects fetched: ${projects.length}"); } else { - logSafe("No projects found or API call failed.", level: LogLevel.warning); + logSafe("No Global projects found or API call failed.", level: LogLevel.warning); } isLoadingProjects.value = false; diff --git a/lib/helpers/services/app_initializer.dart b/lib/helpers/services/app_initializer.dart index d674a39..0f6e7d0 100644 --- a/lib/helpers/services/app_initializer.dart +++ b/lib/helpers/services/app_initializer.dart @@ -27,18 +27,28 @@ Future initializeApp() async { await ThemeCustomizer.init(); logSafe("πŸ’‘ Theme customizer initialized."); - Get.put(PermissionController()); - logSafe("πŸ’‘ PermissionController injected."); + final token = LocalStorage.getString('jwt_token'); + if (token != null && token.isNotEmpty) { + if (!Get.isRegistered()) { + Get.put(PermissionController()); + logSafe("πŸ’‘ PermissionController injected."); + } - Get.put(ProjectController(), permanent: true); - logSafe("πŸ’‘ ProjectController injected as permanent."); + if (!Get.isRegistered()) { + Get.put(ProjectController(), permanent: true); + logSafe("πŸ’‘ ProjectController injected as permanent."); + } + } else { + logSafe("⚠️ No token found. Skipping PermissionController and ProjectController injection."); + } AppStyle.init(); logSafe("πŸ’‘ AppStyle initialized."); logSafe("βœ… App initialization completed successfully."); } catch (e, stacktrace) { - logSafe("β›” Error during app initialization", + logSafe( + "β›” Error during app initialization", level: LogLevel.error, error: e, stackTrace: stacktrace, diff --git a/lib/helpers/services/auth_service.dart b/lib/helpers/services/auth_service.dart index bff8272..5c0bc0c 100644 --- a/lib/helpers/services/auth_service.dart +++ b/lib/helpers/services/auth_service.dart @@ -247,32 +247,45 @@ class AuthService { } /// Handle login success flow - static Future _handleLoginSuccess(Map data) async { - logSafe("Processing login success..."); + static Future _handleLoginSuccess(Map data) async { + logSafe("Processing login success..."); - final jwtToken = data['token']; - final refreshToken = data['refreshToken']; - final mpinToken = data['mpinToken']; + final jwtToken = data['token']; + final refreshToken = data['refreshToken']; + final mpinToken = data['mpinToken']; - await LocalStorage.setJwtToken(jwtToken); - await LocalStorage.setLoggedInUser(true); + // Save tokens + await LocalStorage.setJwtToken(jwtToken); + await LocalStorage.setLoggedInUser(true); - 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().fetchProjects(); - - isLoggedIn = true; - logSafe("Login flow completed."); + 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(); + } + + // Inject controllers if not already registered + if (!Get.isRegistered()) { + Get.put(PermissionController()); + logSafe("βœ… PermissionController injected after login."); + } + + if (!Get.isRegistered()) { + Get.put(ProjectController(), permanent: true); + logSafe("βœ… ProjectController injected after login."); + } + + // Load data into controllers + await Get.find().loadData(jwtToken); + await Get.find().fetchProjects(); + + isLoggedIn = true; + logSafe("βœ… Login flow completed and controllers initialized."); } +} \ No newline at end of file -- 2.43.0 From 1e1bcc3aa4a66a7d2a9b2df37549f25e9b2f56a6 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 9 Jul 2025 16:40:23 +0530 Subject: [PATCH 37/39] feat(otp): implement email saving and loading functionality in OTPController --- lib/controller/auth/otp_controller.dart | 27 +++++++++++++++++++++-- lib/helpers/services/app_initializer.dart | 22 ++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/lib/controller/auth/otp_controller.dart b/lib/controller/auth/otp_controller.dart index 06ebf88..43c76ac 100644 --- a/lib/controller/auth/otp_controller.dart +++ b/lib/controller/auth/otp_controller.dart @@ -25,6 +25,7 @@ class OTPController extends GetxController { void onInit() { super.onInit(); timer.value = 0; + _loadSavedEmail(); logSafe("[OTPController] Initialized"); } @@ -53,7 +54,6 @@ class OTPController extends GetxController { "[OTPController] OTP send failed", level: LogLevel.warning, error: result['error'], - ); showAppSnackbar( title: "Error", @@ -85,6 +85,7 @@ class OTPController extends GetxController { if (success) { email.value = userEmail; isOTPSent.value = true; + await _saveEmailIfRemembered(userEmail); _startTimer(); _clearOTPFields(); } @@ -144,7 +145,7 @@ class OTPController extends GetxController { Get.offAllNamed('/home'); } else { final error = result['error'] ?? "Failed to verify OTP"; - logSafe("[OTPController] OTP verification failed", level: LogLevel.warning, error: error, ); + logSafe("[OTPController] OTP verification failed", level: LogLevel.warning, error: error); showAppSnackbar( title: "Error", message: error, @@ -189,10 +190,32 @@ class OTPController extends GetxController { for (final node in focusNodes) { node.unfocus(); } + + // Optionally remove saved email + LocalStorage.removeToken('otp_email'); } bool _validateEmail(String email) { final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$'); return regex.hasMatch(email); } + + /// Save email to local storage if "remember me" is set + Future _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 _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"); + } + } } diff --git a/lib/helpers/services/app_initializer.dart b/lib/helpers/services/app_initializer.dart index 0f6e7d0..5d55b0c 100644 --- a/lib/helpers/services/app_initializer.dart +++ b/lib/helpers/services/app_initializer.dart @@ -7,7 +7,7 @@ import 'package:marco/helpers/theme/theme_customizer.dart'; import 'package:marco/helpers/theme/app_theme.dart'; import 'package:url_strategy/url_strategy.dart'; import 'package:marco/helpers/services/app_logger.dart'; - +import 'package:marco/helpers/services/auth_service.dart'; Future initializeApp() async { try { logSafe("πŸ’‘ Starting app initialization..."); @@ -24,6 +24,20 @@ Future initializeApp() async { await LocalStorage.init(); logSafe("πŸ’‘ Local storage initialized."); + // If a refresh token is found, try to refresh the JWT token + final refreshToken = await LocalStorage.getRefreshToken(); + if (refreshToken != null && refreshToken.isNotEmpty) { + logSafe("πŸ” Refresh token found. Attempting to refresh JWT..."); + final success = await AuthService.refreshToken(); + + if (!success) { + logSafe("⚠️ Refresh token invalid or expired. Skipping controller injection."); + // Optionally, clear tokens and force logout here if needed + } + } else { + logSafe("❌ No refresh token found. Skipping refresh."); + } + await ThemeCustomizer.init(); logSafe("πŸ’‘ Theme customizer initialized."); @@ -38,8 +52,12 @@ Future initializeApp() async { Get.put(ProjectController(), permanent: true); logSafe("πŸ’‘ ProjectController injected as permanent."); } + + // Load data into controllers if required + await Get.find().loadData(token); + await Get.find().fetchProjects(); } else { - logSafe("⚠️ No token found. Skipping PermissionController and ProjectController injection."); + logSafe("⚠️ No valid JWT token found. Skipping controller initialization."); } AppStyle.init(); -- 2.43.0 From 71f9e54d589eb300afa24c17c950a18ea4c9736f Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 10 Jul 2025 11:16:15 +0530 Subject: [PATCH 38/39] feat(api): update daily task details endpoint to use new URL --- lib/helpers/services/api_endpoints.dart | 2 +- lib/view/taskPlaning/daily_progress.dart | 59 +++++++++++++----------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index f0de389..4dbeec6 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -25,7 +25,7 @@ class ApiEndpoints { static const String getDailyTask = "/task/list"; static const String reportTask = "/task/report"; static const String commentTask = "/task/comment"; - static const String dailyTaskDetails = "/project/details"; + static const String dailyTaskDetails = "/project/details-old"; static const String assignDailyTask = "/task/assign"; static const String getWorkStatus = "/master/work-status"; static const String approveReportAction = "/task/approve"; diff --git a/lib/view/taskPlaning/daily_progress.dart b/lib/view/taskPlaning/daily_progress.dart index 21d704a..3aea78d 100644 --- a/lib/view/taskPlaning/daily_progress.dart +++ b/lib/view/taskPlaning/daily_progress.dart @@ -87,7 +87,7 @@ class _DailyProgressReportScreenState extends State Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.min, children: [ MyText.titleLarge( 'Daily Task Progress', @@ -461,41 +461,44 @@ class _DailyProgressReportScreenState extends State : Colors.red[700], ), const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (task.reportedDate == null || - task.reportedDate - .toString() - .isEmpty) ...[ - TaskActionButtons.reportButton( - context: context, - task: task, - completed: completed.toInt(), - refreshCallback: _refreshData, - ), - const SizedBox(width: 8), - ] else if (task.approvedBy == null) ...[ - TaskActionButtons.reportActionButton( + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (task.reportedDate == null || + task.reportedDate + .toString() + .isEmpty) ...[ + TaskActionButtons.reportButton( + context: context, + task: task, + completed: completed.toInt(), + refreshCallback: _refreshData, + ), + 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, task: task, parentTaskID: parentTaskID, workAreaId: workAreaId.toString(), activityId: activityId.toString(), - completed: completed.toInt(), refreshCallback: _refreshData, ), - const SizedBox(width: 8), ], - TaskActionButtons.commentButton( - context: context, - task: task, - parentTaskID: parentTaskID, - workAreaId: workAreaId.toString(), - activityId: activityId.toString(), - refreshCallback: _refreshData, - ), - ], + ), ) ], ), -- 2.43.0 From 8a729f23fea18c22bac9f09f67d70de076db9918 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 10 Jul 2025 17:58:52 +0530 Subject: [PATCH 39/39] _handleButtonPressed --- .../attendance/attendence_action_button.dart | 66 ++++++++++++++++--- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/lib/model/attendance/attendence_action_button.dart b/lib/model/attendance/attendence_action_button.dart index 7ab8f65..08b725e 100644 --- a/lib/model/attendance/attendence_action_button.dart +++ b/lib/model/attendance/attendence_action_button.dart @@ -147,16 +147,25 @@ class _AttendanceActionButtonState extends State { pickedTime.minute, ); - if (selectedDateTime.isAfter(checkInTime)) { - return selectedDateTime; - } else { + final now = DateTime.now(); + + if (selectedDateTime.isBefore(checkInTime)) { showAppSnackbar( title: "Invalid Time", - message: "Please select a time after check-in time.", + message: "Time must be after check-in.", + type: SnackbarType.warning, + ); + return null; + } else if (selectedDateTime.isAfter(now)) { + showAppSnackbar( + title: "Invalid Time", + message: "Future time is not allowed.", type: SnackbarType.warning, ); return null; } + + return selectedDateTime; } return null; } @@ -217,6 +226,30 @@ class _AttendanceActionButtonState extends State { break; } + DateTime? selectedTime; + + // βœ… New condition: Yesterday Check-In + CheckOut action + final isYesterdayCheckIn = widget.employee.checkIn != null && + DateUtils.isSameDay( + widget.employee.checkIn, + DateTime.now().subtract(const Duration(days: 1)), + ); + + if (isYesterdayCheckIn && + widget.employee.checkOut == null && + actionText == ButtonActions.checkOut) { + selectedTime = await showTimePickerForRegularization( + context: context, + checkInTime: widget.employee.checkIn!, + ); + + if (selectedTime == null) { + widget.attendanceController.uploadingStates[uniqueLogKey]?.value = + false; + return; + } + } + final userComment = await _showCommentBottomSheet(context, actionText); if (userComment == null || userComment.isEmpty) { widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false; @@ -225,13 +258,14 @@ class _AttendanceActionButtonState extends State { bool success = false; if (actionText == ButtonActions.requestRegularize) { - final selectedTime = await showTimePickerForRegularization( - context: context, - checkInTime: widget.employee.checkIn!, - ); - if (selectedTime != null) { + final regularizeTime = selectedTime ?? + await showTimePickerForRegularization( + context: context, + checkInTime: widget.employee.checkIn!, + ); + if (regularizeTime != null) { final formattedSelectedTime = - DateFormat("hh:mm a").format(selectedTime); + DateFormat("hh:mm a").format(regularizeTime); success = await widget.attendanceController.captureAndUploadAttendance( widget.employee.id, widget.employee.employeeId, @@ -242,6 +276,18 @@ class _AttendanceActionButtonState extends State { markTime: formattedSelectedTime, ); } + } else if (selectedTime != null) { + // βœ… If selectedTime was picked in the new condition + final formattedSelectedTime = DateFormat("hh:mm a").format(selectedTime); + success = await widget.attendanceController.captureAndUploadAttendance( + widget.employee.id, + widget.employee.employeeId, + selectedProjectId, + comment: userComment, + action: updatedAction, + imageCapture: imageCapture, + markTime: formattedSelectedTime, + ); } else { success = await widget.attendanceController.captureAndUploadAttendance( widget.employee.id, -- 2.43.0