From 2d1c1f8a46c7c1e14323c7ef1c963d8a3d0229e7 Mon Sep 17 00:00:00 2001 From: Alex <78383757+Lextical@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:55:00 -0600 Subject: [PATCH] Moved appointments businiss logic to modelview andriod --- .../AppointmentDetailFragment.java | 568 ++++++------------ .../petstoremobile/utils/DateTimeUtils.java | 98 +++ .../viewmodels/AppointmentViewModel.java | 312 +++++++++- 3 files changed, 584 insertions(+), 394 deletions(-) create mode 100644 android/app/src/main/java/com/example/petstoremobile/utils/DateTimeUtils.java diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/AppointmentDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/AppointmentDetailFragment.java index 8e0f2be8..96001734 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/AppointmentDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/AppointmentDetailFragment.java @@ -13,16 +13,12 @@ import androidx.navigation.fragment.NavHostFragment; import com.example.petstoremobile.databinding.FragmentAppointmentDetailBinding; import com.example.petstoremobile.dtos.*; +import com.example.petstoremobile.utils.DateTimeUtils; import com.example.petstoremobile.utils.DialogUtils; import com.example.petstoremobile.utils.Resource; import com.example.petstoremobile.utils.SpinnerUtils; import com.example.petstoremobile.utils.UIUtils; import com.example.petstoremobile.viewmodels.AppointmentViewModel; -import com.example.petstoremobile.viewmodels.CustomerViewModel; -import com.example.petstoremobile.viewmodels.PetViewModel; -import com.example.petstoremobile.viewmodels.ServiceViewModel; -import com.example.petstoremobile.viewmodels.StoreViewModel; -import com.example.petstoremobile.viewmodels.UserViewModel; import java.util.*; @@ -36,40 +32,22 @@ public class AppointmentDetailFragment extends Fragment { private FragmentAppointmentDetailBinding binding; - private long appointmentId = -1; - private boolean isEditing = false; - private boolean isPastAppointment = false; private long preselectedPetId = -1; private long preselectedServiceId = -1; private long preselectedCustomerId = -1; private long preselectedStoreId = -1; private long preselectedStaffId = -1; - private List petList = new ArrayList<>(); - private List serviceList = new ArrayList<>(); - private List customerList = new ArrayList<>(); - private List storeList = new ArrayList<>(); - private List staffList = new ArrayList<>(); - private final Integer[] HOURS = {9,10,11,12,13,14,15,16,17}; private final Integer[] MINUTES = {0,15,30,45}; private AppointmentViewModel appointmentViewModel; - private PetViewModel petViewModel; - private ServiceViewModel serviceViewModel; - private StoreViewModel storeViewModel; - private CustomerViewModel customerViewModel; - private UserViewModel userViewModel; + private boolean isUpdatingUI = false; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); appointmentViewModel = new ViewModelProvider(this).get(AppointmentViewModel.class); - petViewModel = new ViewModelProvider(this).get(PetViewModel.class); - serviceViewModel = new ViewModelProvider(this).get(ServiceViewModel.class); - storeViewModel = new ViewModelProvider(this).get(StoreViewModel.class); - customerViewModel = new ViewModelProvider(this).get(CustomerViewModel.class); - userViewModel = new ViewModelProvider(this).get(UserViewModel.class); } @Override @@ -83,7 +61,8 @@ public class AppointmentDetailFragment extends Fragment { super.onViewCreated(view, savedInstanceState); setupSpinners(); setupDatePicker(); - loadSpinnersData(); + observeViewModel(); + appointmentViewModel.loadInitialFormData(); handleArguments(); binding.btnApptBack.setOnClickListener(v -> navigateBack()); @@ -101,9 +80,10 @@ public class AppointmentDetailFragment extends Fragment { * Configures the adapters for spinners. */ private void setupSpinners() { - SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerAppointmentStatus, - new String[]{"Booked", "Completed", "Cancelled", "Missed"}); + //Status Spinner is empty by default the date determines whats in here + SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerAppointmentStatus, new String[]{}); + // Set up hour and minute spinners String[] hours = new String[HOURS.length]; for (int i = 0; i < HOURS.length; i++) hours[i] = String.format("%02d:00", HOURS[i]); @@ -113,50 +93,41 @@ public class AppointmentDetailFragment extends Fragment { // Pet and Staff spinners disabled by until parent selection UIUtils.setViewsEnabled(false, binding.spinnerPet, binding.spinnerStaff); - // Listener to load pets based on selected customer + // Listener to notify ViewModel of customer selection binding.spinnerCustomer.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView parent, View view, int position, long id) { - if (position > 0 && position <= customerList.size()) { - DropdownDTO selectedCustomer = customerList.get(position - 1); - loadPets(selectedCustomer.getId()); - if (!isEditing) { - UIUtils.setViewsEnabled(true, binding.spinnerPet); - } - } else { - petList.clear(); - refreshPetSpinner(); - if (!isEditing) { - binding.spinnerPet.setSelection(0); - UIUtils.setViewsEnabled(false, binding.spinnerPet); - } - } + appointmentViewModel.onCustomerSelected(position); } - @Override public void onNothingSelected(AdapterView parent) {} }); - // Listener to load staff based on selected store + // Listener to notify ViewModel of store selection binding.spinnerStore.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView parent, View view, int position, long id) { - if (position > 0 && position <= storeList.size()) { - DropdownDTO selectedStore = storeList.get(position - 1); - loadStaff(selectedStore.getId()); - if (!isPastAppointment) { - UIUtils.setViewsEnabled(true, binding.spinnerStaff); - } - } else { - staffList.clear(); - refreshStaffSpinner(); - if (!isEditing) { - binding.spinnerStaff.setSelection(0); - UIUtils.setViewsEnabled(false, binding.spinnerStaff); - } - } + appointmentViewModel.onStoreSelected(position); } + @Override + public void onNothingSelected(AdapterView parent) {} + }); + // Listeners for other selections + binding.spinnerService.setOnItemSelectedListener(new OnIndexSelected(p -> appointmentViewModel.onServiceSelected(p))); + binding.spinnerPet.setOnItemSelectedListener(new OnIndexSelected(p -> appointmentViewModel.onPetSelected(p))); + binding.spinnerStaff.setOnItemSelectedListener(new OnIndexSelected(p -> appointmentViewModel.onStaffSelected(p))); + + // Listeners for time changes + binding.spinnerHour.setOnItemSelectedListener(new OnIndexSelected(p -> notifyDateTimeStatusChange())); + binding.spinnerMinute.setOnItemSelectedListener(new OnIndexSelected(p -> notifyDateTimeStatusChange())); + + // Listener to notify ViewModel of status selection + binding.spinnerAppointmentStatus.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + notifyDateTimeStatusChange(); + } @Override public void onNothingSelected(AdapterView parent) {} }); @@ -167,11 +138,13 @@ public class AppointmentDetailFragment extends Fragment { */ private void setupDatePicker() { binding.etAppointmentDate.setOnClickListener(v -> { - if (isPastAppointment) return; Calendar c = Calendar.getInstance(); DatePickerDialog d = new DatePickerDialog(requireContext(), - (dp,y,m,d1) -> binding.etAppointmentDate.setText( - String.format("%04d-%02d-%02d", y, m+1, d1)), + (dp,y,m,d1) -> { + String selectedDate = String.format("%04d-%02d-%02d", y, m+1, d1); + binding.etAppointmentDate.setText(selectedDate); + notifyDateTimeStatusChange(); + }, c.get(Calendar.YEAR), c.get(Calendar.MONTH), c.get(Calendar.DAY_OF_MONTH)); d.getDatePicker().setMinDate(System.currentTimeMillis() - 1000); @@ -180,118 +153,79 @@ public class AppointmentDetailFragment extends Fragment { } /** - * Fetches all required data for spinners from the backend. + * Observes the ViewModel for UI state and list updates. */ - private void loadSpinnersData() { - loadServices(); - loadCustomers(); - loadStores(); + private void observeViewModel() { + appointmentViewModel.getViewState().observe(getViewLifecycleOwner(), this::applyViewState); + + // Populate spinners when data arrives + appointmentViewModel.getCustomers().observe(getViewLifecycleOwner(), list -> + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerCustomer, list, DropdownDTO::getLabel, "-- Select Customer --", preselectedCustomerId, DropdownDTO::getId)); + + appointmentViewModel.getStores().observe(getViewLifecycleOwner(), list -> + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerStore, list, DropdownDTO::getLabel, "-- Select Store --", preselectedStoreId, DropdownDTO::getId)); + + appointmentViewModel.getServices().observe(getViewLifecycleOwner(), list -> + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerService, list, ServiceDTO::getServiceName, "-- Select Service --", preselectedServiceId, ServiceDTO::getServiceId)); + + appointmentViewModel.getCustomerPets().observe(getViewLifecycleOwner(), list -> + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerPet, list, DropdownDTO::getLabel, "-- Select Pet --", preselectedPetId, DropdownDTO::getId)); + + appointmentViewModel.getStoreEmployees().observe(getViewLifecycleOwner(), list -> + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerStaff, list, DropdownDTO::getLabel, "-- Select Staff --", preselectedStaffId, DropdownDTO::getId)); } /** - * Loads the list of pets from the ViewModel, filtered by customerId. + * Applies the ViewState provided by the ViewModel to the UI components. */ - private void loadPets(Long customerId) { - petViewModel.getCustomerPets(customerId).observe(getViewLifecycleOwner(), resource -> { - if (resource.status == Resource.Status.SUCCESS && resource.data != null) { - petList = resource.data; - refreshPetSpinner(); - } - }); + private void applyViewState(AppointmentViewModel.ViewState state) { + isUpdatingUI = true; + + // Mode specific UI + binding.tvApptMode.setText(state.isEditing ? "Edit Appointment" : "Add Appointment"); + binding.tvAppointmentId.setText("ID: " + appointmentViewModel.getAppointmentId()); + binding.tvAppointmentId.setVisibility(state.isEditing ? View.VISIBLE : View.GONE); + binding.btnDeleteAppointment.setVisibility(state.isDeleteVisible ? View.VISIBLE : View.GONE); + binding.btnSaveAppointment.setVisibility(state.isSaveVisible ? View.VISIBLE : View.GONE); + + // Enabling/Disabling Views + UIUtils.setViewsEnabled(state.isCustomerEnabled, binding.spinnerCustomer); + UIUtils.setViewsEnabled(state.isStoreEnabled, binding.spinnerStore); + UIUtils.setViewsEnabled(state.isPetEnabled, binding.spinnerPet); + UIUtils.setViewsEnabled(state.isServiceEnabled, binding.spinnerService); + UIUtils.setViewsEnabled(state.isStaffEnabled, binding.spinnerStaff); + UIUtils.setViewsEnabled(state.isDateEnabled, binding.etAppointmentDate); + UIUtils.setViewsEnabled(state.isTimeEnabled, binding.spinnerHour, binding.spinnerMinute); + UIUtils.setViewsEnabled(state.isStatusEnabled, binding.spinnerAppointmentStatus); + + // Alpha for disabled look + float alpha = 1.0f; + float disabledAlpha = 0.5f; + UIUtils.setViewsAlpha(state.isCustomerEnabled ? alpha : disabledAlpha, binding.tvLabelCustomer); + UIUtils.setViewsAlpha(state.isStoreEnabled ? alpha : disabledAlpha, binding.tvLabelStore); + UIUtils.setViewsAlpha(state.isPetEnabled ? alpha : disabledAlpha, binding.tvLabelPet); + UIUtils.setViewsAlpha(state.isServiceEnabled ? alpha : disabledAlpha, binding.tvLabelService); + UIUtils.setViewsAlpha(state.isStaffEnabled ? alpha : disabledAlpha, binding.tvLabelStaff); + UIUtils.setViewsAlpha(state.isDateEnabled ? alpha : disabledAlpha, binding.tvLabelDate); + UIUtils.setViewsAlpha(state.isTimeEnabled ? alpha : disabledAlpha, binding.tvLabelTime); + + // Update status options + Object selected = binding.spinnerAppointmentStatus.getSelectedItem(); + String current = selected != null ? selected.toString() : ""; + SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerAppointmentStatus, state.availableStatuses); + SpinnerUtils.setSelectionByValue(binding.spinnerAppointmentStatus, current); + + isUpdatingUI = false; } - /** - * Populates the pet selection spinner. - */ - private void refreshPetSpinner() { - SpinnerUtils.populateSpinner(requireContext(), binding.spinnerPet, petList, - DropdownDTO::getLabel, "-- Select Pet --", - preselectedPetId, DropdownDTO::getId); - } + private void notifyDateTimeStatusChange() { + if (isUpdatingUI) return; - /** - * Loads the list of services from the API. - */ - private void loadServices() { - serviceViewModel.getAllServices(0, 200, null, "serviceName").observe(getViewLifecycleOwner(), resource -> { - if (resource.status == Resource.Status.SUCCESS && resource.data != null) { - serviceList = resource.data.getContent(); - refreshServiceSpinner(); - } - }); - } - - /** - * Populates the service selection spinner. - */ - private void refreshServiceSpinner() { - SpinnerUtils.populateSpinner(requireContext(), binding.spinnerService, serviceList, - ServiceDTO::getServiceName, "-- Select Service --", - preselectedServiceId, ServiceDTO::getServiceId); - } - - /** - * Loads the list of customers from the API. - */ - private void loadCustomers() { - customerViewModel.getCustomerDropdowns().observe(getViewLifecycleOwner(), resource -> { - if (resource.status == Resource.Status.SUCCESS && resource.data != null) { - customerList = resource.data; - refreshCustomerSpinner(); - } - }); - } - - /** - * Populates the customer selection spinner. - */ - private void refreshCustomerSpinner() { - SpinnerUtils.populateSpinner(requireContext(), binding.spinnerCustomer, customerList, - DropdownDTO::getLabel, - "-- Select Customer --", - preselectedCustomerId, DropdownDTO::getId); - } - - /** - * Loads the list of stores from the API. - */ - private void loadStores() { - storeViewModel.getStoreDropdowns().observe(getViewLifecycleOwner(), resource -> { - if (resource.status == Resource.Status.SUCCESS && resource.data != null) { - storeList = resource.data; - refreshStoreSpinner(); - } - }); - } - - /** - * Populates the store selection spinner. - */ - private void refreshStoreSpinner() { - SpinnerUtils.populateSpinner(requireContext(), binding.spinnerStore, storeList, - DropdownDTO::getLabel, "-- Select Store --", - preselectedStoreId, DropdownDTO::getId); - } - - /** - * Loads the list of staff for a specific store. - */ - private void loadStaff(Long storeId) { - storeViewModel.getStoreEmployees(storeId).observe(getViewLifecycleOwner(), resource -> { - if (resource.status == Resource.Status.SUCCESS && resource.data != null) { - staffList = resource.data; - refreshStaffSpinner(); - } - }); - } - - /** - * Populates the staff selection spinner. - */ - private void refreshStaffSpinner() { - SpinnerUtils.populateSpinner(requireContext(), binding.spinnerStaff, staffList, - DropdownDTO::getLabel, "-- Select Staff --", - preselectedStaffId, DropdownDTO::getId); + String date = binding.etAppointmentDate.getText().toString(); + String time = buildTimeString(); + Object selected = binding.spinnerAppointmentStatus.getSelectedItem(); + String status = selected != null ? selected.toString() : ""; + appointmentViewModel.onDateOrTimeChanged(date, time, status); } /** @@ -300,27 +234,10 @@ public class AppointmentDetailFragment extends Fragment { private void handleArguments() { Bundle a = getArguments(); if (a != null && a.containsKey("appointmentId")) { - //edit mode - isEditing = true; - appointmentId = a.getLong("appointmentId"); - binding.tvApptMode.setText("Edit Appointment"); - binding.tvAppointmentId.setText("ID: " + appointmentId); - binding.tvAppointmentId.setVisibility(View.VISIBLE); - binding.btnDeleteAppointment.setVisibility(View.VISIBLE); - - UIUtils.setViewsEnabled(false, binding.spinnerCustomer, binding.spinnerStore, binding.spinnerPet, binding.spinnerService); - UIUtils.setViewsAlpha(0.5f, binding.tvLabelCustomer, binding.tvLabelStore, binding.tvLabelPet, binding.tvLabelService); - + appointmentViewModel.setAppointmentId(a.getLong("appointmentId")); loadAppointmentData(); } else { - //add mode - binding.tvApptMode.setText("Add Appointment"); - binding.btnDeleteAppointment.setVisibility(View.GONE); - binding.tvAppointmentId.setVisibility(View.GONE); - - UIUtils.setViewsEnabled(true, binding.spinnerCustomer, binding.spinnerStore, binding.spinnerService); - UIUtils.setViewsEnabled(false, binding.spinnerPet, binding.spinnerStaff, binding.spinnerAppointmentStatus); - UIUtils.setViewsAlpha(1.0f, binding.tvLabelCustomer, binding.tvLabelStore, binding.tvLabelPet, binding.tvLabelService, binding.tvLabelStaff); + appointmentViewModel.setAppointmentId(-1); } } @@ -328,229 +245,83 @@ public class AppointmentDetailFragment extends Fragment { * Fetches specific appointment details from the backend using the ID. */ private void loadAppointmentData() { - appointmentViewModel.getAppointmentById(appointmentId).observe(getViewLifecycleOwner(), resource -> { - if (resource == null) return; - if (resource.status == Resource.Status.SUCCESS && resource.data != null) { - AppointmentDTO a = resource.data; - preselectedPetId = (a.getPetId() != null) ? a.getPetId() : -1; - preselectedServiceId = (a.getServiceId() != null) ? a.getServiceId() : -1; - preselectedCustomerId = (a.getCustomerId() != null) ? a.getCustomerId() : -1; - preselectedStoreId = (a.getStoreId() != null) ? a.getStoreId() : -1; - preselectedStaffId = (a.getEmployeeId() != null) ? a.getEmployeeId() : -1; + appointmentViewModel.loadAppointment().observe(getViewLifecycleOwner(), resource -> { + if (resource == null || resource.status != Resource.Status.SUCCESS || resource.data == null) return; + AppointmentDTO a = resource.data; + preselectedPetId = a.getPetId() != null ? a.getPetId() : -1; + preselectedServiceId = a.getServiceId() != null ? a.getServiceId() : -1; + preselectedCustomerId = a.getCustomerId() != null ? a.getCustomerId() : -1; + preselectedStoreId = a.getStoreId() != null ? a.getStoreId() : -1; + preselectedStaffId = a.getEmployeeId() != null ? a.getEmployeeId() : -1; - binding.etAppointmentDate.setText(a.getAppointmentDate()); - - // Pre-fill time spinners - String time = a.getAppointmentTime() != null ? a.getAppointmentTime() : "09:00"; - if (time.length() > 5) time = time.substring(0, 5); - String[] parts = time.split(":"); - if (parts.length == 2) { - try { - int hour = Integer.parseInt(parts[0]); - int min = Integer.parseInt(parts[1]); - for (int i = 0; i < HOURS.length; i++) - if (HOURS[i] == hour) { binding.spinnerHour.setSelection(i); break; } - for (int i = 0; i < MINUTES.length; i++) - if (MINUTES[i] == min) { binding.spinnerMinute.setSelection(i); break; } - } catch (NumberFormatException ignored) {} - } - - // Match Title labels with backend values - String status = a.getAppointmentStatus(); - if (status != null && !status.isEmpty()) { - String formattedStatus = status.substring(0, 1).toUpperCase() + status.substring(1).toLowerCase(); - SpinnerUtils.setSelectionByValue(binding.spinnerAppointmentStatus, formattedStatus); - } - - checkIfPastAndDisable(a.getAppointmentDate(), time); - - refreshPetSpinner(); - refreshServiceSpinner(); - refreshCustomerSpinner(); - refreshStoreSpinner(); - refreshStaffSpinner(); - } else if (resource.status == Resource.Status.ERROR) { - Toast.makeText(getContext(), "Failed to load appointment: " + resource.message, Toast.LENGTH_SHORT).show(); + binding.etAppointmentDate.setText(a.getAppointmentDate()); + parseAndSetTimeSpinners(a.getAppointmentTime() != null ? a.getAppointmentTime() : "09:00"); + + String status = a.getAppointmentStatus(); + if (status != null && !status.isEmpty()) { + SpinnerUtils.setSelectionByValue(binding.spinnerAppointmentStatus, DateTimeUtils.formatStatusFromBackend(status)); } + notifyDateTimeStatusChange(); }); } - /** - * Checks if the appointment is in the past and disables fields. - */ - private void checkIfPastAndDisable(String date, String time) { - if (date == null || time == null) return; - try { - Calendar selected = Calendar.getInstance(); - String[] dateParts = date.split("-"); - String[] timeParts = time.split(":"); - selected.set( - Integer.parseInt(dateParts[0]), - Integer.parseInt(dateParts[1]) - 1, - Integer.parseInt(dateParts[2]), - Integer.parseInt(timeParts[0]), - Integer.parseInt(timeParts[1]), - 0 - ); - - Object selectedItem = binding.spinnerAppointmentStatus.getSelectedItem(); - String currentStatus = selectedItem != null ? selectedItem.toString() : ""; - - // If the appointment is already Cancelled, disable all fields - if ("Cancelled".equalsIgnoreCase(currentStatus)) { - isPastAppointment = true; - disableAllExceptStatus(); - UIUtils.setViewsEnabled(false, binding.spinnerAppointmentStatus); - binding.btnSaveAppointment.setVisibility(View.GONE); - return; - } - - // If the appointment date/time is in the past - if (selected.before(Calendar.getInstance())) { - isPastAppointment = true; - disableAllExceptStatus(); - - // Make status spinner only have Completed or Missed - SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerAppointmentStatus, - new String[]{"Completed", "Missed"}); - - // Restore selection if it's already one of the valid options - if (currentStatus.equals("Completed") || currentStatus.equals("Missed")) { - SpinnerUtils.setSelectionByValue(binding.spinnerAppointmentStatus, currentStatus); - } - } - } catch (Exception e) { - Log.e("APPT_DETAIL", "Error parsing date/time for past check: " + e.getMessage()); - } - } - - /** - * Disables all input fields except the status spinner - */ - private void disableAllExceptStatus() { - UIUtils.setViewsEnabled(false, - binding.spinnerCustomer, binding.spinnerStore, binding.spinnerPet, - binding.spinnerService, binding.spinnerStaff, binding.etAppointmentDate, - binding.spinnerHour, binding.spinnerMinute); - - UIUtils.setViewsAlpha(0.5f, - binding.tvLabelCustomer, binding.tvLabelStore, binding.tvLabelPet, - binding.tvLabelService, binding.tvLabelStaff, binding.tvLabelDate, - binding.tvLabelTime); - - // Keep status enabled - UIUtils.setViewsEnabled(true, binding.spinnerAppointmentStatus); - } - /** * Validates input and saves the appointment to the backend. */ private void saveAppointment() { - if (binding.spinnerCustomer.getSelectedItemPosition() == 0) { - Toast.makeText(getContext(), "Select a customer", Toast.LENGTH_SHORT).show(); return; - } - if (binding.spinnerStore.getSelectedItemPosition() == 0) { - Toast.makeText(getContext(), "Select a store", Toast.LENGTH_SHORT).show(); return; - } - if (binding.spinnerPet.getSelectedItemPosition() == 0) { - Toast.makeText(getContext(), "Select a pet", Toast.LENGTH_SHORT).show(); return; - } - if (binding.spinnerService.getSelectedItemPosition() == 0) { - Toast.makeText(getContext(), "Select a service", Toast.LENGTH_SHORT).show(); return; - } + if (!validateRequiredFields()) return; + String date = binding.etAppointmentDate.getText().toString().trim(); - if (date.isEmpty()) { - Toast.makeText(getContext(), "Select a date", Toast.LENGTH_SHORT).show(); return; - } - - DropdownDTO customer = customerList.get(binding.spinnerCustomer.getSelectedItemPosition() - 1); - DropdownDTO store = storeList.get(binding.spinnerStore.getSelectedItemPosition() - 1); - DropdownDTO pet = petList.get(binding.spinnerPet.getSelectedItemPosition() - 1); - ServiceDTO service = serviceList.get(binding.spinnerService.getSelectedItemPosition() - 1); - - Long employeeId = null; - if (binding.spinnerStaff.getSelectedItemPosition() > 0) { - employeeId = staffList.get(binding.spinnerStaff.getSelectedItemPosition() - 1).getId(); - } - - String time = String.format("%02d:%02d", - HOURS[binding.spinnerHour.getSelectedItemPosition()], - MINUTES[binding.spinnerMinute.getSelectedItemPosition()]); - - // Get status and convert to uppercase for backend + String time = buildTimeString(); String status = binding.spinnerAppointmentStatus.getSelectedItem().toString().toUpperCase(); - - // Validate future date+time if status is BOOKED - if ("BOOKED".equalsIgnoreCase(status)) { - try { - String[] dateParts = date.split("-"); - String[] timeParts = time.split(":"); - Calendar selected = Calendar.getInstance(); - selected.set( - Integer.parseInt(dateParts[0]), - Integer.parseInt(dateParts[1]) - 1, - Integer.parseInt(dateParts[2]), - Integer.parseInt(timeParts[0]), - Integer.parseInt(timeParts[1]), - 0 - ); - if (selected.before(Calendar.getInstance())) { - DialogUtils.showInfoDialog(requireContext(), "Invalid Time", - "Booked appointments must be in the future. " + - "Please select a future date and time."); - return; - } - } catch (Exception e) { - Log.e("APPT_SAVE", "Date parse error: " + e.getMessage()); - } + if (!appointmentViewModel.isValidFutureBooking(status, date, time)) { + DialogUtils.showInfoDialog(requireContext(), "Invalid Time", "Booked appointments must be in the future."); + return; } - // Build DTO with all required IDs - AppointmentDTO dto = new AppointmentDTO( - customer.getId(), - store.getId(), - service.getServiceId(), - employeeId, - date, - time, - status, - pet.getId() - ); - - androidx.lifecycle.Observer> observer = resource -> { + appointmentViewModel.saveAppointment(date, time, status).observe(getViewLifecycleOwner(), resource -> { if (resource.status == Resource.Status.SUCCESS) { - Toast.makeText(getContext(), isEditing ? "Updated" : "Saved", Toast.LENGTH_SHORT).show(); + AppointmentViewModel.ViewState state = appointmentViewModel.getViewState().getValue(); + String message = (state != null && state.isEditing) ? "Updated" : "Saved"; + Toast.makeText(getContext(), message, Toast.LENGTH_SHORT).show(); navigateBack(); } else if (resource.status == Resource.Status.ERROR) { handleSaveError(resource.message); } - }; + }); + } - if (isEditing) { - appointmentViewModel.updateAppointment(appointmentId, dto).observe(getViewLifecycleOwner(), observer); - } else { - appointmentViewModel.createAppointment(dto).observe(getViewLifecycleOwner(), observer); - } + /** + * Validates that all required fields are selected. + */ + private boolean validateRequiredFields() { + if (binding.spinnerCustomer.getSelectedItemPosition() == 0) return showToast("Select a customer"); + if (binding.spinnerStore.getSelectedItemPosition() == 0) return showToast("Select a store"); + if (binding.spinnerPet.getSelectedItemPosition() == 0) return showToast("Select a pet"); + if (binding.spinnerService.getSelectedItemPosition() == 0) return showToast("Select a service"); + if (binding.etAppointmentDate.getText().toString().trim().isEmpty()) return showToast("Select a date"); + return true; + } + + private boolean showToast(String msg) { + Toast.makeText(getContext(), msg, Toast.LENGTH_SHORT).show(); + return false; + } + + /** + * Builds a time string from the hour and minute spinners. + */ + private String buildTimeString() { + return String.format("%02d:%02d", HOURS[binding.spinnerHour.getSelectedItemPosition()], MINUTES[binding.spinnerMinute.getSelectedItemPosition()]); } /** * Handles errors that occur during the saving process. */ private void handleSaveError(String errorMessage) { - if (errorMessage != null) { - Log.e("APPT_SAVE", "Error: " + errorMessage); - if (errorMessage.toLowerCase().contains("future")) { - DialogUtils.showInfoDialog(requireContext(), "Invalid Date/Time", - "Booked appointments must be scheduled in the future."); - } else if (errorMessage.toLowerCase().contains("not available")) { - showNoAvailabilityDialog(); - } else { - Toast.makeText(getContext(), errorMessage, Toast.LENGTH_SHORT).show(); - } - } else { - Toast.makeText(getContext(), "Something went wrong", Toast.LENGTH_SHORT).show(); - } + if (errorMessage != null && errorMessage.toLowerCase().contains("not available")) showNoAvailabilityDialog(); + else Toast.makeText(getContext(), errorMessage != null ? errorMessage : "Error saving", Toast.LENGTH_SHORT).show(); } /** @@ -559,11 +330,9 @@ public class AppointmentDetailFragment extends Fragment { private void showNoAvailabilityDialog() { new androidx.appcompat.app.AlertDialog.Builder(requireContext()) .setTitle("No Availability") - .setMessage("This time slot is already booked. Please choose a different time or date.") + .setMessage("This time slot is already booked.") .setPositiveButton("Change Time", (d, w) -> d.dismiss()) - .setNegativeButton("Cancel Booking", (d, w) -> navigateBack()) - .setCancelable(false) - .show(); + .setNegativeButton("Cancel Booking", (d, w) -> navigateBack()).show(); } /** @@ -571,13 +340,8 @@ public class AppointmentDetailFragment extends Fragment { */ private void confirmDelete() { DialogUtils.showDeleteConfirmDialog(requireContext(), "Appointment", () -> - appointmentViewModel.deleteAppointment(appointmentId).observe(getViewLifecycleOwner(), resource -> { - if (resource.status == Resource.Status.SUCCESS) { - Toast.makeText(getContext(), "Deleted", Toast.LENGTH_SHORT).show(); - navigateBack(); - } else if (resource.status == Resource.Status.ERROR) { - Toast.makeText(getContext(), "Delete failed", Toast.LENGTH_SHORT).show(); - } + appointmentViewModel.deleteAppointment().observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) navigateBack(); })); } @@ -587,4 +351,24 @@ public class AppointmentDetailFragment extends Fragment { private void navigateBack() { NavHostFragment.findNavController(this).popBackStack(); } + + /** + * Parses a time string and sets the hour and minute spinners. + */ + private void parseAndSetTimeSpinners(String time) { + int[] parsedTime = DateTimeUtils.parseTimeString(time); + if (parsedTime == null) return; + for (int i = 0; i < HOURS.length; i++) if (HOURS[i] == parsedTime[0]) binding.spinnerHour.setSelection(i); + for (int i = 0; i < MINUTES.length; i++) if (MINUTES[i] == parsedTime[1]) binding.spinnerMinute.setSelection(i); + } + + /** + * Helper listener for simple index reporting. + */ + private static class OnIndexSelected implements AdapterView.OnItemSelectedListener { + private final java.util.function.Consumer callback; + public OnIndexSelected(java.util.function.Consumer callback) { this.callback = callback; } + @Override public void onItemSelected(AdapterView p, View v, int pos, long id) { callback.accept(pos); } + @Override public void onNothingSelected(AdapterView p) {} + } } diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/DateTimeUtils.java b/android/app/src/main/java/com/example/petstoremobile/utils/DateTimeUtils.java new file mode 100644 index 00000000..4e79a190 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/DateTimeUtils.java @@ -0,0 +1,98 @@ +package com.example.petstoremobile.utils; + +import android.util.Log; + +import java.util.Calendar; + +/** + * Utility class for date and time operations. + */ +public class DateTimeUtils { + + private static final String TAG = "DateTimeUtils"; + + /** + * Formats status from backend format to UI format + * (backend is using all caps so we lower case them with this function) + */ + public static String formatStatusFromBackend(String status) { + if (status == null || status.isEmpty()) return status; + return status.substring(0, 1).toUpperCase() + status.substring(1).toLowerCase(); + } + + /** + * Converts a date and time string to a Calendar object. + * format: date = "YYYY-MM-DD", time = "HH:MM" + */ + public static Calendar parseDateTimeToCalendar(String date, String time) throws Exception { + Calendar calendar = Calendar.getInstance(); + String[] dateParts = date.split("-"); + String[] timeParts = time.split(":"); + + calendar.set( + Integer.parseInt(dateParts[0]), + Integer.parseInt(dateParts[1]) - 1, + Integer.parseInt(dateParts[2]), + Integer.parseInt(timeParts[0]), + Integer.parseInt(timeParts[1]), + 0 + ); + return calendar; + } + + /** + * Checks if a given date is in the past. + * format: date = "YYYY-MM-DD" + */ + public static boolean isDateInPast(String date) { + if (date == null || date.isEmpty()) return false; + try { + Calendar selected = Calendar.getInstance(); + String[] dateParts = date.split("-"); + selected.set( + Integer.parseInt(dateParts[0]), + Integer.parseInt(dateParts[1]) - 1, + Integer.parseInt(dateParts[2]), + 0, 0, 0 + ); + return selected.before(Calendar.getInstance()); + } catch (Exception e) { + Log.e(TAG, "Error parsing date: " + e.getMessage()); + return false; + } + } + + /** + * Checks if a given date and time are in the past. + * format: date = "YYYY-MM-DD", time = "HH:MM" + */ + public static boolean isDateTimeInPast(String date, String time) { + if (date == null || date.isEmpty() || time == null || time.isEmpty()) return false; + try { + Calendar selected = parseDateTimeToCalendar(date, time); + return selected.before(Calendar.getInstance()); + } catch (Exception e) { + Log.e(TAG, "Error parsing date/time: " + e.getMessage()); + return false; + } + } + + /** + * Parses a time string and returns hour and minute indices for the spinners. + */ + public static int[] parseTimeString(String time) { + if (time == null || time.isEmpty()) return null; + if (time.length() > 5) time = time.substring(0, 5); + + String[] parts = time.split(":"); + if (parts.length != 2) return null; + + try { + int hour = Integer.parseInt(parts[0]); + int min = Integer.parseInt(parts[1]); + return new int[]{hour, min}; + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/AppointmentViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AppointmentViewModel.java index 69f24c95..32f359e7 100644 --- a/android/app/src/main/java/com/example/petstoremobile/viewmodels/AppointmentViewModel.java +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AppointmentViewModel.java @@ -1,14 +1,23 @@ package com.example.petstoremobile.viewmodels; import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import com.example.petstoremobile.dtos.AppointmentDTO; import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.DropdownDTO; import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.ServiceDTO; import com.example.petstoremobile.repositories.AppointmentRepository; +import com.example.petstoremobile.repositories.CustomerRepository; +import com.example.petstoremobile.repositories.PetRepository; +import com.example.petstoremobile.repositories.ServiceRepository; +import com.example.petstoremobile.repositories.StoreRepository; +import com.example.petstoremobile.utils.DateTimeUtils; import com.example.petstoremobile.utils.Resource; +import java.util.ArrayList; import java.util.List; import javax.inject.Inject; @@ -18,12 +27,42 @@ import dagger.hilt.android.lifecycle.HiltViewModel; @HiltViewModel public class AppointmentViewModel extends ViewModel { private final AppointmentRepository repository; + private final CustomerRepository customerRepository; + private final StoreRepository storeRepository; + private final PetRepository petRepository; + private final ServiceRepository serviceRepository; + + private final MutableLiveData> customers = new MutableLiveData<>(); + private final MutableLiveData> stores = new MutableLiveData<>(); + private final MutableLiveData> services = new MutableLiveData<>(); + private final MutableLiveData> customerPets = new MutableLiveData<>(); + private final MutableLiveData> storeEmployees = new MutableLiveData<>(); + + private final MutableLiveData viewState = new MutableLiveData<>(new ViewState()); + + private long appointmentId = -1; + private Long currentCustomerId; + private Long currentStoreId; + private Long currentPetId; + private Long currentServiceId; + private Long currentStaffId; @Inject - public AppointmentViewModel(AppointmentRepository repository) { + public AppointmentViewModel( + AppointmentRepository repository, + CustomerRepository customerRepository, + StoreRepository storeRepository, + PetRepository petRepository, + ServiceRepository serviceRepository) { this.repository = repository; + this.customerRepository = customerRepository; + this.storeRepository = storeRepository; + this.petRepository = petRepository; + this.serviceRepository = serviceRepository; } + // API CRUD + /** * Fetches a paginated list of all appointments with optional filters. */ @@ -65,4 +104,273 @@ public class AppointmentViewModel extends ViewModel { public LiveData> bulkDeleteAppointments(List ids) { return repository.bulkDeleteAppointments(new BulkDeleteRequest(ids)); } -} \ No newline at end of file + + // Initial Data Loading + + /** + * Loads initial dropdown data for customers, stores, and services. + */ + public void loadInitialFormData() { + customerRepository.getCustomerDropdowns().observeForever(r -> { + if (r.status == Resource.Status.SUCCESS) customers.setValue(r.data); + }); + storeRepository.getStoreDropdowns().observeForever(r -> { + if (r.status == Resource.Status.SUCCESS) stores.setValue(r.data); + }); + serviceRepository.getAllServices(0, 200, null, "serviceName").observeForever(r -> { + if (r.status == Resource.Status.SUCCESS && r.data != null) services.setValue(r.data.getContent()); + }); + } + + // LiveData Getters + + public LiveData> getCustomers() { return customers; } + public LiveData> getStores() { return stores; } + public LiveData> getServices() { return services; } + public LiveData> getCustomerPets() { return customerPets; } + public LiveData> getStoreEmployees() { return storeEmployees; } + public LiveData getViewState() { return viewState; } + + //State Getters + + public long getAppointmentId() { return appointmentId; } + + /** + * Sets the current appointment ID and updates the mode. + */ + public void setAppointmentId(long id) { + this.appointmentId = id; + initMode(id != -1); + } + + // Selection Handlers for spinners + + public void onCustomerSelected(int position) { + List list = customers.getValue(); + if (position > 0 && list != null && position <= list.size()) { + currentCustomerId = list.get(position - 1).getId(); + loadPetsForCustomer(currentCustomerId); + updateViewState(s -> { + s.selectedCustomerId = currentCustomerId; + s.isPetEnabled = !s.isEditing; + }); + } else { + currentCustomerId = null; + customerPets.setValue(new ArrayList<>()); + updateViewState(s -> { + s.selectedCustomerId = null; + s.isPetEnabled = false; + }); + } + } + + public void onStoreSelected(int position) { + List list = stores.getValue(); + if (position > 0 && list != null && position <= list.size()) { + currentStoreId = list.get(position - 1).getId(); + loadEmployeesForStore(currentStoreId); + updateViewState(s -> { + s.selectedStoreId = currentStoreId; + s.isStaffEnabled = !s.isPast; + }); + } else { + currentStoreId = null; + storeEmployees.setValue(new ArrayList<>()); + updateViewState(s -> { + s.selectedStoreId = null; + s.isStaffEnabled = false; + }); + } + } + + public void onServiceSelected(int position) { + List list = services.getValue(); + currentServiceId = (position > 0 && list != null && position <= list.size()) ? list.get(position - 1).getServiceId() : null; + updateViewState(s -> s.selectedServiceId = currentServiceId); + } + + public void onPetSelected(int position) { + List list = customerPets.getValue(); + currentPetId = (position > 0 && list != null && position <= list.size()) ? list.get(position - 1).getId() : null; + updateViewState(s -> s.selectedPetId = currentPetId); + } + + public void onStaffSelected(int position) { + List list = storeEmployees.getValue(); + currentStaffId = (position > 0 && list != null && position <= list.size()) ? list.get(position - 1).getId() : null; + updateViewState(s -> s.selectedStaffId = currentStaffId); + } + + private void loadPetsForCustomer(Long customerId) { + petRepository.getCustomerPets(customerId).observeForever(r -> { + if (r.status == Resource.Status.SUCCESS) customerPets.setValue(r.data); + }); + } + + private void loadEmployeesForStore(Long storeId) { + storeRepository.getStoreEmployees(storeId).observeForever(r -> { + if (r.status == Resource.Status.SUCCESS) storeEmployees.setValue(r.data); + }); + } + + // Appointment Detail CRUD + + /** + * Fetches appointment details and populates internal state. + */ + public LiveData> loadAppointment() { + MutableLiveData> result = new MutableLiveData<>(); + repository.getAppointmentById(appointmentId).observeForever(resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + AppointmentDTO a = resource.data; + currentCustomerId = a.getCustomerId(); + currentStoreId = a.getStoreId(); + currentPetId = a.getPetId(); + currentServiceId = a.getServiceId(); + currentStaffId = a.getEmployeeId(); + + updateViewState(s -> { + s.selectedCustomerId = currentCustomerId; + s.selectedStoreId = currentStoreId; + s.selectedPetId = currentPetId; + s.selectedServiceId = currentServiceId; + s.selectedStaffId = currentStaffId; + }); + + if (currentCustomerId != null) loadPetsForCustomer(currentCustomerId); + if (currentStoreId != null) loadEmployeesForStore(currentStoreId); + } + result.setValue(resource); + }); + return result; + } + + /** + * Saves the appointment by building the DTO from tracked state. + */ + public LiveData> saveAppointment(String date, String time, String status) { + AppointmentDTO dto = new AppointmentDTO(currentCustomerId, currentStoreId, currentServiceId, currentStaffId, date, time, status, currentPetId); + if (appointmentId != -1) { + return repository.updateAppointment(appointmentId, dto); + } else { + return repository.createAppointment(dto); + } + } + + /** + * Deletes the current appointment. + */ + public LiveData> deleteAppointment() { + return repository.deleteAppointment(appointmentId); + } + + // --- UI Logic --- + + public void onDateOrTimeChanged(String date, String time, String currentStatus) { + updateViewState(s -> { + s.availableStatuses = calculateAvailableStatuses(s.isEditing, date, time, currentStatus); + boolean isPast = DateTimeUtils.isDateTimeInPast(date, time); + boolean isCancelled = "Cancelled".equalsIgnoreCase(currentStatus); + + if (isCancelled) { + s.isPast = true; + setAllFieldsEnabled(s, false); + s.isStatusEnabled = false; + s.isSaveVisible = false; + } else if (isPast) { + s.isPast = true; + setAllFieldsEnabled(s, false); + s.isStatusEnabled = true; + } else { + s.isPast = false; + if (!s.isEditing) { + s.isCustomerEnabled = true; + s.isStoreEnabled = true; + s.isServiceEnabled = true; + s.isPetEnabled = currentCustomerId != null; + } + s.isDateEnabled = true; + s.isTimeEnabled = true; + s.isStatusEnabled = true; + } + }); + } + + private String[] calculateAvailableStatuses(boolean isEditing, String date, String currentTime, String currentStatus) { + if (!isEditing) return new String[]{"Booked"}; + if (date == null || date.isEmpty()) return new String[]{}; + if ("Cancelled".equalsIgnoreCase(currentStatus)) return new String[]{"Cancelled"}; + if (DateTimeUtils.isDateTimeInPast(date, currentTime)) return new String[]{"Completed", "Missed"}; + return new String[]{"Booked", "Cancelled"}; + } + + private void setAllFieldsEnabled(ViewState s, boolean enabled) { + s.isCustomerEnabled = enabled; + s.isStoreEnabled = enabled; + s.isPetEnabled = enabled; + s.isServiceEnabled = enabled; + s.isStaffEnabled = enabled; + s.isDateEnabled = enabled; + s.isTimeEnabled = enabled; + } + + public void initMode(boolean isEditing) { + updateViewState(s -> { + s.isEditing = isEditing; + s.isDeleteVisible = isEditing; + if (isEditing) { + s.isCustomerEnabled = false; + s.isStoreEnabled = false; + s.isPetEnabled = false; + s.isServiceEnabled = false; + } else { + s.isCustomerEnabled = true; + s.isStoreEnabled = true; + s.isServiceEnabled = true; + s.isPetEnabled = false; // until customer selected + s.isStaffEnabled = false; // until store selected + s.availableStatuses = new String[]{"Booked"}; + } + }); + } + + public boolean isValidFutureBooking(String status, String date, String time) { + return !"BOOKED".equalsIgnoreCase(status) || !DateTimeUtils.isDateTimeInPast(date, time); + } + + private void updateViewState(Action action) { + ViewState current = viewState.getValue(); + if (current != null) { + action.run(current); + viewState.setValue(current); + } + } + + private interface Action { void run(T t); } + + /** + * A Class to show the states of Appointment Detail Fragment. + */ + public static class ViewState { + public boolean isPast = false; + public boolean isEditing = false; + public boolean isSaveVisible = true; + public boolean isDeleteVisible = false; + public boolean isCustomerEnabled = true; + public boolean isStoreEnabled = true; + public boolean isPetEnabled = false; + public boolean isServiceEnabled = true; + public boolean isStaffEnabled = false; + public boolean isDateEnabled = true; + public boolean isTimeEnabled = true; + public boolean isStatusEnabled = true; + public String[] availableStatuses = new String[]{}; + + // Selected IDs + public Long selectedCustomerId = null; + public Long selectedStoreId = null; + public Long selectedPetId = null; + public Long selectedServiceId = null; + public Long selectedStaffId = null; + } +}