From 6f646a7cf0bfa396ad9b306b536dcf91339440e7 Mon Sep 17 00:00:00 2001 From: Alex <78383757+Lextical@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:34:28 -0600 Subject: [PATCH] added filter options to appointments in the backend and andriod --- .../petstoremobile/api/AppointmentApi.java | 6 +- .../listfragments/AppointmentFragment.java | 151 +++++++++++++----- .../repositories/AppointmentRepository.java | 8 +- .../viewmodels/AppointmentViewModel.java | 8 +- .../main/res/layout/fragment_appointment.xml | 99 ++++++++++-- .../controller/AppointmentController.java | 15 +- .../repository/AppointmentRepository.java | 27 ++-- .../backend/service/AppointmentService.java | 43 +++-- 8 files changed, 261 insertions(+), 96 deletions(-) diff --git a/android/app/src/main/java/com/example/petstoremobile/api/AppointmentApi.java b/android/app/src/main/java/com/example/petstoremobile/api/AppointmentApi.java index 5d7044cf..483c66db 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/AppointmentApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/AppointmentApi.java @@ -17,7 +17,11 @@ public interface AppointmentApi { @GET("api/v1/appointments") Call> getAllAppointments( @Query("page") int page, - @Query("size") int size); + @Query("size") int size, + @Query("q") String query, + @Query("status") String status, + @Query("storeId") Long storeId, + @Query("date") String date); @GET("api/v1/appointments/{id}") Call getAppointmentById(@Path("id") Long id); diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AppointmentFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AppointmentFragment.java index c93b9e89..fab6a0b5 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AppointmentFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AppointmentFragment.java @@ -16,15 +16,21 @@ import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.AdapterView; import android.widget.Toast; import com.example.petstoremobile.R; import com.example.petstoremobile.adapters.AppointmentAdapter; +import com.example.petstoremobile.adapters.WhiteTextArrayAdapter; import com.example.petstoremobile.databinding.FragmentAppointmentBinding; import com.example.petstoremobile.dtos.AppointmentDTO; +import com.example.petstoremobile.dtos.StoreDTO; import com.example.petstoremobile.fragments.ListFragment; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.SpinnerUtils; import com.example.petstoremobile.viewmodels.AppointmentViewModel; import com.example.petstoremobile.utils.EventDecorator; +import com.example.petstoremobile.viewmodels.StoreViewModel; import com.prolificinteractive.materialcalendarview.CalendarDay; import com.prolificinteractive.materialcalendarview.CalendarMode; @@ -44,10 +50,11 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. private FragmentAppointmentBinding binding; private List appointmentList = new ArrayList<>(); - private List filteredList = new ArrayList<>(); + private List storeList = new ArrayList<>(); private AppointmentAdapter adapter; private AppointmentViewModel appointmentViewModel; + private StoreViewModel storeViewModel; private CalendarDay selectedCalendarDay; private boolean isMonthMode = false; @@ -60,6 +67,7 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); appointmentViewModel = new ViewModelProvider(this).get(AppointmentViewModel.class); + storeViewModel = new ViewModelProvider(this).get(StoreViewModel.class); } /** @@ -72,9 +80,11 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. setupRecyclerView(); setupSearch(); + setupStatusFilter(); + setupStoreFilter(); setupSwipeRefresh(); setupCalendar(); - loadAppointmentData(); + setupFilterToggle(); binding.fabAddAppointment.setOnClickListener(v -> openAppointmentDetails(-1)); @@ -99,6 +109,13 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. binding = null; } + @Override + public void onResume() { + super.onResume(); + loadAppointmentData(); + loadStoreData(); + } + /** * Toggles the calendar between week and month display modes. */ @@ -109,6 +126,28 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. .commit(); } + /** + * Sets up the filter toggle button to show/hide the filter layout. + */ + private void setupFilterToggle() { + binding.btnToggleFilter.setOnClickListener(v -> { + if (binding.layoutFilter.getVisibility() == View.GONE) { + binding.layoutFilter.setVisibility(View.VISIBLE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + binding.layoutFilter.setVisibility(View.GONE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_search); + + // Reset filters when closing + binding.etSearchAppointment.setText(""); + binding.spinnerStatus.setSelection(0); + binding.spinnerStore.setSelection(0); + selectedCalendarDay = null; + binding.calendarView.clearSelection(); + } + }); + } + /** * Sets up the date selection listener for the calendar. */ @@ -124,7 +163,7 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. } else { selectedCalendarDay = null; } - filterAppointments(binding.etSearchAppointment.getText().toString()); + loadAppointmentData(); }); } @@ -157,48 +196,56 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. */ private void setupSearch() { binding.etSearchAppointment.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - filterAppointments(s.toString()); - } - - @Override - public void afterTextChanged(Editable s) { + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) { + loadAppointmentData(); } + @Override public void afterTextChanged(Editable s) {} }); } /** - * Filters the appointment list based on the search query and selected calendar date. + * Configures the status filter spinner. */ - private void filterAppointments(String query) { - filteredList.clear(); - String lowerQuery = query.toLowerCase(); + private void setupStatusFilter() { + String[] statuses = {"All Statuses", "Booked", "Completed", "Cancelled", "Missed"}; + WhiteTextArrayAdapter adapter = new WhiteTextArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_item, statuses); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + binding.spinnerStatus.setAdapter(adapter); - String selectedDateString = null; - if (selectedCalendarDay != null) { - selectedDateString = String.format(Locale.getDefault(), "%04d-%02d-%02d", - selectedCalendarDay.getYear(), selectedCalendarDay.getMonth(), selectedCalendarDay.getDay()); - } - - for (AppointmentDTO a : appointmentList) { - boolean matchesSearch = query.isEmpty() || - (a.getCustomerName() != null && a.getCustomerName().toLowerCase().contains(lowerQuery)) || - (a.getServiceType() != null && a.getServiceType().toLowerCase().contains(lowerQuery)) || - (a.getPetName() != null && a.getPetName().toLowerCase().contains(lowerQuery)); - - boolean matchesDate = (selectedDateString == null) || - (a.getAppointmentDate() != null && a.getAppointmentDate().equals(selectedDateString)); - - if (matchesSearch && matchesDate) { - filteredList.add(a); + binding.spinnerStatus.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + loadAppointmentData(); } - } - adapter.notifyDataSetChanged(); + @Override public void onNothingSelected(AdapterView parent) {} + }); + } + + /** + * Configures the store filter spinner. + */ + private void setupStoreFilter() { + binding.spinnerStore.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + loadAppointmentData(); + } + @Override public void onNothingSelected(AdapterView parent) {} + }); + } + + /** + * Fetches store data to populate the store filter. + */ + private void loadStoreData() { + storeViewModel.getAllStores(0, 100).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + storeList = resource.data.getContent(); + SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerStore, storeList, + StoreDTO::getStoreName, "All Stores", -1L, StoreDTO::getStoreId); + } + }); } /** @@ -214,7 +261,7 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. private void openAppointmentDetails(int position) { Bundle args = new Bundle(); if (position != -1) { - AppointmentDTO a = filteredList.get(position); + AppointmentDTO a = appointmentList.get(position); args.putLong("appointmentId", a.getAppointmentId()); } NavHostFragment.findNavController(this).navigate(R.id.nav_appointment_detail, args); @@ -229,11 +276,27 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. } /** - * Fetches all appointment data from the server. + * Fetches appointment data from the server with all active filters. */ private void loadAppointmentData() { - //Load all appointments from the backend using viewModel - appointmentViewModel.getAllAppointments(0, 500).observe(getViewLifecycleOwner(), resource -> { + String query = binding.etSearchAppointment.getText().toString().trim(); + String status = binding.spinnerStatus.getSelectedItem() != null ? binding.spinnerStatus.getSelectedItem().toString() : "All Statuses"; + + Long storeId = null; + if (binding.spinnerStore.getSelectedItemPosition() > 0 && !storeList.isEmpty()) { + storeId = storeList.get(binding.spinnerStore.getSelectedItemPosition() - 1).getStoreId(); + } + + String selectedDateString = null; + if (selectedCalendarDay != null) { + selectedDateString = String.format(Locale.getDefault(), "%04d-%02d-%02d", + selectedCalendarDay.getYear(), selectedCalendarDay.getMonth(), selectedCalendarDay.getDay()); + } + + if (status.equals("All Statuses")) status = null; + else status = status.toUpperCase(); + + appointmentViewModel.getAllAppointments(0, 500, query, status, storeId, selectedDateString).observe(getViewLifecycleOwner(), resource -> { if (resource == null) return; // Check the status to see if the resource is loaded and display the data @@ -249,7 +312,7 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. appointmentList.clear(); appointmentList.addAll(resource.data.getContent()); updateCalendarDecorators(); - filterAppointments(binding.etSearchAppointment != null ? binding.etSearchAppointment.getText().toString() : ""); + adapter.notifyDataSetChanged(); } break; case ERROR: @@ -266,8 +329,8 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. * Initializes the RecyclerView for displaying appointments. */ private void setupRecyclerView() { - adapter = new AppointmentAdapter(filteredList, this); + adapter = new AppointmentAdapter(appointmentList, this); binding.recyclerViewAppointments.setLayoutManager(new LinearLayoutManager(getContext())); binding.recyclerViewAppointments.setAdapter(adapter); } -} +} \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/AppointmentRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/AppointmentRepository.java index 30e25d0e..21c4fdc9 100644 --- a/android/app/src/main/java/com/example/petstoremobile/repositories/AppointmentRepository.java +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/AppointmentRepository.java @@ -21,10 +21,10 @@ public class AppointmentRepository extends BaseRepository { } /** - * Retrieves a paginated list of all appointments from the API. + * Retrieves a paginated list of all appointments from the API with filtering. */ - public LiveData>> getAllAppointments(int page, int size) { - return executeCall(appointmentApi.getAllAppointments(page, size)); + public LiveData>> getAllAppointments(int page, int size, String query, String status, Long storeId, String date) { + return executeCall(appointmentApi.getAllAppointments(page, size, query, status, storeId, date)); } /** @@ -54,4 +54,4 @@ public class AppointmentRepository extends BaseRepository { public LiveData> deleteAppointment(Long id) { return executeCall(appointmentApi.deleteAppointment(id)); } -} +} \ No newline at end of file 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 23db67d0..c97dc97c 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 @@ -22,10 +22,10 @@ public class AppointmentViewModel extends ViewModel { } /** - * Fetches a paginated list of all appointments. + * Fetches a paginated list of all appointments with optional filters. */ - public LiveData>> getAllAppointments(int page, int size) { - return repository.getAllAppointments(page, size); + public LiveData>> getAllAppointments(int page, int size, String query, String status, Long storeId, String date) { + return repository.getAllAppointments(page, size, query, status, storeId, date); } /** @@ -55,4 +55,4 @@ public class AppointmentViewModel extends ViewModel { public LiveData> deleteAppointment(Long id) { return repository.deleteAppointment(id); } -} +} \ No newline at end of file diff --git a/android/app/src/main/res/layout/fragment_appointment.xml b/android/app/src/main/res/layout/fragment_appointment.xml index 10e17d48..f8a98ec2 100644 --- a/android/app/src/main/res/layout/fragment_appointment.xml +++ b/android/app/src/main/res/layout/fragment_appointment.xml @@ -35,7 +35,8 @@ android:text="Appointments" android:textColor="@color/white" android:textSize="20sp" - android:textStyle="bold"/> + android:textStyle="bold" + android:layout_marginStart="8dp"/> + + + + + + + + + + + + + + + + + + + + + + + + - - > getAllAppointments( @RequestParam(required = false) String q, + @RequestParam(required = false) Long storeId, + @RequestParam(required = false) String status, + @RequestParam(required = false) String date, + @RequestParam(required = false) Long customerId, + @RequestParam(required = false) Long employeeId, Pageable pageable) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String role = authentication.getAuthorities().stream() .findFirst() .map(authority -> authority.getAuthority().replace("ROLE_", "")) .orElse(null); - Long customerId = null; + Long effectiveCustomerId = customerId; if (role != null && role.equals("CUSTOMER")) { User user = AuthenticationHelper.getAuthenticatedUser(userRepository); - customerId = user.getId(); + effectiveCustomerId = user.getId(); } - return ResponseEntity.ok(appointmentService.getAllAppointments(q, pageable, customerId)); + LocalDate appointmentDate = (date != null && !date.isBlank()) ? LocalDate.parse(date) : null; + + return ResponseEntity.ok(appointmentService.getAllAppointments( + q, effectiveCustomerId, employeeId, storeId, status, appointmentDate, pageable)); } @GetMapping("/{id}") diff --git a/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java b/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java index 00edebf9..dc7d40ef 100644 --- a/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java @@ -22,20 +22,25 @@ public interface AppointmentRepository extends JpaRepository List findByStoreAndDate(@Param("storeId") Long storeId, @Param("date") LocalDate date); @Query("SELECT a FROM Appointment a LEFT JOIN a.pet p WHERE " + + "(:q IS NULL OR (" + "LOWER(a.customer.firstName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + "LOWER(a.customer.lastName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + "LOWER(a.service.serviceName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + - "LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%'))") - Page searchAppointments(@Param("q") String query, Pageable pageable); - - Page findByCustomerId(Long customerId, Pageable pageable); - - @Query("SELECT a FROM Appointment a LEFT JOIN a.pet p WHERE a.customer.id = :customerId AND (" + - "LOWER(a.customer.firstName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + - "LOWER(a.customer.lastName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + - "LOWER(a.service.serviceName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + - "LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')))") - Page searchAppointmentsByCustomer(@Param("customerId") Long customerId, @Param("q") String query, Pageable pageable); + "LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%'))" + + ")) AND " + + "(:customerId IS NULL OR a.customer.id = :customerId) AND " + + "(:employeeId IS NULL OR a.employee.id = :employeeId) AND " + + "(:storeId IS NULL OR a.store.storeId = :storeId) AND " + + "(:status IS NULL OR LOWER(a.appointmentStatus) = LOWER(:status)) AND " + + "(:date IS NULL OR a.appointmentDate = :date)") + Page searchAppointments( + @Param("q") String query, + @Param("customerId") Long customerId, + @Param("employeeId") Long employeeId, + @Param("storeId") Long storeId, + @Param("status") String status, + @Param("date") LocalDate date, + Pageable pageable); @Query("SELECT a FROM Appointment a JOIN FETCH a.service WHERE a.employee.id = :employeeId AND a.appointmentDate = :date AND LOWER(a.appointmentStatus) NOT IN ('cancelled', 'missed')") List findByEmployeeIdAndAppointmentDate(@Param("employeeId") Long employeeId, @Param("date") LocalDate date); diff --git a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java index d7a65149..d037b057 100644 --- a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java +++ b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java @@ -45,22 +45,27 @@ public class AppointmentService { } @Transactional(readOnly = true) - public Page getAllAppointments(String query, Pageable pageable, Long customerId) { - Page appointments; + public Page getAllAppointments( + String query, + Long customerId, + Long employeeId, + Long storeId, + String status, + LocalDate date, + Pageable pageable) { - if (customerId != null) { - if (query != null && !query.trim().isEmpty()) { - appointments = appointmentRepository.searchAppointmentsByCustomer(customerId, query, pageable); - } else { - appointments = appointmentRepository.findByCustomerId(customerId, pageable); - } - } else { - if (query != null && !query.trim().isEmpty()) { - appointments = appointmentRepository.searchAppointments(query, pageable); - } else { - appointments = appointmentRepository.findAll(pageable); - } - } + String normalizedQuery = normalizeFilter(query); + String normalizedStatus = normalizeFilter(status); + + Page appointments = appointmentRepository.searchAppointments( + normalizedQuery, + customerId, + employeeId, + storeId, + normalizedStatus, + date, + pageable + ); return appointments.map(this::mapToResponse); } @@ -204,6 +209,14 @@ public class AppointmentService { return availableSlots; } + private String normalizeFilter(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + private void validateAppointmentRequest(AppointmentRequest request) { if ("Booked".equalsIgnoreCase(request.getAppointmentStatus())) { LocalDateTime appointmentDateTime = LocalDateTime.of(request.getAppointmentDate(), request.getAppointmentTime());