added pull down refresh

This commit is contained in:
Vaibhav Surve 2025-08-08 15:19:29 +05:30
parent 0e177e5a1f
commit 2fb3c36ba4
17 changed files with 805 additions and 641 deletions

View File

@ -173,17 +173,25 @@ class ExpenseController extends GetxController {
if (result != null) { if (result != null) {
try { try {
final expenseResponse = ExpenseResponse.fromJson(result); final expenseResponse = ExpenseResponse.fromJson(result);
expenses.assignAll(expenseResponse.data.data);
logSafe("Expenses loaded: ${expenses.length}"); // If the backend returns no data, treat it as empty list
logSafe( if (expenseResponse.data.data.isEmpty) {
"Pagination Info: Page ${expenseResponse.data.currentPage} of ${expenseResponse.data.totalPages} | Total: ${expenseResponse.data.totalEntites}"); 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) { } catch (e) {
errorMessage.value = 'Failed to parse expenses: $e'; errorMessage.value = 'Failed to parse expenses: $e';
logSafe("Parse error in fetchExpenses: $e", level: LogLevel.error); logSafe("Parse error in fetchExpenses: $e", level: LogLevel.error);
} }
} else { } 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); logSafe("fetchExpenses failed: null response", level: LogLevel.error);
} }
} catch (e, stack) { } catch (e, stack) {

View File

@ -1,6 +1,6 @@
class ApiEndpoints { class ApiEndpoints {
static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://stageapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api"; static const String baseUrl = "https://api.marcoaiot.com/api";
// Dashboard Module API Endpoints // Dashboard Module API Endpoints
static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview"; static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview";

View File

@ -436,18 +436,16 @@ class ApiService {
int pageSize = 20, int pageSize = 20,
int pageNumber = 1, int pageNumber = 1,
}) async { }) async {
// Build the endpoint with query parameters
String endpoint = ApiEndpoints.getExpenseList; String endpoint = ApiEndpoints.getExpenseList;
final queryParams = <String, String>{ final queryParams = {
'pageSize': pageSize.toString(), 'pageSize': pageSize.toString(),
'pageNumber': pageNumber.toString(), 'pageNumber': pageNumber.toString(),
}; };
if (filter != null && filter.isNotEmpty) { if (filter?.isNotEmpty ?? false) {
queryParams['filter'] = filter; queryParams['filter'] = filter!;
} }
// Build the full URI
final uri = Uri.parse(endpoint).replace(queryParameters: queryParams); final uri = Uri.parse(endpoint).replace(queryParameters: queryParams);
logSafe("Fetching expense list with URI: $uri"); logSafe("Fetching expense list with URI: $uri");
@ -456,20 +454,22 @@ class ApiService {
if (response == null) { if (response == null) {
logSafe("Expense list request failed: null response", logSafe("Expense list request failed: null response",
level: LogLevel.error); level: LogLevel.error);
return null; return null; // real failure
} }
// Directly parse and return the entire JSON response
final body = response.body.trim(); final body = response.body.trim();
if (body.isEmpty) { if (body.isEmpty) {
logSafe("Expense list response body is empty", level: LogLevel.error); logSafe("Expense list response body is empty", level: LogLevel.warning);
return null; return {
"status": true,
"data": {"data": [], "totalPages": 0, "currentPage": pageNumber}
}; // treat as empty list
} }
final jsonResponse = jsonDecode(body); final jsonResponse = jsonDecode(body);
if (jsonResponse is Map<String, dynamic>) { if (jsonResponse is Map<String, dynamic>) {
logSafe("Expense list response parsed successfully"); 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 { } else {
logSafe("Unexpected response structure: $jsonResponse", logSafe("Unexpected response structure: $jsonResponse",
level: LogLevel.error); level: LogLevel.error);

View File

@ -76,14 +76,12 @@ class SearchAndFilter extends StatelessWidget {
final TextEditingController controller; final TextEditingController controller;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
final VoidCallback onFilterTap; final VoidCallback onFilterTap;
final VoidCallback onRefreshTap;
final ExpenseController expenseController; final ExpenseController expenseController;
const SearchAndFilter({ const SearchAndFilter({
required this.controller, required this.controller,
required this.onChanged, required this.onChanged,
required this.onFilterTap, required this.onFilterTap,
required this.onRefreshTap,
required this.expenseController, required this.expenseController,
super.key, 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), MySpacing.width(4),
Obx(() { Obx(() {
return IconButton( return IconButton(

View File

@ -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 // Employee List - Card Style
static Widget employeeListSkeletonLoader() { static Widget employeeListSkeletonLoader() {
return Column( return Column(

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
class MyRefreshIndicator extends StatelessWidget {
final Future<void> 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,
);
}
}

View File

@ -42,11 +42,7 @@ class AttendanceLogsTab extends StatelessWidget {
children: [ children: [
MyText.titleMedium("Attendance Logs", fontWeight: 600), MyText.titleMedium("Attendance Logs", fontWeight: 600),
controller.isLoading.value controller.isLoading.value
? const SizedBox( ? SkeletonLoaders.dateSkeletonLoader()
height: 20,
width: 20,
child: LinearProgressIndicator(),
)
: MyText.bodySmall( : MyText.bodySmall(
dateRangeText, dateRangeText,
fontWeight: 600, fontWeight: 600,

View File

@ -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/regularization_requests_tab.dart';
import 'package:marco/view/dashboard/Attendence/attendance_logs_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/view/dashboard/Attendence/todays_attendance_tab.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
class AttendanceScreen extends StatefulWidget { class AttendanceScreen extends StatefulWidget {
const AttendanceScreen({super.key}); const AttendanceScreen({super.key});
@ -70,7 +70,8 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
IconButton( 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'), onPressed: () => Get.offNamed('/dashboard'),
), ),
MySpacing.width(8), MySpacing.width(8),
@ -78,14 +79,18 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.titleLarge('Attendance', fontWeight: 700, color: Colors.black), MyText.titleLarge('Attendance',
fontWeight: 700, color: Colors.black),
MySpacing.height(2), MySpacing.height(2),
GetBuilder<ProjectController>( GetBuilder<ProjectController>(
builder: (projectController) { builder: (projectController) {
final projectName = projectController.selectedProject?.name ?? 'Select Project'; final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row( return Row(
children: [ children: [
const Icon(Icons.work_outline, size: 14, color: Colors.grey), const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4), MySpacing.width(4),
Expanded( Expanded(
child: MyText.bodySmall( child: MyText.bodySmall(
@ -133,18 +138,24 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
); );
if (result != null) { if (result != null) {
final selectedProjectId = projectController.selectedProjectId.value; final selectedProjectId =
projectController.selectedProjectId.value;
final selectedView = result['selectedTab'] as String?; final selectedView = result['selectedTab'] as String?;
if (selectedProjectId.isNotEmpty) { if (selectedProjectId.isNotEmpty) {
try { try {
await attendanceController.fetchEmployeesByProject(selectedProjectId); await attendanceController
await attendanceController.fetchAttendanceLogs(selectedProjectId); .fetchEmployeesByProject(selectedProjectId);
await attendanceController.fetchRegularizationLogs(selectedProjectId); await attendanceController
await attendanceController.fetchProjectData(selectedProjectId); .fetchAttendanceLogs(selectedProjectId);
await attendanceController
.fetchRegularizationLogs(selectedProjectId);
await attendanceController
.fetchProjectData(selectedProjectId);
} catch (_) {} } catch (_) {}
attendanceController.update(['attendance_dashboard_controller']); attendanceController
.update(['attendance_dashboard_controller']);
} }
if (selectedView != null && selectedView != selectedTab) { if (selectedView != null && selectedView != selectedTab) {
@ -154,20 +165,7 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Icon(Icons.tune, color: Colors.blueAccent, size: 20), child: Icon(Icons.tune, size: 18),
),
),
),
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),
), ),
), ),
), ),
@ -203,7 +201,10 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: PreferredSize(preferredSize: const Size.fromHeight(72), child: _buildAppBar()), appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: _buildAppBar(),
),
body: SafeArea( body: SafeArea(
child: GetBuilder<AttendanceController>( child: GetBuilder<AttendanceController>(
init: attendanceController, init: attendanceController,
@ -212,25 +213,29 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
final selectedProjectId = projectController.selectedProjectId.value; final selectedProjectId = projectController.selectedProjectId.value;
final noProjectSelected = selectedProjectId.isEmpty; final noProjectSelected = selectedProjectId.isEmpty;
return SingleChildScrollView( return MyRefreshIndicator(
padding: MySpacing.zero, onRefresh: _refreshData,
child: Column( child: SingleChildScrollView(
crossAxisAlignment: CrossAxisAlignment.start, physics: const AlwaysScrollableScrollPhysics(),
children: [ padding: MySpacing.zero,
MySpacing.height(flexSpacing), child: Column(
_buildFilterAndRefreshRow(), crossAxisAlignment: CrossAxisAlignment.start,
MySpacing.height(flexSpacing), children: [
MyFlex( MySpacing.height(flexSpacing),
children: [ _buildFilterAndRefreshRow(),
MyFlexItem( MySpacing.height(flexSpacing),
sizes: 'lg-12 md-12 sm-12', MyFlex(
child: noProjectSelected children: [
? _buildNoProjectWidget() MyFlexItem(
: _buildSelectedTabContent(), sizes: 'lg-12 md-12 sm-12',
), child: noProjectSelected
], ? _buildNoProjectWidget()
), : _buildSelectedTabContent(),
], ),
],
),
],
),
), ),
); );
}, },

View File

@ -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/model/directory/add_comment_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/model/directory/add_contact_bottom_sheet.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 // HELPER: Delta to HTML conversion
String _convertDeltaToHtml(dynamic delta) { String _convertDeltaToHtml(dynamic delta) {
@ -120,8 +121,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), icon: const Icon(Icons.arrow_back_ios_new,
onPressed: () => Get.offAllNamed('/dashboard/directory-main-page'), color: Colors.black, size: 20),
onPressed: () =>
Get.offAllNamed('/dashboard/directory-main-page'),
), ),
MySpacing.width(8), MySpacing.width(8),
Expanded( Expanded(
@ -129,7 +132,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
MyText.titleLarge('Contact Profile', fontWeight: 700, color: Colors.black), MyText.titleLarge('Contact Profile',
fontWeight: 700, color: Colors.black),
MySpacing.height(2), MySpacing.height(2),
GetBuilder<ProjectController>( GetBuilder<ProjectController>(
builder: (p) => ProjectLabel(p.selectedProject?.name), builder: (p) => ProjectLabel(p.selectedProject?.name),
@ -145,7 +149,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
Widget _buildSubHeader() { Widget _buildSubHeader() {
final firstName = contact.name.split(" ").first; 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( return Padding(
padding: MySpacing.xy(16, 12), padding: MySpacing.xy(16, 12),
@ -153,21 +158,27 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row(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), MySpacing.width(12),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.titleSmall(contact.name, fontWeight: 600, color: Colors.black), MyText.titleSmall(contact.name,
fontWeight: 600, color: Colors.black),
MySpacing.height(2), MySpacing.height(2),
MyText.bodySmall(contact.organization, fontWeight: 500, color: Colors.grey[700]), MyText.bodySmall(contact.organization,
fontWeight: 500, color: Colors.grey[700]),
], ],
), ),
]), ]),
TabBar( TabBar(
labelColor: Colors.red, labelColor: Colors.red,
unselectedLabelColor: Colors.black, unselectedLabelColor: Colors.black,
indicator: MaterialIndicator( indicator: MaterialIndicator(
color: Colors.red, color: Colors.red,
height: 4, height: 4,
topLeftRadius: 8, topLeftRadius: 8,
@ -193,25 +204,38 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
?.name) ?.name)
.whereType<String>() .whereType<String>()
.join(", "); .join(", ");
final projectNames = contact.projectIds?.map((id) => final projectNames = contact.projectIds
projectController.projects.firstWhereOrNull((p) => p.id == id)?.name).whereType<String>().join(", ") ?? "-"; ?.map((id) => projectController.projects
.firstWhereOrNull((p) => p.id == id)
?.name)
.whereType<String>()
.join(", ") ??
"-";
final category = contact.contactCategory?.name ?? "-"; final category = contact.contactCategory?.name ?? "-";
Widget multiRows({required List<dynamic> items, required IconData icon, required String label, required String typeLabel, required Function(String)? onTap, required Function(String)? onLongPress}) { Widget multiRows(
{required List<dynamic> items,
required IconData icon,
required String label,
required String typeLabel,
required Function(String)? onTap,
required Function(String)? onLongPress}) {
return items.isNotEmpty return items.isNotEmpty
? Column( ? Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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( ...items.skip(1).map(
(val) => _iconInfoRow( (val) => _iconInfoRow(
null, null,
'', '',
val, val,
onTap: () => onTap?.call(val), onTap: () => onTap?.call(val),
onLongPress: () => onLongPress?.call(val), onLongPress: () => onLongPress?.call(val),
), ),
), ),
], ],
) )
: _iconInfoRow(icon, label, "-"); : _iconInfoRow(icon, label, "-");
@ -228,32 +252,38 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
// BASIC INFO CARD // BASIC INFO CARD
_infoCard("Basic Info", [ _infoCard("Basic Info", [
multiRows( multiRows(
items: contact.contactEmails.map((e) => e.emailAddress).toList(), items:
contact.contactEmails.map((e) => e.emailAddress).toList(),
icon: Icons.email, icon: Icons.email,
label: "Email", label: "Email",
typeLabel: "Email", typeLabel: "Email",
onTap: (email) => LauncherUtils.launchEmail(email), onTap: (email) => LauncherUtils.launchEmail(email),
onLongPress: (email) => LauncherUtils.copyToClipboard(email, typeLabel: "Email"), onLongPress: (email) =>
LauncherUtils.copyToClipboard(email, typeLabel: "Email"),
), ),
multiRows( multiRows(
items: contact.contactPhones.map((p) => p.phoneNumber).toList(), items:
contact.contactPhones.map((p) => p.phoneNumber).toList(),
icon: Icons.phone, icon: Icons.phone,
label: "Phone", label: "Phone",
typeLabel: "Phone", typeLabel: "Phone",
onTap: (phone) => LauncherUtils.launchPhone(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), _iconInfoRow(Icons.location_on, "Address", contact.address),
]), ]),
// ORGANIZATION CARD // ORGANIZATION CARD
_infoCard("Organization", [ _infoCard("Organization", [
_iconInfoRow(Icons.business, "Organization", contact.organization), _iconInfoRow(
Icons.business, "Organization", contact.organization),
_iconInfoRow(Icons.category, "Category", category), _iconInfoRow(Icons.category, "Category", category),
]), ]),
// META INFO CARD // META INFO CARD
_infoCard("Meta Info", [ _infoCard("Meta Info", [
_iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"), _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), _iconInfoRow(Icons.work_outline, "Projects", projectNames),
]), ]),
// DESCRIPTION CARD // DESCRIPTION CARD
@ -285,15 +315,16 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
); );
if (result == true) { if (result == true) {
await directoryController.fetchContacts(); await directoryController.fetchContacts();
final updated = final updated = directoryController.allContacts
directoryController.allContacts.firstWhereOrNull((c) => c.id == contact.id); .firstWhereOrNull((c) => c.id == contact.id);
if (updated != null) { if (updated != null) {
setState(() => contact = updated); setState(() => contact = updated);
} }
} }
}, },
icon: const Icon(Icons.edit, color: Colors.white), 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<ContactDetailScreen> {
if (!directoryController.contactCommentsMap.containsKey(contactId)) { if (!directoryController.contactCommentsMap.containsKey(contactId)) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final comments = directoryController.getCommentsForContact(contactId).reversed.toList();
final comments = directoryController
.getCommentsForContact(contactId)
.reversed
.toList();
final editingId = directoryController.editingCommentId.value; final editingId = directoryController.editingCommentId.value;
return Stack( return Stack(
children: [ children: [
comments.isEmpty MyRefreshIndicator(
? Center( onRefresh: () async {
child: MyText.bodyLarge("No comments yet.", color: Colors.grey), await directoryController.fetchCommentsForContact(contactId);
) },
: Padding( child: comments.isEmpty
padding: MySpacing.xy(12, 12), ? ListView(
child: ListView.separated( physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100), children: [
itemCount: comments.length, SizedBox(
separatorBuilder: (_, __) => MySpacing.height(14), height: MediaQuery.of(context).size.height * 0.6,
itemBuilder: (_, index) => _buildCommentItem(comments[index], editingId, contact.id), 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) if (editingId == null)
Positioned( Positioned(
bottom: 20, bottom: 20,
@ -336,11 +392,15 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
isScrollControlled: true, isScrollControlled: true,
); );
if (result == true) { if (result == true) {
await directoryController.fetchCommentsForContact(contactId); await directoryController
.fetchCommentsForContact(contactId);
} }
}, },
icon: const Icon(Icons.add_comment, color: Colors.white), 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<ContactDetailScreen> {
color: isEditing ? Colors.indigo : Colors.grey.shade300, color: isEditing ? Colors.indigo : Colors.grey.shade300,
width: 1.2, 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -406,7 +468,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
color: Colors.indigo, color: Colors.indigo,
), ),
onPressed: () { onPressed: () {
directoryController.editingCommentId.value = isEditing ? null : comment.id; directoryController.editingCommentId.value =
isEditing ? null : comment.id;
}, },
), ),
], ],
@ -467,7 +530,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (label.isNotEmpty) 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), if (label.isNotEmpty) MySpacing.height(2),
MyText.bodyMedium(value, color: Colors.grey[800]), MyText.bodyMedium(value, color: Colors.grey[800]),
], ],
@ -489,7 +553,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.titleSmall(title, fontWeight: 700, color: Colors.indigo[700]), MyText.titleSmall(title,
fontWeight: 700, color: Colors.indigo[700]),
MySpacing.height(8), MySpacing.height(8),
...children, ...children,
], ],

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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/directory_controller.dart';
import 'package:marco/controller/directory/create_bucket_controller.dart'; import 'package:marco/controller/directory/create_bucket_controller.dart';
@ -24,7 +25,8 @@ class DirectoryView extends StatefulWidget {
class _DirectoryViewState extends State<DirectoryView> { class _DirectoryViewState extends State<DirectoryView> {
final DirectoryController controller = Get.find(); final DirectoryController controller = Get.find();
final TextEditingController searchController = TextEditingController(); final TextEditingController searchController = TextEditingController();
final PermissionController permissionController = Get.put(PermissionController()); final PermissionController permissionController =
Get.put(PermissionController());
Future<void> _refreshDirectory() async { Future<void> _refreshDirectory() async {
try { try {
@ -187,18 +189,6 @@ class _DirectoryViewState extends State<DirectoryView> {
), ),
), ),
MySpacing.width(8), 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(() { Obx(() {
final isFilterActive = controller.hasActiveFilters(); final isFilterActive = controller.hasActiveFilters();
return Stack( return Stack(
@ -382,178 +372,231 @@ class _DirectoryViewState extends State<DirectoryView> {
), ),
Expanded( Expanded(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value) { return MyRefreshIndicator(
return ListView.separated( onRefresh: _refreshDirectory,
itemCount: 10, backgroundColor: Colors.indigo,
separatorBuilder: (_, __) => MySpacing.height(12), color: Colors.white,
itemBuilder: (_, __) => SkeletonLoaders.contactSkeletonCard(), child: controller.isLoading.value
); ? ListView.separated(
} physics: const AlwaysScrollableScrollPhysics(),
itemCount: 10,
if (controller.filteredContacts.isEmpty) { separatorBuilder: (_, __) => MySpacing.height(12),
return Center( itemBuilder: (_, __) =>
child: Column( SkeletonLoaders.contactSkeletonCard(),
mainAxisAlignment: MainAxisAlignment.center, )
children: [ : controller.filteredContacts.isEmpty
const Icon(Icons.contact_page_outlined, ? ListView(
size: 60, color: Colors.grey), physics: const AlwaysScrollableScrollPhysics(),
const SizedBox(height: 12), children: [
MyText.bodyMedium('No contacts found.', fontWeight: 500), SizedBox(
], height:
), MediaQuery.of(context).size.height * 0.6,
); child: Center(
} child: Column(
mainAxisAlignment: MainAxisAlignment.center,
return ListView.separated( children: [
padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 80), const Icon(Icons.contact_page_outlined,
itemCount: controller.filteredContacts.length, size: 60, color: Colors.grey),
separatorBuilder: (_, __) => MySpacing.height(12), const SizedBox(height: 12),
itemBuilder: (_, index) { MyText.bodyMedium('No contacts found.',
final contact = controller.filteredContacts[index]; fontWeight: 500),
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,
),
),
],
),
),
), ),
),
),
],
)
: 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) return InkWell(
if (contact.contactPhones.isNotEmpty) onTap: () {
Padding( Get.to(() =>
padding: const EdgeInsets.only( ContactDetailScreen(contact: contact));
bottom: 8, top: 4), },
child: Row( child: Padding(
children: [ padding:
Expanded( const EdgeInsets.fromLTRB(12, 10, 12, 0),
child: GestureDetector( child: Row(
onTap: () => LauncherUtils crossAxisAlignment:
.launchPhone(contact CrossAxisAlignment.start,
.contactPhones children: [
.first Avatar(
.phoneNumber), firstName: firstName,
onLongPress: () => lastName: lastName,
LauncherUtils.copyToClipboard( size: 35),
contact.contactPhones.first MySpacing.width(12),
.phoneNumber, Expanded(
typeLabel: 'Phone', child: Column(
), crossAxisAlignment:
child: Row( CrossAxisAlignment.start,
children: [ children: [
const Icon( MyText.titleSmall(contact.name,
Icons.phone_outlined, fontWeight: 600,
size: 16, overflow:
color: Colors.indigo), TextOverflow.ellipsis),
MySpacing.width(4), MyText.bodySmall(
Expanded( contact.organization,
child: MyText.labelSmall( color: Colors.grey[700],
contact.contactPhones.first overflow:
.phoneNumber, TextOverflow.ellipsis),
overflow: MySpacing.height(8),
TextOverflow.ellipsis, if (contact
color: Colors.indigo, .contactEmails.isNotEmpty)
decoration: TextDecoration GestureDetector(
.underline, 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( Column(
onTap: () => children: [
LauncherUtils.launchWhatsApp( const Icon(Icons.arrow_forward_ios,
contact.contactPhones.first color: Colors.grey, size: 16),
.phoneNumber), MySpacing.height(8),
child: const FaIcon( ],
FontAwesomeIcons.whatsapp, ),
color: Colors.green, ],
size: 16,
),
),
],
),
), ),
if (tags.isNotEmpty) ...[ ),
MySpacing.height(2), );
MyText.labelSmall(tags.join(', '), },
color: Colors.grey[500],
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
],
),
), ),
Column(
children: [
const Icon(Icons.arrow_forward_ios,
color: Colors.grey, size: 16),
MySpacing.height(8),
],
),
],
),
),
);
},
); );
}), }),
), )
], ],
), ),
); );

View File

@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill; 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_quill_delta_from_html/flutter_quill_delta_from_html.dart';
import 'package:flutter_html/flutter_html.dart' as html; 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/controller/directory/notes_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
@ -104,21 +105,6 @@ 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; final notes = controller.filteredNotesList;
if (notes.isEmpty) { if (notes.isEmpty) {
return Center( return MyRefreshIndicator(
child: Column( onRefresh: _refreshNotes,
mainAxisAlignment: MainAxisAlignment.center, child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [ children: [
const Icon(Icons.note_alt_outlined, SizedBox(
size: 60, color: Colors.grey), height: MediaQuery.of(context).size.height * 0.6,
const SizedBox(height: 12), child: Center(
MyText.bodyMedium('No notes found.', fontWeight: 500), 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( return MyRefreshIndicator(
padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 80), onRefresh: _refreshNotes,
itemCount: notes.length, child: ListView.separated(
separatorBuilder: (_, __) => MySpacing.height(12), physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (_, index) { padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 80),
final note = notes[index]; itemCount: notes.length,
separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) {
final note = notes[index];
return Obx(() { return Obx(() {
final isEditing = controller.editingNoteId.value == note.id; final isEditing = controller.editingNoteId.value == note.id;
final initials = note.contactName.trim().isNotEmpty final initials = note.contactName.trim().isNotEmpty
? note.contactName ? note.contactName
.trim() .trim()
.split(' ') .split(' ')
.map((e) => e[0]) .map((e) => e[0])
.take(2) .take(2)
.join() .join()
.toUpperCase() .toUpperCase()
: "NA"; : "NA";
final createdDate = DateTimeUtils.convertUtcToLocal( final createdDate = DateTimeUtils.convertUtcToLocal(
note.createdAt.toString(), note.createdAt.toString(),
format: 'dd MMM yyyy'); format: 'dd MMM yyyy');
final createdTime = DateTimeUtils.convertUtcToLocal( final createdTime = DateTimeUtils.convertUtcToLocal(
note.createdAt.toString(), note.createdAt.toString(),
format: 'hh:mm a'); format: 'hh:mm a');
final decodedDelta = HtmlToDelta().convert(note.note); final decodedDelta = HtmlToDelta().convert(note.note);
final quillController = isEditing final quillController = isEditing
? quill.QuillController( ? quill.QuillController(
document: quill.Document.fromDelta(decodedDelta), document: quill.Document.fromDelta(decodedDelta),
selection: TextSelection.collapsed( selection: TextSelection.collapsed(
offset: decodedDelta.length), offset: decodedDelta.length),
) )
: null; : null;
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
padding: MySpacing.xy(12, 12), padding: MySpacing.xy(12, 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isEditing ? Colors.indigo[50] : Colors.white, color: isEditing ? Colors.indigo[50] : Colors.white,
border: Border.all( border: Border.all(
color: isEditing ? Colors.indigo : Colors.grey.shade300, color:
width: 1.1, 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), child: Column(
boxShadow: const [ crossAxisAlignment: CrossAxisAlignment.start,
BoxShadow( children: [
color: Colors.black12, /// Header Row
blurRadius: 4, Row(
offset: Offset(0, 2)), crossAxisAlignment: CrossAxisAlignment.start,
], children: [
), Avatar(
child: Column( firstName: initials, lastName: '', size: 40),
crossAxisAlignment: CrossAxisAlignment.start, MySpacing.width(12),
children: [ Expanded(
/// Header Row child: Column(
Row( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ MyText.titleSmall(
Avatar(firstName: initials, lastName: '', size: 40), "${note.contactName} (${note.organizationName})",
MySpacing.width(12), fontWeight: 600,
Expanded( overflow: TextOverflow.ellipsis,
child: Column( color: Colors.indigo[800],
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ MyText.bodySmall(
MyText.titleSmall( "by ${note.createdBy.firstName}$createdDate, $createdTime",
"${note.contactName} (${note.organizationName})", color: Colors.grey[600],
fontWeight: 600, ),
overflow: TextOverflow.ellipsis, ],
color: Colors.indigo[800], ),
),
MyText.bodySmall(
"by ${note.createdBy.firstName}$createdDate, $createdTime",
color: Colors.grey[600],
),
],
), ),
), IconButton(
IconButton( icon: Icon(
icon: Icon( isEditing ? Icons.close : Icons.edit,
isEditing ? Icons.close : Icons.edit, color: Colors.indigo,
color: Colors.indigo, size: 20,
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,
),
},
),
],
),
);
});
},
); );
}), }),
), ),

View File

@ -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/helpers/utils/launcher_utils.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
class EmployeeDetailPage extends StatefulWidget { class EmployeeDetailPage extends StatefulWidget {
final String employeeId; final String employeeId;
@ -255,40 +256,46 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
} }
return SafeArea( return SafeArea(
child: SingleChildScrollView( child: MyRefreshIndicator(
padding: const EdgeInsets.fromLTRB(12, 20, 12, 80), onRefresh: () async {
child: Column( await controller.fetchEmployeeDetails(widget.employeeId);
crossAxisAlignment: CrossAxisAlignment.center, },
children: [ child: SingleChildScrollView(
Row( physics: const AlwaysScrollableScrollPhysics(),
children: [ padding: const EdgeInsets.fromLTRB(12, 20, 12, 80),
Avatar( child: Column(
firstName: employee.firstName, crossAxisAlignment: CrossAxisAlignment.center,
lastName: employee.lastName, children: [
size: 45, Row(
), children: [
MySpacing.width(16), Avatar(
Expanded( firstName: employee.firstName,
child: Column( lastName: employee.lastName,
crossAxisAlignment: CrossAxisAlignment.start, size: 45,
children: [
MyText.titleMedium(
'${employee.firstName} ${employee.lastName}',
fontWeight: 700,
),
MySpacing.height(6),
MyText.bodySmall(
_getDisplayValue(employee.jobRole),
fontWeight: 500,
),
],
), ),
), MySpacing.width(16),
], Expanded(
), child: Column(
MySpacing.height(14), crossAxisAlignment: CrossAxisAlignment.start,
_buildInfoCard(employee), children: [
], MyText.titleMedium(
'${employee.firstName} ${employee.lastName}',
fontWeight: 700,
),
MySpacing.height(6),
MyText.bodySmall(
_getDisplayValue(employee.jobRole),
fontWeight: 500,
),
],
),
),
],
),
MySpacing.height(14),
_buildInfoCard(employee),
],
),
), ),
), ),
); );

View File

@ -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/view/employees/assign_employee_bottom_sheet.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
class EmployeesScreen extends StatefulWidget { class EmployeesScreen extends StatefulWidget {
const EmployeesScreen({super.key}); const EmployeesScreen({super.key});
@ -151,19 +152,24 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
tag: 'employee_screen_controller', tag: 'employee_screen_controller',
builder: (_) { builder: (_) {
_filterEmployees(_searchController.text); _filterEmployees(_searchController.text);
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 40), return MyRefreshIndicator(
child: Column( onRefresh: _refreshEmployees,
crossAxisAlignment: CrossAxisAlignment.start, child: SingleChildScrollView(
children: [ physics: const AlwaysScrollableScrollPhysics(),
MySpacing.height(flexSpacing), padding: const EdgeInsets.only(bottom: 40),
_buildSearchAndActionRow(), child: Column(
MySpacing.height(flexSpacing), crossAxisAlignment: CrossAxisAlignment.start,
Padding( children: [
padding: MySpacing.x(flexSpacing), MySpacing.height(flexSpacing),
child: _buildEmployeeList(), _buildSearchAndActionRow(),
), MySpacing.height(flexSpacing),
], Padding(
padding: MySpacing.x(flexSpacing),
child: _buildEmployeeList(),
),
],
),
), ),
); );
}, },
@ -266,8 +272,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
children: [ children: [
Expanded(child: _buildSearchField()), Expanded(child: _buildSearchField()),
const SizedBox(width: 8), const SizedBox(width: 8),
_buildRefreshButton(),
const SizedBox(width: 4),
_buildPopupMenu(), _buildPopupMenu(),
], ],
), ),
@ -315,20 +319,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> 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() { Widget _buildPopupMenu() {
if (!_permissionController.hasPermission(Permissions.viewAllEmployees)) { if (!_permissionController.hasPermission(Permissions.viewAllEmployees)) {
return const SizedBox.shrink(); return const SizedBox.shrink();

View File

@ -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/controller/expense/add_expense_controller.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:url_launcher/url_launcher.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/expense_detail_helpers.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
@ -92,36 +93,41 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
colorCode: expense.status.color); colorCode: expense.status.color);
final formattedAmount = formatExpenseAmount(expense.amount); final formattedAmount = formatExpenseAmount(expense.amount);
return SingleChildScrollView( return MyRefreshIndicator(
padding: EdgeInsets.fromLTRB( onRefresh: () async {
8, 8, 8, 30 + MediaQuery.of(context).padding.bottom), await controller.fetchExpenseDetails();
child: Center( },
child: Container( child: SingleChildScrollView(
constraints: const BoxConstraints(maxWidth: 520), padding: EdgeInsets.fromLTRB(
child: Card( 8, 8, 8, 30 + MediaQuery.of(context).padding.bottom),
shape: RoundedRectangleBorder( child: Center(
borderRadius: BorderRadius.circular(10)), child: Container(
elevation: 3, constraints: const BoxConstraints(maxWidth: 520),
child: Padding( child: Card(
padding: const EdgeInsets.symmetric( shape: RoundedRectangleBorder(
vertical: 14, horizontal: 14), borderRadius: BorderRadius.circular(10)),
child: Column( elevation: 3,
crossAxisAlignment: CrossAxisAlignment.start, child: Padding(
children: [ padding: const EdgeInsets.symmetric(
_InvoiceHeader(expense: expense), vertical: 14, horizontal: 14),
const Divider(height: 30, thickness: 1.2), child: Column(
_InvoiceParties(expense: expense), crossAxisAlignment: CrossAxisAlignment.start,
const Divider(height: 30, thickness: 1.2), children: [
_InvoiceDetailsTable(expense: expense), _InvoiceHeader(expense: expense),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
_InvoiceDocuments(documents: expense.documents), _InvoiceParties(expense: expense),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
_InvoiceTotals( _InvoiceDetailsTable(expense: expense),
expense: expense, const Divider(height: 30, thickness: 1.2),
formattedAmount: formattedAmount, _InvoiceDocuments(documents: expense.documents),
statusColor: statusColor, const Divider(height: 30, thickness: 1.2),
), _InvoiceTotals(
], expense: expense,
formattedAmount: formattedAmount,
statusColor: statusColor,
),
],
),
), ),
), ),
), ),

View File

@ -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/view/expense/expense_filter_bottom_sheet.dart';
import 'package:marco/helpers/widgets/expense_main_components.dart'; import 'package:marco/helpers/widgets/expense_main_components.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
class ExpenseMainScreen extends StatefulWidget { class ExpenseMainScreen extends StatefulWidget {
const ExpenseMainScreen({super.key}); const ExpenseMainScreen({super.key});
@ -31,7 +32,9 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
expenseController.fetchExpenses(); expenseController.fetchExpenses();
} }
void _refreshExpenses() => expenseController.fetchExpenses(); Future<void> _refreshExpenses() async {
await expenseController.fetchExpenses();
}
void _openFilterBottomSheet() { void _openFilterBottomSheet() {
showModalBottomSheet( showModalBottomSheet(
@ -81,7 +84,6 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
controller: searchController, controller: searchController,
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
onFilterTap: _openFilterBottomSheet, onFilterTap: _openFilterBottomSheet,
onRefreshTap: _refreshExpenses,
expenseController: expenseController, expenseController: expenseController,
), ),
ToggleButtonsRow( ToggleButtonsRow(
@ -90,38 +92,55 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
), ),
Expanded( Expanded(
child: Obx(() { child: Obx(() {
// Loader while fetching first time
if (expenseController.isLoading.value && if (expenseController.isLoading.value &&
expenseController.expenses.isEmpty) { expenseController.expenses.isEmpty) {
return SkeletonLoaders.expenseListSkeletonLoader(); return SkeletonLoaders.expenseListSkeletonLoader();
} }
if (expenseController.errorMessage.isNotEmpty) {
return Center(
child: MyText.bodyMedium(
expenseController.errorMessage.value,
color: Colors.red,
),
);
}
final filteredList = _getFilteredExpenses(); final filteredList = _getFilteredExpenses();
return NotificationListener<ScrollNotification>( return MyRefreshIndicator(
onNotification: (ScrollNotification scrollInfo) { onRefresh: _refreshExpenses,
if (scrollInfo.metrics.pixels == child: filteredList.isEmpty
scrollInfo.metrics.maxScrollExtent && ? ListView(
!expenseController.isLoading.value) { physics:
expenseController.loadMoreExpenses(); const AlwaysScrollableScrollPhysics(), // important
} children: [
return false; SizedBox(
}, height: MediaQuery.of(context).size.height * 0.5,
child: ExpenseList( child: Center(
expenseList: filteredList, child: MyText.bodyMedium(
onViewDetail: () => expenseController.fetchExpenses(), expenseController.errorMessage.isNotEmpty
), ? expenseController.errorMessage.value
: "No expenses found",
color:
expenseController.errorMessage.isNotEmpty
? Colors.red
: Colors.grey,
),
),
),
],
)
: NotificationListener<ScrollNotification>(
onNotification: (scrollInfo) {
if (scrollInfo.metrics.pixels ==
scrollInfo.metrics.maxScrollExtent &&
!expenseController.isLoading.value) {
expenseController.loadMoreExpenses();
}
return false;
},
child: ExpenseList(
expenseList: filteredList,
onViewDetail: () =>
expenseController.fetchExpenses(),
),
),
); );
}), }),
), )
], ],
), ),
), ),

View File

@ -16,6 +16,7 @@ import 'package:marco/controller/project_controller.dart';
import 'package:marco/model/dailyTaskPlaning/task_action_buttons.dart'; import 'package:marco/model/dailyTaskPlaning/task_action_buttons.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
class DailyProgressReportScreen extends StatefulWidget { class DailyProgressReportScreen extends StatefulWidget {
const DailyProgressReportScreen({super.key}); const DailyProgressReportScreen({super.key});
@ -127,24 +128,32 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
), ),
), ),
body: SafeArea( body: SafeArea(
child: SingleChildScrollView( child: MyRefreshIndicator(
padding: MySpacing.x(0), onRefresh: _refreshData,
child: GetBuilder<DailyTaskController>( child: CustomScrollView(
init: dailyTaskController, physics:
tag: 'daily_progress_report_controller', const AlwaysScrollableScrollPhysics(),
builder: (controller) { slivers: [
return Column( SliverToBoxAdapter(
crossAxisAlignment: CrossAxisAlignment.start, child: GetBuilder<DailyTaskController>(
children: [ init: dailyTaskController,
MySpacing.height(flexSpacing), tag: 'daily_progress_report_controller',
_buildActionBar(), builder: (controller) {
Padding( return Column(
padding: MySpacing.x(8), crossAxisAlignment: CrossAxisAlignment.start,
child: _buildDailyProgressReportTab(), children: [
), MySpacing.height(flexSpacing),
], _buildActionBar(),
); Padding(
}, padding: MySpacing.x(8),
child: _buildDailyProgressReportTab(),
),
],
);
},
),
),
],
), ),
), ),
), ),
@ -163,14 +172,6 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
tooltip: 'Filter Project', tooltip: 'Filter Project',
onTap: _openFilterSheet, 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<DailyProgressReportScreen>
.toString() .toString()
.isEmpty) && .isEmpty) &&
permissionController.hasPermission( permissionController.hasPermission(
Permissions.assignReportTask)) ...[ Permissions
.assignReportTask)) ...[
TaskActionButtons.reportButton( TaskActionButtons.reportButton(
context: context, context: context,
task: task, task: task,
@ -478,8 +480,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
const SizedBox(width: 4), const SizedBox(width: 4),
] else if (task.approvedBy == null && ] else if (task.approvedBy == null &&
permissionController.hasPermission( permissionController.hasPermission(
Permissions Permissions.approveTask)) ...[
.approveTask)) ...[
TaskActionButtons.reportActionButton( TaskActionButtons.reportActionButton(
context: context, context: context,
task: task, task: task,

View File

@ -12,6 +12,7 @@ import 'package:percent_indicator/percent_indicator.dart';
import 'package:marco/model/dailyTaskPlaning/assign_task_bottom_sheet .dart'; import 'package:marco/model/dailyTaskPlaning/assign_task_bottom_sheet .dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
class DailyTaskPlaningScreen extends StatefulWidget { class DailyTaskPlaningScreen extends StatefulWidget {
DailyTaskPlaningScreen({super.key}); DailyTaskPlaningScreen({super.key});
@ -112,60 +113,45 @@ class _DailyTaskPlaningScreenState extends State<DailyTaskPlaningScreen>
), ),
), ),
body: SafeArea( body: SafeArea(
child: SingleChildScrollView( child: MyRefreshIndicator(
padding: MySpacing.x(0), onRefresh: () async {
child: GetBuilder<DailyTaskPlaningController>( final projectId = projectController.selectedProjectId.value;
init: dailyTaskPlaningController, if (projectId.isNotEmpty) {
tag: 'daily_task_planing_controller', try {
builder: (controller) { await dailyTaskPlaningController.fetchTaskData(projectId);
return Column( } catch (e) {
crossAxisAlignment: CrossAxisAlignment.start, debugPrint('Error refreshing task data: ${e.toString()}');
children: [ }
MySpacing.height(flexSpacing), }
Padding( },
padding: MySpacing.x(flexSpacing), child: SingleChildScrollView(
child: Row( physics:
mainAxisAlignment: MainAxisAlignment.end, const AlwaysScrollableScrollPhysics(), // <-- always allow drag
children: [ padding: MySpacing.x(0),
const SizedBox(width: 8), child: ConstrainedBox(
MyText.bodyMedium("Refresh", fontWeight: 600), // <-- ensures full screen height
Tooltip( constraints: BoxConstraints(
message: 'Refresh Data', minHeight: MediaQuery.of(context).size.height -
child: InkWell( kToolbarHeight -
borderRadius: BorderRadius.circular(24), MediaQuery.of(context).padding.top,
onTap: () async { ),
final projectId = child: GetBuilder<DailyTaskPlaningController>(
projectController.selectedProjectId.value; init: dailyTaskPlaningController,
if (projectId.isNotEmpty) { tag: 'daily_task_planing_controller',
try { builder: (controller) {
await dailyTaskPlaningController return Column(
.fetchTaskData(projectId); crossAxisAlignment: CrossAxisAlignment.start,
} catch (e) { children: [
debugPrint( MySpacing.height(flexSpacing),
'Error refreshing task data: ${e.toString()}'); Padding(
} padding: MySpacing.x(8),
} child: dailyProgressReportTab(),
}, ),
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(),
),
],
);
},
), ),
), ),
), ),