added api and craeted add project bottomsheet

This commit is contained in:
Vaibhav Surve 2025-10-01 15:06:23 +05:30
parent 2b8196b216
commit c9e6840161
6 changed files with 574 additions and 15 deletions

View File

@ -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 = <ProjectStatus>[].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<ProjectStatus> 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<bool> 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();
}
}

View File

@ -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";

View File

@ -288,6 +288,60 @@ class ApiService {
}
}
/// Create Project API
static Future<bool> 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<String, dynamic> 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<OrganizationListResponse?> getAssignedOrganizations(
String projectId) async {
@ -1730,19 +1784,18 @@ class ApiService {
return false;
}
static Future<List<dynamic>?> 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<List<dynamic>?> 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<bool> updateContact(
String contactId, Map<String, dynamic> payload) async {

View File

@ -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) ...[

View File

@ -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<T?> showAddExpenseBottomSheet<T>({
@ -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<String>(
icon: Icons.work_outline,
title: "Project",

View File

@ -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<CreateProjectBottomSheet> createState() =>
_CreateProjectBottomSheetState();
}
class _CreateProjectBottomSheetState extends State<CreateProjectBottomSheet> {
final _formKey = GlobalKey<FormState>();
final CreateProjectController controller = Get.put(CreateProjectController());
DateTime? _startDate;
DateTime? _endDate;
Future<void> _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<void> _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<String> items;
final ValueChanged<String> 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<String>(
context: context,
position: position,
items: items
.map((item) => PopupMenuItem<String>(
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),
),
),
),
),
],
);
}