diff --git a/lib/controller/service_project/service_project_details_screen_controller.dart b/lib/controller/service_project/service_project_details_screen_controller.dart index 2761723..6f70896 100644 --- a/lib/controller/service_project/service_project_details_screen_controller.dart +++ b/lib/controller/service_project/service_project_details_screen_controller.dart @@ -2,6 +2,7 @@ import 'package:get/get.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/model/service_project/service_projects_details_model.dart'; import 'package:marco/model/service_project/job_list_model.dart'; +import 'package:marco/model/service_project/service_project_job_detail_model.dart'; class ServiceProjectDetailsController extends GetxController { // Selected project id @@ -13,13 +14,18 @@ class ServiceProjectDetailsController extends GetxController { // Job list var jobList = [].obs; + // Job detail for a selected job + var jobDetail = Rxn(); + // Loading states var isLoading = false.obs; var isJobLoading = false.obs; + var isJobDetailLoading = false.obs; // Error messages var errorMessage = ''.obs; var jobErrorMessage = ''.obs; + var jobDetailErrorMessage = ''.obs; // Pagination var pageNumber = 1; @@ -53,14 +59,12 @@ class ServiceProjectDetailsController extends GetxController { errorMessage.value = ''; try { - final result = - await ApiService.getServiceProjectDetailApi(projectId.value); + 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"; + errorMessage.value = result?.message ?? "Failed to fetch project details"; } } catch (e) { errorMessage.value = "Error: $e"; @@ -83,7 +87,7 @@ class ServiceProjectDetailsController extends GetxController { try { final result = await ApiService.getServiceProjectJobListApi( - projectId: "", + projectId: projectId.value, pageNumber: pageNumber, pageSize: pageSize, isActive: true, @@ -122,4 +126,28 @@ class ServiceProjectDetailsController extends GetxController { fetchProjectJobs(initialLoad: true), ]); } + + /// Fetch job details by job ID + Future fetchJobDetail(String jobId) async { + if (jobId.isEmpty) { + jobDetailErrorMessage.value = "Invalid job ID"; + return; + } + + isJobDetailLoading.value = true; + jobDetailErrorMessage.value = ''; + + try { + final result = await ApiService.getServiceProjectJobDetailApi(jobId); + if (result != null) { + jobDetail.value = result; + } else { + jobDetailErrorMessage.value = "Failed to fetch job details"; + } + } catch (e) { + jobDetailErrorMessage.value = "Error fetching job details: $e"; + } finally { + isJobDetailLoading.value = false; + } + } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 35e7831..d4fae11 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,8 +1,8 @@ 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://devapi.marcoaiot.com/api"; - static const String baseUrl = "https://mapi.marcoaiot.com/api"; + // static const String baseUrl = "https://mapi.marcoaiot.com/api"; static const String getMasterCurrencies = "/Master/currencies/list"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index a5e2219..923baa0 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -36,6 +36,7 @@ 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'; import 'package:marco/model/service_project/job_list_model.dart'; +import 'package:marco/model/service_project/service_project_job_detail_model.dart'; class ApiService { static const bool enableLogs = true; @@ -307,6 +308,35 @@ class ApiService { // Service Project Module APIs + +/// Get details for a single Service Project Job +static Future getServiceProjectJobDetailApi(String jobId) async { + final endpoint = "${ApiEndpoints.getServiceProjectJobDetail}/$jobId"; + logSafe("Fetching Job Detail for Job ID: $jobId"); + + try { + final response = await _getRequest(endpoint); + if (response == null) { + logSafe("Service Project Job Detail request failed: null response", level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData( + response, + label: "Service Project Job Detail", + ); + + if (jsonResponse != null) { + return JobDetailsResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getServiceProjectJobDetailApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; +} + /// Create a new Service Project Job static Future createServiceProjectJobApi({ required String title, diff --git a/lib/helpers/widgets/avatar.dart b/lib/helpers/widgets/avatar.dart index fdb32de..c206631 100644 --- a/lib/helpers/widgets/avatar.dart +++ b/lib/helpers/widgets/avatar.dart @@ -5,14 +5,16 @@ import 'package:marco/helpers/widgets/my_text.dart'; class Avatar extends StatelessWidget { final String firstName; final String lastName; + final String? imageUrl; final double size; - final Color? backgroundColor; + final Color? backgroundColor; final Color textColor; const Avatar({ super.key, required this.firstName, required this.lastName, + this.imageUrl, this.size = 46.0, this.backgroundColor, this.textColor = Colors.white, @@ -20,9 +22,24 @@ class Avatar extends StatelessWidget { @override Widget build(BuildContext context) { - String initials = "${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}".toUpperCase(); + if (imageUrl != null && imageUrl!.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(size / 2), + child: Image.network( + imageUrl!, + width: size, + height: size, + fit: BoxFit.cover, + ), + ); + } - final Color bgColor = backgroundColor ?? _getFlatColorFromName('$firstName$lastName'); + String initials = + "${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}" + .toUpperCase(); + + final Color bgColor = + backgroundColor ?? _getFlatColorFromName('$firstName$lastName'); return MyContainer.rounded( height: size, @@ -32,36 +49,36 @@ class Avatar extends StatelessWidget { child: Center( child: MyText( initials, - fontSize: size * 0.45, // 👈 scales with avatar size + fontSize: size * 0.45, fontWeight: 600, color: textColor, ), ), ); } - - // Use fixed flat color palette and pick based on hash - Color _getFlatColorFromName(String name) { - final colors = [ - Color(0xFFE57373), // Red - Color(0xFFF06292), // Pink - Color(0xFFBA68C8), // Purple - Color(0xFF9575CD), // Deep Purple - Color(0xFF7986CB), // Indigo - Color(0xFF64B5F6), // Blue - Color(0xFF4FC3F7), // Light Blue - Color(0xFF4DD0E1), // Cyan - Color(0xFF4DB6AC), // Teal - Color(0xFF81C784), // Green - Color(0xFFAED581), // Light Green - Color(0xFFDCE775), // Lime - Color(0xFFFFD54F), // Amber - Color(0xFFFFB74D), // Orange - Color(0xFFA1887F), // Brown - Color(0xFF90A4AE), // Blue Grey - ]; - - int index = name.hashCode.abs() % colors.length; - return colors[index]; - } +} + +// Use fixed flat color palette and pick based on hash +Color _getFlatColorFromName(String name) { + final colors = [ + Color(0xFFE57373), // Red + Color(0xFFF06292), // Pink + Color(0xFFBA68C8), // Purple + Color(0xFF9575CD), // Deep Purple + Color(0xFF7986CB), // Indigo + Color(0xFF64B5F6), // Blue + Color(0xFF4FC3F7), // Light Blue + Color(0xFF4DD0E1), // Cyan + Color(0xFF4DB6AC), // Teal + Color(0xFF81C784), // Green + Color(0xFFAED581), // Light Green + Color(0xFFDCE775), // Lime + Color(0xFFFFD54F), // Amber + Color(0xFFFFB74D), // Orange + Color(0xFFA1887F), // Brown + Color(0xFF90A4AE), // Blue Grey + ]; + + int index = name.hashCode.abs() % colors.length; + return colors[index]; } diff --git a/lib/helpers/widgets/custom_app_bar.dart b/lib/helpers/widgets/custom_app_bar.dart index fdd6d5c..0b39ad0 100644 --- a/lib/helpers/widgets/custom_app_bar.dart +++ b/lib/helpers/widgets/custom_app_bar.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { final String title; @@ -17,67 +18,67 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { return PreferredSize( preferredSize: const Size.fromHeight(72), - child: Container( - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.15), - blurRadius: 0.5, - offset: const Offset(0, 0.5), - ) - ], - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: onBackPressed ?? Get.back, - splashRadius: 24, + 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, ), - const SizedBox(width: 8), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleLarge( - title, - fontWeight: 700, - color: Colors.black, - ), - const SizedBox(height: 2), - GetBuilder( - builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return Row( - children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), - const SizedBox(width: 4), - Expanded( - child: MyText.bodySmall( - projectName, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ), + 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 (copied exactly) + GetBuilder( + 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], ), - ], - ); - }, - ), - ], - ), + ), + ], + ); + }, + ), + ], ), - ], - ), + ), + ], ), ), ), diff --git a/lib/model/service_project/service_project_job_detail_model.dart b/lib/model/service_project/service_project_job_detail_model.dart new file mode 100644 index 0000000..a74f309 --- /dev/null +++ b/lib/model/service_project/service_project_job_detail_model.dart @@ -0,0 +1,244 @@ +class JobDetailsResponse { + final bool success; + final String message; + final JobData? data; + final dynamic errors; + final int statusCode; + final String timestamp; + + JobDetailsResponse({ + required this.success, + required this.message, + this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory JobDetailsResponse.fromJson(Map json) { + return JobDetailsResponse( + success: json['success'] as bool, + message: json['message'] as String, + data: json['data'] != null ? JobData.fromJson(json['data']) : null, + errors: json['errors'], + statusCode: json['statusCode'] as int, + timestamp: json['timestamp'] as String, + ); + } +} + +class JobData { + final String id; + final String title; + final String description; + final Project project; + final List assignees; + final Status status; + final String startDate; + final String dueDate; + final bool isActive; + final String createdAt; + final User createdBy; + final List tags; + final List updateLogs; + + JobData({ + required this.id, + required this.title, + required this.description, + required this.project, + required this.assignees, + required this.status, + required this.startDate, + required this.dueDate, + required this.isActive, + required this.createdAt, + required this.createdBy, + required this.tags, + required this.updateLogs, + }); + + factory JobData.fromJson(Map json) { + return JobData( + id: json['id'] as String, + title: json['title'] as String, + description: json['description'] as String, + project: Project.fromJson(json['project']), + assignees: (json['assignees'] as List) + .map((e) => Assignee.fromJson(e)) + .toList(), + status: Status.fromJson(json['status']), + startDate: json['startDate'] as String, + dueDate: json['dueDate'] as String, + isActive: json['isActive'] as bool, + createdAt: json['createdAt'] as String, + createdBy: User.fromJson(json['createdBy']), + tags: (json['tags'] as List).map((e) => Tag.fromJson(e)).toList(), + updateLogs: (json['updateLogs'] as List) + .map((e) => UpdateLog.fromJson(e)) + .toList(), + ); + } +} + +class Project { + final String id; + final String name; + final String shortName; + final String assignedDate; + final String contactName; + final String contactPhone; + final String contactEmail; + + Project({ + required this.id, + required this.name, + required this.shortName, + required this.assignedDate, + required this.contactName, + required this.contactPhone, + required this.contactEmail, + }); + + factory Project.fromJson(Map json) { + return Project( + id: json['id'] as String, + name: json['name'] as String, + shortName: json['shortName'] as String, + assignedDate: json['assignedDate'] as String, + contactName: json['contactName'] as String, + contactPhone: json['contactPhone'] as String, + contactEmail: json['contactEmail'] as String, + ); + } +} + +class Assignee { + final String id; + final String firstName; + final String lastName; + final String email; + final String photo; + final String jobRoleId; + final String jobRoleName; + + Assignee({ + required this.id, + required this.firstName, + required this.lastName, + required this.email, + required this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory Assignee.fromJson(Map json) { + return Assignee( + id: json['id'] as String, + firstName: json['firstName'] as String, + lastName: json['lastName'] as String, + email: json['email'] as String, + photo: json['photo'] as String, + jobRoleId: json['jobRoleId'] as String, + jobRoleName: json['jobRoleName'] as String, + ); + } +} + +class Status { + final String id; + final String name; + final String displayName; + final int level; + + Status({ + required this.id, + required this.name, + required this.displayName, + required this.level, + }); + + factory Status.fromJson(Map json) { + return Status( + id: json['id'] as String, + name: json['name'] as String, + displayName: json['displayName'] as String, + level: json['level'] as int, + ); + } +} + +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, + required this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory User.fromJson(Map json) { + return User( + id: json['id'] as String, + firstName: json['firstName'] as String, + lastName: json['lastName'] as String, + email: json['email'] as String, + photo: json['photo'] as String, + jobRoleId: json['jobRoleId'] as String, + jobRoleName: json['jobRoleName'] as String, + ); + } +} + +class Tag { + final String id; + final String name; + + Tag({ + required this.id, + required this.name, + }); + + factory Tag.fromJson(Map json) { + return Tag( + id: json['id'] as String, + name: json['name'] as String, + ); + } +} + +class UpdateLog { + final String id; + final Status? status; + final Status nextStatus; + final String comment; + final User updatedBy; + + UpdateLog({ + required this.id, + this.status, + required this.nextStatus, + required this.comment, + required this.updatedBy, + }); + + factory UpdateLog.fromJson(Map json) { + return UpdateLog( + id: json['id'] as String, + status: json['status'] != null ? Status.fromJson(json['status']) : null, + nextStatus: Status.fromJson(json['nextStatus']), + comment: json['comment'] as String, + updatedBy: User.fromJson(json['updatedBy']), + ); + } +} diff --git a/lib/view/service_project/service_project_details_screen.dart b/lib/view/service_project/service_project_details_screen.dart index ad39ef7..6e1dc36 100644 --- a/lib/view/service_project/service_project_details_screen.dart +++ b/lib/view/service_project/service_project_details_screen.dart @@ -1,7 +1,6 @@ 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'; @@ -9,6 +8,9 @@ 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'; class ServiceProjectDetailsScreen extends StatefulWidget { final String projectId; @@ -345,115 +347,118 @@ class _ServiceProjectDetailsScreenState } final job = controller.jobList[index]; - return 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: [ - // Job Title - MyText.titleMedium(job.title, fontWeight: 700), - MySpacing.height(6), - - // Job Description - 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(), - ), + 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], ), - 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: CircleAvatar( - radius: 12, - backgroundImage: assignee.photo.isNotEmpty - ? NetworkImage(assignee.photo) - : null, - child: assignee.photo.isEmpty - ? Text(assignee.firstName[0]) - : 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), + // 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(), + ), ), - const Spacer(), + MySpacing.height(8), - // 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), + // 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), ), - child: Text( - job.status.displayName, - style: TextStyle( - fontSize: 12, + + const Spacer(), + + // Status Chip + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( color: job.status.name.toLowerCase() == 'completed' - ? Colors.green[800] - : Colors.orange[800], - fontWeight: FontWeight.w600, + ? 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, + ), ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ); @@ -466,64 +471,9 @@ class _ServiceProjectDetailsScreenState 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 Project Details', - fontWeight: 700, - color: Colors.black, - ), - MySpacing.height(2), - GetBuilder( - 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], - ), - ), - ], - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), + appBar: CustomAppBar( + title: "Service Project Details", + onBackPressed: () => Get.toNamed('/dashboard/service-projects'), ), body: SafeArea( child: Column( diff --git a/lib/view/service_project/service_project_job_detail_screen.dart b/lib/view/service_project/service_project_job_detail_screen.dart new file mode 100644 index 0000000..09c2990 --- /dev/null +++ b/lib/view/service_project/service_project_job_detail_screen.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/service_project/service_project_details_screen_controller.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/utils/date_time_utils.dart'; +import 'package:marco/helpers/utils/launcher_utils.dart'; +import 'package:timeline_tile/timeline_tile.dart'; +import 'package:marco/model/service_project/service_project_job_detail_model.dart'; +import 'package:marco/helpers/widgets/custom_app_bar.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; + +class JobDetailsScreen extends StatefulWidget { + final String jobId; + + const JobDetailsScreen({super.key, required this.jobId}); + + @override + State createState() => _JobDetailsScreenState(); +} + +class _JobDetailsScreenState extends State with UIMixin { + late final ServiceProjectDetailsController controller; + + @override + void initState() { + super.initState(); + controller = Get.put(ServiceProjectDetailsController()); + controller.fetchJobDetail(widget.jobId); + } + + Widget _buildSectionCard({ + required String title, + required IconData titleIcon, + required List 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, + fontWeight: 700, + fontSize: 16, + ) + ], + ), + MySpacing.height(8), + const Divider(), + ...children + ], + ), + ), + ); + } + + Widget _rowTile(String label, String value, {bool copyable = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: MyText.bodySmall(label, + color: Colors.grey[600], fontWeight: 600), + ), + Expanded( + flex: 5, + child: GestureDetector( + onLongPress: copyable + ? () => LauncherUtils.copyToClipboard(value, typeLabel: label) + : null, + child: MyText.bodyMedium(value, + fontWeight: 600, + color: copyable ? Colors.blue : Colors.black87), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: CustomAppBar( + title: "Service Project Job Details", + onBackPressed: () => Get.back(), + ), + body: 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: [ + // ====== 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.task_outlined, size: 35), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium(job.title, fontWeight: 700), + MySpacing.height(5), + MyText.bodySmall(job.project.name, + color: Colors.grey[700]), + ], + ), + ) + ], + ), + ), + ), + + MySpacing.height(20), + + // ====== Job Information ======= + _buildSectionCard( + title: "Job Information", + titleIcon: Icons.info_outline, + children: [ + _rowTile("Description", job.description), + _rowTile( + "Start Date", + DateTimeUtils.convertUtcToLocal(job.startDate, + format: "dd MMM yyyy"), + ), + _rowTile( + "Due Date", + DateTimeUtils.convertUtcToLocal(job.dueDate, + format: "dd MMM yyyy"), + ), + _rowTile("Status", job.status.displayName), + ], + ), + + MySpacing.height(16), + + // ====== Assignees ======= + _buildSectionCard( + title: "Assigned To", + titleIcon: Icons.people_outline, + children: job.assignees.isNotEmpty + ? job.assignees.map((a) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Avatar( + firstName: a.firstName, + lastName: a.lastName, + size: + 32, + backgroundColor: + a.photo.isEmpty ? null : Colors.transparent, + textColor: Colors.white, + ), + MySpacing.width(10), + MyText.bodyMedium("${a.firstName} ${a.lastName}"), + ], + ), + ); + }).toList() + : [MyText.bodySmall("No assignees", color: Colors.grey)], + ), + + MySpacing.height(16), + + // ====== Tags ======= + if (job.tags.isNotEmpty) + _buildSectionCard( + title: "Tags", + titleIcon: Icons.label_outline, + children: [ + Wrap( + spacing: 6, + runSpacing: 6, + children: job.tags.map((tag) { + return Chip( + label: Text(tag.name), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + ); + }).toList(), + ) + ], + ), + + MySpacing.height(16), + + // ====== Update Logs (Timeline UI) ======= + if (job.updateLogs.isNotEmpty) + _buildSectionCard( + title: "Update Logs", + titleIcon: Icons.history, + children: [ + JobTimeline(logs: job.updateLogs), + ], + ), + + MySpacing.height(40), + ], + ), + ); + }), + ); + } +} + +class JobTimeline extends StatelessWidget { + final List logs; + + const JobTimeline({super.key, required this.logs}); + + @override + Widget build(BuildContext context) { + if (logs.isEmpty) { + return MyText.bodyMedium('No timeline available', color: Colors.grey); + } + + // Show latest updates at the top + final reversedLogs = logs.reversed.toList(); + + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: reversedLogs.length, + itemBuilder: (_, index) { + final log = reversedLogs[index]; + + final statusName = log.status?.displayName ?? "Created"; + final nextStatusName = log.nextStatus.displayName; + final comment = log.comment; + + final updatedBy = + "${log.updatedBy.firstName} ${log.updatedBy.lastName}"; + + final initials = + "${log.updatedBy.firstName.isNotEmpty ? log.updatedBy.firstName[0] : ''}" + "${log.updatedBy.lastName.isNotEmpty ? log.updatedBy.lastName[0] : ''}"; + + return TimelineTile( + alignment: TimelineAlign.start, + isFirst: index == 0, + isLast: index == reversedLogs.length - 1, + indicatorStyle: IndicatorStyle( + width: 16, + height: 16, + indicator: Container( + decoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + ), + ), + beforeLineStyle: LineStyle( + color: Colors.grey.shade300, + thickness: 2, + ), + endChild: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // STATUS CHANGE ROW + MyText.bodyMedium( + "$statusName → $nextStatusName", + fontWeight: 600, + ), + const SizedBox(height: 8), + + // COMMENT + if (comment.isNotEmpty) MyText.bodyMedium(comment), + + const SizedBox(height: 10), + + // Updated by + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + child: MyText.bodySmall(initials, fontWeight: 600), + ), + const SizedBox(width: 6), + Expanded( + child: MyText.bodySmall(updatedBy), + ), + ], + ), + + const SizedBox(height: 10), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/view/service_project/service_project_screen.dart b/lib/view/service_project/service_project_screen.dart index 8831369..50bdee7 100644 --- a/lib/view/service_project/service_project_screen.dart +++ b/lib/view/service_project/service_project_screen.dart @@ -8,7 +8,7 @@ import 'package:marco/controller/service_project/service_project_screen_controll 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'; -import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/custom_app_bar.dart'; class ServiceProjectScreen extends StatefulWidget { const ServiceProjectScreen({super.key}); @@ -194,64 +194,9 @@ class _ServiceProjectScreenState extends State 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'), - ), - 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( - 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], - ), - ), - ], - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), + appBar: CustomAppBar( + title: "Service Projects", + onBackPressed: () => Get.toNamed('/dashboard'), ), body: Column( children: [