added service projet screen

This commit is contained in:
Vaibhav Surve 2025-11-12 15:36:10 +05:30
parent 7b6520597e
commit 1de5e7fae7
10 changed files with 1309 additions and 11 deletions

View File

@ -0,0 +1,53 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/service_project/service_projects_details_model.dart';
class ServiceProjectDetailsController extends GetxController {
// Selected project id
var projectId = ''.obs;
// Project details
var projectDetail = Rxn<ProjectDetail>();
// Loading state
var isLoading = false.obs;
// Error message
var errorMessage = ''.obs;
/// Set project id and fetch its details
void setProjectId(String id) {
projectId.value = id;
fetchProjectDetail();
}
/// Fetch project detail from API
Future<void> fetchProjectDetail() async {
if (projectId.value.isEmpty) {
errorMessage.value = "Invalid project ID";
return;
}
isLoading.value = true;
errorMessage.value = '';
try {
final result = await ApiService.getServiceProjectDetailApi(projectId.value);
if (result != null && result.data != null) {
projectDetail.value = result.data!;
} else {
errorMessage.value = result?.message ?? "Failed to fetch project details";
}
} catch (e) {
errorMessage.value = "Error: $e";
} finally {
isLoading.value = false;
}
}
/// Refresh project details manually
Future<void> refresh() async {
await fetchProjectDetail();
}
}

View File

@ -0,0 +1,36 @@
import 'package:get/get.dart';
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;
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;
}
Future<void> fetchProjects({int pageNumber = 1, int pageSize = 20}) async {
try {
isLoading.value = true;
final result = await ApiService.getServiceProjectsListApi(
pageNumber: pageNumber, pageSize: pageSize);
if (result != null && result.data != null) {
projects.assignAll(result.data!.data ?? []);
}
} finally {
isLoading.value = false;
}
}
void updateSearch(String query) {
searchQuery.value = query;
}
}

View File

@ -1,7 +1,9 @@
class ApiEndpoints { class ApiEndpoints {
static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://stageapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api";
static const String baseUrl = "https://mapi.marcoaiot.com/api";
static const String getMasterCurrencies = "/Master/currencies/list"; static const String getMasterCurrencies = "/Master/currencies/list";
static const String getMasterExpensesCategories = static const String getMasterExpensesCategories =
@ -127,4 +129,8 @@ class ApiEndpoints {
static const String getAssignedServices = "/Project/get/assigned/services"; static const String getAssignedServices = "/Project/get/assigned/services";
static const String getAdvancePayments = '/Expense/get/transactions'; static const String getAdvancePayments = '/Expense/get/transactions';
// Service Project Module API Endpoints
static const String getServiceProjectsList = "/serviceproject/list";
static const String getServiceProjectDetail = "/serviceproject/details";
} }

View File

@ -33,6 +33,8 @@ import 'package:marco/model/finance/payment_request_list_model.dart';
import 'package:marco/model/finance/payment_request_filter.dart'; import 'package:marco/model/finance/payment_request_filter.dart';
import 'package:marco/model/finance/payment_request_details_model.dart'; import 'package:marco/model/finance/payment_request_details_model.dart';
import 'package:marco/model/finance/advance_payment_model.dart'; import 'package:marco/model/finance/advance_payment_model.dart';
import 'package:marco/model/service_project/service_projects_list_model.dart';
import 'package:marco/model/service_project/service_projects_details_model.dart';
class ApiService { class ApiService {
static const bool enableLogs = true; static const bool enableLogs = true;
@ -302,6 +304,76 @@ class ApiService {
} }
} }
// Service Project Module APIs
/// Get details of a single service project
static Future<ServiceProjectDetailModel?> getServiceProjectDetailApi(String projectId) async {
final endpoint = "${ApiEndpoints.getServiceProjectDetail}/$projectId";
logSafe("Fetching details for Service Project ID: $projectId");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Service Project Detail request failed: null response", level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(
response,
label: "Service Project Detail",
);
if (jsonResponse != null) {
return ServiceProjectDetailModel.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getServiceProjectDetailApi: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Service Project List
static Future<ServiceProjectListModel?> getServiceProjectsListApi({
int pageNumber = 1,
int pageSize = 20,
}) async {
const endpoint = ApiEndpoints.getServiceProjectsList;
logSafe("Fetching Service Project List");
try {
final queryParams = {
'pageNumber': pageNumber.toString(),
'pageSize': pageSize.toString(),
};
final response = await _getRequest(endpoint, queryParams: queryParams);
if (response == null) {
logSafe("Service Project List request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(
response,
label: "Service Project List",
);
if (jsonResponse != null) {
return ServiceProjectListModel.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getServiceProjectsListApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Edit Expense Payment Request /// Edit Expense Payment Request
static Future<bool> editExpensePaymentRequestApi({ static Future<bool> editExpensePaymentRequestApi({
required String id, required String id,
@ -1707,8 +1779,8 @@ class ApiService {
String? reimbursedById, String? reimbursedById,
double? baseAmount, double? baseAmount,
double? taxAmount, double? taxAmount,
double? tdsPercent, double? tdsPercent,
double? netPayable, double? netPayable,
}) async { }) async {
final Map<String, dynamic> payload = { final Map<String, dynamic> payload = {
"expenseId": expenseId, "expenseId": expenseId,

View File

@ -0,0 +1,241 @@
class ServiceProjectDetailModel {
final bool success;
final String message;
final ProjectDetail? data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
ServiceProjectDetailModel({
required this.success,
required this.message,
this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory ServiceProjectDetailModel.fromJson(Map<String, dynamic> json) {
return ServiceProjectDetailModel(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null ? ProjectDetail.fromJson(json['data']) : null,
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.parse(json['timestamp'] ?? DateTime.now().toIso8601String()),
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
class ProjectDetail {
final String id;
final String name;
final String shortName;
final String address;
final DateTime assignedDate;
final Status? status;
final Client? client;
final List<Service>? services;
final int numberOfJobs;
final String contactName;
final String contactPhone;
final String contactEmail;
final DateTime createdAt;
final User? createdBy;
final DateTime updatedAt;
final User? updatedBy;
ProjectDetail({
required this.id,
required this.name,
required this.shortName,
required this.address,
required this.assignedDate,
this.status,
this.client,
this.services,
required this.numberOfJobs,
required this.contactName,
required this.contactPhone,
required this.contactEmail,
required this.createdAt,
this.createdBy,
required this.updatedAt,
this.updatedBy,
});
factory ProjectDetail.fromJson(Map<String, dynamic> json) {
return ProjectDetail(
id: json['id'] ?? '',
name: json['name'] ?? '',
shortName: json['shortName'] ?? '',
address: json['address'] ?? '',
assignedDate: DateTime.parse(json['assignedDate'] ?? DateTime.now().toIso8601String()),
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)))
: [],
numberOfJobs: json['numberOfJobs'] ?? 0,
contactName: json['contactName'] ?? '',
contactPhone: json['contactPhone'] ?? '',
contactEmail: json['contactEmail'] ?? '',
createdAt: DateTime.parse(json['createdAt'] ?? DateTime.now().toIso8601String()),
createdBy: json['createdBy'] != null ? User.fromJson(json['createdBy']) : null,
updatedAt: DateTime.parse(json['updatedAt'] ?? DateTime.now().toIso8601String()),
updatedBy: json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'shortName': shortName,
'address': address,
'assignedDate': assignedDate.toIso8601String(),
'status': status?.toJson(),
'client': client?.toJson(),
'services': services?.map((x) => x.toJson()).toList(),
'numberOfJobs': numberOfJobs,
'contactName': contactName,
'contactPhone': contactPhone,
'contactEmail': contactEmail,
'createdAt': createdAt.toIso8601String(),
'createdBy': createdBy?.toJson(),
'updatedAt': updatedAt.toIso8601String(),
'updatedBy': updatedBy?.toJson(),
};
}
class Status {
final String id;
final String status;
Status({required this.id, required this.status});
factory Status.fromJson(Map<String, dynamic> json) =>
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,
this.email,
this.contactPerson,
this.address,
this.contactNumber,
this.sprid,
});
factory Client.fromJson(Map<String, dynamic> json) => Client(
id: json['id'] ?? '',
name: json['name'] ?? '',
email: json['email'],
contactPerson: json['contactPerson'],
address: json['address'],
contactNumber: json['contactNumber'],
sprid: json['sprid'],
);
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,
this.description,
required this.isSystem,
required this.isActive,
});
factory Service.fromJson(Map<String, dynamic> json) => 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 User {
final String id;
final String firstName;
final String lastName;
final String email;
final String? photo;
final String? jobRoleId;
final String? jobRoleName;
User({
required this.id,
required this.firstName,
required this.lastName,
required this.email,
this.photo,
this.jobRoleId,
this.jobRoleName,
});
factory User.fromJson(Map<String, dynamic> json) => User(
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

@ -0,0 +1,127 @@
class ServiceProjectListModel {
final bool success;
final String message;
final ProjectData? data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
ServiceProjectListModel({
required this.success,
required this.message,
this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory ServiceProjectListModel.fromJson(Map<String, dynamic> json) {
return ServiceProjectListModel(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null ? ProjectData.fromJson(json['data']) : null,
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.parse(json['timestamp'] ?? DateTime.now().toIso8601String()),
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
class ProjectData {
final int currentPage;
final int totalPages;
final int totalEntities;
final List<ProjectItem>? data;
ProjectData({
required this.currentPage,
required this.totalPages,
required this.totalEntities,
this.data,
});
factory ProjectData.fromJson(Map<String, dynamic> json) {
return ProjectData(
currentPage: json['currentPage'] ?? 1,
totalPages: json['totalPages'] ?? 1,
totalEntities: json['totalEntites'] ?? 0,
data: json['data'] != null
? List<ProjectItem>.from(json['data'].map((x) => ProjectItem.fromJson(x)))
: [],
);
}
Map<String, dynamic> toJson() => {
'currentPage': currentPage,
'totalPages': totalPages,
'totalEntites': totalEntities,
'data': data?.map((x) => x.toJson()).toList(),
};
}
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;
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,
});
factory ProjectItem.fromJson(Map<String, dynamic> json) {
return 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),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'shortName': shortName,
'projectAddress': projectAddress,
'contactPerson': contactPerson,
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
'projectStatusId': projectStatusId,
'teamSize': teamSize,
'completedWork': completedWork,
'plannedWork': plannedWork,
};
}

View File

@ -24,7 +24,6 @@ import 'package:marco/view/tenant/tenant_selection_screen.dart';
import 'package:marco/view/finance/finance_screen.dart'; import 'package:marco/view/finance/finance_screen.dart';
import 'package:marco/view/finance/advance_payment_screen.dart'; import 'package:marco/view/finance/advance_payment_screen.dart';
import 'package:marco/view/finance/payment_request_screen.dart'; import 'package:marco/view/finance/payment_request_screen.dart';
import 'package:marco/view/service_project/service_project_details_screen.dart';
import 'package:marco/view/service_project/service_project_screen.dart'; import 'package:marco/view/service_project/service_project_screen.dart';
class AuthMiddleware extends GetMiddleware { class AuthMiddleware extends GetMiddleware {
@override @override
@ -136,11 +135,6 @@ getPageRoute() {
), ),
// Service Projects // Service Projects
GetPage(
name: '/dashboard/service-project-details',
page: () => ServiceProjectDetailsScreen(),
middlewares: [AuthMiddleware()],
),
GetPage( GetPage(
name: '/dashboard/service-projects', name: '/dashboard/service-projects',
page: () => ServiceProjectScreen(), page: () => ServiceProjectScreen(),

View File

@ -0,0 +1,405 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/controller/service_project/service_project_details_screen_controller.dart';
import 'package:marco/helpers/utils/launcher_utils.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class ServiceProjectDetailsScreen extends StatefulWidget {
final String projectId;
const ServiceProjectDetailsScreen({super.key, required this.projectId});
@override
State<ServiceProjectDetailsScreen> createState() =>
_ServiceProjectDetailsScreenState();
}
class _ServiceProjectDetailsScreenState
extends State<ServiceProjectDetailsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final ServiceProjectDetailsController controller =
Get.put(ServiceProjectDetailsController());
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
controller.setProjectId(widget.projectId);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
// ---------------- Helper Widgets ----------------
Widget _buildDetailRow({
required IconData icon,
required String label,
required String value,
VoidCallback? onTap,
VoidCallback? onLongPress,
bool isActionable = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: InkWell(
onTap: isActionable && value != 'NA' ? onTap : null,
onLongPress: isActionable && value != 'NA' ? onLongPress : null,
borderRadius: BorderRadius.circular(5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.redAccent.withOpacity(0.1),
borderRadius: BorderRadius.circular(5),
),
child: Icon(icon, size: 20, color: Colors.redAccent),
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
MySpacing.height(4),
Text(
value,
style: TextStyle(
fontSize: 15,
color: isActionable && value != 'NA'
? Colors.redAccent
: Colors.black87,
fontWeight: FontWeight.w500,
decoration: isActionable && value != 'NA'
? TextDecoration.underline
: TextDecoration.none,
),
),
],
),
),
if (isActionable && value != 'NA')
Icon(Icons.chevron_right, color: Colors.grey[400], size: 20),
],
),
),
);
}
Widget _buildSectionCard({
required String title,
required IconData titleIcon,
required List<Widget> children,
}) {
return Card(
elevation: 2,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(titleIcon, size: 20, color: Colors.redAccent),
MySpacing.width(8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87),
),
],
),
MySpacing.height(8),
const Divider(),
...children,
],
),
),
);
}
String _formatDate(DateTime? date) {
if (date == null) return 'NA';
try {
return DateFormat('d/M/yyyy').format(date);
} catch (_) {
return 'NA';
}
}
Widget _buildProfileTab() {
final project = controller.projectDetail.value;
if (project == null) return const Center(child: Text("No project data"));
return Padding(
padding: MySpacing.all(12),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Header
Card(
elevation: 2,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.work_outline,
size: 45, color: Colors.redAccent),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(project.name, fontWeight: 700),
MySpacing.height(6),
MyText.bodySmall(
project.client?.name ?? 'N/A', fontWeight: 500),
],
),
),
],
),
),
),
MySpacing.height(16),
// Project Information
_buildSectionCard(
title: 'Project Information',
titleIcon: Icons.info_outline,
children: [
_buildDetailRow(
icon: Icons.calendar_today_outlined,
label: 'Assigned Date',
value: _formatDate(project.assignedDate),
),
_buildDetailRow(
icon: Icons.location_on_outlined,
label: 'Address',
value: project.address,
),
_buildDetailRow(
icon: Icons.people_outline,
label: 'Contact Name',
value: project.contactName,
),
_buildDetailRow(
icon: Icons.phone_outlined,
label: 'Contact Phone',
value: project.contactPhone,
isActionable: true,
onTap: () =>
LauncherUtils.launchPhone(project.contactPhone),
onLongPress: () => LauncherUtils.copyToClipboard(
project.contactPhone,
typeLabel: 'Phone'),
),
_buildDetailRow(
icon: Icons.email_outlined,
label: 'Contact Email',
value: project.contactEmail,
isActionable: true,
onTap: () =>
LauncherUtils.launchEmail(project.contactEmail),
onLongPress: () => LauncherUtils.copyToClipboard(
project.contactEmail,
typeLabel: 'Email'),
),
],
),
MySpacing.height(12),
// Status
if (project.status != null)
_buildSectionCard(
title: 'Status',
titleIcon: Icons.flag_outlined,
children: [
_buildDetailRow(
icon: Icons.info_outline,
label: 'Status',
value: project.status!.status,
),
],
),
// Services
if (project.services != null && project.services!.isNotEmpty)
_buildSectionCard(
title: 'Services',
titleIcon: Icons.miscellaneous_services_outlined,
children: project.services!.map((service) {
return _buildDetailRow(
icon: Icons.build_outlined,
label: service.name,
value: service.description ?? '-',
);
}).toList(),
),
MySpacing.height(12),
// Client Section
if (project.client != null)
_buildSectionCard(
title: 'Client Information',
titleIcon: Icons.business_outlined,
children: [
_buildDetailRow(
icon: Icons.person_outline,
label: 'Client Name',
value: project.client!.name,
),
_buildDetailRow(
icon: Icons.phone_outlined,
label: 'Client Phone',
value: project.client!.contactNumber ?? 'NA',
isActionable: true,
onTap: () => LauncherUtils.launchPhone(
project.client!.contactNumber ?? ''),
onLongPress: () => LauncherUtils.copyToClipboard(
project.client!.contactNumber ?? '',
typeLabel: 'Phone'),
),
],
),
MySpacing.height(40),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: 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.toNamed('/dashboard/service-projects'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Service Projects',
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: Column(
children: [
// ---------------- TabBar ----------------
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red,
indicatorWeight: 3,
isScrollable: false,
tabs: const [
Tab(text: "Profile"),
Tab(text: "Jobs"),
],
),
),
// ---------------- TabBarView ----------------
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.errorMessage.value.isNotEmpty) {
return Center(child: Text(controller.errorMessage.value));
}
return TabBarView(
controller: _tabController,
children: [
// Profile Tab
_buildProfileTab(),
// Jobs Tab - empty
Container(color: Colors.white),
],
);
}),
),
],
),
);
}
}

View File

@ -0,0 +1,364 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/controller/service_project/service_project_screen_controller.dart';
import 'package:marco/model/service_project/service_projects_list_model.dart';
import 'package:marco/helpers/utils/date_time_utils.dart ';
import 'package:marco/view/service_project/service_project_details_screen.dart';
class ServiceProjectScreen extends StatefulWidget {
const ServiceProjectScreen({super.key});
@override
State<ServiceProjectScreen> createState() => _ServiceProjectScreenState();
}
class _ServiceProjectScreenState extends State<ServiceProjectScreen>
with UIMixin {
final TextEditingController searchController = TextEditingController();
final ServiceProjectController controller =
Get.put(ServiceProjectController());
@override
void initState() {
super.initState();
controller.fetchProjects();
searchController.addListener(() {
controller.updateSearch(searchController.text);
});
}
Future<void> _refreshProjects() async {
await controller.fetchProjects();
}
Widget _buildProjectCard(ProjectItem project) {
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
shadowColor: Colors.indigo.withOpacity(0.10),
color: Colors.white,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () {
// Navigate to ServiceProjectDetailsScreen
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
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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,
),
),
],
),
],
),
),
],
),
MySpacing.height(12),
_buildDetailRow(
Icons.date_range_outlined,
Colors.teal,
"${DateTimeUtils.convertUtcToLocal(project.startDate.toIso8601String(), format: DateTimeUtils.defaultFormat)} To "
"${DateTimeUtils.convertUtcToLocal(project.endDate.toIso8601String(), format: DateTimeUtils.defaultFormat)}",
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]),
],
),
],
),
),
),
);
}
// Helper to build colored tags
Widget _buildTag(String label) {
return Container(
decoration: BoxDecoration(
color: Colors.indigo.withOpacity(0.08),
borderRadius: BorderRadius.circular(6),
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child:
MyText.labelSmall(label, color: Colors.indigo[700], fontWeight: 500),
);
}
// Helper for detail row with icon and text
Widget _buildDetailRow(IconData icon, Color iconColor, String value,
{double fontSize = 12}) {
return Row(
children: [
Icon(icon, size: 19, color: iconColor),
MySpacing.width(8),
Flexible(
child: MyText.bodySmall(
value,
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(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.work_outline, size: 60, color: Colors.grey),
MySpacing.height(18),
MyText.titleMedium('No matching projects found.',
fontWeight: 600, color: Colors.grey),
MySpacing.height(10),
MyText.bodySmall('Try adjusting your filters or refresh.',
color: Colors.grey),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
/// APPBAR
appBar: 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),
MyText.titleLarge(
'Service Projects',
fontWeight: 700,
color: Colors.black,
),
],
),
),
),
),
body: Column(
children: [
/// SEARCH + FILTER BAR
Padding(
padding: MySpacing.xy(8, 8),
child: Row(
children: [
Expanded(
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),
),
),
),
),
),
MySpacing.width(8),
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5),
),
child: IconButton(
icon:
const Icon(Icons.tune, size: 20, color: Colors.black87),
onPressed: () {
// TODO: Open filter bottom sheet
},
),
),
MySpacing.width(10),
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5),
),
child: PopupMenuButton<int>(
padding: EdgeInsets.zero,
icon: const Icon(Icons.more_vert,
size: 20, color: Colors.black87),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
itemBuilder: (context) => [
const PopupMenuItem<int>(
enabled: false,
height: 30,
child: Text("Actions",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey)),
),
const PopupMenuItem<int>(
value: 1,
child: Row(
children: [
SizedBox(width: 10),
Expanded(child: Text("Manage Projects")),
Icon(Icons.chevron_right,
size: 20, color: Colors.indigo),
],
),
),
],
),
),
],
),
),
/// PROJECT LIST
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: 80),
itemCount: projects.length,
separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) =>
_buildProjectCard(projects[index]),
),
);
}),
),
],
),
);
}
}

View File

@ -227,8 +227,8 @@ 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) > 0 || (building.plannedWork ) > 0 ||
(building.completedWork ?? 0) > 0) (building.completedWork ) > 0)
.toList(); .toList();
if (buildings.isEmpty) { if (buildings.isEmpty) {