From c9e6840161a1c6330fd54ea10e2c5b8dda701067 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 1 Oct 2025 15:06:23 +0530 Subject: [PATCH] added api and craeted add project bottomsheet --- .../project/create_project_controller.dart | 113 ++++++ lib/helpers/services/api_endpoints.dart | 5 + lib/helpers/services/api_service.dart | 77 +++- lib/helpers/utils/base_bottom_sheet.dart | 14 +- .../expense/add_expense_bottom_sheet.dart | 26 ++ .../project/create_project_bottom_sheet.dart | 354 ++++++++++++++++++ 6 files changed, 574 insertions(+), 15 deletions(-) create mode 100644 lib/controller/project/create_project_controller.dart create mode 100644 lib/view/project/create_project_bottom_sheet.dart diff --git a/lib/controller/project/create_project_controller.dart b/lib/controller/project/create_project_controller.dart new file mode 100644 index 0000000..2fecef0 --- /dev/null +++ b/lib/controller/project/create_project_controller.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/services/app_logger.dart'; + +class ProjectStatus { + final String id; + final String name; + + ProjectStatus({required this.id, required this.name}); +} + +class CreateProjectController extends GetxController { + // Observables + var isSubmitting = false.obs; + var statusList = [].obs; + ProjectStatus? selectedStatus; + + /// Text controllers for form fields + final nameCtrl = TextEditingController(); + final shortNameCtrl = TextEditingController(); + final addressCtrl = TextEditingController(); + final contactCtrl = TextEditingController(); + final startDateCtrl = TextEditingController(); + final endDateCtrl = TextEditingController(); + + @override + void onInit() { + super.onInit(); + loadHardcodedStatuses(); + } + + /// Hardcoded project statuses + void loadHardcodedStatuses() { + final List statuses = [ + ProjectStatus( + id: "b74da4c2-d07e-46f2-9919-e75e49b12731", name: "Active"), + ProjectStatus( + id: "cdad86aa-8a56-4ff4-b633-9c629057dfef", name: "In Progress"), + ProjectStatus( + id: "603e994b-a27f-4e5d-a251-f3d69b0498ba", name: "On Hold"), + ProjectStatus( + id: "ef1c356e-0fe0-42df-a5d3-8daee355492d", name: "In Active"), + ProjectStatus( + id: "33deaef9-9af1-4f2a-b443-681ea0d04f81", name: "Completed"), + ]; + statusList.assignAll(statuses); + } + + /// Create project API call using ApiService + Future createProject({ + required String name, + required String projectAddress, + required String shortName, + required String contactPerson, + required DateTime startDate, + required DateTime endDate, + required String projectStatusId, + }) async { + try { + isSubmitting.value = true; + + final success = await ApiService.createProjectApi( + name: name, + projectAddress: projectAddress, + shortName: shortName, + contactPerson: contactPerson, + startDate: startDate, + endDate: endDate, + projectStatusId: projectStatusId, + ); + + if (success) { + showAppSnackbar( + title: "Success", + message: "Project created successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to create project", + type: SnackbarType.error, + ); + } + + return success; + } catch (e, stack) { + logSafe("Create project error: $e", level: LogLevel.error); + logSafe("Stacktrace: $stack", level: LogLevel.debug); + showAppSnackbar( + title: "Error", + message: "An unexpected error occurred", + type: SnackbarType.error, + ); + return false; + } finally { + isSubmitting.value = false; + } + } + + @override + void onClose() { + nameCtrl.dispose(); + shortNameCtrl.dispose(); + addressCtrl.dispose(); + contactCtrl.dispose(); + startDateCtrl.dispose(); + endDateCtrl.dispose(); + super.onClose(); + } +} diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index b0331b6..eea7ae8 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -11,6 +11,11 @@ class ApiEndpoints { static const String getDashboardTeams = "/dashboard/teams"; static const String getDashboardProjects = "/dashboard/projects"; +///// Projects Module API Endpoints + static const String createProject = "/project"; + + + // Attendance Module API Endpoints static const String getProjects = "/project/list"; static const String getGlobalProjects = "/project/list/basic"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 924fa6c..8fed938 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -288,6 +288,60 @@ class ApiService { } } + + /// Create Project API + static Future createProjectApi({ + required String name, + required String projectAddress, + required String shortName, + required String contactPerson, + required DateTime startDate, + required DateTime endDate, + required String projectStatusId, + }) async { + const endpoint = ApiEndpoints.createProject; + logSafe("Creating project: $name"); + + final Map payload = { + "name": name, + "projectAddress": projectAddress, + "shortName": shortName, + "contactPerson": contactPerson, + "startDate": startDate.toIso8601String(), + "endDate": endDate.toIso8601String(), + "projectStatusId": projectStatusId, + }; + + try { + final response = + await _postRequest(endpoint, payload, customTimeout: extendedTimeout); + + if (response == null) { + logSafe("Create project failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Create project response status: ${response.statusCode}"); + logSafe("Create project response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Project created successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to create project: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during createProjectApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + /// Get Organizations assigned to a Project static Future getAssignedOrganizations( String projectId) async { @@ -1730,19 +1784,18 @@ class ApiService { return false; } -static Future?> getDirectoryComments( - String contactId, { - bool active = true, -}) async { - final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active"; - final response = await _getRequest(url); - final data = response != null - ? _parseResponse(response, label: 'Directory Comments') - : null; - - return data is List ? data : null; -} + static Future?> getDirectoryComments( + String contactId, { + bool active = true, + }) async { + final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active"; + final response = await _getRequest(url); + final data = response != null + ? _parseResponse(response, label: 'Directory Comments') + : null; + return data is List ? data : null; + } static Future updateContact( String contactId, Map payload) async { diff --git a/lib/helpers/utils/base_bottom_sheet.dart b/lib/helpers/utils/base_bottom_sheet.dart index b07a361..d43da3f 100644 --- a/lib/helpers/utils/base_bottom_sheet.dart +++ b/lib/helpers/utils/base_bottom_sheet.dart @@ -4,7 +4,7 @@ import 'package:marco/helpers/widgets/my_text.dart'; class BaseBottomSheet extends StatelessWidget { final String title; - final String? subtitle; + final String? subtitle; final Widget child; final VoidCallback onCancel; final VoidCallback onSubmit; @@ -21,7 +21,7 @@ class BaseBottomSheet extends StatelessWidget { required this.child, required this.onCancel, required this.onSubmit, - this.subtitle, + this.subtitle, this.isSubmitting = false, this.submitText = 'Submit', this.submitColor = Colors.indigo, @@ -71,7 +71,15 @@ class BaseBottomSheet extends StatelessWidget { ), ), MySpacing.height(12), - MyText.titleLarge(title, fontWeight: 700), + + // ✅ Centered title + Center( + child: MyText.titleLarge( + title, + fontWeight: 700, + textAlign: TextAlign.center, + ), + ), // ✅ Subtitle shown just below header if provided if (subtitle != null && subtitle!.isNotEmpty) ...[ diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index c91ccee..bd21ef5 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -11,6 +11,7 @@ import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart'; +import 'package:marco/view/project/create_project_bottom_sheet.dart'; /// Show bottom sheet wrapper Future showAddExpenseBottomSheet({ @@ -157,6 +158,31 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 👇 Add New Project Button + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () async { + await Get.bottomSheet( + const CreateProjectBottomSheet(), + isScrollControlled: true, + ); + + // 🔄 Refresh project list after adding new project (optional) + await controller.fetchGlobalProjects(); + }, + icon: const Icon(Icons.add, color: Colors.blue), + label: const Text( + "Add Project", + style: TextStyle( + color: Colors.blue, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + _gap(), + _buildDropdownField( icon: Icons.work_outline, title: "Project", diff --git a/lib/view/project/create_project_bottom_sheet.dart b/lib/view/project/create_project_bottom_sheet.dart new file mode 100644 index 0000000..015cbef --- /dev/null +++ b/lib/view/project/create_project_bottom_sheet.dart @@ -0,0 +1,354 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/controller/project/create_project_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.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/widgets/my_snackbar.dart'; + +class CreateProjectBottomSheet extends StatefulWidget { + const CreateProjectBottomSheet({Key? key}) : super(key: key); + + @override + State createState() => + _CreateProjectBottomSheetState(); +} + +class _CreateProjectBottomSheetState extends State { + final _formKey = GlobalKey(); + final CreateProjectController controller = Get.put(CreateProjectController()); + + DateTime? _startDate; + DateTime? _endDate; + + Future _pickDate({required bool isStart}) async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() { + if (isStart) { + _startDate = picked; + controller.startDateCtrl.text = + DateFormat('yyyy-MM-dd').format(picked); + } else { + _endDate = picked; + controller.endDateCtrl.text = DateFormat('yyyy-MM-dd').format(picked); + } + }); + } + } + + Future _handleSubmit() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + if (_startDate == null || _endDate == null) { + showAppSnackbar( + title: "Error", + message: "Please select both start and end dates", + type: SnackbarType.error, + ); + return; + } + + if (controller.selectedStatus == null) { + showAppSnackbar( + title: "Error", + message: "Please select project status", + type: SnackbarType.error, + ); + return; + } + + // Call API + final success = await controller.createProject( + name: controller.nameCtrl.text.trim(), + shortName: controller.shortNameCtrl.text.trim(), + projectAddress: controller.addressCtrl.text.trim(), + contactPerson: controller.contactCtrl.text.trim(), + startDate: _startDate!, + endDate: _endDate!, + projectStatusId: controller.selectedStatus!.id, + ); + + if (success) Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + return BaseBottomSheet( + title: "Create Project", + onCancel: () => Navigator.pop(context), + onSubmit: _handleSubmit, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(16), + + /// Project Name + LabeledInput( + label: "Project Name", + hint: "Enter project name", + controller: controller.nameCtrl, + validator: (value) => + value == null || value.trim().isEmpty ? "Required" : null, + isRequired: true, + ), + MySpacing.height(16), + + /// Short Name + LabeledInput( + label: "Short Name", + hint: "Enter short name", + controller: controller.shortNameCtrl, + validator: (value) => + value == null || value.trim().isEmpty ? "Required" : null, + isRequired: true, + ), + MySpacing.height(16), + + /// Project Address + LabeledInput( + label: "Project Address", + hint: "Enter project address", + controller: controller.addressCtrl, + validator: (value) => + value == null || value.trim().isEmpty ? "Required" : null, + isRequired: true, + ), + MySpacing.height(16), + + /// Contact Person + LabeledInput( + label: "Contact Person", + hint: "Enter contact person", + controller: controller.contactCtrl, + validator: (value) => + value == null || value.trim().isEmpty ? "Required" : null, + isRequired: true, + ), + MySpacing.height(16), + + /// Start Date + GestureDetector( + onTap: () => _pickDate(isStart: true), + child: AbsorbPointer( + child: LabeledInput( + label: "Start Date", + hint: "Select start date", + controller: controller.startDateCtrl, + validator: (value) => + _startDate == null ? "Required" : null, + isRequired: true, + ), + ), + ), + MySpacing.height(16), + + /// End Date + GestureDetector( + onTap: () => _pickDate(isStart: false), + child: AbsorbPointer( + child: LabeledInput( + label: "End Date", + hint: "Select end date", + controller: controller.endDateCtrl, + validator: (value) => _endDate == null ? "Required" : null, + isRequired: true, + ), + ), + ), + MySpacing.height(16), + + /// Project Status using PopupMenuButton + Obx(() { + if (controller.statusList.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + return LabeledDropdownPopup( + label: "Project Status", + hint: "Select status", + value: controller.selectedStatus?.name, + items: controller.statusList.map((e) => e.name).toList(), + onChanged: (selected) { + final status = controller.statusList + .firstWhere((s) => s.name == selected); + setState(() => controller.selectedStatus = status); + }, + isRequired: true, + ); + }), + MySpacing.height(16), + ], + ), + ), + ), + ); + } +} + +/// ----------------- LabeledInput ----------------- +class LabeledInput extends StatelessWidget { + final String label; + final String hint; + final TextEditingController controller; + final String? Function(String?) validator; + final bool isRequired; + + const LabeledInput({ + Key? key, + required this.label, + required this.hint, + required this.controller, + required this.validator, + this.isRequired = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + MyText.labelMedium(label), + if (isRequired) + const Text( + " *", + style: + TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + ), + ], + ), + MySpacing.height(8), + TextFormField( + controller: controller, + validator: validator, + decoration: 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: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: EdgeInsets.all(16), + ), + ), + ], + ); +} + +/// ----------------- LabeledDropdownPopup ----------------- +class LabeledDropdownPopup extends StatelessWidget { + final String label; + final String hint; + final String? value; + final List items; + final ValueChanged onChanged; + final bool isRequired; + + LabeledDropdownPopup({ + Key? key, + required this.label, + required this.hint, + required this.value, + required this.items, + required this.onChanged, + this.isRequired = false, + }) : super(key: key); + + final GlobalKey _fieldKey = GlobalKey(); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + MyText.labelMedium(label), + if (isRequired) + const Text( + " *", + style: + TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + ), + ], + ), + MySpacing.height(8), + GestureDetector( + key: _fieldKey, + onTap: () async { + // Get the position of the widget + final RenderBox box = + _fieldKey.currentContext!.findRenderObject() as RenderBox; + final Offset offset = box.localToGlobal(Offset.zero); + final RelativeRect position = RelativeRect.fromLTRB( + offset.dx, + offset.dy + box.size.height, + offset.dx + box.size.width, + offset.dy, + ); + + final selected = await showMenu( + context: context, + position: position, + items: items + .map((item) => PopupMenuItem( + value: item, + child: Text(item), + )) + .toList(), + ); + if (selected != null) onChanged(selected); + }, + child: AbsorbPointer( + child: TextFormField( + readOnly: true, + controller: TextEditingController(text: value ?? ""), + validator: (val) => isRequired && (val == null || val.isEmpty) + ? "Required" + : null, + decoration: 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: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + borderSide: + BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: const EdgeInsets.all(16), + suffixIcon: const Icon(Icons.expand_more), + ), + ), + ), + ), + ], + ); +}