made chnages in details screen and list screen

This commit is contained in:
Vaibhav Surve 2025-11-12 16:48:53 +05:30
parent 1de5e7fae7
commit c8a8d45c66
6 changed files with 417 additions and 246 deletions

View File

@ -3,33 +3,56 @@ import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/service_project/service_projects_list_model.dart';
class ServiceProjectController extends GetxController {
var projects = <ProjectItem>[].obs;
var isLoading = false.obs;
var searchQuery = ''.obs;
final projects = <ProjectItem>[].obs;
final isLoading = false.obs;
final searchQuery = ''.obs;
RxList<ProjectItem> get filteredProjects {
if (searchQuery.value.isEmpty) return projects;
return projects
.where((p) =>
p.name.toLowerCase().contains(searchQuery.value.toLowerCase()) ||
p.contactPerson.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList()
.obs;
/// Computed filtered project list
List<ProjectItem> get filteredProjects {
final query = searchQuery.value.trim().toLowerCase();
if (query.isEmpty) return projects;
return projects.where((p) {
final nameMatch = p.name.toLowerCase().contains(query);
final shortNameMatch = p.shortName.toLowerCase().contains(query);
final addressMatch = p.address.toLowerCase().contains(query);
final contactMatch = p.contactName.toLowerCase().contains(query);
final clientMatch = p.client != null &&
(p.client!.name.toLowerCase().contains(query) ||
p.client!.contactPerson.toLowerCase().contains(query));
return nameMatch ||
shortNameMatch ||
addressMatch ||
contactMatch ||
clientMatch;
}).toList();
}
/// Fetch projects from API
Future<void> fetchProjects({int pageNumber = 1, int pageSize = 20}) async {
try {
isLoading.value = true;
final result = await ApiService.getServiceProjectsListApi(
pageNumber: pageNumber, pageSize: pageSize);
pageNumber: pageNumber,
pageSize: pageSize,
);
if (result != null && result.data != null) {
projects.assignAll(result.data!.data ?? []);
projects.assignAll(result.data!.data);
} else {
projects.clear();
}
} catch (e) {
// Optional: log or show error
rethrow;
} finally {
isLoading.value = false;
}
}
/// Update search
void updateSearch(String query) {
searchQuery.value = query;
}

View File

@ -22,7 +22,7 @@ class ServiceProjectListModel {
data: json['data'] != null ? ProjectData.fromJson(json['data']) : null,
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.parse(json['timestamp'] ?? DateTime.now().toIso8601String()),
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
);
}
@ -40,13 +40,13 @@ class ProjectData {
final int currentPage;
final int totalPages;
final int totalEntities;
final List<ProjectItem>? data;
final List<ProjectItem> data;
ProjectData({
required this.currentPage,
required this.totalPages,
required this.totalEntities,
this.data,
required this.data,
});
factory ProjectData.fromJson(Map<String, dynamic> json) {
@ -55,7 +55,8 @@ class ProjectData {
totalPages: json['totalPages'] ?? 1,
totalEntities: json['totalEntites'] ?? 0,
data: json['data'] != null
? List<ProjectItem>.from(json['data'].map((x) => ProjectItem.fromJson(x)))
? List<ProjectItem>.from(
json['data'].map((x) => ProjectItem.fromJson(x)))
: [],
);
}
@ -64,7 +65,7 @@ class ProjectData {
'currentPage': currentPage,
'totalPages': totalPages,
'totalEntites': totalEntities,
'data': data?.map((x) => x.toJson()).toList(),
'data': data.map((x) => x.toJson()).toList(),
};
}
@ -72,27 +73,31 @@ class ProjectItem {
final String id;
final String name;
final String shortName;
final String projectAddress;
final String contactPerson;
final DateTime startDate;
final DateTime endDate;
final String projectStatusId;
final int teamSize;
final double completedWork;
final double plannedWork;
final String address;
final DateTime assignedDate;
final Status? status;
final Client? client;
final List<Service> services;
final String contactName;
final String contactPhone;
final String contactEmail;
final DateTime createdAt;
final CreatedBy? createdBy;
ProjectItem({
required this.id,
required this.name,
required this.shortName,
required this.projectAddress,
required this.contactPerson,
required this.startDate,
required this.endDate,
required this.projectStatusId,
required this.teamSize,
required this.completedWork,
required this.plannedWork,
required this.address,
required this.assignedDate,
this.status,
this.client,
required this.services,
required this.contactName,
required this.contactPhone,
required this.contactEmail,
required this.createdAt,
this.createdBy,
});
factory ProjectItem.fromJson(Map<String, dynamic> json) {
@ -100,14 +105,24 @@ class ProjectItem {
id: json['id'] ?? '',
name: json['name'] ?? '',
shortName: json['shortName'] ?? '',
projectAddress: json['projectAddress'] ?? '',
contactPerson: json['contactPerson'] ?? '',
startDate: DateTime.parse(json['startDate'] ?? DateTime.now().toIso8601String()),
endDate: DateTime.parse(json['endDate'] ?? DateTime.now().toIso8601String()),
projectStatusId: json['projectStatusId'] ?? '',
teamSize: json['teamSize'] ?? 0,
completedWork: (json['completedWork']?.toDouble() ?? 0.0),
plannedWork: (json['plannedWork']?.toDouble() ?? 0.0),
address: json['address'] ?? '',
assignedDate:
DateTime.tryParse(json['assignedDate'] ?? '') ?? DateTime.now(),
status:
json['status'] != null ? Status.fromJson(json['status']) : null,
client:
json['client'] != null ? Client.fromJson(json['client']) : null,
services: json['services'] != null
? List<Service>.from(json['services'].map((x) => Service.fromJson(x)))
: [],
contactName: json['contactName'] ?? '',
contactPhone: json['contactPhone'] ?? '',
contactEmail: json['contactEmail'] ?? '',
createdAt:
DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(),
createdBy: json['createdBy'] != null
? CreatedBy.fromJson(json['createdBy'])
: null,
);
}
@ -115,13 +130,155 @@ class ProjectItem {
'id': id,
'name': name,
'shortName': shortName,
'projectAddress': projectAddress,
'contactPerson': contactPerson,
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
'projectStatusId': projectStatusId,
'teamSize': teamSize,
'completedWork': completedWork,
'plannedWork': plannedWork,
'address': address,
'assignedDate': assignedDate.toIso8601String(),
'status': status?.toJson(),
'client': client?.toJson(),
'services': services.map((x) => x.toJson()).toList(),
'contactName': contactName,
'contactPhone': contactPhone,
'contactEmail': contactEmail,
'createdAt': createdAt.toIso8601String(),
'createdBy': createdBy?.toJson(),
};
}
class Status {
final String id;
final String status;
Status({
required this.id,
required this.status,
});
factory Status.fromJson(Map<String, dynamic> json) {
return Status(
id: json['id'] ?? '',
status: json['status'] ?? '',
);
}
Map<String, dynamic> toJson() => {
'id': id,
'status': status,
};
}
class Client {
final String id;
final String name;
final String email;
final String contactPerson;
final String address;
final String contactNumber;
final int sprid;
Client({
required this.id,
required this.name,
required this.email,
required this.contactPerson,
required this.address,
required this.contactNumber,
required this.sprid,
});
factory Client.fromJson(Map<String, dynamic> json) {
return Client(
id: json['id'] ?? '',
name: json['name'] ?? '',
email: json['email'] ?? '',
contactPerson: json['contactPerson'] ?? '',
address: json['address'] ?? '',
contactNumber: json['contactNumber'] ?? '',
sprid: json['sprid'] ?? 0,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
'contactPerson': contactPerson,
'address': address,
'contactNumber': contactNumber,
'sprid': sprid,
};
}
class Service {
final String id;
final String name;
final String description;
final bool isSystem;
final bool isActive;
Service({
required this.id,
required this.name,
required this.description,
required this.isSystem,
required this.isActive,
});
factory Service.fromJson(Map<String, dynamic> json) {
return Service(
id: json['id'] ?? '',
name: json['name'] ?? '',
description: json['description'] ?? '',
isSystem: json['isSystem'] ?? false,
isActive: json['isActive'] ?? false,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'description': description,
'isSystem': isSystem,
'isActive': isActive,
};
}
class CreatedBy {
final String id;
final String firstName;
final String lastName;
final String email;
final String photo;
final String jobRoleId;
final String jobRoleName;
CreatedBy({
required this.id,
required this.firstName,
required this.lastName,
required this.email,
required this.photo,
required this.jobRoleId,
required this.jobRoleName,
});
factory CreatedBy.fromJson(Map<String, dynamic> json) {
return CreatedBy(
id: json['id'] ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
email: json['email'] ?? '',
photo: json['photo'] ?? '',
jobRoleId: json['jobRoleId'] ?? '',
jobRoleName: json['jobRoleName'] ?? '',
);
}
Map<String, dynamic> toJson() => {
'id': id,
'firstName': firstName,
'lastName': lastName,
'email': email,
'photo': photo,
'jobRoleId': jobRoleId,
'jobRoleName': jobRoleName,
};
}

View File

@ -80,14 +80,9 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: contentTheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(5),
),
child: Icon(
icon,
size: 20,
color: contentTheme.primary,
),
),
MySpacing.width(16),
@ -95,28 +90,22 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
MyText(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
fontWeight: 500,
),
MySpacing.height(4),
Text(
MyText(
value,
style: TextStyle(
fontSize: 15,
color: isActionable && value != 'NA'
? contentTheme.primary
? Colors.blueAccent
: Colors.black87,
fontWeight: FontWeight.w500,
fontWeight: 500,
decoration: isActionable && value != 'NA'
? TextDecoration.underline
: TextDecoration.none,
),
),
],
),
),
@ -151,17 +140,14 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
Icon(
titleIcon,
size: 20,
color: contentTheme.primary,
),
MySpacing.width(8),
Text(
MyText(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
fontWeight: 700,
color: Colors.black87,
),
),
],
),
MySpacing.height(8),
@ -198,7 +184,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
final employee = controller.selectedEmployeeDetails.value;
if (employee == null) {
return const Center(child: Text('No employee details found.'));
return Center(child: MyText('No employee details found.'));
}
return SafeArea(
@ -226,7 +212,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 45,
size: 35,
),
MySpacing.width(16),
Expanded(
@ -444,9 +430,10 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
);
},
backgroundColor: contentTheme.primary,
label: const Text(
label: MyText(
'Assign to Project',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
fontSize: 14,
fontWeight: 500,
),
icon: const Icon(Icons.add),
);

View File

@ -362,13 +362,13 @@ class PaymentRequestPermissionHelper {
// ------------------ Sub-widgets ------------------
class _Header extends StatelessWidget with UIMixin {
class _Header extends StatefulWidget {
final PaymentRequestData request;
final Color Function(String) colorParser;
final VoidCallback? onEdit;
final EmployeeInfo? employeeInfo;
_Header({
const _Header({
required this.request,
required this.colorParser,
this.onEdit,
@ -376,12 +376,17 @@ class _Header extends StatelessWidget with UIMixin {
});
@override
Widget build(BuildContext context) {
final statusColor = colorParser(request.expenseStatus.color);
State<_Header> createState() => _HeaderState();
}
final canEdit = employeeInfo != null &&
class _HeaderState extends State<_Header> with UIMixin {
@override
Widget build(BuildContext context) {
final statusColor = widget.colorParser(widget.request.expenseStatus.color);
final canEdit = widget.employeeInfo != null &&
PaymentRequestPermissionHelper.canEditPaymentRequest(
employeeInfo, request);
widget.employeeInfo, widget.request);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -390,13 +395,13 @@ class _Header extends StatelessWidget with UIMixin {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(
'ID: ${request.paymentRequestUID}',
'ID: ${widget.request.paymentRequestUID}',
fontWeight: 700,
fontSize: 14,
),
if (canEdit)
IconButton(
onPressed: onEdit,
onPressed: widget.onEdit,
icon: Icon(Icons.edit, color: contentTheme.primary),
tooltip: "Edit Payment Request",
),
@ -417,7 +422,7 @@ class _Header extends StatelessWidget with UIMixin {
Expanded(
child: MyText.bodySmall(
DateTimeUtils.convertUtcToLocal(
request.createdAt.toIso8601String(),
widget.request.createdAt.toIso8601String(),
format: 'dd MMM yyyy'),
fontWeight: 600,
overflow: TextOverflow.ellipsis,
@ -438,7 +443,7 @@ class _Header extends StatelessWidget with UIMixin {
SizedBox(
width: 100,
child: MyText.labelSmall(
request.expenseStatus.displayName,
widget.request.expenseStatus.displayName,
color: statusColor,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
@ -629,21 +634,22 @@ class _DetailsTable extends StatelessWidget {
children: [
// Basic Info
_labelValueRow("Payment Request ID:", request.paymentRequestUID),
if (request.paidTransactionId != null && request.paidTransactionId!.isNotEmpty)
if (request.paidTransactionId != null &&
request.paidTransactionId!.isNotEmpty)
_labelValueRow("Transaction ID:", request.paidTransactionId!),
_labelValueRow("Payee:", request.payee),
_labelValueRow("Project:", request.project.name),
_labelValueRow("Expense Category:", request.expenseCategory.name),
// Amounts
_labelValueRow(
"Amount:", "${request.currency.symbol} ${request.amount.toStringAsFixed(2)}"),
_labelValueRow("Amount:",
"${request.currency.symbol} ${request.amount.toStringAsFixed(2)}"),
if (request.baseAmount != null)
_labelValueRow(
"Base Amount:", "${request.currency.symbol} ${request.baseAmount!.toStringAsFixed(2)}"),
_labelValueRow("Base Amount:",
"${request.currency.symbol} ${request.baseAmount!.toStringAsFixed(2)}"),
if (request.taxAmount != null)
_labelValueRow(
"Tax Amount:", "${request.currency.symbol} ${request.taxAmount!.toStringAsFixed(2)}"),
_labelValueRow("Tax Amount:",
"${request.currency.symbol} ${request.taxAmount!.toStringAsFixed(2)}"),
if (request.expenseCategory.noOfPersonsRequired)
_labelValueRow("Additional Persons Required:", "Yes"),
if (request.expenseCategory.isAttachmentRequried)
@ -652,43 +658,44 @@ class _DetailsTable extends StatelessWidget {
// Dates
_labelValueRow(
"Due Date:",
DateTimeUtils.convertUtcToLocal(
request.dueDate.toIso8601String(),
DateTimeUtils.convertUtcToLocal(request.dueDate.toIso8601String(),
format: 'dd MMM yyyy')),
_labelValueRow(
"Created At:",
DateTimeUtils.convertUtcToLocal(
request.createdAt.toIso8601String(),
DateTimeUtils.convertUtcToLocal(request.createdAt.toIso8601String(),
format: 'dd MMM yyyy')),
_labelValueRow(
"Updated At:",
DateTimeUtils.convertUtcToLocal(
request.updatedAt.toIso8601String(),
DateTimeUtils.convertUtcToLocal(request.updatedAt.toIso8601String(),
format: 'dd MMM yyyy')),
// Payment Info
if (request.paidAt != null)
_labelValueRow(
"Transaction Date:",
DateTimeUtils.convertUtcToLocal(
request.paidAt!.toIso8601String(),
DateTimeUtils.convertUtcToLocal(request.paidAt!.toIso8601String(),
format: 'dd MMM yyyy')),
if (request.paidBy != null)
_labelValueRow(
"Paid By:", "${request.paidBy!.firstName} ${request.paidBy!.lastName}"),
_labelValueRow("Paid By:",
"${request.paidBy!.firstName} ${request.paidBy!.lastName}"),
// Flags
_labelValueRow("Advance Payment:", request.isAdvancePayment ? "Yes" : "No"),
_labelValueRow("Expense Created:", request.isExpenseCreated ? "Yes" : "No"),
_labelValueRow(
"Advance Payment:", request.isAdvancePayment ? "Yes" : "No"),
_labelValueRow(
"Expense Created:", request.isExpenseCreated ? "Yes" : "No"),
_labelValueRow("Active:", request.isActive ? "Yes" : "No"),
// Recurring Payment Info
if (request.recurringPayment != null) ...[
const SizedBox(height: 6),
MyText.bodySmall("Recurring Payment Info:", fontWeight: 600),
_labelValueRow("Recurring ID:", request.recurringPayment!.recurringPaymentUID),
_labelValueRow("Amount:", "${request.currency.symbol} ${request.recurringPayment!.amount.toStringAsFixed(2)}"),
_labelValueRow("Variable Amount:", request.recurringPayment!.isVariable ? "Yes" : "No"),
_labelValueRow(
"Recurring ID:", request.recurringPayment!.recurringPaymentUID),
_labelValueRow("Amount:",
"${request.currency.symbol} ${request.recurringPayment!.amount.toStringAsFixed(2)}"),
_labelValueRow("Variable Amount:",
request.recurringPayment!.isVariable ? "Yes" : "No"),
],
// Description & Attachments

View File

@ -20,15 +20,20 @@ class ServiceProjectDetailsScreen extends StatefulWidget {
class _ServiceProjectDetailsScreenState
extends State<ServiceProjectDetailsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final ServiceProjectDetailsController controller =
Get.put(ServiceProjectDetailsController());
late final TabController _tabController;
late final ServiceProjectDetailsController controller;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
controller = Get.put(ServiceProjectDetailsController());
// Fetch project detail safely after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.setProjectId(widget.projectId);
});
}
@override
@ -57,39 +62,34 @@ class _ServiceProjectDetailsScreenState
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.redAccent.withOpacity(0.1),
borderRadius: BorderRadius.circular(5),
child: Icon(
icon,
size: 20,
),
child: Icon(icon, size: 20, color: Colors.redAccent),
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
MyText.bodySmall(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
fontWeight: 500,
),
MySpacing.height(4),
Text(
MyText.bodyMedium(
value,
style: TextStyle(
fontSize: 15,
color: isActionable && value != 'NA'
? Colors.redAccent
? Colors.blueAccent
: Colors.black87,
fontWeight: FontWeight.w500,
fontWeight: 500,
decoration: isActionable && value != 'NA'
? TextDecoration.underline
: TextDecoration.none,
),
),
],
),
),
@ -117,14 +117,13 @@ class _ServiceProjectDetailsScreenState
children: [
Row(
children: [
Icon(titleIcon, size: 20, color: Colors.redAccent),
Icon(titleIcon, size: 20),
MySpacing.width(8),
Text(
MyText.bodyLarge(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87),
fontWeight: 700,
color: Colors.black87,
),
],
),
@ -148,7 +147,9 @@ class _ServiceProjectDetailsScreenState
Widget _buildProfileTab() {
final project = controller.projectDetail.value;
if (project == null) return const Center(child: Text("No project data"));
if (project == null) {
return Center(child: MyText.bodyMedium("No project data"));
}
return Padding(
padding: MySpacing.all(12),
@ -166,8 +167,7 @@ class _ServiceProjectDetailsScreenState
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.work_outline,
size: 45, color: Colors.redAccent),
const Icon(Icons.work_outline, size: 35),
MySpacing.width(16),
Expanded(
child: Column(
@ -175,8 +175,8 @@ class _ServiceProjectDetailsScreenState
children: [
MyText.titleMedium(project.name, fontWeight: 700),
MySpacing.height(6),
MyText.bodySmall(
project.client?.name ?? 'N/A', fontWeight: 500),
MyText.bodySmall(project.client?.name ?? 'N/A',
fontWeight: 500),
],
),
),
@ -211,8 +211,7 @@ class _ServiceProjectDetailsScreenState
label: 'Contact Phone',
value: project.contactPhone,
isActionable: true,
onTap: () =>
LauncherUtils.launchPhone(project.contactPhone),
onTap: () => LauncherUtils.launchPhone(project.contactPhone),
onLongPress: () => LauncherUtils.copyToClipboard(
project.contactPhone,
typeLabel: 'Phone'),
@ -222,8 +221,7 @@ class _ServiceProjectDetailsScreenState
label: 'Contact Email',
value: project.contactEmail,
isActionable: true,
onTap: () =>
LauncherUtils.launchEmail(project.contactEmail),
onTap: () => LauncherUtils.launchEmail(project.contactEmail),
onLongPress: () => LauncherUtils.copyToClipboard(
project.contactEmail,
typeLabel: 'Email'),
@ -369,9 +367,9 @@ class _ServiceProjectDetailsScreenState
indicatorColor: Colors.red,
indicatorWeight: 3,
isScrollable: false,
tabs: const [
Tab(text: "Profile"),
Tab(text: "Jobs"),
tabs: [
Tab(child: MyText.bodyMedium("Profile")),
Tab(child: MyText.bodyMedium("Jobs")),
],
),
),
@ -383,7 +381,8 @@ class _ServiceProjectDetailsScreenState
return const Center(child: CircularProgressIndicator());
}
if (controller.errorMessage.value.isNotEmpty) {
return Center(child: Text(controller.errorMessage.value));
return Center(
child: MyText.bodyMedium(controller.errorMessage.value));
}
return TabBarView(

View File

@ -21,11 +21,15 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
final TextEditingController searchController = TextEditingController();
final ServiceProjectController controller =
Get.put(ServiceProjectController());
@override
void initState() {
super.initState();
// Fetch projects safely after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.fetchProjects();
});
searchController.addListener(() {
controller.updateSearch(searchController.text);
});
@ -45,17 +49,16 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
borderRadius: BorderRadius.circular(14),
onTap: () {
// Navigate to ServiceProjectDetailsScreen
Get.to(
() => ServiceProjectDetailsScreen(projectId: project.id),
);
Get.to(() => ServiceProjectDetailsScreen(projectId: project.id));
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// Header Row: Avatar | Name & Tags | Status
/// Project Header
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
@ -63,61 +66,70 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
children: [
MyText.titleMedium(
project.name,
fontWeight: 800,
),
MySpacing.height(2),
Row(
children: [
if (project.shortName.isNotEmpty)
_buildTag(project.shortName),
if (project.shortName.isNotEmpty)
MySpacing.width(6),
Icon(Icons.location_on,
size: 15, color: Colors.deepOrange.shade400),
MySpacing.width(2),
Flexible(
child: MyText.bodySmall(
project.projectAddress,
color: Colors.grey[700],
overflow: TextOverflow.ellipsis,
),
fontWeight: 700,
),
MySpacing.height(4),
],
),
],
),
if (project.status?.status.isNotEmpty ?? false)
Container(
decoration: BoxDecoration(
color: Colors.indigo.withOpacity(0.08),
borderRadius: BorderRadius.circular(6),
),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
child: MyText.labelSmall(
project.status!.status,
color: Colors.indigo[700],
fontWeight: 600,
),
),
],
),
MySpacing.height(12),
MySpacing.height(10),
/// Assigned Date
_buildDetailRow(
Icons.date_range_outlined,
Colors.teal,
"${DateTimeUtils.convertUtcToLocal(project.startDate.toIso8601String(), format: DateTimeUtils.defaultFormat)} To "
"${DateTimeUtils.convertUtcToLocal(project.endDate.toIso8601String(), format: DateTimeUtils.defaultFormat)}",
"Assigned: ${DateTimeUtils.convertUtcToLocal(project.assignedDate.toIso8601String(), format: DateTimeUtils.defaultFormat)}",
fontSize: 13,
),
MySpacing.height(8),
/// Client Info
if (project.client != null)
_buildDetailRow(
Icons.account_circle_outlined,
Colors.indigo,
"Client: ${project.client!.name} (${project.client!.contactPerson})",
fontSize: 13,
),
MySpacing.height(8),
/// Contact Info
_buildDetailRow(
Icons.phone,
Colors.green,
"Contact: ${project.contactName} (${project.contactPhone})",
fontSize: 13,
),
MySpacing.height(12),
/// Stats
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildStatColumn(Icons.people_alt_rounded, "Team",
"${project.teamSize}", Colors.blue[700]),
_buildStatColumn(
Icons.check_circle,
"Completed",
"${project.completedWork.toStringAsFixed(1)}%",
Colors.green[600]),
_buildStatColumn(
Icons.pending,
"Planned",
"${project.plannedWork.toStringAsFixed(1)}%",
Colors.orange[800]),
],
/// Services List
if (project.services.isNotEmpty)
Wrap(
spacing: 6,
runSpacing: 4,
children: project.services
.map((service) => _buildServiceChip(service.name))
.toList(),
),
],
),
@ -126,25 +138,27 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
);
}
// Helper to build colored tags
Widget _buildTag(String label) {
Widget _buildServiceChip(String name) {
return Container(
decoration: BoxDecoration(
color: Colors.indigo.withOpacity(0.08),
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child:
MyText.labelSmall(label, color: Colors.indigo[700], fontWeight: 500),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
child: MyText.labelSmall(
name,
color: Colors.orange[800],
fontWeight: 500,
),
);
}
// Helper for detail row with icon and text
Widget _buildDetailRow(IconData icon, Color iconColor, String value,
{double fontSize = 12}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, size: 19, color: iconColor),
Icon(icon, size: 18, color: iconColor),
MySpacing.width(8),
Flexible(
child: MyText.bodySmall(
@ -152,27 +166,12 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
color: Colors.grey[900],
fontWeight: 500,
fontSize: fontSize,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
// Helper for stats column (icon + label + value)
Widget _buildStatColumn(
IconData icon, String label, String value, Color? color) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, color: color, size: 19),
SizedBox(height: 3),
MyText.labelSmall(value, color: color, fontWeight: 700),
MyText.bodySmall(label, color: Colors.grey[500], fontSize: 11),
],
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
@ -194,8 +193,6 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
/// APPBAR
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
@ -211,7 +208,7 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.back(),
onPressed: () => Get.toNamed('/dashboard'),
),
MySpacing.width(8),
MyText.titleLarge(
@ -224,10 +221,9 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
),
),
),
body: Column(
children: [
/// SEARCH + FILTER BAR
/// Search bar and actions
Padding(
padding: MySpacing.xy(8, 8),
child: Row(
@ -245,8 +241,9 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController,
builder: (context, value, _) {
if (value.text.isEmpty)
if (value.text.isEmpty) {
return const SizedBox.shrink();
}
return IconButton(
icon: const Icon(Icons.clear,
size: 20, color: Colors.grey),
@ -308,10 +305,11 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
const PopupMenuItem<int>(
enabled: false,
height: 30,
child: Text("Actions",
child: Text(
"Actions",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey)),
fontWeight: FontWeight.bold, color: Colors.grey),
),
),
const PopupMenuItem<int>(
value: 1,
@ -331,7 +329,7 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
),
),
/// PROJECT LIST
/// Project List
Expanded(
child: Obx(() {
if (controller.isLoading.value) {