marco.pms.mobileapp/lib/view/service_project/service_project_details_screen.dart
2025-11-18 17:50:47 +05:30

691 lines
24 KiB
Dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.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/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/service_project/add_service_project_job_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/view/service_project/service_project_job_detail_screen.dart';
import 'package:marco/helpers/widgets/custom_app_bar.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/model/service_project/service_project_allocation_bottomsheet.dart';
import 'package:marco/model/employees/employee_model.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, UIMixin {
late final TabController _tabController;
late final ServiceProjectDetailsController controller;
final ScrollController _jobScrollController = ScrollController();
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
controller = Get.put(ServiceProjectDetailsController());
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.setProjectId(widget.projectId);
});
_tabController.addListener(() {
if (!_tabController.indexIsChanging) {
setState(() {});
if (_tabController.index == 1 && controller.jobList.isEmpty) {
controller.fetchProjectJobs();
} else if (_tabController.index == 2 && controller.teamList.isEmpty) {
controller.fetchProjectTeams();
}
}
});
_jobScrollController.addListener(() {
if (_tabController.index == 1 &&
_jobScrollController.position.pixels >=
_jobScrollController.position.maxScrollExtent - 100) {
controller.fetchMoreJobs();
}
});
}
@override
void dispose() {
_tabController.dispose();
_jobScrollController.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),
child: Icon(
icon,
size: 20,
),
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(
label,
fontSize: 12,
color: Colors.grey[600],
fontWeight: 500,
),
MySpacing.height(4),
MyText.bodyMedium(
value,
fontSize: 15,
color: isActionable && value != 'NA'
? Colors.blueAccent
: Colors.black87,
fontWeight: 500,
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),
MySpacing.width(8),
MyText.bodyLarge(
title,
fontSize: 16,
fontWeight: 700,
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 Center(child: MyText.bodyMedium("No project data"));
}
return Padding(
padding: MySpacing.all(12),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Header Card
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: 35),
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),
],
),
),
);
}
Widget _buildJobsTab() {
return Obx(() {
if (controller.isJobLoading.value && controller.jobList.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (controller.jobErrorMessage.value.isNotEmpty &&
controller.jobList.isEmpty) {
return Center(
child: MyText.bodyMedium(controller.jobErrorMessage.value));
}
if (controller.jobList.isEmpty) {
return Center(child: MyText.bodyMedium("No jobs found"));
}
return ListView.separated(
controller: _jobScrollController,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
itemCount: controller.jobList.length + 1,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
if (index == controller.jobList.length) {
return controller.hasMoreJobs.value
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
)
: const SizedBox.shrink();
}
final job = controller.jobList[index];
return InkWell(
onTap: () {
Get.to(() => JobDetailsScreen(jobId: job.id));
},
child: Card(
elevation: 3,
shadowColor: Colors.black26,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(job.title, fontWeight: 700),
MySpacing.height(6),
MyText.bodySmall(
job.description.isNotEmpty
? job.description
: "No description provided",
color: Colors.grey[700],
),
// Tags
if (job.tags != null && job.tags!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Wrap(
spacing: 2,
runSpacing: 4,
children: job.tags!.map((tag) {
return Chip(
label: Text(
tag.name,
style: const TextStyle(fontSize: 12),
),
backgroundColor: Colors.grey[200],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
);
}).toList(),
),
),
MySpacing.height(8),
// Assignees & Status
Row(
children: [
if (job.assignees != null && job.assignees!.isNotEmpty)
...job.assignees!.map((assignee) {
return Padding(
padding: const EdgeInsets.only(right: 6),
child: Avatar(
firstName: assignee.firstName,
lastName: assignee.lastName,
size: 24,
imageUrl: assignee.photo.isNotEmpty
? assignee.photo
: null,
),
);
}).toList(),
],
),
MySpacing.height(8),
// Date Row with Status Chip
Row(
children: [
// Dates (same as existing)
const Icon(Icons.calendar_today_outlined,
size: 14, color: Colors.grey),
MySpacing.width(4),
Text(
"${DateTimeUtils.convertUtcToLocal(job.startDate, format: 'dd MMM yyyy')} to "
"${DateTimeUtils.convertUtcToLocal(job.dueDate, format: 'dd MMM yyyy')}",
style:
const TextStyle(fontSize: 12, color: Colors.grey),
),
const Spacer(),
// Status Chip
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: job.status.name.toLowerCase() == 'completed'
? Colors.green[100]
: Colors.orange[100],
borderRadius: BorderRadius.circular(5),
),
child: Text(
job.status.displayName,
style: TextStyle(
fontSize: 12,
color:
job.status.name.toLowerCase() == 'completed'
? Colors.green[800]
: Colors.orange[800],
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
),
);
},
);
});
}
Widget _buildTeamsTab() {
return Obx(() {
if (controller.isTeamLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.teamErrorMessage.value.isNotEmpty &&
controller.teamList.isEmpty) {
return Center(child: MyText.bodyMedium(controller.teamErrorMessage.value));
}
if (controller.teamList.isEmpty) {
return Center(child: MyText.bodyMedium("No team members found"));
}
// Group team members by their role ID
final Map<String, List> roleGroups = {};
for (var team in controller.teamList) {
roleGroups.putIfAbsent(team.teamRole.id, () => []).add(team);
}
return ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: roleGroups.keys.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final roleId = roleGroups.keys.elementAt(index);
final teamMembers = roleGroups[roleId]!;
final roleName = teamMembers.first.teamRole.name;
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 3,
shadowColor: Colors.black26,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Role header
MyText.bodyLarge(
roleName,
fontWeight: 700,
color: Colors.black87,
),
const Divider(height: 20, thickness: 1),
// List of team members inside this role card
...teamMembers.map((team) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Avatar(
firstName: team.employee.firstName,
lastName: team.employee.lastName,
size: 32,
imageUrl: (team.employee.photo?.isNotEmpty ?? false)
? team.employee.photo
: null,
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
"${team.employee.firstName} ${team.employee.lastName}",
fontWeight: 600,
),
MyText.bodySmall(
"Status: ${team.isActive ? 'Active' : 'Inactive'}",
color: Colors.grey[700],
),
],
),
),
],
),
);
}).toList(),
],
),
),
);
},
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar(
title: "Service Project Details",
onBackPressed: () => Get.toNamed('/dashboard/service-projects'),
),
body: SafeArea(
child: 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: [
Tab(child: MyText.bodyMedium("Profile")),
Tab(child: MyText.bodyMedium("Jobs")),
Tab(child: MyText.bodyMedium("Teams")),
],
),
),
// TabBarView
Expanded(
child: Obx(() {
if (controller.isLoading.value &&
controller.projectDetail.value == null) {
return const Center(child: CircularProgressIndicator());
}
if (controller.errorMessage.value.isNotEmpty &&
controller.projectDetail.value == null) {
return Center(
child: MyText.bodyMedium(controller.errorMessage.value));
}
return TabBarView(
controller: _tabController,
children: [
_buildProfileTab(),
_buildJobsTab(),
_buildTeamsTab(),
],
);
}),
),
],
),
),
floatingActionButton: _tabController.index == 1
? FloatingActionButton.extended(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => AddServiceProjectJobBottomSheet(
projectId: widget.projectId,
),
);
},
backgroundColor: contentTheme.primary,
icon: const Icon(Icons.add),
label: MyText.bodyMedium("Add Job", color: Colors.white),
)
: _tabController.index == 2
? FloatingActionButton.extended(
onPressed: () async {
// Prepare existing allocations grouped by role
Map<String, List<EmployeeModel>> allocationsMap = {};
for (var team in controller.teamList) {
if (!allocationsMap.containsKey(team.teamRole.id)) {
allocationsMap[team.teamRole.id] = [];
}
allocationsMap[team.teamRole.id]!.add(EmployeeModel(
id: team.employee.id,
jobRoleID: team.teamRole.id,
employeeId: team.employee.id,
name:
"${team.employee.firstName} ${team.employee.lastName}",
designation: team.teamRole.name,
firstName: team.employee.firstName,
lastName: team.employee.lastName,
activity: 0,
action: 0,
jobRole: team.teamRole.name,
email: team.employee.email ?? '',
phoneNumber: '',
));
}
final existingAllocations =
allocationsMap.entries.map((entry) {
final role = controller.teamList
.firstWhere((team) => team.teamRole.id == entry.key)
.teamRole;
return RoleEmployeeAllocation(
role: role, employees: entry.value);
}).toList();
final result = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => SimpleProjectAllocationBottomSheet(
projectId: widget.projectId,
existingAllocations: existingAllocations,
),
);
if (result == true) {
controller.fetchProjectTeams();
}
},
backgroundColor: contentTheme.primary,
icon: const Icon(Icons.group_add),
label: MyText.bodyMedium("Manage Team", color: Colors.white),
)
: null,
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
);
}
}