added pull down refresh
This commit is contained in:
parent
0e177e5a1f
commit
2fb3c36ba4
@ -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) {
|
||||||
|
@ -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";
|
||||||
|
@ -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);
|
||||||
|
@ -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(
|
||||||
|
@ -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(
|
||||||
|
32
lib/helpers/widgets/my_refresh_indicator.dart
Normal file
32
lib/helpers/widgets/my_refresh_indicator.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
@ -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(),
|
||||||
],
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -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,
|
||||||
],
|
],
|
||||||
|
@ -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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -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,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -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();
|
||||||
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -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,
|
||||||
|
@ -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(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user