Refactor UI components to use CustomAppBar and improve layout consistency

- Replaced existing AppBar implementations with CustomAppBar in multiple screens including PaymentRequestDetailScreen, PaymentRequestMainScreen, ServiceProjectDetailsScreen, JobDetailsScreen, DailyProgressReportScreen, DailyTaskPlanningScreen, and ServiceProjectScreen.
- Enhanced visual hierarchy by adding gradient backgrounds behind app bars for better aesthetics.
- Streamlined SafeArea usage to ensure proper content display across different devices.
- Improved code readability and maintainability by removing redundant code and consolidating UI elements.
This commit is contained in:
Vaibhav Surve 2025-11-27 19:07:24 +05:30
parent 84156167ea
commit 259f2aa928
23 changed files with 1908 additions and 1997 deletions

View File

@ -142,8 +142,8 @@ class DocumentController extends GetxController {
); );
if (response != null && response.success) { if (response != null && response.success) {
if (response.data.data.isNotEmpty) { if (response.data?.data.isNotEmpty ?? false) {
documents.addAll(response.data.data); documents.addAll(response.data!.data);
pageNumber.value++; pageNumber.value++;
} else { } else {
hasMore.value = false; hasMore.value = false;

View File

@ -3,95 +3,108 @@ import 'package:get/get.dart';
import 'package:on_field_work/controller/project_controller.dart'; import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart'; import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { class CustomAppBar extends StatelessWidget
with UIMixin
implements PreferredSizeWidget {
final String title; final String title;
final String? projectName; final String? projectName;
final VoidCallback? onBackPressed; final VoidCallback? onBackPressed;
final Color? backgroundColor;
const CustomAppBar({ CustomAppBar({
super.key, super.key,
required this.title, required this.title,
this.projectName, this.projectName,
this.onBackPressed, this.onBackPressed,
this.backgroundColor,
}); });
@override @override
Widget build(BuildContext context) { Size get preferredSize => const Size.fromHeight(72);
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(
Icons.arrow_back_ios_new,
color: Colors.black,
size: 20,
),
onPressed: onBackPressed ?? () => Get.back(),
),
MySpacing.width(5),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// TITLE
MyText.titleLarge(
title,
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
// PROJECT NAME ROW
GetBuilder<ProjectController>(
builder: (projectController) {
// NEW LOGIC simple and safe
final displayProjectName =
projectName ??
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(
Icons.work_outline,
size: 14,
color: Colors.grey,
),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
displayProjectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
);
}
@override @override
Size get preferredSize => const Size.fromHeight(72); Widget build(BuildContext context) {
final Color effectiveBackgroundColor =
backgroundColor ?? contentTheme.primary;
const Color onPrimaryColor = Colors.white;
const double horizontalPadding = 16.0;
return AppBar(
backgroundColor: effectiveBackgroundColor,
elevation: 0,
automaticallyImplyLeading: false,
titleSpacing: 0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(bottom: Radius.circular(0)),
),
leading: Padding(
padding: MySpacing.only(left: horizontalPadding),
child: IconButton(
icon: const Icon(
Icons.arrow_back_ios_new,
color: onPrimaryColor,
size: 20,
),
onPressed: onBackPressed ?? () => Get.back(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
title: Padding(
padding: MySpacing.only(right: horizontalPadding, left: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
title,
fontWeight: 800,
color: onPrimaryColor,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
MySpacing.height(3),
GetBuilder<ProjectController>(
builder: (projectController) {
final displayProjectName = projectName ??
projectController.selectedProject?.name ??
'Select Project';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.folder_open,
size: 14, color: onPrimaryColor),
MySpacing.width(4),
Flexible(
child: MyText.bodySmall(
displayProjectName,
fontWeight: 500,
color: onPrimaryColor.withOpacity(0.8),
overflow: TextOverflow.ellipsis,
),
),
MySpacing.width(2),
const Icon(Icons.keyboard_arrow_down,
size: 18, color: onPrimaryColor),
],
);
},
),
],
),
),
actions: [
Padding(
padding: MySpacing.only(right: horizontalPadding),
child: IconButton(
icon: const Icon(Icons.home, color: onPrimaryColor, size: 24),
onPressed: () => Get.offAllNamed('/dashboard'),
),
),
],
);
}
} }

View File

@ -148,7 +148,7 @@ class DocumentType {
final String name; final String name;
final String? regexExpression; final String? regexExpression;
final String allowedContentType; final String allowedContentType;
final int maxSizeAllowedInMB; final double maxSizeAllowedInMB;
final bool isValidationRequired; final bool isValidationRequired;
final bool isMandatory; final bool isMandatory;
final bool isSystem; final bool isSystem;

View File

@ -1,7 +1,7 @@
class DocumentsResponse { class DocumentsResponse {
final bool success; final bool success;
final String message; final String message;
final DocumentDataWrapper data; final DocumentDataWrapper? data;
final dynamic errors; final dynamic errors;
final int statusCode; final int statusCode;
final DateTime timestamp; final DateTime timestamp;
@ -9,7 +9,7 @@ class DocumentsResponse {
DocumentsResponse({ DocumentsResponse({
required this.success, required this.success,
required this.message, required this.message,
required this.data, this.data,
this.errors, this.errors,
required this.statusCode, required this.statusCode,
required this.timestamp, required this.timestamp,
@ -19,11 +19,13 @@ class DocumentsResponse {
return DocumentsResponse( return DocumentsResponse(
success: json['success'] ?? false, success: json['success'] ?? false,
message: json['message'] ?? '', message: json['message'] ?? '',
data: DocumentDataWrapper.fromJson(json['data']), data: json['data'] != null
? DocumentDataWrapper.fromJson(json['data'])
: null,
errors: json['errors'], errors: json['errors'],
statusCode: json['statusCode'] ?? 0, statusCode: json['statusCode'] ?? 0,
timestamp: json['timestamp'] != null timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp']) ? DateTime.tryParse(json['timestamp']) ?? DateTime.now()
: DateTime.now(), : DateTime.now(),
); );
} }
@ -32,7 +34,7 @@ class DocumentsResponse {
return { return {
'success': success, 'success': success,
'message': message, 'message': message,
'data': data.toJson(), 'data': data?.toJson(),
'errors': errors, 'errors': errors,
'statusCode': statusCode, 'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(), 'timestamp': timestamp.toIso8601String(),
@ -61,9 +63,10 @@ class DocumentDataWrapper {
currentPage: json['currentPage'] ?? 0, currentPage: json['currentPage'] ?? 0,
totalPages: json['totalPages'] ?? 0, totalPages: json['totalPages'] ?? 0,
totalEntites: json['totalEntites'] ?? 0, totalEntites: json['totalEntites'] ?? 0,
data: (json['data'] as List<dynamic>? ?? []) data: (json['data'] as List<dynamic>?)
.map((e) => DocumentItem.fromJson(e)) ?.map((e) => DocumentItem.fromJson(e))
.toList(), .toList() ??
[],
); );
} }
@ -83,28 +86,28 @@ class DocumentItem {
final String name; final String name;
final String documentId; final String documentId;
final String description; final String description;
final DateTime uploadedAt; final DateTime? uploadedAt;
final String? parentAttachmentId; final String? parentAttachmentId;
final bool isCurrentVersion; final bool isCurrentVersion;
final int version; final int version;
final bool isActive; final bool isActive;
final bool? isVerified; final bool? isVerified;
final UploadedBy uploadedBy; final UploadedBy? uploadedBy;
final DocumentType documentType; final DocumentType? documentType;
DocumentItem({ DocumentItem({
required this.id, required this.id,
required this.name, required this.name,
required this.documentId, required this.documentId,
required this.description, required this.description,
required this.uploadedAt, this.uploadedAt,
this.parentAttachmentId, this.parentAttachmentId,
required this.isCurrentVersion, required this.isCurrentVersion,
required this.version, required this.version,
required this.isActive, required this.isActive,
this.isVerified, this.isVerified,
required this.uploadedBy, this.uploadedBy,
required this.documentType, this.documentType,
}); });
factory DocumentItem.fromJson(Map<String, dynamic> json) { factory DocumentItem.fromJson(Map<String, dynamic> json) {
@ -113,14 +116,20 @@ class DocumentItem {
name: json['name'] ?? '', name: json['name'] ?? '',
documentId: json['documentId'] ?? '', documentId: json['documentId'] ?? '',
description: json['description'] ?? '', description: json['description'] ?? '',
uploadedAt: DateTime.parse(json['uploadedAt']), uploadedAt: json['uploadedAt'] != null
? DateTime.tryParse(json['uploadedAt'])
: null,
parentAttachmentId: json['parentAttachmentId'], parentAttachmentId: json['parentAttachmentId'],
isCurrentVersion: json['isCurrentVersion'] ?? false, isCurrentVersion: json['isCurrentVersion'] ?? false,
version: json['version'] ?? 0, version: json['version'] ?? 0,
isActive: json['isActive'] ?? false, isActive: json['isActive'] ?? false,
isVerified: json['isVerified'], isVerified: json['isVerified'],
uploadedBy: UploadedBy.fromJson(json['uploadedBy']), uploadedBy: json['uploadedBy'] != null
documentType: DocumentType.fromJson(json['documentType']), ? UploadedBy.fromJson(json['uploadedBy'])
: null,
documentType: json['documentType'] != null
? DocumentType.fromJson(json['documentType'])
: null,
); );
} }
@ -130,14 +139,14 @@ class DocumentItem {
'name': name, 'name': name,
'documentId': documentId, 'documentId': documentId,
'description': description, 'description': description,
'uploadedAt': uploadedAt.toIso8601String(), 'uploadedAt': uploadedAt?.toIso8601String(),
'parentAttachmentId': parentAttachmentId, 'parentAttachmentId': parentAttachmentId,
'isCurrentVersion': isCurrentVersion, 'isCurrentVersion': isCurrentVersion,
'version': version, 'version': version,
'isActive': isActive, 'isActive': isActive,
'isVerified': isVerified, 'isVerified': isVerified,
'uploadedBy': uploadedBy.toJson(), 'uploadedBy': uploadedBy?.toJson(),
'documentType': documentType.toJson(), 'documentType': documentType?.toJson(),
}; };
} }
} }
@ -208,7 +217,7 @@ class DocumentType {
final String name; final String name;
final String? regexExpression; final String? regexExpression;
final String? allowedContentType; final String? allowedContentType;
final int? maxSizeAllowedInMB; final double? maxSizeAllowedInMB;
final bool isValidationRequired; final bool isValidationRequired;
final bool isMandatory; final bool isMandatory;
final bool isSystem; final bool isSystem;
@ -232,7 +241,7 @@ class DocumentType {
return DocumentType( return DocumentType(
id: json['id'] ?? '', id: json['id'] ?? '',
name: json['name'] ?? '', name: json['name'] ?? '',
regexExpression: json['regexExpression'], // nullable regexExpression: json['regexExpression'],
allowedContentType: json['allowedContentType'], allowedContentType: json['allowedContentType'],
maxSizeAllowedInMB: json['maxSizeAllowedInMB'], maxSizeAllowedInMB: json['maxSizeAllowedInMB'],
isValidationRequired: json['isValidationRequired'] ?? false, isValidationRequired: json['isValidationRequired'] ?? false,

View File

@ -14,6 +14,7 @@ import 'package:on_field_work/view/Attendence/regularization_requests_tab.dart';
import 'package:on_field_work/view/Attendence/attendance_logs_tab.dart'; import 'package:on_field_work/view/Attendence/attendance_logs_tab.dart';
import 'package:on_field_work/view/Attendence/todays_attendance_tab.dart'; import 'package:on_field_work/view/Attendence/todays_attendance_tab.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class AttendanceScreen extends StatefulWidget { class AttendanceScreen extends StatefulWidget {
const AttendanceScreen({super.key}); const AttendanceScreen({super.key});
@ -75,61 +76,6 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
} }
} }
Widget _buildAppBar() {
return AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Attendance',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
);
}
Widget _buildFilterSearchRow() { Widget _buildFilterSearchRow() {
return Padding( return Padding(
padding: MySpacing.xy(8, 8), padding: MySpacing.xy(8, 8),
@ -358,24 +304,44 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( final Color appBarColor = contentTheme.primary;
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: _buildAppBar(),
),
body: SafeArea(
child: GetBuilder<AttendanceController>(
init: attendanceController,
tag: 'attendance_dashboard_controller',
builder: (controller) {
final selectedProjectId = projectController.selectedProjectId.value;
final noProjectSelected = selectedProjectId.isEmpty;
return MyRefreshIndicator( return Scaffold(
onRefresh: _refreshData, appBar: CustomAppBar(
child: Builder( title: "Attendance",
builder: (context) { backgroundColor: appBarColor,
return SingleChildScrollView( onBackPressed: () => Get.toNamed('/dashboard'),
),
body: Stack(
children: [
// Gradient container at top
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Main content
SafeArea(
child: GetBuilder<AttendanceController>(
init: attendanceController,
tag: 'attendance_dashboard_controller',
builder: (controller) {
final selectedProjectId =
projectController.selectedProjectId.value;
final noProjectSelected = selectedProjectId.isEmpty;
return MyRefreshIndicator(
onRefresh: _refreshData,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.zero, padding: MySpacing.zero,
child: Column( child: Column(
@ -394,12 +360,12 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
), ),
], ],
), ),
); ),
}, );
), },
); ),
}, ),
), ],
), ),
); );
} }

View File

@ -6,17 +6,12 @@ import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dart'; import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/my_card.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/attendance_overview_chart.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/project_progress_chart.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.dart'; import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart'; import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart'; import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/dashboard_overview_widgets.dart';
import 'package:on_field_work/view/layouts/layout.dart'; import 'package:on_field_work/view/layouts/layout.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart'; import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
class DashboardScreen extends StatefulWidget { class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key}); const DashboardScreen({super.key});
@ -44,64 +39,289 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
@override //---------------------------------------------------------------------------
Widget build(BuildContext context) { // REUSABLE CARD (smaller, minimal)
return Layout( //---------------------------------------------------------------------------
child: SingleChildScrollView(
padding: const EdgeInsets.all(10), Widget _cardWrapper({required Widget child}) {
child: Column( return Container(
crossAxisAlignment: CrossAxisAlignment.start, margin: const EdgeInsets.only(bottom: 16),
children: [ padding: const EdgeInsets.all(14),
_buildDashboardCards(), decoration: BoxDecoration(
MySpacing.height(24), color: Colors.white,
_buildProjectSelector(), borderRadius: BorderRadius.circular(5),
MySpacing.height(24), border: Border.all(color: Colors.black12.withOpacity(.04)),
_buildAttendanceChartSection(), boxShadow: [
MySpacing.height(12), BoxShadow(
MySpacing.height(24), color: Colors.black12.withOpacity(.05),
_buildProjectProgressChartSection(), blurRadius: 12,
MySpacing.height(24), offset: const Offset(0, 4),
SizedBox( )
width: double.infinity, ],
child: DashboardOverviewWidgets.teamsOverview(), ),
), child: child,
MySpacing.height(24), );
SizedBox( }
width: double.infinity,
child: DashboardOverviewWidgets.tasksOverview(), //---------------------------------------------------------------------------
), // SECTION TITLE
MySpacing.height(24), //---------------------------------------------------------------------------
ExpenseByStatusWidget(controller: dashboardController),
MySpacing.height(24), Widget _sectionTitle(String title) {
ExpenseTypeReportChart(), return Padding(
MySpacing.height(24), padding: const EdgeInsets.only(left: 4, bottom: 8),
MonthlyExpenseDashboardChart(), child: Text(
], title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.black87,
), ),
), ),
); );
} }
/// ---------------- Dynamic Dashboard Cards ---------------- //---------------------------------------------------------------------------
Widget _buildDashboardCards() { // CONDITIONAL QUICK ACTION CARD
//---------------------------------------------------------------------------
Widget _conditionalQuickActionCard() {
// STATIC CONDITION
String status = "O"; // <-- change if needed
bool isCheckedIn = status == "O";
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
gradient: LinearGradient(
colors: isCheckedIn
? [Colors.red.shade200, Colors.red.shade400]
: [Colors.green.shade200, Colors.green.shade400],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title & Status
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
isCheckedIn ? "Checked-In" : "Not Checked-In",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Icon(
isCheckedIn ? LucideIcons.log_out : LucideIcons.log_in,
color: Colors.white,
size: 24,
),
],
),
const SizedBox(height: 8),
// Description
Text(
isCheckedIn
? "You are currently checked-in. Don't forget to check-out after your work."
: "You are not checked-in yet. Please check-in to start your work.",
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
const SizedBox(height: 12),
// Action Buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (!isCheckedIn)
ElevatedButton.icon(
onPressed: () {
// Check-In action
},
label: const Text("Check-In"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green.shade700,
foregroundColor: Colors.white,
),
),
if (isCheckedIn)
ElevatedButton.icon(
onPressed: () {
// Check-Out action
},
label: const Text("Check-Out"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
),
),
],
),
],
),
);
}
//---------------------------------------------------------------------------
// QUICK ACTIONS (updated to use the single card)
//---------------------------------------------------------------------------
Widget _quickActions() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle("Quick Action"), // Change title to singular
_conditionalQuickActionCard(), // Use the new conditional card
],
);
}
//---------------------------------------------------------------------------
// PROJECT DROPDOWN (clean compact)
//---------------------------------------------------------------------------
Widget _projectSelector() {
return Obx(() { return Obx(() {
if (menuController.isLoading.value) { final isLoading = projectController.isLoading.value;
return SkeletonLoaders.dashboardCardsSkeleton(); final expanded = projectController.isProjectSelectionExpanded.value;
final projects = projectController.projects;
final selectedId = projectController.selectedProjectId.value;
if (isLoading) {
return const Center(child: CircularProgressIndicator());
} }
if (menuController.hasError.value || menuController.menuItems.isEmpty) { return Column(
return const Center( crossAxisAlignment: CrossAxisAlignment.start,
child: Text( children: [
"Failed to load menus. Please try again later.", _sectionTitle("Project"),
style: TextStyle(color: Colors.red),
// Compact Selector
GestureDetector(
onTap: () => projectController.isProjectSelectionExpanded.toggle(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.black12.withOpacity(.15)),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(.04),
blurRadius: 6,
offset: const Offset(0, 2),
)
],
),
child: Row(
children: [
const Icon(Icons.work_outline, color: Colors.blue, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
projects
.firstWhereOrNull((p) => p.id == selectedId)
?.name ??
"Select Project",
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w600),
),
),
Icon(
expanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 26,
color: Colors.black54,
)
],
),
),
), ),
);
if (expanded) _projectDropdownList(projects, selectedId),
],
);
});
}
Widget _projectDropdownList(projects, selectedId) {
return Container(
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.black12.withOpacity(.2)),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(.07),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
constraints:
BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.33),
child: Column(
children: [
TextField(
decoration: InputDecoration(
hintText: "Search project...",
isDense: true,
prefixIcon: const Icon(Icons.search),
border:
OutlineInputBorder(borderRadius: BorderRadius.circular(5)),
),
),
const SizedBox(height: 10),
Expanded(
child: ListView.builder(
itemCount: projects.length,
itemBuilder: (_, index) {
final project = projects[index];
return RadioListTile<String>(
dense: true,
value: project.id,
groupValue: selectedId,
onChanged: (v) {
if (v != null) {
projectController.updateSelectedProject(v);
projectController.isProjectSelectionExpanded.value =
false;
}
},
title: Text(project.name),
);
},
),
),
],
),
);
}
//---------------------------------------------------------------------------
// DASHBOARD MODULE CARDS (UPDATED FOR MINIMAL PADDING / SLL SIZE)
//---------------------------------------------------------------------------
Widget _dashboardCards() {
return Obx(() {
if (menuController.isLoading.value) {
return const Center(child: CircularProgressIndicator());
} }
final projectSelected = projectController.selectedProject != null; final projectSelected = projectController.selectedProject != null;
// Define dashboard card meta with order final cardOrder = [
final List<String> cardOrder = [
MenuItems.attendance, MenuItems.attendance,
MenuItems.employees, MenuItems.employees,
MenuItems.dailyTaskPlanning, MenuItems.dailyTaskPlanning,
@ -109,10 +329,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
MenuItems.directory, MenuItems.directory,
MenuItems.finance, MenuItems.finance,
MenuItems.documents, MenuItems.documents,
MenuItems.serviceProjects MenuItems.serviceProjects,
]; ];
final Map<String, _DashboardCardMeta> cardMeta = { final meta = {
MenuItems.attendance: MenuItems.attendance:
_DashboardCardMeta(LucideIcons.scan_face, contentTheme.success), _DashboardCardMeta(LucideIcons.scan_face, contentTheme.success),
MenuItems.employees: MenuItems.employees:
@ -131,373 +351,136 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
_DashboardCardMeta(LucideIcons.package, contentTheme.info), _DashboardCardMeta(LucideIcons.package, contentTheme.info),
}; };
// Filter only available menus that exist in cardMeta final allowed = {
final allowedMenusMap = { for (var m in menuController.menuItems)
for (var menu in menuController.menuItems) if (m.available && meta.containsKey(m.id)) m.id: m
if (menu.available && cardMeta.containsKey(menu.id)) menu.id: menu
}; };
if (allowedMenusMap.isEmpty) { final filtered = cardOrder.where(allowed.containsKey).toList();
return const Center(
child: Text( return Column(
"No accessible modules found.", crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle(color: Colors.grey), children: [
_sectionTitle("Modules"),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
// **More compact grid**
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
childAspectRatio: 1.2, // smaller & tighter
),
itemCount: filtered.length,
itemBuilder: (context, index) {
final id = filtered[index];
final item = allowed[id]!;
final cardMeta = meta[id]!;
final isEnabled =
item.name == "Attendance" ? true : projectSelected;
return GestureDetector(
onTap: () {
if (!isEnabled) {
Get.defaultDialog(
title: "No Project Selected",
middleText: "Please select a project first.",
);
} else {
Get.toNamed(item.mobileLink);
}
},
child: Container(
// **Reduced padding**
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: isEnabled ? Colors.white : Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.black12.withOpacity(.1),
width: 0.7,
),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(.05),
blurRadius: 4,
offset: const Offset(0, 2),
)
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
cardMeta.icon,
size: 20, // **smaller icon**
color:
isEnabled ? cardMeta.color : Colors.grey.shade400,
),
const SizedBox(height: 3),
Text(
item.name,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 9.5, // **reduced text size**
fontWeight: FontWeight.w600,
color:
isEnabled ? Colors.black87 : Colors.grey.shade600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
},
), ),
); ],
} );
// Create list of cards in fixed order
final stats =
cardOrder.where((id) => allowedMenusMap.containsKey(id)).map((id) {
final menu = allowedMenusMap[id]!;
final meta = cardMeta[id]!;
return _DashboardStatItem(
meta.icon, menu.name, meta.color, menu.mobileLink);
}).toList();
return LayoutBuilder(builder: (context, constraints) {
int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 4);
double cardWidth =
(constraints.maxWidth - (crossAxisCount - 1) * 6) / crossAxisCount;
return Wrap(
spacing: 6,
runSpacing: 6,
alignment: WrapAlignment.start,
children: stats
.map((stat) =>
_buildDashboardCard(stat, projectSelected, cardWidth))
.toList(),
);
});
}); });
} }
Widget _buildDashboardCard( //---------------------------------------------------------------------------
_DashboardStatItem stat, bool isProjectSelected, double width) { // MAIN UI
final isEnabled = stat.title == "Attendance" ? true : isProjectSelected; //---------------------------------------------------------------------------
return Opacity( @override
opacity: isEnabled ? 1.0 : 0.4, Widget build(BuildContext context) {
child: IgnorePointer( return Scaffold(
ignoring: !isEnabled, backgroundColor: const Color(0xfff5f6fa),
child: InkWell( body: Layout(
onTap: () => _onDashboardCardTap(stat, isEnabled), child: SingleChildScrollView(
borderRadius: BorderRadius.circular(5), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 14),
child: MyCard.bordered( child: Column(
width: width, crossAxisAlignment: CrossAxisAlignment.start,
height: 60, children: [
paddingAll: 4, _projectSelector(),
borderRadiusAll: 5, MySpacing.height(20),
border: Border.all(color: Colors.grey.withOpacity(0.15)), _quickActions(),
child: Column( MySpacing.height(20),
mainAxisAlignment: MainAxisAlignment.center, // The updated module cards
children: [ _dashboardCards(),
Container( MySpacing.height(20),
padding: const EdgeInsets.all(4), _sectionTitle("Reports & Analytics"),
decoration: BoxDecoration( _cardWrapper(child: ExpenseTypeReportChart()),
color: stat.color.withOpacity(0.1), _cardWrapper(
borderRadius: BorderRadius.circular(4), child:
), ExpenseByStatusWidget(controller: dashboardController)),
child: Icon( _cardWrapper(child: MonthlyExpenseDashboardChart()),
stat.icon, MySpacing.height(20),
size: 16, ],
color: stat.color,
),
),
MySpacing.height(4),
Flexible(
child: Text(
stat.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 10,
overflow: TextOverflow.ellipsis,
),
maxLines: 2,
),
),
],
),
), ),
), ),
), ),
); );
} }
void _onDashboardCardTap(_DashboardStatItem statItem, bool isEnabled) {
if (!isEnabled) {
Get.defaultDialog(
title: "No Project Selected",
middleText: "Please select a project before accessing this module.",
confirm: ElevatedButton(
onPressed: () => Get.back(),
child: const Text("OK"),
),
);
} else {
Get.toNamed(statItem.route);
}
}
/// ---------------- Project Progress Chart ----------------
Widget _buildProjectProgressChartSection() {
return Obx(() {
if (dashboardController.projectChartData.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: Text("No project progress data available."),
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(5),
child: SizedBox(
height: 400,
child: ProjectProgressChart(
data: dashboardController.projectChartData,
),
),
);
});
}
/// ---------------- Attendance Chart ----------------
Widget _buildAttendanceChartSection() {
return Obx(() {
final attendanceMenu = menuController.menuItems
.firstWhereOrNull((m) => m.id == MenuItems.attendance);
if (attendanceMenu == null || !attendanceMenu.available)
return const SizedBox.shrink();
final isProjectSelected = projectController.selectedProject != null;
return Opacity(
opacity: isProjectSelected ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !isProjectSelected,
child: ClipRRect(
borderRadius: BorderRadius.circular(5),
child: SizedBox(
height: 400,
child: AttendanceDashboardChart(),
),
),
),
);
});
}
/// ---------------- Project Selector (Inserted between Attendance & Project Progress)
Widget _buildProjectSelector() {
return Obx(() {
final isLoading = projectController.isLoading.value;
final isExpanded = projectController.isProjectSelectionExpanded.value;
final projects = projectController.projects;
final selectedProjectId = projectController.selectedProjectId.value;
final hasProjects = projects.isNotEmpty;
if (isLoading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (!hasProjects) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: const [
Icon(Icons.warning_amber_outlined, color: Colors.redAccent),
SizedBox(width: 8),
Text(
"No Project Assigned",
style: TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
final selectedProject =
projects.firstWhereOrNull((p) => p.id == selectedProjectId);
final searchNotifier = ValueNotifier<String>("");
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => projectController.isProjectSelectionExpanded.toggle(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.grey.withOpacity(0.15)),
color: Colors.white,
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 1,
offset: Offset(0, 1))
],
),
child: Row(
children: [
const Icon(Icons.work_outline,
size: 18, color: Colors.blueAccent),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
selectedProject?.name ?? "Select Project",
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w700),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
"Tap to switch project (${projects.length})",
style: const TextStyle(
fontSize: 12, color: Colors.black54),
),
],
),
),
Icon(
isExpanded
? Icons.arrow_drop_up_outlined
: Icons.arrow_drop_down_outlined,
color: Colors.black,
),
],
),
),
),
if (isExpanded)
ValueListenableBuilder<String>(
valueListenable: searchNotifier,
builder: (context, query, _) {
final lowerQuery = query.toLowerCase();
final filteredProjects = lowerQuery.isEmpty
? projects
: projects
.where((p) => p.name.toLowerCase().contains(lowerQuery))
.toList();
return Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.grey.withOpacity(0.12)),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 4,
offset: Offset(0, 2))
],
),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.35),
child: Column(
children: [
TextField(
decoration: InputDecoration(
isDense: true,
prefixIcon: const Icon(Icons.search, size: 18),
hintText: "Search project",
hintStyle: const TextStyle(fontSize: 13),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 2),
),
onChanged: (value) => searchNotifier.value = value,
),
const SizedBox(height: 8),
if (filteredProjects.isEmpty)
const Expanded(
child: Center(
child: Text("No projects found",
style: TextStyle(
fontSize: 13, color: Colors.black54)),
),
)
else
Expanded(
child: ListView.builder(
shrinkWrap: true,
itemCount: filteredProjects.length,
itemBuilder: (context, index) {
final project = filteredProjects[index];
final isSelected =
project.id == selectedProjectId;
return RadioListTile<String>(
value: project.id,
groupValue: selectedProjectId,
onChanged: (value) {
if (value != null) {
projectController
.updateSelectedProject(value);
projectController.isProjectSelectionExpanded
.value = false;
}
},
title: Text(
project.name,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
color: isSelected
? Colors.blueAccent
: Colors.black87,
),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 0),
activeColor: Colors.blueAccent,
tileColor: isSelected
? Colors.blueAccent.withOpacity(0.06)
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
visualDensity:
const VisualDensity(vertical: -4),
);
},
),
),
],
),
);
},
),
],
);
});
}
}
/// ---------------- Dashboard Card Models ----------------
class _DashboardStatItem {
final IconData icon;
final String title;
final Color color;
final String route;
_DashboardStatItem(this.icon, this.title, this.color, this.route);
} }
class _DashboardCardMeta { class _DashboardCardMeta {

View File

@ -13,6 +13,7 @@ import 'package:on_field_work/helpers/utils/date_time_utils.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.dart'; import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class ContactDetailScreen extends StatefulWidget { class ContactDetailScreen extends StatefulWidget {
final ContactModel contact; final ContactModel contact;
@ -23,18 +24,21 @@ class ContactDetailScreen extends StatefulWidget {
} }
class _ContactDetailScreenState extends State<ContactDetailScreen> class _ContactDetailScreenState extends State<ContactDetailScreen>
with UIMixin { with SingleTickerProviderStateMixin, UIMixin {
late final DirectoryController directoryController; late final DirectoryController directoryController;
late final ProjectController projectController; late final ProjectController projectController;
late Rx<ContactModel> contactRx; late Rx<ContactModel> contactRx;
late TabController _tabController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
directoryController = Get.find<DirectoryController>(); directoryController = Get.find<DirectoryController>();
projectController = Get.find<ProjectController>(); projectController = Get.put(ProjectController());
contactRx = widget.contact.obs; contactRx = widget.contact.obs;
_tabController = TabController(length: 2, vsync: this);
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
await directoryController.fetchCommentsForContact(contactRx.value.id, await directoryController.fetchCommentsForContact(contactRx.value.id,
active: true); active: true);
@ -50,65 +54,72 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
} }
@override @override
Widget build(BuildContext context) { void dispose() {
return DefaultTabController( _tabController.dispose();
length: 2, super.dispose();
child: Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: _buildMainAppBar(),
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() => _buildSubHeader(contactRx.value)),
const Divider(height: 1, thickness: 0.5, color: Colors.grey),
Expanded(
child: TabBarView(children: [
Obx(() => _buildDetailsTab(contactRx.value)),
_buildCommentsTab(),
]),
),
],
),
),
),
);
} }
PreferredSizeWidget _buildMainAppBar() { @override
return AppBar( Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.2, body: Stack(
automaticallyImplyLeading: false, children: [
titleSpacing: 0, // GRADIENT BEHIND APPBAR & TABBAR
title: Padding( Positioned.fill(
padding: MySpacing.xy(16, 0), child: Column(
child: Row( children: [
crossAxisAlignment: CrossAxisAlignment.center, Container(
children: [ height: 120,
IconButton( decoration: BoxDecoration(
icon: const Icon(Icons.arrow_back_ios_new, gradient: LinearGradient(
color: Colors.black, size: 20), begin: Alignment.topCenter,
onPressed: () => end: Alignment.bottomCenter,
Get.offAllNamed('/dashboard/directory-main-page'), colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
Expanded(child: Container(color: Colors.grey[100])),
],
), ),
MySpacing.width(8), ),
Expanded(
child: Column( // MAIN CONTENT
crossAxisAlignment: CrossAxisAlignment.start, SafeArea(
mainAxisSize: MainAxisSize.min, top: true,
children: [ bottom: true,
MyText.titleLarge('Contact Profile', child: Column(
fontWeight: 700, color: Colors.black), children: [
MySpacing.height(2), // APPBAR
GetBuilder<ProjectController>(builder: (p) { CustomAppBar(
return ProjectLabel(p.selectedProject?.name); title: 'Contact Profile',
}), backgroundColor: Colors.transparent,
], onBackPressed: () =>
), Get.offAllNamed('/dashboard/directory-main-page'),
),
// SUBHEADER + TABBAR
Obx(() => _buildSubHeader(contactRx.value)),
// TABBAR VIEW
Expanded(
child: TabBarView(
controller: _tabController,
children: [
Obx(() => _buildDetailsTab(contactRx.value)),
_buildCommentsTab(),
],
),
),
],
), ),
], ),
), ],
), ),
); );
} }
@ -118,39 +129,44 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
final lastName = final lastName =
contact.name.split(" ").length > 1 ? contact.name.split(" ").last : ""; contact.name.split(" ").length > 1 ? contact.name.split(" ").last : "";
return Padding( return Container(
padding: MySpacing.xy(16, 12), color: Colors.transparent,
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: MySpacing.xy(16, 12),
children: [ child: Column(
Row(children: [ crossAxisAlignment: CrossAxisAlignment.start,
Avatar(firstName: firstName, lastName: lastName, size: 35), children: [
MySpacing.width(12), Row(children: [
Column( Avatar(firstName: firstName, lastName: lastName, size: 35),
crossAxisAlignment: CrossAxisAlignment.start, MySpacing.width(12),
children: [ Column(
MyText.titleSmall(contact.name, crossAxisAlignment: CrossAxisAlignment.start,
fontWeight: 600, color: Colors.black), children: [
MySpacing.height(2), MyText.titleSmall(contact.name,
MyText.bodySmall(contact.organization, fontWeight: 600, color: Colors.black),
fontWeight: 500, color: Colors.grey[700]), MySpacing.height(2),
MyText.bodySmall(contact.organization,
fontWeight: 500, color: Colors.grey[700]),
],
),
]),
TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: contentTheme.primary,
tabs: const [
Tab(text: "Details"),
Tab(text: "Notes"),
], ],
), ),
]), ],
TabBar( ),
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: contentTheme.primary,
tabs: const [
Tab(text: "Details"),
Tab(text: "Notes"),
],
),
],
), ),
); );
} }
// --- DETAILS TAB ---
Widget _buildDetailsTab(ContactModel contact) { Widget _buildDetailsTab(ContactModel contact) {
final tags = contact.tags.map((e) => e.name).join(", "); final tags = contact.tags.map((e) => e.name).join(", ");
final bucketNames = contact.bucketIds final bucketNames = contact.bucketIds
@ -228,7 +244,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
_iconInfoRow(Icons.location_on, "Address", contact.address), _iconInfoRow(Icons.location_on, "Address", contact.address),
]), ]),
_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),
]), ]),
_infoCard("Meta Info", [ _infoCard("Meta Info", [
@ -281,6 +298,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
); );
} }
// --- COMMENTS TAB ---
Widget _buildCommentsTab() { Widget _buildCommentsTab() {
return Obx(() { return Obx(() {
final contactId = contactRx.value.id; final contactId = contactRx.value.id;
@ -622,25 +640,3 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
); );
} }
} }
class ProjectLabel extends StatelessWidget {
final String? projectName;
const ProjectLabel(this.projectName, {super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.work_outline, size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName ?? 'Select Project',
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
}
}

View File

@ -13,6 +13,7 @@ import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/model/document/document_edit_bottom_sheet.dart'; import 'package:on_field_work/model/document/document_edit_bottom_sheet.dart';
import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart'; import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class DocumentDetailsPage extends StatefulWidget { class DocumentDetailsPage extends StatefulWidget {
final String documentId; final String documentId;
@ -23,7 +24,7 @@ class DocumentDetailsPage extends StatefulWidget {
State<DocumentDetailsPage> createState() => _DocumentDetailsPageState(); State<DocumentDetailsPage> createState() => _DocumentDetailsPageState();
} }
class _DocumentDetailsPageState extends State<DocumentDetailsPage> { class _DocumentDetailsPageState extends State<DocumentDetailsPage> with UIMixin {
final DocumentDetailsController controller = final DocumentDetailsController controller =
Get.find<DocumentDetailsController>(); Get.find<DocumentDetailsController>();
@ -49,50 +50,78 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF1F1F1), backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar( appBar: CustomAppBar(
title: 'Document Details', title: 'Document Details',
backgroundColor: appBarColor,
onBackPressed: () { onBackPressed: () {
Get.back(); Get.back();
}, },
), ),
body: Obx(() { body: Stack(
if (controller.isLoading.value) { children: [
return SkeletonLoaders.documentDetailsSkeletonLoader(); // Gradient behind content
} Container(
height: 80,
final docResponse = controller.documentDetails.value; decoration: BoxDecoration(
if (docResponse == null || docResponse.data == null) { gradient: LinearGradient(
return Center( begin: Alignment.topCenter,
child: MyText.bodyMedium( end: Alignment.bottomCenter,
"Failed to load document details.", colors: [
color: Colors.grey, appBarColor,
), appBarColor.withOpacity(0.0),
); ],
} ),
final doc = docResponse.data!;
return MyRefreshIndicator(
onRefresh: _onRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailsCard(doc),
const SizedBox(height: 20),
MyText.titleMedium("Versions",
fontWeight: 700, color: Colors.black),
const SizedBox(height: 10),
_buildVersionsSection(),
],
), ),
), ),
);
}), // Main content
SafeArea(
child: Obx(() {
if (controller.isLoading.value) {
return SkeletonLoaders.documentDetailsSkeletonLoader();
}
final docResponse = controller.documentDetails.value;
if (docResponse == null || docResponse.data == null) {
return Center(
child: MyText.bodyMedium(
"Failed to load document details.",
color: Colors.grey,
),
);
}
final doc = docResponse.data!;
return MyRefreshIndicator(
onRefresh: _onRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailsCard(doc),
const SizedBox(height: 20),
MyText.titleMedium(
"Versions",
fontWeight: 700,
color: Colors.black,
),
const SizedBox(height: 10),
_buildVersionsSection(),
],
),
),
);
}),
),
],
),
); );
} }

View File

@ -428,14 +428,21 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
} }
Widget _buildDocumentCard(DocumentItem doc, bool showDateHeader) { Widget _buildDocumentCard(DocumentItem doc, bool showDateHeader) {
final uploadDate = final uploadDate = doc.uploadedAt != null
DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal()); ? DateFormat("dd MMM yyyy").format(doc.uploadedAt!.toLocal())
final uploadTime = DateFormat("hh:mm a").format(doc.uploadedAt.toLocal()); : '-';
final uploader = doc.uploadedBy.firstName.isNotEmpty final uploadTime = doc.uploadedAt != null
? "${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}".trim() ? DateFormat("hh:mm a").format(doc.uploadedAt!.toLocal())
: "You"; : '';
final iconColor = _getDocumentTypeColor(doc.documentType.name); final uploader =
(doc.uploadedBy != null && doc.uploadedBy!.firstName.isNotEmpty)
? "${doc.uploadedBy!.firstName} ${doc.uploadedBy!.lastName ?? ''}"
.trim()
: "You";
final iconColor =
_getDocumentTypeColor(doc.documentType?.name ?? 'unknown');
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -473,17 +480,16 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: iconColor.withOpacity(0.1), color: iconColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: Icon( child: Icon(
_getDocumentIcon(doc.documentType.name), _getDocumentIcon(doc.documentType?.name ?? 'unknown'),
color: iconColor, color: iconColor,
size: 24, size: 24,
), )),
),
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
child: Column( child: Column(
@ -497,7 +503,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
child: MyText.labelSmall( child: MyText.labelSmall(
doc.documentType.name, doc.documentType?.name ?? 'Unknown',
fontWeight: 600, fontWeight: 600,
color: iconColor, color: iconColor,
letterSpacing: 0.3, letterSpacing: 0.3,
@ -872,12 +878,18 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
} }
final doc = docs[index]; final doc = docs[index];
final currentDate = DateFormat("dd MMM yyyy") final currentDate = doc.uploadedAt != null
.format(doc.uploadedAt.toLocal());
final prevDate = index > 0
? DateFormat("dd MMM yyyy") ? DateFormat("dd MMM yyyy")
.format(docs[index - 1].uploadedAt.toLocal()) .format(doc.uploadedAt!.toLocal())
: '';
final prevDate = index > 0
? (docs[index - 1].uploadedAt != null
? DateFormat("dd MMM yyyy").format(
docs[index - 1].uploadedAt!.toLocal())
: '')
: null; : null;
final showDateHeader = currentDate != prevDate; final showDateHeader = currentDate != prevDate;
return _buildDocumentCard(doc, showDateHeader); return _buildDocumentCard(doc, showDateHeader);

View File

@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:on_field_work/view/employees/employee_detail_screen.dart'; import 'package:on_field_work/view/employees/employee_detail_screen.dart';
import 'package:on_field_work/view/document/user_document_screen.dart'; import 'package:on_field_work/view/document/user_document_screen.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart'; import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class EmployeeProfilePage extends StatefulWidget { class EmployeeProfilePage extends StatefulWidget {
final String employeeId; final String employeeId;
@ -14,7 +15,7 @@ class EmployeeProfilePage extends StatefulWidget {
} }
class _EmployeeProfilePageState extends State<EmployeeProfilePage> class _EmployeeProfilePageState extends State<EmployeeProfilePage>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin, UIMixin {
late TabController _tabController; late TabController _tabController;
@override @override
@ -31,44 +32,64 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF1F1F1), backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Employee Profile", title: "Employee Profile",
onBackPressed: () => Get.back(), onBackPressed: () => Get.back(),
backgroundColor: appBarColor,
), ),
body: Column( body: Stack(
children: [ children: [
// ---------------- TabBar outside AppBar ---------------- // === Gradient at the top behind AppBar + TabBar ===
Container( Container(
color: Colors.white, height: 50,
child: TabBar( decoration: BoxDecoration(
controller: _tabController, gradient: LinearGradient(
labelColor: Colors.black, begin: Alignment.topCenter,
unselectedLabelColor: Colors.grey, end: Alignment.bottomCenter,
indicatorColor: Colors.red, colors: [
tabs: const [ appBarColor,
Tab(text: "Details"), appBarColor.withOpacity(0.0),
Tab(text: "Documents"), ],
], ),
), ),
), ),
SafeArea(
// ---------------- TabBarView ---------------- top: false,
Expanded( bottom: true,
child: TabBarView( child: Column(
controller: _tabController,
children: [ children: [
// Details Tab Container(
EmployeeDetailPage( decoration: const BoxDecoration(color: Colors.transparent),
employeeId: widget.employeeId, child: TabBar(
fromProfile: true, controller: _tabController,
labelColor: Colors.white,
unselectedLabelColor: Colors.white70,
indicatorColor: Colors.white,
indicatorWeight: 3,
tabs: const [
Tab(text: "Details"),
Tab(text: "Documents"),
],
),
), ),
Expanded(
// Documents Tab child: TabBarView(
UserDocumentsPage( controller: _tabController,
entityId: widget.employeeId, children: [
isEmployee: true, EmployeeDetailPage(
employeeId: widget.employeeId,
fromProfile: true,
),
UserDocumentsPage(
entityId: widget.employeeId,
isEmployee: true,
),
],
),
), ),
], ],
), ),

View File

@ -17,6 +17,7 @@ import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/view/employees/employee_profile_screen.dart'; import 'package:on_field_work/view/employees/employee_profile_screen.dart';
import 'package:on_field_work/view/employees/manage_reporting_bottom_sheet.dart'; import 'package:on_field_work/view/employees/manage_reporting_bottom_sheet.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class EmployeesScreen extends StatefulWidget { class EmployeesScreen extends StatefulWidget {
const EmployeesScreen({super.key}); const EmployeesScreen({super.key});
@ -104,7 +105,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (_) => AssignProjectBottomSheet( builder: (_) => AssignProjectBottomSheet(
employeeId: employeeId, employeeId: employeeId,
jobRoleId: employeeData['jobRoleId'] as String, jobRoleId: employeeData['jobRoleId'] as String,
), ),
); );
@ -113,98 +114,69 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( final Color appBarColor = contentTheme.primary;
backgroundColor: Colors.white,
appBar: _buildAppBar(),
floatingActionButton: _buildFloatingActionButton(),
body: SafeArea(
child: GetBuilder<EmployeesScreenController>(
init: _employeeController,
tag: 'employee_screen_controller',
builder: (_) {
_filterEmployees(_searchController.text);
return MyRefreshIndicator(
onRefresh: _refreshEmployees,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(15),
child: _buildSearchField(),
),
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(flexSpacing),
child: _buildEmployeeList(),
),
],
),
),
);
},
),
),
);
}
PreferredSizeWidget _buildAppBar() { return Scaffold(
return PreferredSize( backgroundColor: const Color(0xFFF5F5F5),
preferredSize: const Size.fromHeight(72), appBar: CustomAppBar(
child: AppBar( title: "Employees",
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: appBarColor,
elevation: 0.5, projectName: Get.find<ProjectController>().selectedProject?.name ??
automaticallyImplyLeading: false, 'Select Project',
titleSpacing: 0, onBackPressed: () => Get.offNamed('/dashboard'),
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Employees',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
), ),
body: Stack(
children: [
// Gradient behind content
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Main content
SafeArea(
child: GetBuilder<EmployeesScreenController>(
init: _employeeController,
tag: 'employee_screen_controller',
builder: (_) {
_filterEmployees(_searchController.text);
return MyRefreshIndicator(
onRefresh: _refreshEmployees,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(15),
child: _buildSearchField(),
),
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(flexSpacing),
child: _buildEmployeeList(),
),
],
),
),
);
},
),
),
],
),
floatingActionButton: _buildFloatingActionButton(),
); );
} }

View File

@ -14,7 +14,7 @@ import 'package:on_field_work/controller/expense/add_expense_controller.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/widgets/expense/expense_detail_helpers.dart'; import 'package:on_field_work/helpers/widgets/expense/expense_detail_helpers.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart'; import 'package:on_field_work/helpers/widgets/my_text.dart';
@ -81,34 +81,62 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
canSubmit.value = result; canSubmit.value = result;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( final Color appBarColor = contentTheme.primary;
backgroundColor: const Color(0xFFF7F7F7),
appBar: _AppBar(projectController: projectController),
body: SafeArea(
child: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
WidgetsBinding.instance.addPostFrameCallback((_) { return Scaffold(
_checkPermissionToSubmit(expense); backgroundColor: const Color(0xFFF7F7F7),
}); appBar: CustomAppBar(
title: "Expense Details",
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard/expense-main-page'),
),
body: Stack(
children: [
// Gradient behind content
Container(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
final statusColor = getExpenseStatusColor(expense.status.name, // Main content
colorCode: expense.status.color); SafeArea(
final formattedAmount = formatExpenseAmount(expense.amount); child: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
return MyRefreshIndicator( final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense);
});
final statusColor = getExpenseStatusColor(
expense.status.name,
colorCode: expense.status.color,
);
final formattedAmount = formatExpenseAmount(expense.amount);
return MyRefreshIndicator(
onRefresh: () async { onRefresh: () async {
await controller.fetchExpenseDetails(); await controller.fetchExpenseDetails();
}, },
child: SingleChildScrollView( child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom), 12, 12, 12, 30 + MediaQuery.of(context).padding.bottom
),
child: Center( child: Center(
child: Container( child: Container(
constraints: const BoxConstraints(maxWidth: 520), constraints: const BoxConstraints(maxWidth: 520),
@ -122,21 +150,21 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// ---------------- Header & Status ---------------- // Header & Status
_InvoiceHeader(expense: expense), _InvoiceHeader(expense: expense),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// ---------------- Activity Logs ---------------- // Activity Logs
InvoiceLogs(logs: expense.expenseLogs), InvoiceLogs(logs: expense.expenseLogs),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// ---------------- Amount & Summary ----------------
// Amount & Summary
Row( Row(
children: [ children: [
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.bodyMedium('Amount', MyText.bodyMedium('Amount', fontWeight: 600),
fontWeight: 600),
const SizedBox(height: 4), const SizedBox(height: 4),
MyText.bodyLarge( MyText.bodyLarge(
formattedAmount, formattedAmount,
@ -146,7 +174,6 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
], ],
), ),
const Spacer(), const Spacer(),
// Optional: Pre-approved badge
if (expense.preApproved) if (expense.preApproved)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -165,19 +192,19 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
), ),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// ---------------- Parties ---------------- // Parties
_InvoicePartiesTable(expense: expense), _InvoicePartiesTable(expense: expense),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// ---------------- Expense Details ---------------- // Expense Details
_InvoiceDetailsTable(expense: expense), _InvoiceDetailsTable(expense: expense),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// ---------------- Documents ---------------- // Documents
_InvoiceDocuments(documents: expense.documents), _InvoiceDocuments(documents: expense.documents),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// ---------------- Totals ---------------- // Totals
_InvoiceTotals( _InvoiceTotals(
expense: expense, expense: expense,
formattedAmount: formattedAmount, formattedAmount: formattedAmount,
@ -189,122 +216,109 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
), ),
), ),
), ),
)); ),
}), );
), }),
floatingActionButton: Obx(() { ),
if (controller.isLoading.value) return buildLoadingSkeleton(); ],
),
floatingActionButton: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value; final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) { if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display.")); return const SizedBox.shrink();
} }
if (!_checkedPermission) { if (!_checkedPermission) {
_checkedPermission = true; _checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense); _checkPermissionToSubmit(expense);
}); });
} }
if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) { if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return FloatingActionButton.extended( return FloatingActionButton.extended(
onPressed: () async { onPressed: () async {
final editData = { final editData = {
'id': expense.id, 'id': expense.id,
'projectName': expense.project.name, 'projectName': expense.project.name,
'amount': expense.amount, 'amount': expense.amount,
'supplerName': expense.supplerName, 'supplerName': expense.supplerName,
'description': expense.description, 'description': expense.description,
'transactionId': expense.transactionId, 'transactionId': expense.transactionId,
'location': expense.location, 'location': expense.location,
'transactionDate': expense.transactionDate, 'transactionDate': expense.transactionDate,
'noOfPersons': expense.noOfPersons, 'noOfPersons': expense.noOfPersons,
'expensesTypeId': expense.expensesType.id, 'expensesTypeId': expense.expensesType.id,
'paymentModeId': expense.paymentMode.id, 'paymentModeId': expense.paymentMode.id,
'paidById': expense.paidBy.id, 'paidById': expense.paidBy.id,
'paidByFirstName': expense.paidBy.firstName, 'paidByFirstName': expense.paidBy.firstName,
'paidByLastName': expense.paidBy.lastName, 'paidByLastName': expense.paidBy.lastName,
'attachments': expense.documents 'attachments': expense.documents
.map((doc) => { .map((doc) => {
'url': doc.preSignedUrl, 'url': doc.preSignedUrl,
'fileName': doc.fileName, 'fileName': doc.fileName,
'documentId': doc.documentId, 'documentId': doc.documentId,
'contentType': doc.contentType, 'contentType': doc.contentType,
}) })
.toList(), .toList(),
}; };
logSafe('editData: $editData', level: LogLevel.info);
final addCtrl = Get.put(AddExpenseController()); final addCtrl = Get.put(AddExpenseController());
await addCtrl.loadMasterData();
addCtrl.populateFieldsForEdit(editData);
await addCtrl.loadMasterData(); await showAddExpenseBottomSheet(isEdit: true);
addCtrl.populateFieldsForEdit(editData); await controller.fetchExpenseDetails();
},
backgroundColor: contentTheme.primary,
icon: const Icon(Icons.edit),
label: MyText.bodyMedium("Edit Expense",
fontWeight: 600, color: Colors.white),
);
}),
bottomNavigationBar: Obx(() {
final expense = controller.expense.value;
if (expense == null) return const SizedBox();
await showAddExpenseBottomSheet(isEdit: true); return SafeArea(
await controller.fetchExpenseDetails(); child: Container(
}, decoration: const BoxDecoration(
backgroundColor: contentTheme.primary, color: Colors.white,
icon: const Icon(Icons.edit), border: Border(top: BorderSide(color: Color(0x11000000))),
label: MyText.bodyMedium("Edit Expense",
fontWeight: 600, color: Colors.white),
);
}),
bottomNavigationBar: Obx(() {
final expense = controller.expense.value;
if (expense == null) return const SizedBox();
return SafeArea(
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Color(0x11000000))),
),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 10,
runSpacing: 10,
children: expense.nextStatus.where((next) {
const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final rawPermissions = next.permissionIds;
final parsedPermissions =
controller.parsePermissionIds(rawPermissions);
final isSubmitStatus = next.id == submitStatusId;
final isCreatedByCurrentUser =
employeeInfo?.id == expense.createdBy.id;
logSafe(
'🔐 Permission Logic:\n'
'🔸 Status: ${next.name}\n'
'🔸 Status ID: ${next.id}\n'
'🔸 Parsed Permissions: $parsedPermissions\n'
'🔸 Is Submit: $isSubmitStatus\n'
'🔸 Created By Current User: $isCreatedByCurrentUser',
level: LogLevel.debug,
);
if (isSubmitStatus) {
// Submit can be done ONLY by the creator
return isCreatedByCurrentUser;
}
// All other statuses - check permission normally
return permissionController.hasAnyPermission(parsedPermissions);
}).map((next) {
return _statusButton(context, controller, expense, next);
}).toList(),
),
), ),
); padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
}), child: Wrap(
); alignment: WrapAlignment.center,
} spacing: 10,
runSpacing: 10,
children: expense.nextStatus.where((next) {
const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final rawPermissions = next.permissionIds;
final parsedPermissions =
controller.parsePermissionIds(rawPermissions);
final isSubmitStatus = next.id == submitStatusId;
final isCreatedByCurrentUser =
employeeInfo?.id == expense.createdBy.id;
if (isSubmitStatus) return isCreatedByCurrentUser;
return permissionController.hasAnyPermission(parsedPermissions);
}).map((next) {
return _statusButton(context, controller, expense, next);
}).toList(),
),
),
);
}),
);
}
Widget _statusButton(BuildContext context, ExpenseDetailController controller, Widget _statusButton(BuildContext context, ExpenseDetailController controller,
ExpenseDetailModel expense, dynamic next) { ExpenseDetailModel expense, dynamic next) {
@ -449,64 +463,6 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
} }
} }
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
final ProjectController projectController;
const _AppBar({required this.projectController});
@override
Widget build(BuildContext context) {
return AppBar(
automaticallyImplyLeading: false,
elevation: 1,
backgroundColor: Colors.white,
title: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.toNamed('/dashboard/expense-main-page'),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Expense Details',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
class _InvoiceHeader extends StatelessWidget { class _InvoiceHeader extends StatelessWidget {
final ExpenseDetailModel expense; final ExpenseDetailModel expense;
const _InvoiceHeader({required this.expense}); const _InvoiceHeader({required this.expense});

View File

@ -12,6 +12,7 @@ import 'package:on_field_work/helpers/widgets/expense/expense_main_components.da
import 'package:on_field_work/helpers/utils/permission_constants.dart'; import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class ExpenseMainScreen extends StatefulWidget { class ExpenseMainScreen extends StatefulWidget {
const ExpenseMainScreen({super.key}); const ExpenseMainScreen({super.key});
@ -87,65 +88,103 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: ExpenseAppBar(projectController: projectController), appBar: CustomAppBar(
body: Column( title: "Expense & Reimbursement",
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard/finance'),
),
body: Stack(
children: [ children: [
// ---------------- TabBar ---------------- // === FULL GRADIENT BEHIND APPBAR & TABBAR ===
Container( Positioned.fill(
color: Colors.white, child: Column(
child: TabBar( children: [
controller: _tabController, Container(
labelColor: Colors.black, height: 80,
unselectedLabelColor: Colors.grey, decoration: BoxDecoration(
indicatorColor: Colors.red, gradient: LinearGradient(
tabs: const [ begin: Alignment.topCenter,
Tab(text: "Current Month"), end: Alignment.bottomCenter,
Tab(text: "History"), colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
Expanded(
child:
Container(color: Colors.grey[100]),
),
], ],
), ),
), ),
// ---------------- Gray background for rest ---------------- // === MAIN CONTENT ===
Expanded( SafeArea(
child: Container( top: false,
color: Colors.grey[100], bottom: true,
child: Column( child: Column(
children: [ children: [
// ---------------- Search ---------------- // TAB BAR WITH TRANSPARENT BACKGROUND
Padding( Container(
padding: decoration: const BoxDecoration(color: Colors.transparent),
const EdgeInsets.symmetric(horizontal: 0, vertical: 0), child: TabBar(
child: SearchAndFilter( controller: _tabController,
controller: searchController, labelColor: Colors.white,
onChanged: (_) => setState(() {}), unselectedLabelColor: Colors.white70,
onFilterTap: _openFilterBottomSheet, indicatorColor: Colors.white,
expenseController: expenseController, indicatorWeight: 3,
), tabs: const [
Tab(text: "Current Month"),
Tab(text: "History"),
],
), ),
),
// ---------------- TabBarView ---------------- // CONTENT AREA
Expanded( Expanded(
child: TabBarView( child: Container(
controller: _tabController, color: Colors.transparent,
child: Column(
children: [ children: [
_buildExpenseList(isHistory: false), // SEARCH & FILTER
_buildExpenseList(isHistory: true), Padding(
padding: const EdgeInsets.symmetric(horizontal: 0),
child: SearchAndFilter(
controller: searchController,
onChanged: (_) => setState(() {}),
onFilterTap: _openFilterBottomSheet,
expenseController: expenseController,
),
),
// TABBAR VIEW
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildExpenseList(isHistory: false),
_buildExpenseList(isHistory: true),
],
),
),
], ],
), ),
), ),
], ),
), ],
), ),
), ),
], ],
), ),
floatingActionButton: Obx(() { floatingActionButton: Obx(() {
// Show loader or hide FAB while permissions are loading if (permissionController.permissions.isEmpty)
if (permissionController.permissions.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
}
final canUpload = final canUpload =
permissionController.hasPermission(Permissions.expenseUpload); permissionController.hasPermission(Permissions.expenseUpload);

View File

@ -3,10 +3,9 @@ import 'package:get/get.dart';
import 'package:on_field_work/controller/finance/advance_payment_controller.dart'; import 'package:on_field_work/controller/finance/advance_payment_controller.dart';
import 'package:on_field_work/controller/project_controller.dart'; import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class AdvancePaymentScreen extends StatefulWidget { class AdvancePaymentScreen extends StatefulWidget {
const AdvancePaymentScreen({super.key}); const AdvancePaymentScreen({super.key});
@ -49,148 +48,106 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
appBar: _buildAppBar(), appBar: CustomAppBar(
title: "Advance Payments",
// SafeArea added so nothing hides under system navigation buttons onBackPressed: () => Get.offNamed('/dashboard/finance'),
body: SafeArea( backgroundColor: appBarColor,
bottom: true, ),
child: GestureDetector( body: Stack(
onTap: () => FocusScope.of(context).unfocus(), children: [
child: RefreshIndicator( // ===== TOP GRADIENT =====
onRefresh: () async { Container(
final emp = controller.selectedEmployee.value; height: 100,
if (emp != null) { decoration: BoxDecoration(
await controller.fetchAdvancePayments(emp.id.toString()); gradient: LinearGradient(
} begin: Alignment.topCenter,
}, end: Alignment.bottomCenter,
color: Colors.white, colors: [
backgroundColor: contentTheme.primary, appBarColor,
strokeWidth: 2.5, appBarColor.withOpacity(0.0),
displacement: 60, ],
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
// Extra bottom padding so content does NOT go under 3-button navbar
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 20,
),
child: Column(
children: [
_buildSearchBar(),
_buildEmployeeDropdown(context),
_buildTopBalance(),
_buildPaymentList(),
],
),
), ),
), ),
), ),
),
),
);
}
// ---------------- AppBar ---------------- // ===== MAIN CONTENT =====
PreferredSizeWidget _buildAppBar() { SafeArea(
return PreferredSize( top: false,
preferredSize: const Size.fromHeight(72), bottom: true,
child: AppBar( child: GestureDetector(
backgroundColor: const Color(0xFFF5F5F5), onTap: () => FocusScope.of(context).unfocus(),
elevation: 0.5, child: RefreshIndicator(
automaticallyImplyLeading: false, onRefresh: () async {
titleSpacing: 0, final emp = controller.selectedEmployee.value;
title: Padding( if (emp != null) {
padding: MySpacing.xy(16, 0), await controller.fetchAdvancePayments(emp.id.toString());
child: Row( }
crossAxisAlignment: CrossAxisAlignment.center, },
children: [ color: Colors.white,
IconButton( backgroundColor: appBarColor,
icon: const Icon(Icons.arrow_back_ios_new, strokeWidth: 2.5,
color: Colors.black, size: 20), displacement: 60,
onPressed: () => Get.offNamed('/dashboard/finance'), child: SingleChildScrollView(
), physics: const AlwaysScrollableScrollPhysics(),
MySpacing.width(8), child: Padding(
Expanded( padding: EdgeInsets.only(
child: Column( bottom: MediaQuery.of(context).padding.bottom + 20,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Advance Payments',
fontWeight: 700,
color: Colors.black,
), ),
MySpacing.height(2), child: Column(
GetBuilder<ProjectController>( children: [
builder: (_) { // ===== SEARCH BAR FLOATING OVER GRADIENT =====
final name = projectController.selectedProject?.name ?? Padding(
'Select Project'; padding: const EdgeInsets.symmetric(
return Row( horizontal: 12, vertical: 8),
children: [ child: SizedBox(
const Icon(Icons.work_outline, height: 38,
size: 14, color: Colors.grey), child: TextField(
MySpacing.width(4), controller: _searchCtrl,
Expanded( focusNode: _searchFocus,
child: MyText.bodySmall( onChanged: (v) =>
name, controller.searchQuery.value = v.trim(),
fontWeight: 600, decoration: InputDecoration(
overflow: TextOverflow.ellipsis, contentPadding: const EdgeInsets.symmetric(
color: Colors.grey[700], horizontal: 12, vertical: 0),
prefixIcon: const Icon(Icons.search,
size: 20, color: Colors.grey),
hintText: 'Search Employee...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: Colors.grey.shade300, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: Colors.grey.shade300, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: appBarColor, width: 1.5),
),
), ),
), ),
], ),
); ),
},
),
],
),
),
],
),
),
),
);
}
// ---------------- Search ---------------- // ===== EMPLOYEE DROPDOWN =====
Widget _buildSearchBar() { _buildEmployeeDropdown(context),
return Container(
color: Colors.grey[100], // ===== TOP BALANCE =====
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), _buildTopBalance(),
child: Row(
children: [ // ===== PAYMENTS LIST =====
Expanded( _buildPaymentList(),
child: SizedBox( ],
height: 38, ),
child: TextField(
controller: _searchCtrl,
focusNode: _searchFocus,
onChanged: (v) => controller.searchQuery.value = v.trim(),
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
prefixIcon:
const Icon(Icons.search, size: 20, color: Colors.grey),
hintText: 'Search Employee...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: Colors.grey.shade300, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: Colors.grey.shade300, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: contentTheme.primary, width: 1.5),
), ),
), ),
), ),

View File

@ -6,13 +6,14 @@ import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dar
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/my_card.dart'; import 'package:on_field_work/helpers/widgets/my_card.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.dart'; import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart'; import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart'; import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart'; import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart'; import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/model/dynamicMenu/dynamic_menu_model.dart'; import 'package:on_field_work/model/dynamicMenu/dynamic_menu_model.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class FinanceScreen extends StatefulWidget { class FinanceScreen extends StatefulWidget {
const FinanceScreen({super.key}); const FinanceScreen({super.key});
@ -52,132 +53,116 @@ class _FinanceScreenState extends State<FinanceScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF8F9FA), backgroundColor: const Color(0xFFF8F9FA),
appBar: PreferredSize( appBar: CustomAppBar(
preferredSize: const Size.fromHeight(72), title: "Finance",
child: AppBar( onBackPressed: () => Get.offAllNamed( '/dashboard' ),
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: appBarColor,
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Finance',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
), ),
body: SafeArea( body: Stack(
top: false, // keep appbar area same children: [
bottom: true, // avoid system bottom buttons // Top fade under AppBar
child: FadeTransition( Container(
opacity: _fadeAnimation, height: 40,
child: Obx(() { decoration: BoxDecoration(
if (menuController.isLoading.value) { gradient: LinearGradient(
return const Center(child: CircularProgressIndicator()); begin: Alignment.topCenter,
} end: Alignment.bottomCenter,
colors: [
if (menuController.hasError.value || appBarColor,
menuController.menuItems.isEmpty) { appBarColor.withOpacity(0.0),
return const Center(
child: Text(
"Failed to load menus. Please try again later.",
style: TextStyle(color: Colors.red),
),
);
}
final financeMenuIds = [
MenuItems.expenseReimbursement,
MenuItems.paymentRequests,
MenuItems.advancePaymentStatements,
];
final financeMenus = menuController.menuItems
.where((m) => financeMenuIds.contains(m.id) && m.available)
.toList();
if (financeMenus.isEmpty) {
return const Center(
child: Text(
"You dont have access to the Finance section.",
style: TextStyle(color: Colors.grey),
),
);
}
// ---- IMPORTANT FIX: Add bottom safe padding ----
final double bottomInset =
MediaQuery.of(context).viewPadding.bottom;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
bottomInset +
24, // ensures charts never go under system buttons
),
child: Column(
children: [
_buildFinanceModulesCompact(financeMenus),
MySpacing.height(24),
ExpenseByStatusWidget(controller: dashboardController),
MySpacing.height(24),
ExpenseTypeReportChart(),
MySpacing.height(24),
MonthlyExpenseDashboardChart(),
], ],
), ),
); ),
}), ),
),
// Bottom fade (above system buttons or FAB)
Align(
alignment: Alignment.bottomCenter,
child: Container(
height: 60,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
appBarColor.withOpacity(0.05),
Colors.transparent,
],
),
),
),
),
// Main scrollable content
SafeArea(
top: false,
bottom: true,
child: FadeTransition(
opacity: _fadeAnimation,
child: Obx(() {
if (menuController.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (menuController.hasError.value ||
menuController.menuItems.isEmpty) {
return const Center(
child: Text(
"Failed to load menus. Please try again later.",
style: TextStyle(color: Colors.red),
),
);
}
final financeMenuIds = [
MenuItems.expenseReimbursement,
MenuItems.paymentRequests,
MenuItems.advancePaymentStatements,
];
final financeMenus = menuController.menuItems
.where((m) => financeMenuIds.contains(m.id) && m.available)
.toList();
if (financeMenus.isEmpty) {
return const Center(
child: Text(
"You dont have access to the Finance section.",
style: TextStyle(color: Colors.grey),
),
);
}
final double bottomInset =
MediaQuery.of(context).viewPadding.bottom;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
bottomInset + 24,
),
child: Column(
children: [
_buildFinanceModulesCompact(financeMenus),
MySpacing.height(24),
ExpenseByStatusWidget(controller: dashboardController),
MySpacing.height(24),
ExpenseTypeReportChart(),
MySpacing.height(24),
MonthlyExpenseDashboardChart(),
],
),
);
}),
),
),
],
), ),
); );
} }

View File

@ -21,6 +21,7 @@ import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/model/finance/payment_request_rembursement_bottom_sheet.dart'; import 'package:on_field_work/model/finance/payment_request_rembursement_bottom_sheet.dart';
import 'package:on_field_work/model/finance/make_expense_bottom_sheet.dart'; import 'package:on_field_work/model/finance/make_expense_bottom_sheet.dart';
import 'package:on_field_work/model/finance/add_payment_request_bottom_sheet.dart'; import 'package:on_field_work/model/finance/add_payment_request_bottom_sheet.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class PaymentRequestDetailScreen extends StatefulWidget { class PaymentRequestDetailScreen extends StatefulWidget {
final String paymentRequestId; final String paymentRequestId;
@ -107,76 +108,101 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: _buildAppBar(), appBar: CustomAppBar(
body: SafeArea( title: "Payment Request Details",
child: Obx(() { backgroundColor: appBarColor,
if (controller.isLoading.value && ),
controller.paymentRequest.value == null) { body: Stack(
return SkeletonLoaders.paymentRequestDetailSkeletonLoader(); children: [
} // ===== TOP GRADIENT =====
Container(
final request = controller.paymentRequest.value; height: 80,
decoration: BoxDecoration(
if ((controller.errorMessage.value).isNotEmpty) { gradient: LinearGradient(
return Center( begin: Alignment.topCenter,
child: MyText.bodyMedium(controller.errorMessage.value)); end: Alignment.bottomCenter,
} colors: [
appBarColor,
if (request == null) { appBarColor.withOpacity(0.0),
return Center(child: MyText.bodyMedium("No data to display.")); ],
}
return MyRefreshIndicator(
onRefresh: controller.fetchPaymentRequestDetail,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
12,
12,
12,
60 + MediaQuery.of(context).padding.bottom,
), ),
child: Center( ),
child: Container( ),
constraints: const BoxConstraints(maxWidth: 520),
child: Card( // ===== MAIN CONTENT =====
shape: RoundedRectangleBorder( SafeArea(
borderRadius: BorderRadius.circular(5)), child: Obx(() {
child: Padding( if (controller.isLoading.value &&
padding: const EdgeInsets.symmetric( controller.paymentRequest.value == null) {
vertical: 12, horizontal: 14), return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
child: Column( }
crossAxisAlignment: CrossAxisAlignment.start,
children: [ final request = controller.paymentRequest.value;
_Header(
request: request, if ((controller.errorMessage.value).isNotEmpty) {
colorParser: _parseColor, return Center(
employeeInfo: employeeInfo, child: MyText.bodyMedium(controller.errorMessage.value));
onEdit: () => }
_openEditPaymentRequestBottomSheet(request),
if (request == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
return MyRefreshIndicator(
onRefresh: controller.fetchPaymentRequestDetail,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
12,
12,
12,
60 + MediaQuery.of(context).padding.bottom,
),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12, horizontal: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Header(
request: request,
colorParser: _parseColor,
employeeInfo: employeeInfo,
onEdit: () =>
_openEditPaymentRequestBottomSheet(request),
),
const Divider(height: 30, thickness: 1.2),
_Logs(
logs: request.updateLogs ?? [],
colorParser: _parseColor,
),
const Divider(height: 30, thickness: 1.2),
_Parties(request: request),
const Divider(height: 30, thickness: 1.2),
_DetailsTable(request: request),
const Divider(height: 30, thickness: 1.2),
_Documents(documents: request.attachments ?? []),
MySpacing.height(24),
],
), ),
const Divider(height: 30, thickness: 1.2), ),
_Logs(
logs: request.updateLogs ?? [],
colorParser: _parseColor,
),
const Divider(height: 30, thickness: 1.2),
_Parties(request: request),
const Divider(height: 30, thickness: 1.2),
_DetailsTable(request: request),
const Divider(height: 30, thickness: 1.2),
_Documents(documents: request.attachments ?? []),
MySpacing.height(24),
],
), ),
), ),
), ),
), ),
), );
), }),
); ),
}), ],
), ),
bottomNavigationBar: _buildBottomActionBar(), bottomNavigationBar: _buildBottomActionBar(),
); );
@ -275,7 +301,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
); );
return; return;
} }
showAppSnackbar( showAppSnackbar(
title: 'Success', title: 'Success',
message: 'Status updated successfully', message: 'Status updated successfully',
@ -295,65 +321,6 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
); );
}); });
} }
PreferredSizeWidget _buildAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.back(),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Payment Request Details',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(builder: (_) {
final name = projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
}),
],
),
),
],
),
),
),
);
}
} }
class PaymentRequestPermissionHelper { class PaymentRequestPermissionHelper {

View File

@ -13,6 +13,7 @@ import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart'; import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class PaymentRequestMainScreen extends StatefulWidget { class PaymentRequestMainScreen extends StatefulWidget {
const PaymentRequestMainScreen({super.key}); const PaymentRequestMainScreen({super.key});
@ -96,53 +97,88 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: _buildAppBar(), appBar: CustomAppBar(
title: "Payment Requests",
// ------------------------ onBackPressed: () => Get.offNamed('/dashboard/finance'),
// FIX: SafeArea prevents content from going under 3-button navbar backgroundColor: appBarColor,
// ------------------------
body: SafeArea(
bottom: true,
child: Column(
children: [
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red,
tabs: const [
Tab(text: "Current Month"),
Tab(text: "History"),
],
),
),
Expanded(
child: Container(
color: Colors.grey[100],
child: Column(
children: [
_buildSearchBar(),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildPaymentRequestList(isHistory: false),
_buildPaymentRequestList(isHistory: true),
],
),
),
],
),
),
),
],
),
), ),
body: Stack(
children: [
// === FULL GRADIENT BEHIND APPBAR & TABBAR ===
Positioned.fill(
child: Column(
children: [
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
Expanded(
child:
Container(color: Colors.grey[100]),
),
],
),
),
// === MAIN CONTENT ===
SafeArea(
top: false,
bottom: true,
child: Column(
children: [
// TAB BAR WITH TRANSPARENT BACKGROUND
Container(
decoration: const BoxDecoration(color: Colors.transparent),
child: TabBar(
controller: _tabController,
labelColor: Colors.white,
unselectedLabelColor: Colors.white70,
indicatorColor: Colors.white,
tabs: const [
Tab(text: "Current Month"),
Tab(text: "History"),
],
),
),
// CONTENT AREA
Expanded(
child: Container(
color: Colors.transparent,
child: Column(
children: [
_buildSearchBar(),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildPaymentRequestList(isHistory: false),
_buildPaymentRequestList(isHistory: true),
],
),
),
],
),
),
),
],
),
),
],
),
floatingActionButton: Obx(() { floatingActionButton: Obx(() {
if (permissionController.permissions.isEmpty) { if (permissionController.permissions.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@ -166,67 +202,6 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
); );
} }
PreferredSizeWidget _buildAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard/finance'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Payment Requests',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final name = projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
);
}
Widget _buildSearchBar() { Widget _buildSearchBar() {
return Padding( return Padding(
padding: MySpacing.fromLTRB(12, 10, 12, 0), padding: MySpacing.fromLTRB(12, 10, 12, 0),

View File

@ -64,15 +64,16 @@ class _LayoutState extends State<Layout> {
body: SafeArea( body: SafeArea(
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onTap: () {}, // project selection removed nothing to close onTap: () {},
child: Column( child: Column(
children: [ children: [
_buildHeader(context, isMobile), _buildHeader(context, isMobile),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
key: controller.scrollKey, key: controller.scrollKey,
padding: EdgeInsets.symmetric( // Removed redundant vertical padding here. DashboardScreen's
horizontal: 0, vertical: isMobile ? 16 : 32), // SingleChildScrollView now handles all internal padding.
padding: EdgeInsets.symmetric(horizontal: 0, vertical: 0),
child: widget.child, child: widget.child,
), ),
), ),

View File

@ -429,6 +429,8 @@ class _ServiceProjectDetailsScreenState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar( appBar: CustomAppBar(
@ -436,55 +438,82 @@ class _ServiceProjectDetailsScreenState
projectName: widget.projectName, projectName: widget.projectName,
onBackPressed: () => Get.toNamed('/dashboard/service-projects'), onBackPressed: () => Get.toNamed('/dashboard/service-projects'),
), ),
body: SafeArea( body: Stack(
child: Column( children: [
children: [ // === TOP FADE BELOW APPBAR ===
// TabBar Container(
Container( height: 80,
color: Colors.white, decoration: BoxDecoration(
child: TabBar( gradient: LinearGradient(
controller: _tabController, begin: Alignment.topCenter,
labelColor: Colors.black, end: Alignment.bottomCenter,
unselectedLabelColor: Colors.grey, colors: [
indicatorColor: Colors.red, appBarColor,
indicatorWeight: 3, appBarColor.withOpacity(0.0),
isScrollable: false,
tabs: [
Tab(child: MyText.bodyMedium("Profile")),
Tab(child: MyText.bodyMedium("Jobs")),
Tab(child: MyText.bodyMedium("Teams")),
], ],
), ),
), ),
),
// TabBarView SafeArea(
Expanded( top: false,
child: Obx(() { bottom: true,
if (controller.isLoading.value && child: Column(
controller.projectDetail.value == null) { children: [
return const Center(child: CircularProgressIndicator()); // === TAB BAR WITH TRANSPARENT BACKGROUND ===
} Container(
if (controller.errorMessage.value.isNotEmpty && decoration: const BoxDecoration(color: Colors.transparent),
controller.projectDetail.value == null) { child: TabBar(
return Center( controller: _tabController,
child: MyText.bodyMedium(controller.errorMessage.value)); labelColor: Colors.white,
} unselectedLabelColor: Colors.white70,
indicatorColor: Colors.white,
indicatorWeight: 3,
tabs: [
Tab(
child: MyText.bodyMedium("Profile",
color: Colors.white)),
Tab(
child:
MyText.bodyMedium("Jobs", color: Colors.white)),
Tab(
child:
MyText.bodyMedium("Teams", color: Colors.white)),
],
),
),
return TabBarView( // === TABBAR VIEW ===
controller: _tabController, Expanded(
children: [ child: Obx(() {
_buildProfileTab(), if (controller.isLoading.value &&
JobsTab( controller.projectDetail.value == null) {
scrollController: _jobScrollController, return const Center(child: CircularProgressIndicator());
projectName: widget.projectName ?? '', }
), if (controller.errorMessage.value.isNotEmpty &&
_buildTeamsTab(), controller.projectDetail.value == null) {
], return Center(
); child:
}), MyText.bodyMedium(controller.errorMessage.value));
}
return TabBarView(
controller: _tabController,
children: [
_buildProfileTab(),
JobsTab(
scrollController: _jobScrollController,
projectName: widget.projectName ?? '',
),
_buildTeamsTab(),
],
);
}),
),
],
), ),
], ),
), ],
), ),
floatingActionButton: _tabController.index == 1 floatingActionButton: _tabController.index == 1
? FloatingActionButton.extended( ? FloatingActionButton.extended(

View File

@ -760,17 +760,20 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final projectName = widget.projectName; final projectName = widget.projectName;
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Job Details Screen", title: "Job Details Screen",
onBackPressed: () => Get.back(), onBackPressed: () => Get.back(),
projectName: projectName, projectName: projectName,
backgroundColor: appBarColor,
), ),
floatingActionButton: Obx(() => FloatingActionButton.extended( floatingActionButton: Obx(() => FloatingActionButton.extended(
onPressed: onPressed:
isEditing.value ? _editJob : () => isEditing.value = true, isEditing.value ? _editJob : () => isEditing.value = true,
backgroundColor: contentTheme.primary, backgroundColor: appBarColor,
label: MyText.bodyMedium( label: MyText.bodyMedium(
isEditing.value ? "Save" : "Edit", isEditing.value ? "Save" : "Edit",
color: Colors.white, color: Colors.white,
@ -778,63 +781,101 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
), ),
icon: Icon(isEditing.value ? Icons.save : Icons.edit), icon: Icon(isEditing.value ? Icons.save : Icons.edit),
)), )),
body: Obx(() { body: Stack(
if (controller.isJobDetailLoading.value) { children: [
return const Center(child: CircularProgressIndicator()); Container(
} height: 80,
decoration: BoxDecoration(
if (controller.jobDetailErrorMessage.value.isNotEmpty) { gradient: LinearGradient(
return Center( begin: Alignment.topCenter,
child: MyText.bodyMedium(controller.jobDetailErrorMessage.value)); end: Alignment.bottomCenter,
} colors: [
appBarColor,
final job = controller.jobDetail.value?.data; appBarColor.withOpacity(0.0),
if (job == null) {
return Center(child: MyText.bodyMedium("No details available"));
}
return SingleChildScrollView(
padding: MySpacing.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAttendanceCard(),
_buildSectionCard(
title: "Job Info",
titleIcon: Icons.task_outlined,
children: [
_editableRow("Title", _titleController),
_editableRow("Description", _descriptionController),
_dateRangePicker(),
], ],
), ),
MySpacing.height(12), ),
_buildSectionCard(
title: "Project Branch",
titleIcon: Icons.account_tree_outlined,
children: [_branchDisplay()],
),
MySpacing.height(16),
_buildSectionCard(
title: "Assignees",
titleIcon: Icons.person_outline,
children: [_assigneeInputWithChips()]),
MySpacing.height(16),
_buildSectionCard(
title: "Tags",
titleIcon: Icons.label_outline,
children: [_tagEditor()]),
MySpacing.height(16),
if ((job.updateLogs?.isNotEmpty ?? false))
_buildSectionCard(
title: "Update Logs",
titleIcon: Icons.history,
children: [JobTimeline(logs: job.updateLogs ?? [])]),
MySpacing.height(80),
],
), ),
);
}), // Bottom fade (for smooth transition above FAB)
Align(
alignment: Alignment.bottomCenter,
child: Container(
height: 60, // adjust based on FAB height
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
appBarColor.withOpacity(0.05),
Colors.transparent,
],
),
),
),
),
// Main scrollable content
Obx(() {
if (controller.isJobDetailLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.jobDetailErrorMessage.value.isNotEmpty) {
return Center(
child: MyText.bodyMedium(
controller.jobDetailErrorMessage.value));
}
final job = controller.jobDetail.value?.data;
if (job == null) {
return Center(child: MyText.bodyMedium("No details available"));
}
return SingleChildScrollView(
padding: MySpacing.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAttendanceCard(),
_buildSectionCard(
title: "Job Info",
titleIcon: Icons.task_outlined,
children: [
_editableRow("Title", _titleController),
_editableRow("Description", _descriptionController),
_dateRangePicker(),
],
),
MySpacing.height(12),
_buildSectionCard(
title: "Project Branch",
titleIcon: Icons.account_tree_outlined,
children: [_branchDisplay()],
),
MySpacing.height(16),
_buildSectionCard(
title: "Assignees",
titleIcon: Icons.person_outline,
children: [_assigneeInputWithChips()]),
MySpacing.height(16),
_buildSectionCard(
title: "Tags",
titleIcon: Icons.label_outline,
children: [_tagEditor()]),
MySpacing.height(16),
if ((job.updateLogs?.isNotEmpty ?? false))
_buildSectionCard(
title: "Update Logs",
titleIcon: Icons.history,
children: [JobTimeline(logs: job.updateLogs ?? [])]),
MySpacing.height(80),
],
),
);
}),
],
),
); );
} }
} }

View File

@ -181,99 +181,115 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Service Projects", title: "Service Projects",
projectName: 'All Service Projects', projectName: 'All Service Projects',
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard'), onBackPressed: () => Get.toNamed('/dashboard'),
), ),
body: Stack(
// FIX 1: Entire body wrapped in SafeArea children: [
body: SafeArea( Container(
bottom: true, height: 80,
child: Column( decoration: BoxDecoration(
children: [ gradient: LinearGradient(
Padding( begin: Alignment.topCenter,
padding: MySpacing.xy(8, 8), end: Alignment.bottomCenter,
child: Row( colors: [
children: [ appBarColor,
Expanded( appBarColor.withOpacity(0.0),
child: SizedBox(
height: 35,
child: TextField(
controller: searchController,
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 12),
prefixIcon: const Icon(Icons.search,
size: 20, color: Colors.grey),
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController,
builder: (context, value, _) {
if (value.text.isEmpty) {
return const SizedBox.shrink();
}
return IconButton(
icon: const Icon(Icons.clear,
size: 20, color: Colors.grey),
onPressed: () {
searchController.clear();
controller.updateSearch('');
},
);
},
),
hintText: 'Search projects...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
),
),
),
], ],
), ),
), ),
Expanded( ),
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
final projects = controller.filteredProjects; // Main content
SafeArea(
return MyRefreshIndicator( bottom: true,
onRefresh: _refreshProjects, child: Column(
backgroundColor: Colors.indigo, children: [
color: Colors.white, Padding(
child: projects.isEmpty padding: MySpacing.xy(8, 8),
? _buildEmptyState() child: Row(
: ListView.separated( children: [
physics: const AlwaysScrollableScrollPhysics(), Expanded(
child: SizedBox(
// FIX 2: Increased bottom padding for landscape height: 35,
padding: MySpacing.only( child: TextField(
left: 8, right: 8, top: 4, bottom: 120), controller: searchController,
decoration: InputDecoration(
itemCount: projects.length, contentPadding:
separatorBuilder: (_, __) => MySpacing.height(12), const EdgeInsets.symmetric(horizontal: 12),
itemBuilder: (_, index) => prefixIcon: const Icon(Icons.search,
_buildProjectCard(projects[index]), size: 20, color: Colors.grey),
suffixIcon:
ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController,
builder: (context, value, _) {
if (value.text.isEmpty)
return const SizedBox.shrink();
return IconButton(
icon: const Icon(Icons.clear,
size: 20, color: Colors.grey),
onPressed: () {
searchController.clear();
controller.updateSearch('');
},
);
},
),
hintText: 'Search projects...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide:
BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide:
BorderSide(color: Colors.grey.shade300),
),
),
),
), ),
); ),
}), ],
),
),
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
final projects = controller.filteredProjects;
return MyRefreshIndicator(
onRefresh: _refreshProjects,
backgroundColor: Colors.indigo,
color: Colors.white,
child: projects.isEmpty
? _buildEmptyState()
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.only(
left: 8, right: 8, top: 4, bottom: 120),
itemCount: projects.length,
separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) =>
_buildProjectCard(projects[index]),
),
);
}),
),
],
), ),
], ),
), ],
), ),
); );
} }

View File

@ -17,6 +17,8 @@ import 'package:on_field_work/model/dailyTaskPlanning/task_action_buttons.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart'; import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart'; import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class DailyProgressReportScreen extends StatefulWidget { class DailyProgressReportScreen extends StatefulWidget {
const DailyProgressReportScreen({super.key}); const DailyProgressReportScreen({super.key});
@ -87,124 +89,94 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
appBar: PreferredSize( backgroundColor: const Color(0xFFF5F5F5),
preferredSize: const Size.fromHeight(72), appBar: CustomAppBar(
child: AppBar( title: 'Daily Progress Report',
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: appBarColor,
elevation: 0.5, projectName:
automaticallyImplyLeading: false, projectController.selectedProject?.name ?? 'Select Project',
titleSpacing: 0, onBackPressed: () => Get.offNamed('/dashboard'),
title: Padding( ),
padding: MySpacing.xy(16, 0), body: Stack(
child: Row( children: [
crossAxisAlignment: CrossAxisAlignment.center, // Gradient behind content (like EmployeesScreen)
children: [ Container(
IconButton( height: 80,
icon: const Icon(Icons.arrow_back_ios_new, decoration: BoxDecoration(
color: Colors.black, size: 20), gradient: LinearGradient(
onPressed: () => Get.offNamed('/dashboard'), begin: Alignment.topCenter,
), end: Alignment.bottomCenter,
MySpacing.width(8), colors: [
Expanded( appBarColor,
child: Column( appBarColor.withOpacity(0.0),
crossAxisAlignment: CrossAxisAlignment.start, ],
mainAxisSize: MainAxisSize.min, ),
children: [
MyText.titleLarge(
'Daily Progress Report',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
), ),
), ),
),
), // Main content
body: SafeArea( SafeArea(
child: MyRefreshIndicator( child: MyRefreshIndicator(
onRefresh: _refreshData, onRefresh: _refreshData,
child: CustomScrollView( child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(), controller: _scrollController,
slivers: [ physics: const AlwaysScrollableScrollPhysics(),
SliverToBoxAdapter( slivers: [
child: GetBuilder<DailyTaskController>( SliverToBoxAdapter(
init: dailyTaskController, child: GetBuilder<DailyTaskController>(
tag: 'daily_progress_report_controller', init: dailyTaskController,
builder: (controller) { tag: 'daily_progress_report_controller',
return Column( builder: (controller) {
crossAxisAlignment: CrossAxisAlignment.start, return Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
MySpacing.height(flexSpacing), children: [
Padding( MySpacing.height(flexSpacing),
padding: MySpacing.x(15), Padding(
child: Row( padding: MySpacing.x(15),
mainAxisAlignment: child: Row(
MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
InkWell( InkWell(
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
onTap: _openFilterSheet, onTap: _openFilterSheet,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4), horizontal: 8, vertical: 4),
child: Row( child: Row(
children: [ children: [
MyText.bodySmall( MyText.bodySmall(
"Filter", "Filter",
fontWeight: 600, fontWeight: 600,
color: Colors.black, color: Colors.black,
),
const SizedBox(width: 4),
const Icon(Icons.tune,
size: 20, color: Colors.black),
],
), ),
const SizedBox(width: 4), ),
Icon(Icons.tune,
size: 20, color: Colors.black),
],
), ),
), ],
), ),
], ),
), MySpacing.height(8),
), Padding(
MySpacing.height(8), padding: MySpacing.x(8),
Padding( child: _buildDailyProgressReportTab(),
padding: MySpacing.x(8), ),
child: _buildDailyProgressReportTab(), ],
), );
], },
); ),
}, ),
), ],
), ),
], ),
), ),
), ],
), ),
); );
} }

View File

@ -15,6 +15,8 @@ import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/controller/tenant/service_controller.dart'; import 'package:on_field_work/controller/tenant/service_controller.dart';
import 'package:on_field_work/helpers/widgets/tenant/service_selector.dart'; import 'package:on_field_work/helpers/widgets/tenant/service_selector.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class DailyTaskPlanningScreen extends StatefulWidget { class DailyTaskPlanningScreen extends StatefulWidget {
DailyTaskPlanningScreen({super.key}); DailyTaskPlanningScreen({super.key});
@ -58,128 +60,99 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
appBar: PreferredSize( backgroundColor: const Color(0xFFF5F5F5),
preferredSize: const Size.fromHeight(72), appBar: CustomAppBar(
child: AppBar( title: 'Daily Task Planning',
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: appBarColor,
elevation: 0.5, projectName:
automaticallyImplyLeading: false, projectController.selectedProject?.name ?? 'Select Project',
titleSpacing: 0, onBackPressed: () => Get.offNamed('/dashboard'),
title: Padding( ),
padding: MySpacing.xy(16, 0), body: Stack(
child: Row( children: [
crossAxisAlignment: CrossAxisAlignment.center, // Gradient behind content
children: [ Container(
IconButton( height: 80,
icon: const Icon(Icons.arrow_back_ios_new, decoration: BoxDecoration(
color: Colors.black, size: 20), gradient: LinearGradient(
onPressed: () => Get.offNamed('/dashboard'), begin: Alignment.topCenter,
), end: Alignment.bottomCenter,
MySpacing.width(8), colors: [
Expanded( appBarColor,
child: Column( appBarColor.withOpacity(0.0),
crossAxisAlignment: CrossAxisAlignment.start, ],
mainAxisSize: MainAxisSize.min, ),
children: [ ),
MyText.titleLarge( ),
'Daily Task Planning',
fontWeight: 700, // Main content
color: Colors.black, SafeArea(
), child: MyRefreshIndicator(
MySpacing.height(2), onRefresh: () async {
GetBuilder<ProjectController>( final projectId = projectController.selectedProjectId.value;
builder: (projectController) { if (projectId.isNotEmpty) {
final projectName = try {
projectController.selectedProject?.name ?? await dailyTaskPlanningController.fetchTaskData(
'Select Project'; projectId,
return Row( serviceId: serviceController.selectedService?.id,
children: [ );
const Icon(Icons.work_outline, } catch (e) {
size: 14, color: Colors.grey), debugPrint('Error refreshing task data: ${e.toString()}');
MySpacing.width(4), }
Expanded( }
child: MyText.bodySmall( },
projectName, child: SingleChildScrollView(
fontWeight: 600, physics: const AlwaysScrollableScrollPhysics(),
overflow: TextOverflow.ellipsis, padding: MySpacing.x(0),
color: Colors.grey[700], child: ConstrainedBox(
), constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height -
kToolbarHeight -
MediaQuery.of(context).padding.top,
),
child: GetBuilder<DailyTaskPlanningController>(
init: dailyTaskPlanningController,
tag: 'daily_task_Planning_controller',
builder: (controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(10),
child: ServiceSelector(
controller: serviceController,
height: 40,
onSelectionChanged: (service) async {
final projectId =
projectController.selectedProjectId.value;
if (projectId.isNotEmpty) {
await dailyTaskPlanningController
.fetchTaskData(
projectId,
serviceId: service?.id,
);
}
},
), ),
], ),
); MySpacing.height(flexSpacing),
}), Padding(
], padding: MySpacing.x(8),
child: dailyProgressReportTab(),
),
],
);
},
), ),
), ),
],
),
),
),
),
body: SafeArea(
child: MyRefreshIndicator(
onRefresh: () async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) {
try {
// keep previous behavior but now fetchTaskData is lighter (buildings only)
await dailyTaskPlanningController.fetchTaskData(
projectId,
serviceId: serviceController.selectedService?.id,
);
} catch (e) {
debugPrint('Error refreshing task data: ${e.toString()}');
}
}
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.x(0),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height -
kToolbarHeight -
MediaQuery.of(context).padding.top,
),
child: GetBuilder<DailyTaskPlanningController>(
init: dailyTaskPlanningController,
tag: 'daily_task_Planning_controller',
builder: (controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(10),
child: ServiceSelector(
controller: serviceController,
height: 40,
onSelectionChanged: (service) async {
final projectId =
projectController.selectedProjectId.value;
if (projectId.isNotEmpty) {
await dailyTaskPlanningController.fetchTaskData(
projectId,
serviceId:
service?.id, // <-- pass selected service
);
}
},
),
),
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(8),
child: dailyProgressReportTab(),
),
],
);
},
), ),
), ),
), ),
), ],
), ),
); );
} }
@ -227,8 +200,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final buildings = dailyTasks final buildings = dailyTasks
.expand((task) => task.buildings) .expand((task) => task.buildings)
.where((building) => .where((building) =>
(building.plannedWork ) > 0 || (building.plannedWork) > 0 || (building.completedWork) > 0)
(building.completedWork ) > 0)
.toList(); .toList();
if (buildings.isEmpty) { if (buildings.isEmpty) {