From 2fb3c36ba4ace52b92629b64875bde39d5585611 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 8 Aug 2025 15:19:29 +0530 Subject: [PATCH] added pull down refresh --- .../expense/expense_screen_controller.dart | 18 +- lib/helpers/services/api_endpoints.dart | 4 +- lib/helpers/services/api_service.dart | 20 +- .../widgets/expense_main_components.dart | 10 - lib/helpers/widgets/my_custom_skeleton.dart | 12 + lib/helpers/widgets/my_refresh_indicator.dart | 32 ++ .../Attendence/attendance_logs_tab.dart | 6 +- .../Attendence/attendance_screen.dart | 95 +++-- lib/view/directory/contact_detail_screen.dart | 161 ++++--- lib/view/directory/directory_view.dart | 397 ++++++++++-------- lib/view/directory/notes_view.dart | 282 +++++++------ .../employees/employee_detail_screen.dart | 73 ++-- lib/view/employees/employees_screen.dart | 48 +-- lib/view/expense/expense_detail_screen.dart | 66 +-- lib/view/expense/expense_screen.dart | 69 +-- lib/view/taskPlaning/daily_progress.dart | 59 +-- lib/view/taskPlaning/daily_task_planing.dart | 94 ++--- 17 files changed, 805 insertions(+), 641 deletions(-) create mode 100644 lib/helpers/widgets/my_refresh_indicator.dart diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 0790ba5..44c088a 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -173,17 +173,25 @@ class ExpenseController extends GetxController { if (result != null) { try { final expenseResponse = ExpenseResponse.fromJson(result); - expenses.assignAll(expenseResponse.data.data); - logSafe("Expenses loaded: ${expenses.length}"); - logSafe( - "Pagination Info: Page ${expenseResponse.data.currentPage} of ${expenseResponse.data.totalPages} | Total: ${expenseResponse.data.totalEntites}"); + // If the backend returns no data, treat it as empty list + if (expenseResponse.data.data.isEmpty) { + expenses.clear(); + errorMessage.value = ''; // no error + logSafe("Expense list is empty."); + } else { + expenses.assignAll(expenseResponse.data.data); + logSafe("Expenses loaded: ${expenses.length}"); + logSafe( + "Pagination Info: Page ${expenseResponse.data.currentPage} of ${expenseResponse.data.totalPages} | Total: ${expenseResponse.data.totalEntites}"); + } } catch (e) { errorMessage.value = 'Failed to parse expenses: $e'; logSafe("Parse error in fetchExpenses: $e", level: LogLevel.error); } } else { - errorMessage.value = 'Failed to fetch expenses from server.'; + // Only treat as error if this means a network or server failure + errorMessage.value = 'Unable to connect to the server.'; logSafe("fetchExpenses failed: null response", level: LogLevel.error); } } catch (e, stack) { diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index ac47286..cf5e9b5 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,6 +1,6 @@ class ApiEndpoints { - static const String baseUrl = "https://stageapi.marcoaiot.com/api"; - // static const String baseUrl = "https://api.marcoaiot.com/api"; + // static const String baseUrl = "https://stageapi.marcoaiot.com/api"; + static const String baseUrl = "https://api.marcoaiot.com/api"; // Dashboard Module API Endpoints static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 8428b70..87e74a0 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -436,18 +436,16 @@ class ApiService { int pageSize = 20, int pageNumber = 1, }) async { - // Build the endpoint with query parameters String endpoint = ApiEndpoints.getExpenseList; - final queryParams = { + final queryParams = { 'pageSize': pageSize.toString(), 'pageNumber': pageNumber.toString(), }; - if (filter != null && filter.isNotEmpty) { - queryParams['filter'] = filter; + if (filter?.isNotEmpty ?? false) { + queryParams['filter'] = filter!; } - // Build the full URI final uri = Uri.parse(endpoint).replace(queryParameters: queryParams); logSafe("Fetching expense list with URI: $uri"); @@ -456,20 +454,22 @@ class ApiService { if (response == null) { logSafe("Expense list request failed: null response", level: LogLevel.error); - return null; + return null; // real failure } - // Directly parse and return the entire JSON response final body = response.body.trim(); if (body.isEmpty) { - logSafe("Expense list response body is empty", level: LogLevel.error); - return null; + logSafe("Expense list response body is empty", level: LogLevel.warning); + return { + "status": true, + "data": {"data": [], "totalPages": 0, "currentPage": pageNumber} + }; // treat as empty list } final jsonResponse = jsonDecode(body); if (jsonResponse is Map) { logSafe("Expense list response parsed successfully"); - return jsonResponse; // Return the entire API response + return jsonResponse; // always return valid JSON, even if data list is empty } else { logSafe("Unexpected response structure: $jsonResponse", level: LogLevel.error); diff --git a/lib/helpers/widgets/expense_main_components.dart b/lib/helpers/widgets/expense_main_components.dart index d502825..0744b48 100644 --- a/lib/helpers/widgets/expense_main_components.dart +++ b/lib/helpers/widgets/expense_main_components.dart @@ -76,14 +76,12 @@ class SearchAndFilter extends StatelessWidget { final TextEditingController controller; final ValueChanged onChanged; final VoidCallback onFilterTap; - final VoidCallback onRefreshTap; final ExpenseController expenseController; const SearchAndFilter({ required this.controller, required this.onChanged, required this.onFilterTap, - required this.onRefreshTap, required this.expenseController, super.key, }); @@ -119,14 +117,6 @@ class SearchAndFilter extends StatelessWidget { ), ), ), - MySpacing.width(8), - Tooltip( - message: 'Refresh Data', - child: IconButton( - icon: const Icon(Icons.refresh, color: Colors.green, size: 24), - onPressed: onRefreshTap, - ), - ), MySpacing.width(4), Obx(() { return IconButton( diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index 3dade46..030583d 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -33,6 +33,18 @@ class SkeletonLoaders { ); } +// Date Skeleton Loader + static Widget dateSkeletonLoader() { + return Container( + height: 14, + width: 90, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ); + } + // Employee List - Card Style static Widget employeeListSkeletonLoader() { return Column( diff --git a/lib/helpers/widgets/my_refresh_indicator.dart b/lib/helpers/widgets/my_refresh_indicator.dart new file mode 100644 index 0000000..2aa3cec --- /dev/null +++ b/lib/helpers/widgets/my_refresh_indicator.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +class MyRefreshIndicator extends StatelessWidget { + final Future Function() onRefresh; + final Widget child; + final Color color; + final Color backgroundColor; + final double strokeWidth; + final double displacement; + + const MyRefreshIndicator({ + super.key, + required this.onRefresh, + required this.child, + this.color = Colors.white, + this.backgroundColor = Colors.blueAccent, + this.strokeWidth = 3.0, + this.displacement = 40.0, + }); + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: onRefresh, + color: color, + backgroundColor: backgroundColor, + strokeWidth: strokeWidth, + displacement: displacement, + child: child, + ); + } +} diff --git a/lib/view/dashboard/Attendence/attendance_logs_tab.dart b/lib/view/dashboard/Attendence/attendance_logs_tab.dart index 3596999..2aeeb3d 100644 --- a/lib/view/dashboard/Attendence/attendance_logs_tab.dart +++ b/lib/view/dashboard/Attendence/attendance_logs_tab.dart @@ -42,11 +42,7 @@ class AttendanceLogsTab extends StatelessWidget { children: [ MyText.titleMedium("Attendance Logs", fontWeight: 600), controller.isLoading.value - ? const SizedBox( - height: 20, - width: 20, - child: LinearProgressIndicator(), - ) + ? SkeletonLoaders.dateSkeletonLoader() : MyText.bodySmall( dateRangeText, fontWeight: 600, diff --git a/lib/view/dashboard/Attendence/attendance_screen.dart b/lib/view/dashboard/Attendence/attendance_screen.dart index ebdd68d..afed054 100644 --- a/lib/view/dashboard/Attendence/attendance_screen.dart +++ b/lib/view/dashboard/Attendence/attendance_screen.dart @@ -13,7 +13,7 @@ import 'package:marco/controller/project_controller.dart'; import 'package:marco/view/dashboard/Attendence/regularization_requests_tab.dart'; import 'package:marco/view/dashboard/Attendence/attendance_logs_tab.dart'; import 'package:marco/view/dashboard/Attendence/todays_attendance_tab.dart'; - +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; class AttendanceScreen extends StatefulWidget { const AttendanceScreen({super.key}); @@ -70,7 +70,8 @@ class _AttendanceScreenState extends State with UIMixin { crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( - icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), onPressed: () => Get.offNamed('/dashboard'), ), MySpacing.width(8), @@ -78,14 +79,18 @@ class _AttendanceScreenState extends State with UIMixin { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.titleLarge('Attendance', fontWeight: 700, color: Colors.black), + MyText.titleLarge('Attendance', + fontWeight: 700, color: Colors.black), 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, size: 14, color: Colors.grey), + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), MySpacing.width(4), Expanded( child: MyText.bodySmall( @@ -133,18 +138,24 @@ class _AttendanceScreenState extends State with UIMixin { ); if (result != null) { - final selectedProjectId = projectController.selectedProjectId.value; + final selectedProjectId = + projectController.selectedProjectId.value; final selectedView = result['selectedTab'] as String?; if (selectedProjectId.isNotEmpty) { try { - await attendanceController.fetchEmployeesByProject(selectedProjectId); - await attendanceController.fetchAttendanceLogs(selectedProjectId); - await attendanceController.fetchRegularizationLogs(selectedProjectId); - await attendanceController.fetchProjectData(selectedProjectId); + await attendanceController + .fetchEmployeesByProject(selectedProjectId); + await attendanceController + .fetchAttendanceLogs(selectedProjectId); + await attendanceController + .fetchRegularizationLogs(selectedProjectId); + await attendanceController + .fetchProjectData(selectedProjectId); } catch (_) {} - attendanceController.update(['attendance_dashboard_controller']); + attendanceController + .update(['attendance_dashboard_controller']); } if (selectedView != null && selectedView != selectedTab) { @@ -154,20 +165,7 @@ class _AttendanceScreenState extends State with UIMixin { }, child: Padding( padding: const EdgeInsets.all(8.0), - child: Icon(Icons.tune, color: Colors.blueAccent, size: 20), - ), - ), - ), - const SizedBox(width: 4), - MyText.bodyMedium("Refresh", fontWeight: 600), - Tooltip( - message: 'Refresh Data', - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: _refreshData, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Icon(Icons.refresh, color: Colors.green, size: 22), + child: Icon(Icons.tune, size: 18), ), ), ), @@ -203,7 +201,10 @@ class _AttendanceScreenState extends State with UIMixin { @override Widget build(BuildContext context) { return Scaffold( - appBar: PreferredSize(preferredSize: const Size.fromHeight(72), child: _buildAppBar()), + appBar: PreferredSize( + preferredSize: const Size.fromHeight(72), + child: _buildAppBar(), + ), body: SafeArea( child: GetBuilder( init: attendanceController, @@ -212,25 +213,29 @@ class _AttendanceScreenState extends State with UIMixin { final selectedProjectId = projectController.selectedProjectId.value; final noProjectSelected = selectedProjectId.isEmpty; - return SingleChildScrollView( - padding: MySpacing.zero, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MySpacing.height(flexSpacing), - _buildFilterAndRefreshRow(), - MySpacing.height(flexSpacing), - MyFlex( - children: [ - MyFlexItem( - sizes: 'lg-12 md-12 sm-12', - child: noProjectSelected - ? _buildNoProjectWidget() - : _buildSelectedTabContent(), - ), - ], - ), - ], + return MyRefreshIndicator( + onRefresh: _refreshData, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: MySpacing.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(flexSpacing), + _buildFilterAndRefreshRow(), + MySpacing.height(flexSpacing), + MyFlex( + children: [ + MyFlexItem( + sizes: 'lg-12 md-12 sm-12', + child: noProjectSelected + ? _buildNoProjectWidget() + : _buildSelectedTabContent(), + ), + ], + ), + ], + ), ), ); }, diff --git a/lib/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart index f670c18..bba51d7 100644 --- a/lib/view/directory/contact_detail_screen.dart +++ b/lib/view/directory/contact_detail_screen.dart @@ -15,6 +15,7 @@ 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'; +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; // HELPER: Delta to HTML conversion String _convertDeltaToHtml(dynamic delta) { @@ -120,8 +121,10 @@ class _ContactDetailScreenState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( - icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), - onPressed: () => Get.offAllNamed('/dashboard/directory-main-page'), + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => + Get.offAllNamed('/dashboard/directory-main-page'), ), MySpacing.width(8), Expanded( @@ -129,7 +132,8 @@ class _ContactDetailScreenState extends State { 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: (p) => ProjectLabel(p.selectedProject?.name), @@ -145,7 +149,8 @@ class _ContactDetailScreenState extends State { Widget _buildSubHeader() { final firstName = contact.name.split(" ").first; - final lastName = contact.name.split(" ").length > 1 ? contact.name.split(" ").last : ""; + final lastName = + contact.name.split(" ").length > 1 ? contact.name.split(" ").last : ""; return Padding( padding: MySpacing.xy(16, 12), @@ -153,21 +158,27 @@ class _ContactDetailScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ - Avatar(firstName: firstName, lastName: lastName, size: 35, backgroundColor: Colors.indigo), + Avatar( + firstName: firstName, + lastName: lastName, + size: 35, + backgroundColor: Colors.indigo), MySpacing.width(12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.titleSmall(contact.name, fontWeight: 600, color: Colors.black), + MyText.titleSmall(contact.name, + fontWeight: 600, color: Colors.black), MySpacing.height(2), - MyText.bodySmall(contact.organization, fontWeight: 500, color: Colors.grey[700]), + MyText.bodySmall(contact.organization, + fontWeight: 500, color: Colors.grey[700]), ], ), ]), TabBar( labelColor: Colors.red, unselectedLabelColor: Colors.black, - indicator: MaterialIndicator( + indicator: MaterialIndicator( color: Colors.red, height: 4, topLeftRadius: 8, @@ -193,25 +204,38 @@ class _ContactDetailScreenState extends State { ?.name) .whereType() .join(", "); - final projectNames = contact.projectIds?.map((id) => - projectController.projects.firstWhereOrNull((p) => p.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 ?? "-"; - Widget multiRows({required List items, required IconData icon, required String label, required String typeLabel, required Function(String)? onTap, required Function(String)? onLongPress}) { + Widget multiRows( + {required List items, + required IconData icon, + required String label, + required String typeLabel, + required Function(String)? onTap, + required Function(String)? onLongPress}) { return items.isNotEmpty ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _iconInfoRow(icon, label, items.first, onTap: () => onTap?.call(items.first), onLongPress: () => onLongPress?.call(items.first)), + _iconInfoRow(icon, label, items.first, + onTap: () => onTap?.call(items.first), + onLongPress: () => onLongPress?.call(items.first)), ...items.skip(1).map( - (val) => _iconInfoRow( - null, - '', - val, - onTap: () => onTap?.call(val), - onLongPress: () => onLongPress?.call(val), - ), - ), + (val) => _iconInfoRow( + null, + '', + val, + onTap: () => onTap?.call(val), + onLongPress: () => onLongPress?.call(val), + ), + ), ], ) : _iconInfoRow(icon, label, "-"); @@ -228,32 +252,38 @@ class _ContactDetailScreenState extends State { // BASIC INFO CARD _infoCard("Basic Info", [ multiRows( - items: contact.contactEmails.map((e) => e.emailAddress).toList(), + items: + contact.contactEmails.map((e) => e.emailAddress).toList(), icon: Icons.email, label: "Email", typeLabel: "Email", onTap: (email) => LauncherUtils.launchEmail(email), - onLongPress: (email) => LauncherUtils.copyToClipboard(email, typeLabel: "Email"), + onLongPress: (email) => + LauncherUtils.copyToClipboard(email, typeLabel: "Email"), ), multiRows( - items: contact.contactPhones.map((p) => p.phoneNumber).toList(), + items: + contact.contactPhones.map((p) => p.phoneNumber).toList(), icon: Icons.phone, label: "Phone", typeLabel: "Phone", onTap: (phone) => LauncherUtils.launchPhone(phone), - onLongPress: (phone) => LauncherUtils.copyToClipboard(phone, typeLabel: "Phone"), + onLongPress: (phone) => + LauncherUtils.copyToClipboard(phone, typeLabel: "Phone"), ), _iconInfoRow(Icons.location_on, "Address", contact.address), ]), // ORGANIZATION CARD _infoCard("Organization", [ - _iconInfoRow(Icons.business, "Organization", contact.organization), + _iconInfoRow( + Icons.business, "Organization", contact.organization), _iconInfoRow(Icons.category, "Category", category), ]), // META INFO CARD _infoCard("Meta Info", [ _iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"), - _iconInfoRow(Icons.folder_shared, "Contact Buckets", bucketNames.isNotEmpty ? bucketNames : "-"), + _iconInfoRow(Icons.folder_shared, "Contact Buckets", + bucketNames.isNotEmpty ? bucketNames : "-"), _iconInfoRow(Icons.work_outline, "Projects", projectNames), ]), // DESCRIPTION CARD @@ -285,15 +315,16 @@ class _ContactDetailScreenState extends State { ); if (result == true) { await directoryController.fetchContacts(); - final updated = - directoryController.allContacts.firstWhereOrNull((c) => c.id == contact.id); + final updated = directoryController.allContacts + .firstWhereOrNull((c) => c.id == contact.id); if (updated != null) { setState(() => contact = updated); } } }, icon: const Icon(Icons.edit, color: Colors.white), - label: const Text("Edit Contact", style: TextStyle(color: Colors.white)), + label: const Text("Edit Contact", + style: TextStyle(color: Colors.white)), ), ), ], @@ -306,24 +337,49 @@ class _ContactDetailScreenState extends State { if (!directoryController.contactCommentsMap.containsKey(contactId)) { return const Center(child: CircularProgressIndicator()); } - final comments = directoryController.getCommentsForContact(contactId).reversed.toList(); + + final comments = directoryController + .getCommentsForContact(contactId) + .reversed + .toList(); final editingId = directoryController.editingCommentId.value; return Stack( children: [ - comments.isEmpty - ? Center( - child: MyText.bodyLarge("No comments yet.", color: Colors.grey), - ) - : Padding( - padding: MySpacing.xy(12, 12), - child: ListView.separated( - padding: const EdgeInsets.only(bottom: 100), - itemCount: comments.length, - separatorBuilder: (_, __) => MySpacing.height(14), - itemBuilder: (_, index) => _buildCommentItem(comments[index], editingId, contact.id), + MyRefreshIndicator( + onRefresh: () async { + await directoryController.fetchCommentsForContact(contactId); + }, + child: comments.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: Center( + child: MyText.bodyLarge( + "No comments yet.", + color: Colors.grey, + ), + ), + ), + ], + ) + : Padding( + padding: MySpacing.xy(12, 12), + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 100), + itemCount: comments.length, + separatorBuilder: (_, __) => MySpacing.height(14), + itemBuilder: (_, index) => _buildCommentItem( + comments[index], + editingId, + contact.id, + ), + ), ), - ), + ), if (editingId == null) Positioned( bottom: 20, @@ -336,11 +392,15 @@ class _ContactDetailScreenState extends State { isScrollControlled: true, ); if (result == true) { - await directoryController.fetchCommentsForContact(contactId); + await directoryController + .fetchCommentsForContact(contactId); } }, 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), + ), ), ), ], @@ -371,7 +431,9 @@ class _ContactDetailScreenState extends State { color: isEditing ? Colors.indigo : Colors.grey.shade300, width: 1.2, ), - boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2))], + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2)) + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -406,7 +468,8 @@ class _ContactDetailScreenState extends State { color: Colors.indigo, ), onPressed: () { - directoryController.editingCommentId.value = isEditing ? null : comment.id; + directoryController.editingCommentId.value = + isEditing ? null : comment.id; }, ), ], @@ -467,7 +530,8 @@ class _ContactDetailScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (label.isNotEmpty) - MyText.bodySmall(label, fontWeight: 600, color: Colors.black87), + MyText.bodySmall(label, + fontWeight: 600, color: Colors.black87), if (label.isNotEmpty) MySpacing.height(2), MyText.bodyMedium(value, color: Colors.grey[800]), ], @@ -489,7 +553,8 @@ class _ContactDetailScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.titleSmall(title, fontWeight: 700, color: Colors.indigo[700]), + MyText.titleSmall(title, + fontWeight: 700, color: Colors.indigo[700]), MySpacing.height(8), ...children, ], diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index 1cecd6c..bfe12f0 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/controller/directory/directory_controller.dart'; import 'package:marco/controller/directory/create_bucket_controller.dart'; @@ -24,7 +25,8 @@ class DirectoryView extends StatefulWidget { class _DirectoryViewState extends State { final DirectoryController controller = Get.find(); final TextEditingController searchController = TextEditingController(); - final PermissionController permissionController = Get.put(PermissionController()); + final PermissionController permissionController = + Get.put(PermissionController()); Future _refreshDirectory() async { try { @@ -185,19 +187,7 @@ class _DirectoryViewState extends State { ), ), ), - ), - MySpacing.width(8), - Tooltip( - message: 'Refresh Data', - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: _refreshDirectory, - child: const Padding( - padding: EdgeInsets.all(0), - child: Icon(Icons.refresh, color: Colors.green, size: 28), - ), - ), - ), + ), MySpacing.width(8), Obx(() { final isFilterActive = controller.hasActiveFilters(); @@ -382,178 +372,231 @@ class _DirectoryViewState extends State { ), Expanded( child: Obx(() { - if (controller.isLoading.value) { - return ListView.separated( - itemCount: 10, - separatorBuilder: (_, __) => MySpacing.height(12), - itemBuilder: (_, __) => SkeletonLoaders.contactSkeletonCard(), - ); - } - - if (controller.filteredContacts.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.contact_page_outlined, - size: 60, color: Colors.grey), - const SizedBox(height: 12), - MyText.bodyMedium('No contacts found.', fontWeight: 500), - ], - ), - ); - } - - return ListView.separated( - padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 80), - itemCount: controller.filteredContacts.length, - separatorBuilder: (_, __) => MySpacing.height(12), - itemBuilder: (_, index) { - final contact = controller.filteredContacts[index]; - final nameParts = contact.name.trim().split(" "); - final firstName = nameParts.first; - final lastName = nameParts.length > 1 ? nameParts.last : ""; - final tags = contact.tags.map((tag) => tag.name).toList(); - - return InkWell( - onTap: () { - Get.to(() => ContactDetailScreen(contact: contact)); - }, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 12, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Avatar( - firstName: firstName, - lastName: lastName, - size: 35), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall(contact.name, - fontWeight: 600, - overflow: TextOverflow.ellipsis), - MyText.bodySmall(contact.organization, - color: Colors.grey[700], - overflow: TextOverflow.ellipsis), - MySpacing.height(8), - - // Show only the first email (if present) - if (contact.contactEmails.isNotEmpty) - GestureDetector( - onTap: () => LauncherUtils.launchEmail( - contact.contactEmails.first.emailAddress), - onLongPress: () => - LauncherUtils.copyToClipboard( - contact.contactEmails.first.emailAddress, - typeLabel: 'Email', - ), - child: Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - children: [ - const Icon(Icons.email_outlined, - size: 16, color: Colors.indigo), - MySpacing.width(4), - Expanded( - child: MyText.labelSmall( - contact.contactEmails.first.emailAddress, - overflow: TextOverflow.ellipsis, - color: Colors.indigo, - decoration: - TextDecoration.underline, - ), - ), - ], - ), - ), + return MyRefreshIndicator( + onRefresh: _refreshDirectory, + backgroundColor: Colors.indigo, + color: Colors.white, + child: controller.isLoading.value + ? ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: 10, + separatorBuilder: (_, __) => MySpacing.height(12), + itemBuilder: (_, __) => + SkeletonLoaders.contactSkeletonCard(), + ) + : controller.filteredContacts.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: + MediaQuery.of(context).size.height * 0.6, + child: 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), + ], ), + ), + ), + ], + ) + : ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: MySpacing.only( + left: 8, right: 8, top: 4, bottom: 80), + itemCount: controller.filteredContacts.length, + separatorBuilder: (_, __) => MySpacing.height(12), + itemBuilder: (_, index) { + final contact = + controller.filteredContacts[index]; + final nameParts = contact.name.trim().split(" "); + final firstName = nameParts.first; + final lastName = + nameParts.length > 1 ? nameParts.last : ""; + final tags = + contact.tags.map((tag) => tag.name).toList(); - // Show only the first phone (if present) - if (contact.contactPhones.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - bottom: 8, top: 4), - child: Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () => LauncherUtils - .launchPhone(contact - .contactPhones - .first - .phoneNumber), - onLongPress: () => - LauncherUtils.copyToClipboard( - contact.contactPhones.first - .phoneNumber, - typeLabel: 'Phone', - ), - child: Row( - children: [ - const Icon( - Icons.phone_outlined, - size: 16, - color: Colors.indigo), - MySpacing.width(4), - Expanded( - child: MyText.labelSmall( - contact.contactPhones.first - .phoneNumber, - overflow: - TextOverflow.ellipsis, - color: Colors.indigo, - decoration: TextDecoration - .underline, + return InkWell( + onTap: () { + Get.to(() => + ContactDetailScreen(contact: contact)); + }, + child: Padding( + padding: + const EdgeInsets.fromLTRB(12, 10, 12, 0), + child: Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Avatar( + firstName: firstName, + lastName: lastName, + size: 35), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + MyText.titleSmall(contact.name, + fontWeight: 600, + overflow: + TextOverflow.ellipsis), + MyText.bodySmall( + contact.organization, + color: Colors.grey[700], + overflow: + TextOverflow.ellipsis), + MySpacing.height(8), + if (contact + .contactEmails.isNotEmpty) + GestureDetector( + onTap: () => + LauncherUtils.launchEmail( + contact + .contactEmails + .first + .emailAddress), + onLongPress: () => LauncherUtils + .copyToClipboard( + contact.contactEmails.first + .emailAddress, + typeLabel: 'Email', + ), + child: Padding( + padding: + const EdgeInsets.only( + bottom: 4), + child: Row( + children: [ + const Icon( + Icons.email_outlined, + size: 16, + color: Colors.indigo), + MySpacing.width(4), + Expanded( + child: + MyText.labelSmall( + contact + .contactEmails + .first + .emailAddress, + overflow: TextOverflow + .ellipsis, + color: Colors.indigo, + decoration: + TextDecoration + .underline, + ), + ), + ], ), ), - ], - ), - ), + ), + if (contact + .contactPhones.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + bottom: 8, top: 4), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => LauncherUtils + .launchPhone(contact + .contactPhones + .first + .phoneNumber), + onLongPress: () => + LauncherUtils + .copyToClipboard( + contact + .contactPhones + .first + .phoneNumber, + typeLabel: 'Phone', + ), + child: Row( + children: [ + const Icon( + Icons + .phone_outlined, + size: 16, + color: Colors + .indigo), + MySpacing.width(4), + Expanded( + child: MyText + .labelSmall( + contact + .contactPhones + .first + .phoneNumber, + overflow: + TextOverflow + .ellipsis, + color: Colors + .indigo, + decoration: + TextDecoration + .underline, + ), + ), + ], + ), + ), + ), + MySpacing.width(8), + GestureDetector( + onTap: () => LauncherUtils + .launchWhatsApp( + contact + .contactPhones + .first + .phoneNumber), + child: const FaIcon( + FontAwesomeIcons + .whatsapp, + color: Colors.green, + size: 16, + ), + ), + ], + ), + ), + if (tags.isNotEmpty) ...[ + MySpacing.height(2), + MyText.labelSmall(tags.join(', '), + color: Colors.grey[500], + maxLines: 1, + overflow: + TextOverflow.ellipsis), + ], + ], ), - MySpacing.width(8), - GestureDetector( - onTap: () => - LauncherUtils.launchWhatsApp( - contact.contactPhones.first - .phoneNumber), - child: const FaIcon( - FontAwesomeIcons.whatsapp, - color: Colors.green, - size: 16, - ), - ), - ], - ), + ), + Column( + children: [ + const Icon(Icons.arrow_forward_ios, + color: Colors.grey, size: 16), + MySpacing.height(8), + ], + ), + ], ), - if (tags.isNotEmpty) ...[ - MySpacing.height(2), - MyText.labelSmall(tags.join(', '), - color: Colors.grey[500], - maxLines: 1, - overflow: TextOverflow.ellipsis), - ], - ], - ), + ), + ); + }, ), - Column( - children: [ - const Icon(Icons.arrow_forward_ios, - color: Colors.grey, size: 16), - MySpacing.height(8), - ], - ), - ], - ), - ), - ); - }, ); }), - ), + ) ], ), ); diff --git a/lib/view/directory/notes_view.dart b/lib/view/directory/notes_view.dart index c4f56c3..9f6829f 100644 --- a/lib/view/directory/notes_view.dart +++ b/lib/view/directory/notes_view.dart @@ -3,6 +3,7 @@ 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/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/controller/directory/notes_controller.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; @@ -103,22 +104,7 @@ class NotesView extends StatelessWidget { ), ), ), - ), - 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), - ), - ), - ), - ), + ), ], ), ), @@ -133,145 +119,163 @@ class NotesView extends StatelessWidget { final notes = controller.filteredNotesList; if (notes.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + return MyRefreshIndicator( + onRefresh: _refreshNotes, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), children: [ - const Icon(Icons.note_alt_outlined, - size: 60, color: Colors.grey), - const SizedBox(height: 12), - MyText.bodyMedium('No notes found.', fontWeight: 500), + SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.note_alt_outlined, + size: 60, color: Colors.grey), + const SizedBox(height: 12), + MyText.bodyMedium('No notes found.', + fontWeight: 500), + ], + ), + ), + ), ], ), ); } - return ListView.separated( - padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 80), - itemCount: notes.length, - separatorBuilder: (_, __) => MySpacing.height(12), - itemBuilder: (_, index) { - final note = notes[index]; + return MyRefreshIndicator( + onRefresh: _refreshNotes, + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 80), + itemCount: notes.length, + separatorBuilder: (_, __) => MySpacing.height(12), + itemBuilder: (_, index) { + final note = notes[index]; - return Obx(() { - final isEditing = controller.editingNoteId.value == note.id; + 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 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 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; + 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, + 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, - ), - }, - ), - ], - ), - ); - }); - }, + ], + ), + ); + }); + }, + ), ); }), ), diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index ceee173..c28beb6 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -10,6 +10,7 @@ import 'package:marco/view/employees/assign_employee_bottom_sheet.dart'; import 'package:marco/helpers/utils/launcher_utils.dart'; import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; class EmployeeDetailPage extends StatefulWidget { final String employeeId; @@ -255,40 +256,46 @@ class _EmployeeDetailPageState extends State { } return SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(12, 20, 12, 80), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Row( - children: [ - Avatar( - firstName: employee.firstName, - lastName: employee.lastName, - size: 45, - ), - MySpacing.width(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleMedium( - '${employee.firstName} ${employee.lastName}', - fontWeight: 700, - ), - MySpacing.height(6), - MyText.bodySmall( - _getDisplayValue(employee.jobRole), - fontWeight: 500, - ), - ], + child: MyRefreshIndicator( + onRefresh: () async { + await controller.fetchEmployeeDetails(widget.employeeId); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(12, 20, 12, 80), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + children: [ + Avatar( + firstName: employee.firstName, + lastName: employee.lastName, + size: 45, ), - ), - ], - ), - MySpacing.height(14), - _buildInfoCard(employee), - ], + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium( + '${employee.firstName} ${employee.lastName}', + fontWeight: 700, + ), + MySpacing.height(6), + MyText.bodySmall( + _getDisplayValue(employee.jobRole), + fontWeight: 500, + ), + ], + ), + ), + ], + ), + MySpacing.height(14), + _buildInfoCard(employee), + ], + ), ), ), ); diff --git a/lib/view/employees/employees_screen.dart b/lib/view/employees/employees_screen.dart index a1ef672..9989926 100644 --- a/lib/view/employees/employees_screen.dart +++ b/lib/view/employees/employees_screen.dart @@ -15,6 +15,7 @@ import 'package:marco/helpers/utils/launcher_utils.dart'; import 'package:marco/view/employees/assign_employee_bottom_sheet.dart'; import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; class EmployeesScreen extends StatefulWidget { const EmployeesScreen({super.key}); @@ -151,19 +152,24 @@ class _EmployeesScreenState extends State with UIMixin { tag: 'employee_screen_controller', builder: (_) { _filterEmployees(_searchController.text); - return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 40), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MySpacing.height(flexSpacing), - _buildSearchAndActionRow(), - MySpacing.height(flexSpacing), - Padding( - padding: MySpacing.x(flexSpacing), - child: _buildEmployeeList(), - ), - ], + + return MyRefreshIndicator( + onRefresh: _refreshEmployees, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(flexSpacing), + _buildSearchAndActionRow(), + MySpacing.height(flexSpacing), + Padding( + padding: MySpacing.x(flexSpacing), + child: _buildEmployeeList(), + ), + ], + ), ), ); }, @@ -266,8 +272,6 @@ class _EmployeesScreenState extends State with UIMixin { children: [ Expanded(child: _buildSearchField()), const SizedBox(width: 8), - _buildRefreshButton(), - const SizedBox(width: 4), _buildPopupMenu(), ], ), @@ -315,20 +319,6 @@ class _EmployeesScreenState extends State with UIMixin { ); } - Widget _buildRefreshButton() { - return Tooltip( - message: 'Refresh Data', - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: _refreshEmployees, - child: const Padding( - padding: EdgeInsets.all(10), - child: Icon(Icons.refresh, color: Colors.green, size: 28), - ), - ), - ); - } - Widget _buildPopupMenu() { if (!_permissionController.hasPermission(Permissions.viewAllEmployees)) { return const SizedBox.shrink(); diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 8579b65..f305beb 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -13,6 +13,7 @@ import 'package:marco/model/expense/reimbursement_bottom_sheet.dart'; import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/expense_detail_helpers.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; @@ -92,36 +93,41 @@ class _ExpenseDetailScreenState extends State { colorCode: expense.status.color); final formattedAmount = formatExpenseAmount(expense.amount); - return SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - 8, 8, 8, 30 + MediaQuery.of(context).padding.bottom), - child: Center( - child: Container( - constraints: const BoxConstraints(maxWidth: 520), - child: Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - elevation: 3, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 14, horizontal: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _InvoiceHeader(expense: expense), - const Divider(height: 30, thickness: 1.2), - _InvoiceParties(expense: expense), - const Divider(height: 30, thickness: 1.2), - _InvoiceDetailsTable(expense: expense), - const Divider(height: 30, thickness: 1.2), - _InvoiceDocuments(documents: expense.documents), - const Divider(height: 30, thickness: 1.2), - _InvoiceTotals( - expense: expense, - formattedAmount: formattedAmount, - statusColor: statusColor, - ), - ], + return MyRefreshIndicator( + onRefresh: () async { + await controller.fetchExpenseDetails(); + }, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 8, 8, 8, 30 + MediaQuery.of(context).padding.bottom), + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 520), + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + elevation: 3, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 14, horizontal: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InvoiceHeader(expense: expense), + const Divider(height: 30, thickness: 1.2), + _InvoiceParties(expense: expense), + const Divider(height: 30, thickness: 1.2), + _InvoiceDetailsTable(expense: expense), + const Divider(height: 30, thickness: 1.2), + _InvoiceDocuments(documents: expense.documents), + const Divider(height: 30, thickness: 1.2), + _InvoiceTotals( + expense: expense, + formattedAmount: formattedAmount, + statusColor: statusColor, + ), + ], + ), ), ), ), diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index 0309b3c..4801f27 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -10,6 +10,7 @@ import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; import 'package:marco/view/expense/expense_filter_bottom_sheet.dart'; import 'package:marco/helpers/widgets/expense_main_components.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; class ExpenseMainScreen extends StatefulWidget { const ExpenseMainScreen({super.key}); @@ -31,7 +32,9 @@ class _ExpenseMainScreenState extends State { expenseController.fetchExpenses(); } - void _refreshExpenses() => expenseController.fetchExpenses(); + Future _refreshExpenses() async { + await expenseController.fetchExpenses(); + } void _openFilterBottomSheet() { showModalBottomSheet( @@ -81,7 +84,6 @@ class _ExpenseMainScreenState extends State { controller: searchController, onChanged: (_) => setState(() {}), onFilterTap: _openFilterBottomSheet, - onRefreshTap: _refreshExpenses, expenseController: expenseController, ), ToggleButtonsRow( @@ -90,38 +92,55 @@ class _ExpenseMainScreenState extends State { ), Expanded( child: Obx(() { + // Loader while fetching first time if (expenseController.isLoading.value && expenseController.expenses.isEmpty) { return SkeletonLoaders.expenseListSkeletonLoader(); } - if (expenseController.errorMessage.isNotEmpty) { - return Center( - child: MyText.bodyMedium( - expenseController.errorMessage.value, - color: Colors.red, - ), - ); - } - final filteredList = _getFilteredExpenses(); - return NotificationListener( - onNotification: (ScrollNotification scrollInfo) { - if (scrollInfo.metrics.pixels == - scrollInfo.metrics.maxScrollExtent && - !expenseController.isLoading.value) { - expenseController.loadMoreExpenses(); - } - return false; - }, - child: ExpenseList( - expenseList: filteredList, - onViewDetail: () => expenseController.fetchExpenses(), - ), + return MyRefreshIndicator( + onRefresh: _refreshExpenses, + child: filteredList.isEmpty + ? ListView( + physics: + const AlwaysScrollableScrollPhysics(), // important + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: Center( + child: MyText.bodyMedium( + expenseController.errorMessage.isNotEmpty + ? expenseController.errorMessage.value + : "No expenses found", + color: + expenseController.errorMessage.isNotEmpty + ? Colors.red + : Colors.grey, + ), + ), + ), + ], + ) + : NotificationListener( + onNotification: (scrollInfo) { + if (scrollInfo.metrics.pixels == + scrollInfo.metrics.maxScrollExtent && + !expenseController.isLoading.value) { + expenseController.loadMoreExpenses(); + } + return false; + }, + child: ExpenseList( + expenseList: filteredList, + onViewDetail: () => + expenseController.fetchExpenses(), + ), + ), ); }), - ), + ) ], ), ), diff --git a/lib/view/taskPlaning/daily_progress.dart b/lib/view/taskPlaning/daily_progress.dart index 0b4bce0..4ab0c7e 100644 --- a/lib/view/taskPlaning/daily_progress.dart +++ b/lib/view/taskPlaning/daily_progress.dart @@ -16,6 +16,7 @@ import 'package:marco/controller/project_controller.dart'; import 'package:marco/model/dailyTaskPlaning/task_action_buttons.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; class DailyProgressReportScreen extends StatefulWidget { const DailyProgressReportScreen({super.key}); @@ -127,24 +128,32 @@ class _DailyProgressReportScreenState extends State ), ), body: SafeArea( - child: SingleChildScrollView( - padding: MySpacing.x(0), - child: GetBuilder( - init: dailyTaskController, - tag: 'daily_progress_report_controller', - builder: (controller) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MySpacing.height(flexSpacing), - _buildActionBar(), - Padding( - padding: MySpacing.x(8), - child: _buildDailyProgressReportTab(), - ), - ], - ); - }, + child: MyRefreshIndicator( + onRefresh: _refreshData, + child: CustomScrollView( + physics: + const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: GetBuilder( + init: dailyTaskController, + tag: 'daily_progress_report_controller', + builder: (controller) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(flexSpacing), + _buildActionBar(), + Padding( + padding: MySpacing.x(8), + child: _buildDailyProgressReportTab(), + ), + ], + ); + }, + ), + ), + ], ), ), ), @@ -163,14 +172,6 @@ class _DailyProgressReportScreenState extends State tooltip: 'Filter Project', onTap: _openFilterSheet, ), - const SizedBox(width: 8), - _buildActionItem( - label: "Refresh", - icon: Icons.refresh, - tooltip: 'Refresh Data', - color: Colors.green, - onTap: _refreshData, - ), ], ), ); @@ -468,7 +469,8 @@ class _DailyProgressReportScreenState extends State .toString() .isEmpty) && permissionController.hasPermission( - Permissions.assignReportTask)) ...[ + Permissions + .assignReportTask)) ...[ TaskActionButtons.reportButton( context: context, task: task, @@ -478,8 +480,7 @@ class _DailyProgressReportScreenState extends State const SizedBox(width: 4), ] else if (task.approvedBy == null && permissionController.hasPermission( - Permissions - .approveTask)) ...[ + Permissions.approveTask)) ...[ TaskActionButtons.reportActionButton( context: context, task: task, diff --git a/lib/view/taskPlaning/daily_task_planing.dart b/lib/view/taskPlaning/daily_task_planing.dart index c174001..4525d11 100644 --- a/lib/view/taskPlaning/daily_task_planing.dart +++ b/lib/view/taskPlaning/daily_task_planing.dart @@ -12,6 +12,7 @@ import 'package:percent_indicator/percent_indicator.dart'; import 'package:marco/model/dailyTaskPlaning/assign_task_bottom_sheet .dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; class DailyTaskPlaningScreen extends StatefulWidget { DailyTaskPlaningScreen({super.key}); @@ -112,60 +113,45 @@ class _DailyTaskPlaningScreenState extends State ), ), body: SafeArea( - child: SingleChildScrollView( - padding: MySpacing.x(0), - child: GetBuilder( - init: dailyTaskPlaningController, - tag: 'daily_task_planing_controller', - builder: (controller) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MySpacing.height(flexSpacing), - Padding( - padding: MySpacing.x(flexSpacing), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const SizedBox(width: 8), - MyText.bodyMedium("Refresh", fontWeight: 600), - Tooltip( - message: 'Refresh Data', - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: () async { - final projectId = - projectController.selectedProjectId.value; - if (projectId.isNotEmpty) { - try { - await dailyTaskPlaningController - .fetchTaskData(projectId); - } catch (e) { - debugPrint( - 'Error refreshing task data: ${e.toString()}'); - } - } - }, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Icon(Icons.refresh, - color: Colors.green, size: 28), - ), - ), - ), - ), - ], - ), - ), - Padding( - padding: MySpacing.x(8), - child: dailyProgressReportTab(), - ), - ], - ); - }, + child: MyRefreshIndicator( + onRefresh: () async { + final projectId = projectController.selectedProjectId.value; + if (projectId.isNotEmpty) { + try { + await dailyTaskPlaningController.fetchTaskData(projectId); + } catch (e) { + debugPrint('Error refreshing task data: ${e.toString()}'); + } + } + }, + child: SingleChildScrollView( + physics: + const AlwaysScrollableScrollPhysics(), // <-- always allow drag + padding: MySpacing.x(0), + child: ConstrainedBox( + // <-- ensures full screen height + constraints: BoxConstraints( + minHeight: MediaQuery.of(context).size.height - + kToolbarHeight - + MediaQuery.of(context).padding.top, + ), + child: GetBuilder( + init: dailyTaskPlaningController, + tag: 'daily_task_planing_controller', + builder: (controller) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(flexSpacing), + Padding( + padding: MySpacing.x(8), + child: dailyProgressReportTab(), + ), + ], + ); + }, + ), + ), ), ), ),