feat: implement job search functionality and archived job toggle in JobsTab; refactor ServiceProjectDetailsScreen to integrate JobsTab
This commit is contained in:
parent
cf7021a982
commit
fc78806af2
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -164,3 +164,27 @@ class MenuItems {
|
||||
/// 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";
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
374
lib/view/service_project/jobs_tab.dart
Normal file
374
lib/view/service_project/jobs_tab.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user