feat: implement job search functionality and archived job toggle in JobsTab; refactor ServiceProjectDetailsScreen to integrate JobsTab

This commit is contained in:
Vaibhav Surve 2025-11-21 16:33:09 +05:30
parent cf7021a982
commit fc78806af2
8 changed files with 735 additions and 378 deletions

View File

@ -17,6 +17,7 @@ class ServiceProjectDetailsController extends GetxController {
var projectDetail = Rxn<ProjectDetail>();
var jobList = <JobEntity>[].obs;
var jobDetail = Rxn<JobDetailsResponse>();
var showArchivedJobs = false.obs; // true = archived, false = active
// Loading states
var isLoading = false.obs;
@ -39,21 +40,47 @@ class ServiceProjectDetailsController extends GetxController {
var teamList = <ServiceProjectAllocation>[].obs;
var isTeamLoading = false.obs;
var teamErrorMessage = ''.obs;
var filteredJobList = <JobEntity>[].obs;
// -------------------- Lifecycle --------------------
@override
void onInit() {
super.onInit();
fetchProjectJobs(); // always load jobs even without projectId
fetchProjectJobs();
filteredJobList.value = jobList;
}
// -------------------- Project --------------------
void setProjectId(String id) {
if (projectId.value == id) return;
projectId.value = id;
fetchProjectDetail();
// Reset pagination and list
pageNumber = 1;
hasMoreJobs.value = true;
fetchProjectJobs(); // no initialLoad
jobList.clear();
filteredJobList.clear();
// Fetch project detail
fetchProjectDetail();
// Always fetch jobs for this project
fetchProjectJobs(refresh: true);
}
void updateJobSearch(String searchText) {
if (searchText.isEmpty) {
filteredJobList.value = jobList;
} else {
filteredJobList.value = jobList.where((job) {
final lowerSearch = searchText.toLowerCase();
return job.title.toLowerCase().contains(lowerSearch) ||
(job.description.toLowerCase().contains(lowerSearch)) ||
(job.tags?.any(
(tag) => tag.name.toLowerCase().contains(lowerSearch)) ??
false);
}).toList();
}
}
Future<void> fetchProjectTeams() async {
@ -135,33 +162,39 @@ class ServiceProjectDetailsController extends GetxController {
}
// -------------------- Job List (modified to always load) --------------------
Future<void> fetchProjectJobs() async {
if (!hasMoreJobs.value) return;
Future<void> fetchProjectJobs({bool refresh = false}) async {
if (projectId.value.isEmpty) return;
if (refresh) pageNumber = 1;
if (!hasMoreJobs.value && !refresh) return;
isJobLoading.value = true;
jobErrorMessage.value = '';
try {
final result = await ApiService.getServiceProjectJobListApi(
projectId: projectId.value, // allows empty projectId
projectId: projectId.value,
pageNumber: pageNumber,
pageSize: pageSize,
isActive: true,
isArchive: showArchivedJobs.value,
);
if (result != null && result.data != null) {
final newJobs = result.data?.data ?? [];
if (pageNumber == 1) {
if (refresh || pageNumber == 1) {
jobList.value = newJobs;
} else {
jobList.addAll(newJobs);
}
filteredJobList.value = jobList;
hasMoreJobs.value = newJobs.length == pageSize;
if (hasMoreJobs.value) pageNumber++;
} else {
jobErrorMessage.value = result?.message ?? "Failed to fetch job list";
jobErrorMessage.value = result?.message ?? "Failed to fetch jobs";
}
} catch (e) {
jobErrorMessage.value = "Error fetching jobs: $e";

View File

@ -1,9 +1,9 @@
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://api.onfieldwork.com/api";
// static const String baseUrl = "https://api.onfieldwork.com/api";
static const String getMasterCurrencies = "/Master/currencies/list";

View File

@ -652,15 +652,18 @@ class ApiService {
}
}
/// Get Service Project Job List
/// Get Service Project Job List (Active or Archived)
static Future<JobResponse?> getServiceProjectJobListApi({
required String projectId,
int pageNumber = 1,
int pageSize = 20,
bool isActive = true,
bool isArchive = false, // new parameter to fetch archived jobs
}) async {
const endpoint = ApiEndpoints.getServiceProjectJobList;
logSafe("Fetching Job List for Service Project: $projectId");
logSafe(
"Fetching Job List for Service Project: $projectId | isActive: $isActive | isArchive: $isArchive",
);
try {
final queryParams = {
@ -668,27 +671,35 @@ class ApiService {
'pageNumber': pageNumber.toString(),
'pageSize': pageSize.toString(),
'isActive': isActive.toString(),
if (isArchive)
'isArchive': 'true',
};
final response = await _getRequest(endpoint, queryParams: queryParams);
if (response == null) {
logSafe("Service Project Job List request failed: null response",
level: LogLevel.error);
logSafe(
"Service Project Job List request failed: null response",
level: LogLevel.error,
);
return null;
}
final jsonResponse = _parseResponseForAllData(
response,
label: "Service Project Job List",
label: isArchive
? "Archived Service Project Job List"
: "Active Service Project Job List",
);
if (jsonResponse != null) {
return JobResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getServiceProjectJobListApi: $e",
level: LogLevel.error);
logSafe(
"Exception during getServiceProjectJobListApi: $e",
level: LogLevel.error,
);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}

View File

@ -161,6 +161,30 @@ class MenuItems {
/// Documents menu
static const String documents = "92d2cc39-9e6a-46b2-ae50-84fbf83c95d3";
/// Service Projects
/// Service Projects
static const String serviceProjects = "7faddfe7-994b-4712-91c2-32ba44129d9b";
}
/// Contains all job status IDs used across the application.
class JobStatus {
/// Level 1 - New
static const String newStatus = "32d76a02-8f44-4aa0-9b66-c3716c45a918";
/// Level 2 - Assigned
static const String assigned = "cfa1886d-055f-4ded-84c6-42a2a8a14a66";
/// Level 3 - In Progress
static const String inProgress = "5a6873a5-fed7-4745-a52f-8f61bf3bd72d";
/// Level 4 - Work Done
static const String workDone = "aab71020-2fb8-44d9-9430-c9a7e9bf33b0";
/// Level 5 - Review Done
static const String reviewDone = "ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7";
/// Level 6 - Closed
static const String closed = "3ddeefb5-ae3c-4e10-a922-35e0a452bb69";
/// Level 7 - On Hold
static const String onHold = "75a0c8b8-9c6a-41af-80bf-b35bab722eb2";
}

View File

@ -6,13 +6,37 @@ import 'package:marco/helpers/widgets/my_snackbar.dart';
class ConfirmDialog extends StatelessWidget {
final String title;
final String message;
/// Text for confirm button (default: "Delete")
final String confirmText;
/// Text for cancel button (default: "Cancel")
final String cancelText;
/// Icon shown in the dialog header (default: Icons.delete)
final IconData icon;
/// Icon for confirm button (default: Icons.delete_forever)
final IconData confirmIcon;
/// Icon for cancel button (default: Icons.close)
final IconData cancelIcon;
/// Background color for confirm button (default: Colors.redAccent)
final Color confirmColor;
/// Callback fired when confirm is pressed and awaited.
final Future<void> Function() onConfirm;
/// External RxBool to observe the loading state, if null internal is used
final RxBool? isProcessing;
/// Custom error message shown in snackbar if confirmation fails
final String errorMessage;
/// Text shown in confirm button while loading
final String loadingText;
const ConfirmDialog({
super.key,
required this.title,
@ -21,28 +45,44 @@ class ConfirmDialog extends StatelessWidget {
this.confirmText = "Delete",
this.cancelText = "Cancel",
this.icon = Icons.delete,
this.confirmIcon = Icons.delete_forever,
this.cancelIcon = Icons.close,
this.confirmColor = Colors.redAccent,
this.isProcessing,
this.errorMessage = "Failed to complete action. Try again.",
this.loadingText = "Submitting…",
});
@override
Widget build(BuildContext context) {
// Use provided RxBool, or create one internally
final RxBool loading = isProcessing ?? false.obs;
final theme = Theme.of(context);
return Dialog(
backgroundColor: theme.colorScheme.surface,
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
child: _ContentView(
title: title,
message: message,
icon: icon,
confirmColor: confirmColor,
confirmText: confirmText,
cancelText: cancelText,
loading: loading,
onConfirm: onConfirm,
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 280,
maxWidth: 480,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 16),
child: _ContentView(
title: title,
message: message,
icon: icon,
confirmColor: confirmColor,
confirmText: confirmText,
cancelText: cancelText,
confirmIcon: confirmIcon,
cancelIcon: cancelIcon,
loading: loading,
onConfirm: onConfirm,
errorMessage: errorMessage,
loadingText: loadingText,
),
),
),
);
@ -50,11 +90,12 @@ class ConfirmDialog extends StatelessWidget {
}
class _ContentView extends StatelessWidget {
final String title, message, confirmText, cancelText;
final IconData icon;
final String title, message, confirmText, cancelText, loadingText;
final IconData icon, confirmIcon, cancelIcon;
final Color confirmColor;
final RxBool loading;
final Future<void> Function() onConfirm;
final String errorMessage;
const _ContentView({
required this.title,
@ -63,71 +104,113 @@ class _ContentView extends StatelessWidget {
required this.confirmColor,
required this.confirmText,
required this.cancelText,
required this.confirmIcon,
required this.cancelIcon,
required this.loading,
required this.onConfirm,
required this.errorMessage,
required this.loadingText,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 48, color: confirmColor),
const SizedBox(height: 16),
MyText.titleLarge(
title,
fontWeight: 600,
color: theme.colorScheme.onBackground,
),
const SizedBox(height: 12),
MyText.bodySmall(
message,
textAlign: TextAlign.center,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
const SizedBox(height: 24),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Obx(() => _DialogButton(
text: cancelText,
icon: Icons.close,
color: Colors.grey,
isLoading: false,
onPressed: loading.value
? null // disable while loading
: () => Navigator.pop(context, false),
)),
Container(
decoration: BoxDecoration(
color: confirmColor.withOpacity(0.12),
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(10),
child: Icon(
icon,
size: 22,
color: confirmColor,
),
),
const SizedBox(width: 12),
Expanded(
child: Obx(() => _DialogButton(
text: confirmText,
icon: Icons.delete_forever,
color: confirmColor,
isLoading: loading.value,
onPressed: () async {
try {
loading.value = true;
await onConfirm(); // 🔥 call API
Navigator.pop(context, true); // close on success
} catch (e) {
// Show error, dialog stays open
showAppSnackbar(
title: "Error",
message: "Failed to delete. Try again.",
type: SnackbarType.error,
);
} finally {
loading.value = false;
}
},
)),
child: MyText.titleLarge(
title,
fontWeight: 700,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 12),
MyText.bodyMedium(
message,
textAlign: TextAlign.left,
color: colorScheme.onSurface.withValues(alpha: 0.75),
),
const SizedBox(height: 20),
Divider(
height: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.center,
child: SizedBox(
width: double.infinity, // allow full available width
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Obx(
() => _DialogButton(
text: cancelText,
icon: cancelIcon,
color: Colors.transparent,
textColor: colorScheme.onSurface,
isFilled: false,
isLoading: false,
onPressed: loading.value ? null : () => Navigator.pop(context, false),
),
),
),
const SizedBox(width: 20),
Expanded(
child: Obx(
() => _DialogButton(
text: loading.value ? loadingText : confirmText,
icon: confirmIcon,
color: confirmColor,
textColor: Colors.white,
isFilled: true,
isLoading: loading.value,
onPressed: () async {
try {
loading.value = true;
await onConfirm();
Navigator.pop(context, true);
} catch (e) {
showAppSnackbar(
title: "Error",
message: errorMessage,
type: SnackbarType.error,
);
} finally {
loading.value = false;
}
},
),
),
),
],
),
),
),
],
);
}
@ -137,6 +220,8 @@ class _DialogButton extends StatelessWidget {
final String text;
final IconData icon;
final Color color;
final Color textColor;
final bool isFilled;
final VoidCallback? onPressed;
final bool isLoading;
@ -144,36 +229,73 @@ class _DialogButton extends StatelessWidget {
required this.text,
required this.icon,
required this.color,
required this.textColor,
required this.isFilled,
required this.onPressed,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
return ElevatedButton.icon(
onPressed: isLoading ? null : onPressed,
icon: isLoading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Icon(icon, color: Colors.white),
label: MyText.bodyMedium(
isLoading ? "Submitting.." : text,
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: color,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 12),
),
final theme = Theme.of(context);
final ButtonStyle style = isFilled
? ElevatedButton.styleFrom(
backgroundColor: color,
foregroundColor: textColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(999),
),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
)
: OutlinedButton.styleFrom(
foregroundColor: textColor,
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.7),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(999),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
);
final Widget iconWidget = isLoading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: isFilled ? Colors.white : theme.colorScheme.primary,
),
)
: Icon(icon, size: 18);
final Widget labelWidget = MyText.bodyMedium(
text,
color: isFilled ? Colors.white : textColor,
fontWeight: 600,
);
final child = Row(
mainAxisSize: MainAxisSize.min,
children: [
iconWidget,
const SizedBox(width: 8),
Flexible(child: labelWidget),
],
);
return isFilled
? ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: style,
child: child,
)
: OutlinedButton(
onPressed: isLoading ? null : onPressed,
style: style,
child: child,
);
}
}

View File

@ -0,0 +1,374 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/controller/service_project/service_project_details_screen_controller.dart';
import 'package:marco/view/service_project/service_project_job_detail_screen.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
class JobsTab extends StatefulWidget {
final ScrollController scrollController;
const JobsTab({super.key, required this.scrollController});
@override
_JobsTabState createState() => _JobsTabState();
}
class _JobsTabState extends State<JobsTab> {
final TextEditingController searchController = TextEditingController();
late ServiceProjectDetailsController controller;
@override
void initState() {
super.initState();
controller = Get.find<ServiceProjectDetailsController>();
// Ensure only active jobs are displayed initially
controller.showArchivedJobs.value = false;
controller.fetchProjectJobs(refresh: true);
searchController.addListener(() {
controller.updateJobSearch(searchController.text);
});
}
@override
void dispose() {
searchController.dispose();
super.dispose();
}
Widget _buildSearchBar() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 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(
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.updateJobSearch('');
},
);
},
),
hintText: 'Search jobs...',
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),
),
),
),
),
),
const SizedBox(width: 10),
Container(
height: 35,
padding: const EdgeInsets.symmetric(horizontal: 5),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5),
),
child: Obx(() {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"Archived",
style: TextStyle(fontSize: 14, color: Colors.black87),
),
Switch(
value: controller.showArchivedJobs.value,
onChanged: (val) {
controller.showArchivedJobs.value = val;
controller.fetchProjectJobs(refresh: true);
},
activeColor: Colors.blue,
inactiveThumbColor: Colors.grey,
),
],
);
}),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
_buildSearchBar(),
Expanded(
child: Obx(() {
if (controller.isJobLoading.value &&
controller.filteredJobList.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (controller.jobErrorMessage.value.isNotEmpty &&
controller.filteredJobList.isEmpty) {
return Center(
child: MyText.bodyMedium(controller.jobErrorMessage.value));
}
if (controller.filteredJobList.isEmpty) {
return Center(child: MyText.bodyMedium("No jobs found"));
}
return ListView.separated(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
itemCount: controller.filteredJobList.length + 1,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
if (index == controller.filteredJobList.length) {
return controller.hasMoreJobs.value
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
)
: const SizedBox.shrink();
}
final job = controller.filteredJobList[index];
return Stack(
children: [
AbsorbPointer(
absorbing: controller.showArchivedJobs
.value, // Disable interactions if archived
child: InkWell(
onTap: () {
if (!controller.showArchivedJobs.value) {
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],
),
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),
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),
Row(
children: [
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(),
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,
),
),
),
],
),
],
),
),
),
),
),
Obx(() {
final isArchivedJob = controller.showArchivedJobs.value;
if (job.status.id != JobStatus.closed &&
job.status.id != JobStatus.reviewDone) {
return const SizedBox.shrink();
}
return Positioned(
top: 10,
right: 10,
child: GestureDetector(
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => ConfirmDialog(
title: isArchivedJob
? "Restore Job?"
: "Archive Job?",
message: isArchivedJob
? "Are you sure you want to restore this job?"
: "Are you sure you want to archive this job?",
confirmText: "Yes",
cancelText: "Cancel",
icon: isArchivedJob
? Icons.restore
: Icons.archive_outlined,
confirmColor: Colors.green,
confirmIcon: Icons.check,
onConfirm: () async {
final operations = [
{
"op": "replace",
"path": "/isArchive",
"value": isArchivedJob ? false : true
}
];
final success =
await ApiService.editServiceProjectJobApi(
jobId: job.id ,
operations: operations,
);
if (success) {
showAppSnackbar(
title: "Success",
message: isArchivedJob
? "Job restored successfully"
: "Job archived successfully",
type: SnackbarType.success,
);
controller.fetchProjectJobs(refresh: true);
} else {
showAppSnackbar(
title: "Error",
message: isArchivedJob
? "Failed to restore job. Please try again."
: "Failed to archive job. Please try again.",
type: SnackbarType.error,
);
}
},
),
);
if (confirmed != true) return;
},
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: isArchivedJob ? Colors.blue : Colors.red,
shape: BoxShape.circle,
),
child: Icon(
isArchivedJob
? Icons.restore
: Icons.archive_outlined,
size: 20,
color: Colors.white,
),
),
),
);
}),
],
);
},
);
}),
),
],
);
}
}

View File

@ -7,12 +7,11 @@ 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';
import 'package:marco/view/service_project/jobs_tab.dart';
class ServiceProjectDetailsScreen extends StatefulWidget {
final String projectId;
@ -319,149 +318,89 @@ class _ServiceProjectDetailsScreenState
);
}
Widget _buildJobsTab() {
Widget _buildTeamsTab() {
return Obx(() {
if (controller.isJobLoading.value && controller.jobList.isEmpty) {
if (controller.isTeamLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.jobErrorMessage.value.isNotEmpty &&
controller.jobList.isEmpty) {
if (controller.teamErrorMessage.value.isNotEmpty &&
controller.teamList.isEmpty) {
return Center(
child: MyText.bodyMedium(controller.jobErrorMessage.value));
child: MyText.bodyMedium(controller.teamErrorMessage.value));
}
if (controller.jobList.isEmpty) {
return Center(child: MyText.bodyMedium("No jobs found"));
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(
controller: _jobScrollController,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
itemCount: controller.jobList.length + 1,
padding: const EdgeInsets.all(12),
itemCount: roleGroups.keys.length,
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 roleId = roleGroups.keys.elementAt(index);
final teamMembers = roleGroups[roleId]!;
final roleName = teamMembers.first.teamRole.name;
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),
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,
),
child: Text(
job.status.displayName,
style: TextStyle(
fontSize: 12,
color:
job.status.name.toLowerCase() == 'completed'
? Colors.green[800]
: Colors.orange[800],
fontWeight: FontWeight.w600,
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(),
],
),
),
);
@ -470,96 +409,6 @@ class _ServiceProjectDetailsScreenState
});
}
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(
@ -606,7 +455,7 @@ class _ServiceProjectDetailsScreenState
controller: _tabController,
children: [
_buildProfileTab(),
_buildJobsTab(),
JobsTab(scrollController: _jobScrollController),
_buildTeamsTab(),
],
);

View File

@ -246,62 +246,6 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
),
),
),
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),
],
),
),
],
),
),
],
),
),