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

View File

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

View File

@ -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 = <String, String>{
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<String, dynamic>) {
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);

View File

@ -76,14 +76,12 @@ class SearchAndFilter extends StatelessWidget {
final TextEditingController controller;
final ValueChanged<String> 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(

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
static Widget employeeListSkeletonLoader() {
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: [
MyText.titleMedium("Attendance Logs", fontWeight: 600),
controller.isLoading.value
? const SizedBox(
height: 20,
width: 20,
child: LinearProgressIndicator(),
)
? SkeletonLoaders.dateSkeletonLoader()
: MyText.bodySmall(
dateRangeText,
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/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<AttendanceScreen> 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<AttendanceScreen> 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<ProjectController>(
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<AttendanceScreen> 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<AttendanceScreen> 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<AttendanceScreen> 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<AttendanceController>(
init: attendanceController,
@ -212,25 +213,29 @@ class _AttendanceScreenState extends State<AttendanceScreen> 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(),
),
],
),
],
),
),
);
},

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/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<ContactDetailScreen> {
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<ContactDetailScreen> {
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<ProjectController>(
builder: (p) => ProjectLabel(p.selectedProject?.name),
@ -145,7 +149,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
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<ContactDetailScreen> {
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<ContactDetailScreen> {
?.name)
.whereType<String>()
.join(", ");
final projectNames = contact.projectIds?.map((id) =>
projectController.projects.firstWhereOrNull((p) => p.id == id)?.name).whereType<String>().join(", ") ?? "-";
final projectNames = contact.projectIds
?.map((id) => projectController.projects
.firstWhereOrNull((p) => p.id == id)
?.name)
.whereType<String>()
.join(", ") ??
"-";
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
? 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<ContactDetailScreen> {
// 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<ContactDetailScreen> {
);
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<ContactDetailScreen> {
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<ContactDetailScreen> {
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<ContactDetailScreen> {
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<ContactDetailScreen> {
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<ContactDetailScreen> {
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<ContactDetailScreen> {
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,
],

View File

@ -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<DirectoryView> {
final DirectoryController controller = Get.find();
final TextEditingController searchController = TextEditingController();
final PermissionController permissionController = Get.put(PermissionController());
final PermissionController permissionController =
Get.put(PermissionController());
Future<void> _refreshDirectory() async {
try {
@ -185,19 +187,7 @@ class _DirectoryViewState extends State<DirectoryView> {
),
),
),
),
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<DirectoryView> {
),
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),
],
),
],
),
),
);
},
);
}),
),
)
],
),
);

View File

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

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/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<EmployeeDetailPage> {
}
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),
],
),
),
),
);

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/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<EmployeesScreen> 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<EmployeesScreen> with UIMixin {
children: [
Expanded(child: _buildSearchField()),
const SizedBox(width: 8),
_buildRefreshButton(),
const SizedBox(width: 4),
_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() {
if (!_permissionController.hasPermission(Permissions.viewAllEmployees)) {
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/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<ExpenseDetailScreen> {
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,
),
],
),
),
),
),

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/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<ExpenseMainScreen> {
expenseController.fetchExpenses();
}
void _refreshExpenses() => expenseController.fetchExpenses();
Future<void> _refreshExpenses() async {
await expenseController.fetchExpenses();
}
void _openFilterBottomSheet() {
showModalBottomSheet(
@ -81,7 +84,6 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
controller: searchController,
onChanged: (_) => setState(() {}),
onFilterTap: _openFilterBottomSheet,
onRefreshTap: _refreshExpenses,
expenseController: expenseController,
),
ToggleButtonsRow(
@ -90,38 +92,55 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
),
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<ScrollNotification>(
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<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/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<DailyProgressReportScreen>
),
),
body: SafeArea(
child: SingleChildScrollView(
padding: MySpacing.x(0),
child: GetBuilder<DailyTaskController>(
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<DailyTaskController>(
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<DailyProgressReportScreen>
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<DailyProgressReportScreen>
.toString()
.isEmpty) &&
permissionController.hasPermission(
Permissions.assignReportTask)) ...[
Permissions
.assignReportTask)) ...[
TaskActionButtons.reportButton(
context: context,
task: task,
@ -478,8 +480,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
const SizedBox(width: 4),
] else if (task.approvedBy == null &&
permissionController.hasPermission(
Permissions
.approveTask)) ...[
Permissions.approveTask)) ...[
TaskActionButtons.reportActionButton(
context: context,
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/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<DailyTaskPlaningScreen>
),
),
body: SafeArea(
child: SingleChildScrollView(
padding: MySpacing.x(0),
child: GetBuilder<DailyTaskPlaningController>(
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<DailyTaskPlaningController>(
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(),
),
],
);
},
),
),
),
),
),