created new emploee selector bottomsheet

This commit is contained in:
Vaibhav Surve 2025-11-14 15:35:41 +05:30
parent 214816ac0f
commit 1f47a55d9c
14 changed files with 1289 additions and 87 deletions

View File

@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class AddServiceProjectJobController extends GetxController {
// Form Controllers
final titleCtrl = TextEditingController();
final descCtrl = TextEditingController();
final tagCtrl = TextEditingController();
final FocusNode searchFocusNode = FocusNode();
final RxBool showEmployeePicker = true.obs;
// Observables
final startDate = Rx<DateTime?>(DateTime.now());
final dueDate = Rx<DateTime?>(DateTime.now().add(const Duration(days: 1)));
final enteredTags = <String>[].obs;
final employees = <EmployeeModel>[].obs;
final selectedAssignees = <EmployeeModel>[].obs;
final isSearchingEmployees = false.obs;
// Loading states
final isLoading = false.obs;
final isAllEmployeeLoading = false.obs;
final allEmployees = <EmployeeModel>[].obs;
final employeeSearchResults = <EmployeeModel>[].obs;
@override
void onInit() {
super.onInit();
searchEmployees(""); // pass empty string safely
}
@override
void onClose() {
titleCtrl.dispose();
descCtrl.dispose();
tagCtrl.dispose();
super.onClose();
}
Future<void> searchEmployees(String query) async {
if (query.trim().isEmpty) {
employeeSearchResults.clear();
return;
}
isSearchingEmployees.value = true;
try {
final data =
await ApiService.searchEmployeesBasic(searchString: query.trim());
if (data is List) {
employeeSearchResults.assignAll(
data
.map((e) => EmployeeModel.fromJson(e as Map<String, dynamic>))
.toList(),
);
} else {
employeeSearchResults.clear();
}
} catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
/// Toggle employee selection
void toggleAssignee(EmployeeModel employee) {
if (selectedAssignees.contains(employee)) {
selectedAssignees.remove(employee);
} else {
selectedAssignees.add(employee);
}
}
/// Create Service Project Job API call
Future<void> createJob(String projectId) async {
if (titleCtrl.text.trim().isEmpty || descCtrl.text.trim().isEmpty) {
showAppSnackbar(
title: "Validation",
message: "Title and Description are required",
type: SnackbarType.warning,
);
return;
}
final assigneeIds = selectedAssignees.map((e) => e.id).toList();
final success = await ApiService.createServiceProjectJobApi(
title: titleCtrl.text.trim(),
description: descCtrl.text.trim(),
projectId: projectId,
assignees: assigneeIds.map((id) => {"id": id}).toList(),
startDate: startDate.value!,
dueDate: dueDate.value!,
tags: enteredTags.map((tag) => {"name": tag}).toList(),
);
if (success) {
Get.back();
showAppSnackbar(
title: "Success",
message: "Job created successfully",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to create job",
type: SnackbarType.error,
);
}
}
}

View File

@ -1,6 +1,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';
class ServiceProjectDetailsController extends GetxController {
// Selected project id
@ -9,19 +10,39 @@ class ServiceProjectDetailsController extends GetxController {
// Project details
var projectDetail = Rxn<ProjectDetail>();
// Loading state
// Job list
var jobList = <JobEntity>[].obs;
// Loading states
var isLoading = false.obs;
var isJobLoading = false.obs;
// Error message
// Error messages
var errorMessage = ''.obs;
var jobErrorMessage = ''.obs;
/// Set project id and fetch its details
// Pagination
var pageNumber = 1;
final int pageSize = 20;
var hasMoreJobs = true.obs;
@override
void onInit() {
super.onInit();
// Fetch job list initially even if projectId is empty
fetchProjectJobs(initialLoad: true);
}
/// Set project id and fetch its details + jobs
void setProjectId(String id) {
projectId.value = id;
fetchProjectDetail();
pageNumber = 1;
hasMoreJobs.value = true;
fetchProjectJobs(initialLoad: true);
}
/// Fetch project detail from API
/// Fetch project detail
Future<void> fetchProjectDetail() async {
if (projectId.value.isEmpty) {
errorMessage.value = "Invalid project ID";
@ -32,12 +53,14 @@ 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";
@ -46,8 +69,57 @@ class ServiceProjectDetailsController extends GetxController {
}
}
/// Refresh project details manually
/// Fetch project job list
Future<void> fetchProjectJobs({bool initialLoad = false}) async {
if (projectId.value.isEmpty && !initialLoad) {
jobErrorMessage.value = "Invalid project ID";
return;
}
if (!hasMoreJobs.value && !initialLoad) return;
isJobLoading.value = true;
jobErrorMessage.value = '';
try {
final result = await ApiService.getServiceProjectJobListApi(
projectId: "",
pageNumber: pageNumber,
pageSize: pageSize,
isActive: true,
);
if (result != null && result.data != null) {
if (initialLoad) {
jobList.value = result.data?.data ?? [];
} else {
jobList.addAll(result.data?.data ?? []);
}
hasMoreJobs.value = (result.data?.data?.length ?? 0) == pageSize;
if (hasMoreJobs.value) pageNumber++;
} else {
jobErrorMessage.value = result?.message ?? "Failed to fetch job list";
}
} catch (e) {
jobErrorMessage.value = "Error fetching jobs: $e";
} finally {
isJobLoading.value = false;
}
}
/// Fetch more jobs for pagination
Future<void> fetchMoreJobs() async {
await fetchProjectJobs();
}
/// Manual refresh
Future<void> refresh() async {
await fetchProjectDetail();
pageNumber = 1;
hasMoreJobs.value = true;
await Future.wait([
fetchProjectDetail(),
fetchProjectJobs(initialLoad: true),
]);
}
}

View File

@ -133,4 +133,7 @@ class ApiEndpoints {
// Service Project Module API Endpoints
static const String getServiceProjectsList = "/serviceproject/list";
static const String getServiceProjectDetail = "/serviceproject/details";
static const String getServiceProjectJobList = "/serviceproject/job/list";
static const String getServiceProjectJobDetail = "/serviceproject/job/details";
static const String createServiceProjectJob = "/serviceproject/job/create";
}

View File

@ -35,6 +35,7 @@ import 'package:marco/model/finance/payment_request_details_model.dart';
import 'package:marco/model/finance/advance_payment_model.dart';
import 'package:marco/model/service_project/service_projects_list_model.dart';
import 'package:marco/model/service_project/service_projects_details_model.dart';
import 'package:marco/model/service_project/job_list_model.dart';
class ApiService {
static const bool enableLogs = true;
@ -306,8 +307,128 @@ class ApiService {
// Service Project Module APIs
/// Create a new Service Project Job
static Future<bool> createServiceProjectJobApi({
required String title,
required String description,
required String projectId,
required List<Map<String, dynamic>> assignees,
required DateTime startDate,
required DateTime dueDate,
required List<Map<String, dynamic>> tags,
}) async {
const endpoint = ApiEndpoints.createServiceProjectJob;
logSafe("Creating Service Project Job for projectId: $projectId");
final body = {
"title": title,
"description": description,
"projectId": projectId,
"assignees": assignees,
"startDate": startDate.toIso8601String(),
"dueDate": dueDate.toIso8601String(),
"tags": tags,
};
try {
final response = await _postRequest(endpoint, body);
if (response == null) {
logSafe("Create Service Project Job failed: null response",
level: LogLevel.error);
return false;
}
logSafe(
"Create Service Project Job response status: ${response.statusCode}");
logSafe("Create Service Project Job response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Service Project Job created successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to create Service Project Job: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
return false;
}
} catch (e, stack) {
logSafe("Exception during createServiceProjectJobApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
}
}
/// Get Service Project Job List
static Future<JobResponse?> getServiceProjectJobListApi({
required String projectId,
int pageNumber = 1,
int pageSize = 20,
bool isActive = true,
}) async {
const endpoint = ApiEndpoints.getServiceProjectJobList;
logSafe("Fetching Job List for Service Project: $projectId");
try {
final queryParams = {
'projectId': projectId,
'pageNumber': pageNumber.toString(),
'pageSize': pageSize.toString(),
'isActive': isActive.toString(),
};
final response = await _getRequest(endpoint, queryParams: queryParams);
if (response == null) {
logSafe("Service Project Job List request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(
response,
label: "Service Project Job List",
);
if (jsonResponse != null) {
return JobResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getServiceProjectJobListApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
// API to get all employees from basic
static Future<List<dynamic>?> allEmployeesBasic({
bool allEmployee = true,
}) async {
final queryParams = <String, String>{};
// Always include allEmployee parameter
queryParams['allEmployee'] = allEmployee.toString();
final response = await _getRequest(
ApiEndpoints.getEmployeesWithoutPermission,
queryParams: queryParams,
);
if (response != null) {
return _parseResponse(response, label: ' All Employees Basic');
}
return null;
}
/// Get details of a single service project
static Future<ServiceProjectDetailModel?> getServiceProjectDetailApi(String projectId) async {
static Future<ServiceProjectDetailModel?> getServiceProjectDetailApi(
String projectId) async {
final endpoint = "${ApiEndpoints.getServiceProjectDetail}/$projectId";
logSafe("Fetching details for Service Project ID: $projectId");
@ -315,7 +436,8 @@ static Future<ServiceProjectDetailModel?> getServiceProjectDetailApi(String proj
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Service Project Detail request failed: null response", level: LogLevel.error);
logSafe("Service Project Detail request failed: null response",
level: LogLevel.error);
return null;
}
@ -328,7 +450,8 @@ static Future<ServiceProjectDetailModel?> getServiceProjectDetailApi(String proj
return ServiceProjectDetailModel.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getServiceProjectDetailApi: $e", level: LogLevel.error);
logSafe("Exception during getServiceProjectDetailApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}

View File

@ -160,4 +160,7 @@ class MenuItems {
/// Documents menu
static const String documents = "92d2cc39-9e6a-46b2-ae50-84fbf83c95d3";
/// Service Projects
static const String serviceProjects = "7faddfe7-994b-4712-91c2-32ba44129d9b";
}

View File

@ -33,11 +33,15 @@ class _DateRangePickerWidgetState extends State<DateRangePickerWidget>
? widget.startDate.value ?? DateTime.now()
: widget.endDate.value ?? DateTime.now();
// Ensure initialDate is within firstDate..lastDate
final first = DateTime(2000);
final last = current.isAfter(DateTime.now()) ? current : DateTime.now();
final DateTime? picked = await showDatePicker(
context: context,
initialDate: current,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
firstDate: first,
lastDate: last,
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.light(
@ -53,15 +57,23 @@ class _DateRangePickerWidgetState extends State<DateRangePickerWidget>
if (picked != null) {
if (isStartDate) {
widget.startDate.value = picked;
} else {
// Auto-adjust endDate if needed
if (widget.endDate.value != null &&
widget.endDate.value!.isBefore(picked)) {
widget.endDate.value = picked;
}
if (widget.onDateRangeSelected != null) {
widget.onDateRangeSelected!(
widget.startDate.value, widget.endDate.value);
} else {
widget.endDate.value = picked;
// Auto-adjust startDate if needed
if (widget.startDate.value != null &&
widget.startDate.value!.isAfter(picked)) {
widget.startDate.value = picked;
}
}
widget.onDateRangeSelected
?.call(widget.startDate.value, widget.endDate.value);
}
}
Widget _dateBox({

View File

@ -35,7 +35,7 @@ void showAppSnackbar({
message,
backgroundColor: backgroundColor,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
margin: const EdgeInsets.all(16),
borderRadius: 8,
duration: const Duration(seconds: 5),

View File

@ -71,4 +71,14 @@ class EmployeeModel {
'phoneNumber': phoneNumber.isEmpty ? '-' : phoneNumber,
};
}
/// Add equality based on unique `id`
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is EmployeeModel && other.id == id;
}
@override
int get hashCode => id.hashCode;
}

View File

@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/helpers/services/api_service.dart';
class EmployeeSelectionBottomSheet extends StatefulWidget {
final List<EmployeeModel> initiallySelected;
final bool multipleSelection;
final String title;
const EmployeeSelectionBottomSheet({
Key? key,
this.initiallySelected = const [],
this.multipleSelection = true,
this.title = 'Select Employees',
}) : super(key: key);
@override
State<EmployeeSelectionBottomSheet> createState() =>
_EmployeeSelectionBottomSheetState();
}
class _EmployeeSelectionBottomSheetState
extends State<EmployeeSelectionBottomSheet> {
final TextEditingController _searchController = TextEditingController();
final RxBool _isSearching = false.obs;
final RxList<EmployeeModel> _searchResults = <EmployeeModel>[].obs;
late RxList<EmployeeModel> _selectedEmployees;
@override
void initState() {
super.initState();
_selectedEmployees = RxList<EmployeeModel>.from(widget.initiallySelected);
_searchEmployees('');
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _searchEmployees(String query) async {
_isSearching.value = true;
final data = await ApiService.searchEmployeesBasic(searchString: query);
final results = (data as List)
.map((e) => EmployeeModel.fromJson(e as Map<String, dynamic>))
.toList();
_searchResults.assignAll(results);
_isSearching.value = false;
}
void _toggleEmployee(EmployeeModel emp) {
if (widget.multipleSelection) {
if (_selectedEmployees.contains(emp)) {
_selectedEmployees.remove(emp);
} else {
_selectedEmployees.add(emp);
}
_selectedEmployees.refresh();
} else {
_selectedEmployees.assignAll([emp]);
_selectedEmployees.refresh();
}
}
void _handleSubmit() {
if (widget.multipleSelection) {
Navigator.of(context).pop(_selectedEmployees.toList());
} else {
Navigator.of(context)
.pop(_selectedEmployees.isNotEmpty ? _selectedEmployees.first : null);
}
}
Widget _searchBar() => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextField(
controller: _searchController,
onChanged: _searchEmployees,
decoration: InputDecoration(
hintText: 'Search employees...',
filled: true,
fillColor: Colors.grey.shade100,
prefixIcon: const Icon(Icons.search, color: Colors.grey),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.close, color: Colors.grey),
onPressed: () {
_searchController.clear();
_searchEmployees('');
},
)
: null,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(30),
borderSide: BorderSide.none,
),
),
),
);
Widget _employeeList() => Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: _searchResults.length,
itemBuilder: (context, index) {
final emp = _searchResults[index];
return Obx(() {
// wrap each tile
final isSelected = _selectedEmployees.contains(emp);
return ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blueAccent,
child: Text(
(emp.firstName.isNotEmpty ? emp.firstName[0] : 'U')
.toUpperCase(),
style: const TextStyle(color: Colors.white),
),
),
title: Text('${emp.firstName} ${emp.lastName}'),
subtitle: Text(emp.email),
trailing: Checkbox(
value: isSelected,
onChanged: (_) => _toggleEmployee(emp),
fillColor: MaterialStateProperty.resolveWith<Color>(
(states) => states.contains(MaterialState.selected)
? Colors.blueAccent
: Colors.white,
),
),
onTap: () => _toggleEmployee(emp),
contentPadding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 4),
);
});
},
),
);
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: widget.title,
onCancel: () => Navigator.of(context).pop(),
onSubmit: _handleSubmit,
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.7,
child: Column(children: [
_searchBar(),
_employeeList(),
]),
),
);
}
}

View File

@ -0,0 +1,241 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/date_range_picker.dart';
import 'package:marco/controller/service_project/add_service_project_job_controller.dart';
import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/employees/multiple_select_bottomsheet.dart';
class AddServiceProjectJobBottomSheet extends StatefulWidget {
final String projectId;
const AddServiceProjectJobBottomSheet({super.key, required this.projectId});
@override
State<AddServiceProjectJobBottomSheet> createState() =>
_AddServiceProjectJobBottomSheetState();
}
class _AddServiceProjectJobBottomSheetState
extends State<AddServiceProjectJobBottomSheet> with UIMixin {
final formKey = GlobalKey<FormState>();
final controller = Get.put(AddServiceProjectJobController());
final TextEditingController _searchController = TextEditingController();
late RxList<EmployeeModel> _selectedEmployees;
@override
void initState() {
super.initState();
_selectedEmployees =
RxList<EmployeeModel>.from(controller.selectedAssignees);
}
@override
void dispose() {
_searchController.dispose();
Get.delete<AddServiceProjectJobController>();
super.dispose();
}
InputDecoration _inputDecoration(String hint) => InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
isDense: true,
);
Widget _labelWithStar(String label, {bool required = false}) => Row(
mainAxisSize: MainAxisSize.min,
children: [
MyText.labelMedium(label),
if (required)
const Text(" *", style: TextStyle(color: Colors.red, fontSize: 14)),
],
);
Widget _textField(String label, TextEditingController ctrl,
{bool required = false, int maxLines = 1}) =>
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_labelWithStar(label, required: required),
MySpacing.height(8),
TextFormField(
controller: ctrl,
maxLines: maxLines,
decoration: _inputDecoration("Enter $label"),
validator: required
? (v) => (v == null || v.trim().isEmpty)
? "$label is required"
: null
: null,
),
],
);
Widget _employeeSelector() => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_labelWithStar("Select Assignees", required: true),
MySpacing.height(8),
GestureDetector(
onTap: () async {
final selectedEmployees =
await showModalBottomSheet<List<EmployeeModel>>(
context: context,
isScrollControlled: true,
builder: (_) => EmployeeSelectionBottomSheet(
multipleSelection: true,
initiallySelected: _selectedEmployees,
),
);
if (selectedEmployees != null) {
setState(() {
_selectedEmployees.assignAll(selectedEmployees);
});
}
},
child: AbsorbPointer(
child: TextFormField(
decoration: _inputDecoration("Select Employees"),
controller: TextEditingController(
text: _selectedEmployees.isEmpty
? ""
: _selectedEmployees
.map((e) => "${e.firstName} ${e.lastName}")
.join(", "),
),
validator: (v) => _selectedEmployees.isEmpty
? "Please select employees"
: null,
),
),
),
],
);
Widget _tagInput() => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 48,
child: TextField(
controller: controller.tagCtrl,
onSubmitted: (v) {
final value = v.trim();
if (value.isNotEmpty &&
!controller.enteredTags.contains(value)) {
controller.enteredTags.add(value);
}
controller.tagCtrl.clear();
},
decoration: _inputDecoration("Start typing to add tags"),
),
),
MySpacing.height(8),
Obx(() => Wrap(
spacing: 8,
children: controller.enteredTags
.map((tag) => Chip(
label: Text(tag),
onDeleted: () => controller.enteredTags.remove(tag)))
.toList(),
)),
],
);
void _toggleEmployee(EmployeeModel emp) {
final contains = _selectedEmployees.contains(emp);
if (contains) {
_selectedEmployees.remove(emp);
} else {
_selectedEmployees.add(emp);
}
controller.toggleAssignee(emp);
}
void _handleSubmit() {
if (!(formKey.currentState?.validate() ?? false)) return;
controller.titleCtrl.text = controller.titleCtrl.text.trim();
controller.descCtrl.text = controller.descCtrl.text.trim();
controller.createJob(widget.projectId);
}
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: "Add New Job",
onCancel: () => Get.back(),
onSubmit: _handleSubmit,
child: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_textField("Title", controller.titleCtrl, required: true),
MySpacing.height(16),
Obx(() {
if (_searchController.text.isNotEmpty)
return const SizedBox.shrink();
return Wrap(
spacing: 8,
runSpacing: 4,
children: _selectedEmployees
.map(
(emp) => Chip(
label: Text('${emp.firstName} ${emp.lastName}'),
onDeleted: () => _toggleEmployee(emp),
),
)
.toList(),
);
}),
_employeeSelector(),
MySpacing.height(16),
MyText.labelMedium("Tags (Optional)"),
MySpacing.height(8),
_tagInput(),
MySpacing.height(16),
_labelWithStar("Select Date Range", required: true),
MySpacing.height(8),
DateRangePickerWidget(
startDate: controller.startDate,
endDate: controller.dueDate,
startLabel: "Start Date",
endLabel: "Due Date",
onDateRangeSelected: (start, end) {
controller.startDate.value = start ?? DateTime.now();
controller.dueDate.value =
end ?? DateTime.now().add(const Duration(days: 1));
},
),
MySpacing.height(16),
_textField("Description", controller.descCtrl,
required: true, maxLines: 3),
],
),
),
);
}
}

View File

@ -0,0 +1,237 @@
class JobResponse {
final bool success;
final String message;
final int statusCode;
final String timestamp;
final JobData? data;
final dynamic errors;
JobResponse({
required this.success,
required this.message,
required this.statusCode,
required this.timestamp,
this.data,
this.errors,
});
factory JobResponse.fromJson(Map<String, dynamic> json) {
return JobResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
statusCode: json['statusCode'] ?? 0,
timestamp: json['timestamp'] ?? '',
data: json['data'] != null ? JobData.fromJson(json['data']) : null,
errors: json['errors'],
);
}
}
class JobData {
final int currentPage;
final int totalPages;
final int totalEntities;
final List<JobEntity>? data;
JobData({
required this.currentPage,
required this.totalPages,
required this.totalEntities,
this.data,
});
factory JobData.fromJson(Map<String, dynamic> json) {
return JobData(
currentPage: json['currentPage'] ?? 0,
totalPages: json['totalPages'] ?? 0,
totalEntities: json['totalEntities'] ?? 0,
data: (json['data'] as List<dynamic>?)
?.map((e) => JobEntity.fromJson(e))
.toList(),
);
}
}
class JobEntity {
final String id;
final String title;
final String description;
final Project project;
final List<Assignee>? assignees;
final Status status;
final String startDate;
final String dueDate;
final bool isActive;
final String createdAt;
final CreatedBy createdBy;
final List<Tag>? tags;
JobEntity({
required this.id,
required this.title,
required this.description,
required this.project,
this.assignees,
required this.status,
required this.startDate,
required this.dueDate,
required this.isActive,
required this.createdAt,
required this.createdBy,
this.tags,
});
factory JobEntity.fromJson(Map<String, dynamic> json) {
return JobEntity(
id: json['id'] ?? '',
title: json['title'] ?? '',
description: json['description'] ?? '',
project: Project.fromJson(json['project']),
assignees: (json['assignees'] as List<dynamic>?)
?.map((e) => Assignee.fromJson(e))
.toList(),
status: Status.fromJson(json['status']),
startDate: json['startDate'] ?? '',
dueDate: json['dueDate'] ?? '',
isActive: json['isActive'] ?? false,
createdAt: json['createdAt'] ?? '',
createdBy: CreatedBy.fromJson(json['createdBy']),
tags: (json['tags'] as List<dynamic>?)
?.map((e) => Tag.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<String, dynamic> json) {
return Project(
id: json['id'] ?? '',
name: json['name'] ?? '',
shortName: json['shortName'] ?? '',
assignedDate: json['assignedDate'] ?? '',
contactName: json['contactName'] ?? '',
contactPhone: json['contactPhone'] ?? '',
contactEmail: json['contactEmail'] ?? '',
);
}
}
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<String, dynamic> json) {
return Assignee(
id: json['id'] ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
email: json['email'] ?? '',
photo: json['photo'] ?? '',
jobRoleId: json['jobRoleId'] ?? '',
jobRoleName: json['jobRoleName'] ?? '',
);
}
}
class Status {
final String id;
final String name;
final String displayName;
Status({
required this.id,
required this.name,
required this.displayName,
});
factory Status.fromJson(Map<String, dynamic> json) {
return Status(
id: json['id'] ?? '',
name: json['name'] ?? '',
displayName: json['displayName'] ?? '',
);
}
}
class CreatedBy {
final String id;
final String firstName;
final String lastName;
final String email;
final String photo;
final String jobRoleId;
final String jobRoleName;
CreatedBy({
required this.id,
required this.firstName,
required this.lastName,
required this.email,
required this.photo,
required this.jobRoleId,
required this.jobRoleName,
});
factory CreatedBy.fromJson(Map<String, dynamic> json) {
return CreatedBy(
id: json['id'] ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
email: json['email'] ?? '',
photo: json['photo'] ?? '',
jobRoleId: json['jobRoleId'] ?? '',
jobRoleName: json['jobRoleName'] ?? '',
);
}
}
class Tag {
final String id;
final String name;
Tag({
required this.id,
required this.name,
});
factory Tag.fromJson(Map<String, dynamic> json) {
return Tag(
id: json['id'] ?? '',
name: json['name'] ?? '',
);
}
}

View File

@ -106,6 +106,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
MenuItems.directory,
MenuItems.finance,
MenuItems.documents,
MenuItems.serviceProjects
];
final Map<String, _DashboardCardMeta> cardMeta = {
@ -123,6 +124,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
_DashboardCardMeta(LucideIcons.wallet, contentTheme.info),
MenuItems.documents:
_DashboardCardMeta(LucideIcons.file_text, contentTheme.info),
MenuItems.serviceProjects:
_DashboardCardMeta(LucideIcons.package, contentTheme.info),
};
// Filter only available menus that exist in cardMeta

View File

@ -5,7 +5,10 @@ import 'package:marco/controller/project_controller.dart';
import 'package:marco/controller/service_project/service_project_details_screen_controller.dart';
import 'package:marco/helpers/utils/launcher_utils.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/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';
class ServiceProjectDetailsScreen extends StatefulWidget {
final String projectId;
@ -19,9 +22,10 @@ class ServiceProjectDetailsScreen extends StatefulWidget {
class _ServiceProjectDetailsScreenState
extends State<ServiceProjectDetailsScreen>
with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin, UIMixin {
late final TabController _tabController;
late final ServiceProjectDetailsController controller;
final ScrollController _jobScrollController = ScrollController();
@override
void initState() {
@ -30,15 +34,32 @@ class _ServiceProjectDetailsScreenState
_tabController = TabController(length: 2, vsync: this);
controller = Get.put(ServiceProjectDetailsController());
// Fetch project detail safely after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.setProjectId(widget.projectId);
});
_tabController.addListener(() {
if (!_tabController.indexIsChanging) {
setState(() {}); // rebuild to show/hide FAB
if (_tabController.index == 1 && controller.jobList.isEmpty) {
controller.fetchProjectJobs();
}
}
});
_jobScrollController.addListener(() {
if (_tabController.index == 1 &&
_jobScrollController.position.pixels >=
_jobScrollController.position.maxScrollExtent - 100) {
controller.fetchMoreJobs();
}
});
}
@override
void dispose() {
_tabController.dispose();
_jobScrollController.dispose();
super.dispose();
}
@ -157,7 +178,7 @@ class _ServiceProjectDetailsScreenState
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Header
// Header Card
Card(
elevation: 2,
shadowColor: Colors.black12,
@ -292,6 +313,155 @@ class _ServiceProjectDetailsScreenState
);
}
Widget _buildJobsTab() {
return Obx(() {
if (controller.isJobLoading.value && controller.jobList.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (controller.jobErrorMessage.value.isNotEmpty &&
controller.jobList.isEmpty) {
return Center(
child: MyText.bodyMedium(controller.jobErrorMessage.value));
}
if (controller.jobList.isEmpty) {
return Center(child: MyText.bodyMedium("No jobs found"));
}
return ListView.separated(
controller: _jobScrollController,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
itemCount: controller.jobList.length + 1,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
if (index == controller.jobList.length) {
return controller.hasMoreJobs.value
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
)
: const SizedBox.shrink();
}
final job = controller.jobList[index];
return 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(),
),
),
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),
),
const Spacer(),
// Status Chip
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: job.status.name.toLowerCase() == 'completed'
? Colors.green[100]
: Colors.orange[100],
borderRadius: BorderRadius.circular(5),
),
child: Text(
job.status.displayName,
style: TextStyle(
fontSize: 12,
color: job.status.name.toLowerCase() == 'completed'
? Colors.green[800]
: Colors.orange[800],
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
);
},
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -320,7 +490,7 @@ class _ServiceProjectDetailsScreenState
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Service Projects',
'Service Project Details',
fontWeight: 700,
color: Colors.black,
),
@ -355,9 +525,10 @@ class _ServiceProjectDetailsScreenState
),
),
),
body: Column(
body: SafeArea(
child: Column(
children: [
// ---------------- TabBar ----------------
// TabBar
Container(
color: Colors.white,
child: TabBar(
@ -374,13 +545,15 @@ class _ServiceProjectDetailsScreenState
),
),
// ---------------- TabBarView ----------------
// TabBarView
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
if (controller.isLoading.value &&
controller.projectDetail.value == null) {
return const Center(child: CircularProgressIndicator());
}
if (controller.errorMessage.value.isNotEmpty) {
if (controller.errorMessage.value.isNotEmpty &&
controller.projectDetail.value == null) {
return Center(
child: MyText.bodyMedium(controller.errorMessage.value));
}
@ -388,17 +561,33 @@ class _ServiceProjectDetailsScreenState
return TabBarView(
controller: _tabController,
children: [
// Profile Tab
_buildProfileTab(),
// Jobs Tab - empty
Container(color: Colors.white),
_buildJobsTab(),
],
);
}),
),
],
),
),
floatingActionButton: _tabController.index == 1
? FloatingActionButton.extended(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => AddServiceProjectJobBottomSheet(
projectId: widget.projectId,
),
);
},
backgroundColor: contentTheme.primary,
icon: const Icon(Icons.add),
label: MyText.bodyMedium("Add Job", color: Colors.white),
)
: null,
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
);
}
}

View File

@ -8,6 +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';
class ServiceProjectScreen extends StatefulWidget {
const ServiceProjectScreen({super.key});
@ -211,11 +212,42 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
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<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),