Merge branch 'AttachmentsToChat'

This commit is contained in:
Alex
2026-04-15 02:15:16 -06:00
41 changed files with 1127 additions and 372 deletions

View File

@@ -10,11 +10,19 @@ import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R; import com.example.petstoremobile.R;
import com.example.petstoremobile.dtos.ActivityLogDTO; import com.example.petstoremobile.dtos.ActivityLogDTO;
import com.example.petstoremobile.utils.DateTimeUtils;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Locale;
public class ActivityLogAdapter extends RecyclerView.Adapter<ActivityLogAdapter.ViewHolder> { public class ActivityLogAdapter extends RecyclerView.Adapter<ActivityLogAdapter.ViewHolder> {
private static final String SEPARATOR = " | ";
private static final SimpleDateFormat INPUT_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault());
private static final SimpleDateFormat OUTPUT_FORMAT = new SimpleDateFormat("MMM d, HH:mm", Locale.getDefault());
private final List<ActivityLogDTO> items; private final List<ActivityLogDTO> items;
public ActivityLogAdapter(List<ActivityLogDTO> items) { public ActivityLogAdapter(List<ActivityLogDTO> items) {
@@ -32,26 +40,77 @@ public class ActivityLogAdapter extends RecyclerView.Adapter<ActivityLogAdapter.
@Override @Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) { public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
ActivityLogDTO log = items.get(position); ActivityLogDTO log = items.get(position);
holder.tvActivity.setText(log.getActivity());
holder.tvUser.setText(log.getFullName() + " (" + log.getUsername() + ")"); String activity = log.getActivity() != null ? log.getActivity() : "";
holder.tvMeta.setText(log.getStoreName() + " · " + log.getRole()); int separatorIndex = activity.indexOf(SEPARATOR);
String timestamp = log.getLogTimestamp(); if (separatorIndex >= 0) {
String date = DateTimeUtils.extractDate(timestamp); holder.tvActivity.setText(activity.substring(0, separatorIndex).trim());
String time = (timestamp != null && timestamp.length() >= 16) ? timestamp.substring(11, 16) : null; holder.tvTechnical.setText(activity.substring(separatorIndex + SEPARATOR.length()).trim());
holder.tvTimestamp.setText(date != null && time != null ? date + " " + time : date); holder.tvTechnical.setVisibility(View.VISIBLE);
} else {
holder.tvActivity.setText(activity);
holder.tvTechnical.setVisibility(View.GONE);
}
String fullName = firstNonBlank(log.getFullName(), log.getFullNameSnapshot(), "Unknown");
String username = firstNonBlank(log.getUsername(), log.getUsernameSnapshot(), "");
holder.tvUser.setText(username.isEmpty() ? fullName : fullName + " (" + username + ")");
String role = firstNonBlank(log.getRole(), log.getRoleSnapshot(), "");
String store = firstNonBlank(log.getStoreName(), log.getStoreNameSnapshot(), "");
if (!role.isEmpty() && !store.isEmpty()) {
holder.tvMeta.setText(store + " · " + formatRole(role));
} else if (!role.isEmpty()) {
holder.tvMeta.setText(formatRole(role));
} else if (!store.isEmpty()) {
holder.tvMeta.setText(store);
} else {
holder.tvMeta.setText("");
}
holder.tvTimestamp.setText(formatTimestamp(log.getLogTimestamp()));
} }
@Override @Override
public int getItemCount() { return items.size(); } public int getItemCount() { return items.size(); }
private String formatTimestamp(String raw) {
if (raw == null) return "";
try {
String normalized = raw.length() > 19 ? raw.substring(0, 19) : raw;
Date date = INPUT_FORMAT.parse(normalized);
return date != null ? OUTPUT_FORMAT.format(date) : raw.substring(0, Math.min(16, raw.length())).replace("T", " ");
} catch (ParseException e) {
return raw.length() >= 16 ? raw.substring(0, 16).replace("T", " ") : raw;
}
}
private String formatRole(String role) {
if (role == null) return "";
switch (role.toUpperCase(Locale.ROOT)) {
case "ADMIN": return "Admin";
case "STAFF": return "Staff";
case "CUSTOMER": return "Customer";
default: return role;
}
}
private String firstNonBlank(String... values) {
for (String v : values) {
if (v != null && !v.isBlank()) return v;
}
return "";
}
public static class ViewHolder extends RecyclerView.ViewHolder { public static class ViewHolder extends RecyclerView.ViewHolder {
TextView tvActivity, tvUser, tvMeta, tvTimestamp; TextView tvActivity, tvTechnical, tvUser, tvMeta, tvTimestamp;
public ViewHolder(@NonNull View itemView) { public ViewHolder(@NonNull View itemView) {
super(itemView); super(itemView);
tvActivity = itemView.findViewById(R.id.tvLogActivity); tvActivity = itemView.findViewById(R.id.tvLogActivity);
tvUser = itemView.findViewById(R.id.tvLogUser); tvTechnical = itemView.findViewById(R.id.tvLogTechnical);
tvMeta = itemView.findViewById(R.id.tvLogMeta); tvUser = itemView.findViewById(R.id.tvLogUser);
tvMeta = itemView.findViewById(R.id.tvLogMeta);
tvTimestamp = itemView.findViewById(R.id.tvLogTimestamp); tvTimestamp = itemView.findViewById(R.id.tvLogTimestamp);
} }
} }

View File

@@ -1,6 +1,5 @@
package com.example.petstoremobile.adapters; package com.example.petstoremobile.adapters;
import android.graphics.Color;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.ViewGroup; import android.view.ViewGroup;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@@ -49,27 +48,6 @@ public class PurchaseOrderAdapter extends RecyclerView.Adapter<PurchaseOrderAdap
binding.tvPOStore.setText("Store: " + (po.getStoreName() != null ? po.getStoreName() : "")); binding.tvPOStore.setText("Store: " + (po.getStoreName() != null ? po.getStoreName() : ""));
binding.tvPODate.setText("Date: " + (po.getOrderDate() != null ? po.getOrderDate() : "")); binding.tvPODate.setText("Date: " + (po.getOrderDate() != null ? po.getOrderDate() : ""));
String status = po.getStatus() != null ? po.getStatus() : "";
binding.tvPOStatus.setText(status);
switch (status.toUpperCase()) {
case "RECEIVED":
binding.tvPOStatus.setBackgroundColor(Color.parseColor("#4CAF50"));
break;
case "PLACED":
binding.tvPOStatus.setBackgroundColor(Color.parseColor("#2196F3"));
break;
case "PENDING":
binding.tvPOStatus.setBackgroundColor(Color.parseColor("#FF9800"));
break;
case "CANCELLED":
binding.tvPOStatus.setBackgroundColor(Color.parseColor("#F44336"));
break;
default:
binding.tvPOStatus.setBackgroundColor(Color.parseColor("#9E9E9E"));
break;
}
holder.itemView.setOnClickListener(v -> listener.onPurchaseOrderClick(position)); holder.itemView.setOnClickListener(v -> listener.onPurchaseOrderClick(position));
} }

View File

@@ -15,6 +15,8 @@ public interface ActivityLogApi {
@Query("limit") int limit, @Query("limit") int limit,
@Query("storeId") Long storeId, @Query("storeId") Long storeId,
@Query("role") String role, @Query("role") String role,
@Query("search") String search @Query("search") String search,
@Query("startDate") String startDate,
@Query("endDate") String endDate
); );
} }

View File

@@ -1,5 +1,6 @@
package com.example.petstoremobile.fragments.listfragments; package com.example.petstoremobile.fragments.listfragments;
import android.app.DatePickerDialog;
import android.os.Bundle; import android.os.Bundle;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
@@ -10,6 +11,7 @@ import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.widget.Toast; import android.widget.Toast;
@@ -27,6 +29,7 @@ import com.example.petstoremobile.viewmodels.ActivityLogListViewModel;
import javax.inject.Inject; import javax.inject.Inject;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Calendar;
import java.util.List; import java.util.List;
import dagger.hilt.android.AndroidEntryPoint; import dagger.hilt.android.AndroidEntryPoint;
@@ -38,6 +41,8 @@ public class ActivityLogFragment extends Fragment {
private ActivityLogAdapter adapter; private ActivityLogAdapter adapter;
private final List<ActivityLogDTO> logList = new ArrayList<>(); private final List<ActivityLogDTO> logList = new ArrayList<>();
private List<DropdownDTO> storeList = new ArrayList<>(); private List<DropdownDTO> storeList = new ArrayList<>();
private String selectedStartDate = null;
private String selectedEndDate = null;
@Inject TokenManager tokenManager; @Inject TokenManager tokenManager;
@@ -74,6 +79,22 @@ public class ActivityLogFragment extends Fragment {
adapter = new ActivityLogAdapter(logList); adapter = new ActivityLogAdapter(logList);
binding.recyclerViewActivityLog.setLayoutManager(new LinearLayoutManager(getContext())); binding.recyclerViewActivityLog.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewActivityLog.setAdapter(adapter); binding.recyclerViewActivityLog.setAdapter(adapter);
binding.recyclerViewActivityLog.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewActivityLog.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
viewModel.loadLogs(false);
}
}
});
} }
private void setupFilters() { private void setupFilters() {
@@ -89,6 +110,35 @@ public class ActivityLogFragment extends Fragment {
? binding.spinnerRoleFilter.getSelectedItem().toString() : "All Roles")); ? binding.spinnerRoleFilter.getSelectedItem().toString() : "All Roles"));
SpinnerUtils.setupFilterSpinner(binding.spinnerStoreFilter, this::onStoreSelected); SpinnerUtils.setupFilterSpinner(binding.spinnerStoreFilter, this::onStoreSelected);
binding.btnStartDate.setOnClickListener(v -> showDatePicker(true));
binding.btnEndDate.setOnClickListener(v -> showDatePicker(false));
binding.btnClearDates.setOnClickListener(v -> {
selectedStartDate = null;
selectedEndDate = null;
binding.btnStartDate.setText("Start Date");
binding.btnEndDate.setText("End Date");
binding.btnClearDates.setVisibility(View.GONE);
viewModel.setDateRange(null, null);
});
}
private void showDatePicker(boolean isStart) {
Calendar cal = Calendar.getInstance();
new DatePickerDialog(requireContext(), (view, year, month, day) -> {
String date = String.format("%04d-%02d-%02d", year, month + 1, day);
String label = String.format("%02d/%02d/%04d", day, month + 1, year);
if (isStart) {
selectedStartDate = date;
binding.btnStartDate.setText(label);
} else {
selectedEndDate = date;
binding.btnEndDate.setText(label);
}
binding.btnClearDates.setVisibility(
selectedStartDate != null || selectedEndDate != null ? View.VISIBLE : View.GONE);
viewModel.setDateRange(selectedStartDate, selectedEndDate);
}, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)).show();
} }
private void onStoreSelected() { private void onStoreSelected() {

View File

@@ -14,6 +14,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R; import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.AdoptionAdapter; import com.example.petstoremobile.adapters.AdoptionAdapter;
@@ -114,14 +115,14 @@ public class AdoptionFragment extends Fragment implements AdoptionAdapter.OnAdop
adapter, adapter,
"adoption", "adoption",
viewModel::bulkDeleteAdoptions, viewModel::bulkDeleteAdoptions,
this::loadAdoptions () -> loadAdoptions(true)
); );
} }
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
loadAdoptions(); loadAdoptions(true);
if (!isStaff()) viewModel.loadStores(); if (!isStaff()) viewModel.loadStores();
} }
@@ -159,7 +160,7 @@ public class AdoptionFragment extends Fragment implements AdoptionAdapter.OnAdop
} else { } else {
selectedCalendarDay = null; selectedCalendarDay = null;
} }
loadAdoptions(); loadAdoptions(true);
}); });
} }
@@ -187,26 +188,42 @@ public class AdoptionFragment extends Fragment implements AdoptionAdapter.OnAdop
adapter = new AdoptionAdapter(adoptionList, this); adapter = new AdoptionAdapter(adoptionList, this);
binding.recyclerViewAdoptions.setLayoutManager(new LinearLayoutManager(getContext())); binding.recyclerViewAdoptions.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewAdoptions.setAdapter(adapter); binding.recyclerViewAdoptions.setAdapter(adapter);
binding.recyclerViewAdoptions.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewAdoptions.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadAdoptions(false);
}
}
});
} }
private void setupSearch() { private void setupSearch() {
UIUtils.attachSearch(binding.etSearchAdoption, this::loadAdoptions); UIUtils.attachSearch(binding.etSearchAdoption, () -> loadAdoptions(true));
} }
private void setupStatusFilter() { private void setupStatusFilter() {
String[] statuses = {"All Statuses", "Completed", "Pending", "Missed", "Cancelled"}; String[] statuses = {"All Statuses", "Completed", "Pending", "Missed", "Cancelled"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatusAdoption, statuses, this::loadAdoptions); SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatusAdoption, statuses, () -> loadAdoptions(true));
} }
private void setupStoreFilter() { private void setupStoreFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerStoreAdoption, this::loadAdoptions); SpinnerUtils.setupFilterSpinner(binding.spinnerStoreAdoption, () -> loadAdoptions(true));
} }
private void setupSwipeRefresh() { private void setupSwipeRefresh() {
binding.swipeRefreshAdoption.setOnRefreshListener(this::loadAdoptions); binding.swipeRefreshAdoption.setOnRefreshListener(() -> loadAdoptions(true));
} }
private void loadAdoptions() { private void loadAdoptions(boolean reset) {
String query = binding.etSearchAdoption.getText().toString().trim(); String query = binding.etSearchAdoption.getText().toString().trim();
String status = binding.spinnerStatusAdoption.getSelectedItem() != null ? binding.spinnerStatusAdoption.getSelectedItem().toString() : "All Statuses"; String status = binding.spinnerStatusAdoption.getSelectedItem() != null ? binding.spinnerStatusAdoption.getSelectedItem().toString() : "All Statuses";
@@ -230,7 +247,7 @@ public class AdoptionFragment extends Fragment implements AdoptionAdapter.OnAdop
if (status.equals("All Statuses")) status = null; if (status.equals("All Statuses")) status = null;
else status = status.toUpperCase(); else status = status.toUpperCase();
viewModel.loadAdoptions(true, query, status, storeId, selectedDateString, null); viewModel.loadAdoptions(reset, query, status, storeId, selectedDateString, null);
} }
private void openDetail(int position) { private void openDetail(int position) {

View File

@@ -9,6 +9,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.util.Log; import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
@@ -123,14 +124,14 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter.
adapter, adapter,
"appointment", "appointment",
viewModel::bulkDeleteAppointments, viewModel::bulkDeleteAppointments,
this::loadAppointmentData () -> loadAppointmentData(true)
); );
} }
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
loadAppointmentData(); loadAppointmentData(true);
if (!isStaff()) viewModel.loadStores(); if (!isStaff()) viewModel.loadStores();
} }
@@ -143,7 +144,7 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter.
private void setupMyAppointmentFilter() { private void setupMyAppointmentFilter() {
binding.btnMyAppointments.setOnClickListener(v -> { binding.btnMyAppointments.setOnClickListener(v -> {
loadAppointmentData(); loadAppointmentData(true);
}); });
} }
@@ -177,7 +178,7 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter.
} else { } else {
selectedCalendarDay = null; selectedCalendarDay = null;
} }
loadAppointmentData(); loadAppointmentData(true);
}); });
} }
@@ -200,20 +201,20 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter.
} }
private void setupSearch() { private void setupSearch() {
UIUtils.attachSearch(binding.etSearchAppointment, this::loadAppointmentData); UIUtils.attachSearch(binding.etSearchAppointment, () -> loadAppointmentData(true));
} }
private void setupStatusFilter() { private void setupStatusFilter() {
String[] statuses = {"All Statuses", "Booked", "Completed", "Cancelled", "Missed"}; String[] statuses = {"All Statuses", "Booked", "Completed", "Cancelled", "Missed"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatus, statuses, this::loadAppointmentData); SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatus, statuses, () -> loadAppointmentData(true));
} }
private void setupStoreFilter() { private void setupStoreFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerStore, this::loadAppointmentData); SpinnerUtils.setupFilterSpinner(binding.spinnerStore, () -> loadAppointmentData(true));
} }
private void setupSwipeRefresh() { private void setupSwipeRefresh() {
binding.swipeRefreshAppointment.setOnRefreshListener(this::loadAppointmentData); binding.swipeRefreshAppointment.setOnRefreshListener(() -> loadAppointmentData(true));
} }
private void openAppointmentDetails(int position) { private void openAppointmentDetails(int position) {
@@ -241,7 +242,7 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter.
return "STAFF".equalsIgnoreCase(tokenManager.getRole()); return "STAFF".equalsIgnoreCase(tokenManager.getRole());
} }
private void loadAppointmentData() { private void loadAppointmentData(boolean reset) {
String query = binding.etSearchAppointment.getText().toString().trim(); String query = binding.etSearchAppointment.getText().toString().trim();
String status = binding.spinnerStatus.getSelectedItem() != null ? binding.spinnerStatus.getSelectedItem().toString() : "All Statuses"; String status = binding.spinnerStatus.getSelectedItem() != null ? binding.spinnerStatus.getSelectedItem().toString() : "All Statuses";
@@ -270,13 +271,29 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter.
if (status.equals("All Statuses")) status = null; if (status.equals("All Statuses")) status = null;
else status = status.toUpperCase(); else status = status.toUpperCase();
viewModel.loadAppointments(query, status, storeId, selectedDateString, employeeId); viewModel.loadAppointments(reset, query, status, storeId, selectedDateString, employeeId);
} }
private void setupRecyclerView() { private void setupRecyclerView() {
adapter = new AppointmentAdapter(appointmentList, this); adapter = new AppointmentAdapter(appointmentList, this);
binding.recyclerViewAppointments.setLayoutManager(new LinearLayoutManager(getContext())); binding.recyclerViewAppointments.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewAppointments.setAdapter(adapter); binding.recyclerViewAppointments.setAdapter(adapter);
binding.recyclerViewAppointments.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewAppointments.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadAppointmentData(false);
}
}
});
} }
@Override @Override

View File

@@ -11,6 +11,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.Navigation; import androidx.navigation.Navigation;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R; import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.CouponAdapter; import com.example.petstoremobile.adapters.CouponAdapter;
@@ -46,7 +47,7 @@ public class CouponFragment extends Fragment implements CouponAdapter.OnCouponCl
setupSwipeRefresh(); setupSwipeRefresh();
observeViewModel(); observeViewModel();
viewModel.loadCoupons(0, 100, null, null, null); applyFilters(true);
binding.fabAddCoupon.setOnClickListener(v -> openDetail(-1)); binding.fabAddCoupon.setOnClickListener(v -> openDetail(-1));
binding.btnBulkDeleteCoupons.setOnClickListener(v -> confirmBulkDelete()); binding.btnBulkDeleteCoupons.setOnClickListener(v -> confirmBulkDelete());
@@ -74,38 +75,54 @@ public class CouponFragment extends Fragment implements CouponAdapter.OnCouponCl
adapter = new CouponAdapter(couponList, this); adapter = new CouponAdapter(couponList, this);
binding.recyclerViewCoupon.setLayoutManager(new LinearLayoutManager(getContext())); binding.recyclerViewCoupon.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewCoupon.setAdapter(adapter); binding.recyclerViewCoupon.setAdapter(adapter);
binding.recyclerViewCoupon.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewCoupon.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
applyFilters(false);
}
}
});
} }
private void setupStatusFilter() { private void setupStatusFilter() {
String[] statuses = {"All Statuses", "Active", "Inactive"}; String[] statuses = {"All Statuses", "Active", "Inactive"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatusCoupon, statuses, this::applyFilters); SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatusCoupon, statuses, () -> applyFilters(true));
} }
private void setupTypeFilter() { private void setupTypeFilter() {
String[] types = {"All Types", "FIXED", "PERCENT"}; String[] types = {"All Types", "FIXED", "PERCENT"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerTypeCoupon, types, this::applyFilters); SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerTypeCoupon, types, () -> applyFilters(true));
} }
private void setupSearch() { private void setupSearch() {
UIUtils.attachSearch(binding.etSearchCoupon, this::applyFilters); UIUtils.attachSearch(binding.etSearchCoupon, () -> applyFilters(true));
} }
private void setupSwipeRefresh() { private void setupSwipeRefresh() {
binding.swipeRefreshCoupon.setOnRefreshListener(this::applyFilters); binding.swipeRefreshCoupon.setOnRefreshListener(() -> applyFilters(true));
} }
private void applyFilters() { private void applyFilters(boolean reset) {
String statusStr = binding.spinnerStatusCoupon.getSelectedItem() != null ? String statusStr = binding.spinnerStatusCoupon.getSelectedItem() != null ?
binding.spinnerStatusCoupon.getSelectedItem().toString() : "All Statuses"; binding.spinnerStatusCoupon.getSelectedItem().toString() : "All Statuses";
Boolean active = null; Boolean active = null;
if (statusStr.equals("Active")) active = true; if (statusStr.equals("Active")) active = true;
else if (statusStr.equals("Inactive")) active = false; else if (statusStr.equals("Inactive")) active = false;
String typeStr = binding.spinnerTypeCoupon.getSelectedItem() != null ? String typeStr = binding.spinnerTypeCoupon.getSelectedItem() != null ?
binding.spinnerTypeCoupon.getSelectedItem().toString() : "All Types"; binding.spinnerTypeCoupon.getSelectedItem().toString() : "All Types";
String discountType = typeStr.equals("All Types") ? null : typeStr; String discountType = typeStr.equals("All Types") ? null : typeStr;
viewModel.loadCoupons(0, 100, active, discountType, null); viewModel.loadCoupons(reset, active, discountType, null);
} }
private void openDetail(long id) { private void openDetail(long id) {
@@ -133,7 +150,7 @@ public class CouponFragment extends Fragment implements CouponAdapter.OnCouponCl
viewModel.bulkDeleteCoupons(ids).observe(getViewLifecycleOwner(), resource -> { viewModel.bulkDeleteCoupons(ids).observe(getViewLifecycleOwner(), resource -> {
if (resource.status == Resource.Status.SUCCESS) { if (resource.status == Resource.Status.SUCCESS) {
adapter.setSelectionMode(false); adapter.setSelectionMode(false);
applyFilters(); applyFilters(true);
} else if (resource.status == Resource.Status.ERROR) { } else if (resource.status == Resource.Status.ERROR) {
UIUtils.showToast(requireContext(), resource.message); UIUtils.showToast(requireContext(), resource.message);
} }

View File

@@ -7,6 +7,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R; import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.CustomerAdapter; import com.example.petstoremobile.adapters.CustomerAdapter;
import com.example.petstoremobile.api.auth.TokenManager; import com.example.petstoremobile.api.auth.TokenManager;
@@ -44,7 +45,7 @@ public class CustomerFragment extends Fragment implements CustomerAdapter.OnCust
setupSwipeRefresh(); setupSwipeRefresh();
observeViewModel(); observeViewModel();
viewModel.loadCustomers(); viewModel.loadCustomers(true);
binding.fabAddCustomer.setOnClickListener(v -> openDetail(-1)); binding.fabAddCustomer.setOnClickListener(v -> openDetail(-1));
@@ -73,6 +74,22 @@ public class CustomerFragment extends Fragment implements CustomerAdapter.OnCust
adapter.setToken(tokenManager.getToken()); adapter.setToken(tokenManager.getToken());
binding.recyclerViewCustomer.setLayoutManager(new LinearLayoutManager(getContext())); binding.recyclerViewCustomer.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewCustomer.setAdapter(adapter); binding.recyclerViewCustomer.setAdapter(adapter);
binding.recyclerViewCustomer.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewCustomer.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
viewModel.loadCustomers(false);
}
}
});
} }
private void setupStatusFilter() { private void setupStatusFilter() {
@@ -92,7 +109,7 @@ public class CustomerFragment extends Fragment implements CustomerAdapter.OnCust
} }
private void setupSwipeRefresh() { private void setupSwipeRefresh() {
binding.swipeRefreshCustomer.setOnRefreshListener(viewModel::loadCustomers); binding.swipeRefreshCustomer.setOnRefreshListener(() -> viewModel.loadCustomers(true));
} }
private void openDetail(int position) { private void openDetail(int position) {

View File

@@ -8,11 +8,11 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.Toast;
import com.example.petstoremobile.R; import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.PetAdapter; import com.example.petstoremobile.adapters.PetAdapter;
@@ -21,7 +21,6 @@ import com.example.petstoremobile.databinding.FragmentPetBinding;
import com.example.petstoremobile.dtos.PetDTO; import com.example.petstoremobile.dtos.PetDTO;
import com.example.petstoremobile.dtos.StoreDTO; import com.example.petstoremobile.dtos.StoreDTO;
import com.example.petstoremobile.utils.BulkDeleteHandler; import com.example.petstoremobile.utils.BulkDeleteHandler;
import com.example.petstoremobile.utils.Resource;
import com.example.petstoremobile.utils.SpinnerUtils; import com.example.petstoremobile.utils.SpinnerUtils;
import com.example.petstoremobile.utils.UIUtils; import com.example.petstoremobile.utils.UIUtils;
import com.example.petstoremobile.viewmodels.PetListViewModel; import com.example.petstoremobile.viewmodels.PetListViewModel;
@@ -91,7 +90,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
viewModel.getSpeciesOptions().observe(getViewLifecycleOwner(), options -> { viewModel.getSpeciesOptions().observe(getViewLifecycleOwner(), options -> {
String[] arr = options.toArray(new String[0]); String[] arr = options.toArray(new String[0]);
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerSpecies, arr, this::loadPetData); SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerSpecies, arr, () -> loadPetData(true));
}); });
} }
@@ -104,14 +103,14 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
adapter, adapter,
"pet", "pet",
viewModel::bulkDeletePets, viewModel::bulkDeletePets,
this::loadPetData () -> loadPetData(true)
); );
} }
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
loadPetData(); loadPetData(true);
viewModel.loadSpecies(); viewModel.loadSpecies();
if (!isStaff()) viewModel.loadStores(); if (!isStaff()) viewModel.loadStores();
} }
@@ -132,28 +131,28 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
} }
private void setupSearch() { private void setupSearch() {
UIUtils.attachSearch(binding.etSearchPet, this::loadPetData); UIUtils.attachSearch(binding.etSearchPet, () -> loadPetData(true));
} }
private void setupStatusFilter() { private void setupStatusFilter() {
String[] statuses = {"All Statuses", "Available", "Adopted", "Owned", "Pending"}; String[] statuses = {"All Statuses", "Available", "Adopted", "Owned", "Pending"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatus, statuses, this::loadPetData); SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatus, statuses, () -> loadPetData(true));
} }
private void setupSpeciesFilter() { private void setupSpeciesFilter() {
String[] initial = {"All Species"}; String[] initial = {"All Species"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerSpecies, initial, this::loadPetData); SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerSpecies, initial, () -> loadPetData(true));
} }
private void setupStoreFilter() { private void setupStoreFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerStore, this::loadPetData); SpinnerUtils.setupFilterSpinner(binding.spinnerStore, () -> loadPetData(true));
} }
private void setupSwipeRefresh() { private void setupSwipeRefresh() {
binding.swipeRefreshPet.setOnRefreshListener(this::loadPetData); binding.swipeRefreshPet.setOnRefreshListener(() -> loadPetData(true));
} }
private void loadPetData() { private void loadPetData(boolean reset) {
String query = binding.etSearchPet.getText().toString().trim(); String query = binding.etSearchPet.getText().toString().trim();
String status = binding.spinnerStatus.getSelectedItem() != null ? binding.spinnerStatus.getSelectedItem().toString() : "All Statuses"; String status = binding.spinnerStatus.getSelectedItem() != null ? binding.spinnerStatus.getSelectedItem().toString() : "All Statuses";
String species = binding.spinnerSpecies.getSelectedItem() != null ? binding.spinnerSpecies.getSelectedItem().toString() : "All Species"; String species = binding.spinnerSpecies.getSelectedItem() != null ? binding.spinnerSpecies.getSelectedItem().toString() : "All Species";
@@ -169,7 +168,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
} }
} }
viewModel.loadPets(query, status, species, storeId); viewModel.loadPets(reset, query, status, species, storeId);
} }
private void setupRecyclerView() { private void setupRecyclerView() {
@@ -178,6 +177,22 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
adapter.setToken(tokenManager.getToken()); adapter.setToken(tokenManager.getToken());
binding.recyclerViewPets.setLayoutManager(new LinearLayoutManager(getContext())); binding.recyclerViewPets.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewPets.setAdapter(adapter); binding.recyclerViewPets.setAdapter(adapter);
binding.recyclerViewPets.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewPets.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadPetData(false);
}
}
});
} }
private void openPetProfile(int position) { private void openPetProfile(int position) {

View File

@@ -8,6 +8,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
@@ -85,28 +86,28 @@ public class ProductFragment extends Fragment implements ProductAdapter.OnProduc
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
loadProductData(); loadProductData(true);
viewModel.loadCategories(); viewModel.loadCategories();
} }
private void setupFilterToggle() { private void setupFilterToggle() {
UIUtils.setupFilterToggle(binding.btnToggleFilter, binding.layoutFilter, UIUtils.setupFilterToggle(binding.btnToggleFilter, binding.layoutFilter,
binding.etSearchProduct, binding.spinnerCategory); binding.etSearchProduct, binding.spinnerCategory);
} }
private void setupSearch() { private void setupSearch() {
UIUtils.attachSearch(binding.etSearchProduct, this::loadProductData); UIUtils.attachSearch(binding.etSearchProduct, () -> loadProductData(true));
} }
private void setupCategoryFilter() { private void setupCategoryFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerCategory, this::loadProductData); SpinnerUtils.setupFilterSpinner(binding.spinnerCategory, () -> loadProductData(true));
} }
private void setupSwipeRefresh() { private void setupSwipeRefresh() {
binding.swipeRefreshProduct.setOnRefreshListener(this::loadProductData); binding.swipeRefreshProduct.setOnRefreshListener(() -> loadProductData(true));
} }
private void loadProductData() { private void loadProductData(boolean reset) {
String query = binding.etSearchProduct.getText().toString().trim(); String query = binding.etSearchProduct.getText().toString().trim();
if (query.isEmpty()) query = null; if (query.isEmpty()) query = null;
@@ -116,7 +117,7 @@ public class ProductFragment extends Fragment implements ProductAdapter.OnProduc
categoryId = categories.get(binding.spinnerCategory.getSelectedItemPosition() - 1).getId(); categoryId = categories.get(binding.spinnerCategory.getSelectedItemPosition() - 1).getId();
} }
viewModel.loadProducts(query, categoryId); viewModel.loadProducts(reset, query, categoryId);
} }
private void setupRecyclerView() { private void setupRecyclerView() {
@@ -124,6 +125,22 @@ public class ProductFragment extends Fragment implements ProductAdapter.OnProduc
adapter.setBaseUrl(baseUrl); adapter.setBaseUrl(baseUrl);
binding.recyclerViewProducts.setLayoutManager(new LinearLayoutManager(getContext())); binding.recyclerViewProducts.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewProducts.setAdapter(adapter); binding.recyclerViewProducts.setAdapter(adapter);
binding.recyclerViewProducts.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewProducts.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadProductData(false);
}
}
});
} }
private void openProductDetails(int position) { private void openProductDetails(int position) {

View File

@@ -11,6 +11,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R; import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.ProductSupplierAdapter; import com.example.petstoremobile.adapters.ProductSupplierAdapter;
@@ -34,7 +35,7 @@ public class ProductSupplierFragment extends Fragment
private FragmentProductSupplierBinding binding; private FragmentProductSupplierBinding binding;
private List<ProductSupplierDTO> psList = new ArrayList<>(); private List<ProductSupplierDTO> psList = new ArrayList<>();
private ProductSupplierAdapter adapter; private ProductSupplierAdapter adapter;
private ProductSupplierListViewModel viewModel; private ProductSupplierListViewModel viewModel;
private BulkDeleteHandler bulkDeleteHandler; private BulkDeleteHandler bulkDeleteHandler;
@@ -97,14 +98,14 @@ public class ProductSupplierFragment extends Fragment
adapter, adapter,
"relationship", "relationship",
viewModel::bulkDeleteProductSuppliers, viewModel::bulkDeleteProductSuppliers,
this::loadData () -> loadData(true)
); );
} }
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
loadData(); loadData(true);
viewModel.loadFilterData(); viewModel.loadFilterData();
} }
@@ -123,25 +124,41 @@ public class ProductSupplierFragment extends Fragment
adapter = new ProductSupplierAdapter(psList, this); adapter = new ProductSupplierAdapter(psList, this);
binding.recyclerViewPS.setLayoutManager(new LinearLayoutManager(getContext())); binding.recyclerViewPS.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewPS.setAdapter(adapter); binding.recyclerViewPS.setAdapter(adapter);
binding.recyclerViewPS.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewPS.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadData(false);
}
}
});
} }
private void setupSearch() { private void setupSearch() {
UIUtils.attachSearch(binding.etSearchPS, this::loadData); UIUtils.attachSearch(binding.etSearchPS, () -> loadData(true));
} }
private void setupProductFilter() { private void setupProductFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerProduct, this::loadData); SpinnerUtils.setupFilterSpinner(binding.spinnerProduct, () -> loadData(true));
} }
private void setupSupplierFilter() { private void setupSupplierFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerSupplier, this::loadData); SpinnerUtils.setupFilterSpinner(binding.spinnerSupplier, () -> loadData(true));
} }
private void setupSwipeRefresh() { private void setupSwipeRefresh() {
binding.swipeRefreshPS.setOnRefreshListener(this::loadData); binding.swipeRefreshPS.setOnRefreshListener(() -> loadData(true));
} }
private void loadData() { private void loadData(boolean reset) {
String query = binding.etSearchPS.getText().toString().trim(); String query = binding.etSearchPS.getText().toString().trim();
if (query.isEmpty()) query = null; if (query.isEmpty()) query = null;
@@ -157,7 +174,7 @@ public class ProductSupplierFragment extends Fragment
supplierId = suppliers.get(binding.spinnerSupplier.getSelectedItemPosition() - 1).getSupId(); supplierId = suppliers.get(binding.spinnerSupplier.getSelectedItemPosition() - 1).getSupId();
} }
viewModel.loadProductSuppliers(query, productId, supplierId); viewModel.loadProductSuppliers(reset, query, productId, supplierId);
} }
private void openDetail(int position) { private void openDetail(int position) {

View File

@@ -11,6 +11,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R; import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.PurchaseOrderAdapter; import com.example.petstoremobile.adapters.PurchaseOrderAdapter;
@@ -83,7 +84,7 @@ public class PurchaseOrderFragment extends Fragment
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
loadData(); loadData(true);
if (!isStaff()) viewModel.loadStores(); if (!isStaff()) viewModel.loadStores();
} }
@@ -101,24 +102,40 @@ public class PurchaseOrderFragment extends Fragment
} }
private void setupSearch() { private void setupSearch() {
UIUtils.attachSearch(binding.etSearchPO, this::loadData); UIUtils.attachSearch(binding.etSearchPO, () -> loadData(true));
} }
private void setupStoreFilter() { private void setupStoreFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerStore, this::loadData); SpinnerUtils.setupFilterSpinner(binding.spinnerStore, () -> loadData(true));
} }
private void setupRecyclerView() { private void setupRecyclerView() {
adapter = new PurchaseOrderAdapter(poList, this); adapter = new PurchaseOrderAdapter(poList, this);
binding.recyclerViewPO.setLayoutManager(new LinearLayoutManager(getContext())); binding.recyclerViewPO.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewPO.setAdapter(adapter); binding.recyclerViewPO.setAdapter(adapter);
binding.recyclerViewPO.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewPO.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadData(false);
}
}
});
} }
private void setupSwipeRefresh() { private void setupSwipeRefresh() {
binding.swipeRefreshPO.setOnRefreshListener(this::loadData); binding.swipeRefreshPO.setOnRefreshListener(() -> loadData(true));
} }
private void loadData() { private void loadData(boolean reset) {
String query = binding.etSearchPO != null ? binding.etSearchPO.getText().toString().trim() : ""; String query = binding.etSearchPO != null ? binding.etSearchPO.getText().toString().trim() : "";
if (query.isEmpty()) query = null; if (query.isEmpty()) query = null;
@@ -133,7 +150,7 @@ public class PurchaseOrderFragment extends Fragment
} }
} }
viewModel.loadPurchaseOrders(query, storeId); viewModel.loadPurchaseOrders(reset, query, storeId);
} }
private void openDetail(int position) { private void openDetail(int position) {

View File

@@ -8,6 +8,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R; import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.EmployeeAdapter; import com.example.petstoremobile.adapters.EmployeeAdapter;
import com.example.petstoremobile.api.auth.TokenManager; import com.example.petstoremobile.api.auth.TokenManager;
@@ -39,15 +40,15 @@ public class StaffFragment extends Fragment implements EmployeeAdapter.OnEmploye
Bundle savedInstanceState) { Bundle savedInstanceState) {
binding = FragmentStaffBinding.inflate(inflater, container, false); binding = FragmentStaffBinding.inflate(inflater, container, false);
viewModel = new ViewModelProvider(this).get(StaffListViewModel.class); viewModel = new ViewModelProvider(this).get(StaffListViewModel.class);
setupRecyclerView(); setupRecyclerView();
setupSearch(); setupSearch();
setupStatusFilter(); setupStatusFilter();
setupStoreFilter(); setupStoreFilter();
setupSwipeRefresh(); setupSwipeRefresh();
observeViewModel(); observeViewModel();
viewModel.loadStaff(); viewModel.loadStaff(true);
viewModel.loadStores(); viewModel.loadStores();
binding.fabAddStaff.setOnClickListener(v -> openDetail(-1)); binding.fabAddStaff.setOnClickListener(v -> openDetail(-1));
@@ -82,6 +83,22 @@ public class StaffFragment extends Fragment implements EmployeeAdapter.OnEmploye
adapter.setToken(tokenManager.getToken()); adapter.setToken(tokenManager.getToken());
binding.recyclerViewStaff.setLayoutManager(new LinearLayoutManager(getContext())); binding.recyclerViewStaff.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewStaff.setAdapter(adapter); binding.recyclerViewStaff.setAdapter(adapter);
binding.recyclerViewStaff.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewStaff.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
viewModel.loadStaff(false);
}
}
});
} }
private void setupStatusFilter() { private void setupStatusFilter() {
@@ -99,9 +116,9 @@ public class StaffFragment extends Fragment implements EmployeeAdapter.OnEmploye
private void applyFilters() { private void applyFilters() {
String query = binding.etSearchStaff.getText().toString().trim(); String query = binding.etSearchStaff.getText().toString().trim();
String status = binding.spinnerStatusStaff.getSelectedItem() != null ? String status = binding.spinnerStatusStaff.getSelectedItem() != null ?
binding.spinnerStatusStaff.getSelectedItem().toString() : "All Statuses"; binding.spinnerStatusStaff.getSelectedItem().toString() : "All Statuses";
Long storeId = null; Long storeId = null;
List<StoreDTO> stores = viewModel.getStores().getValue(); List<StoreDTO> stores = viewModel.getStores().getValue();
if (binding.spinnerStoreStaff.getSelectedItemPosition() > 0 && stores != null && !stores.isEmpty()) { if (binding.spinnerStoreStaff.getSelectedItemPosition() > 0 && stores != null && !stores.isEmpty()) {
@@ -112,7 +129,7 @@ public class StaffFragment extends Fragment implements EmployeeAdapter.OnEmploye
} }
private void setupSwipeRefresh() { private void setupSwipeRefresh() {
binding.swipeRefreshStaff.setOnRefreshListener(viewModel::loadStaff); binding.swipeRefreshStaff.setOnRefreshListener(() -> viewModel.loadStaff(true));
} }
private void openDetail(int position) { private void openDetail(int position) {

View File

@@ -8,6 +8,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
@@ -52,8 +53,8 @@ public class SupplierFragment extends Fragment implements SupplierAdapter.OnSupp
setupFilterToggle(); setupFilterToggle();
setupBulkDelete(); setupBulkDelete();
observeViewModel(); observeViewModel();
loadSupplierData(); loadSupplierData(true);
binding.fabAddSupplier.setOnClickListener(v -> openSupplierDetails(-1)); binding.fabAddSupplier.setOnClickListener(v -> openSupplierDetails(-1));
@@ -83,7 +84,7 @@ public class SupplierFragment extends Fragment implements SupplierAdapter.OnSupp
adapter, adapter,
"supplier", "supplier",
viewModel::bulkDeleteSuppliers, viewModel::bulkDeleteSuppliers,
this::loadSupplierData () -> loadSupplierData(true)
); );
} }
@@ -98,11 +99,11 @@ public class SupplierFragment extends Fragment implements SupplierAdapter.OnSupp
} }
private void setupSearch() { private void setupSearch() {
UIUtils.attachSearch(binding.etSearchSupplier, this::loadSupplierData); UIUtils.attachSearch(binding.etSearchSupplier, () -> loadSupplierData(true));
} }
private void setupSwipeRefresh() { private void setupSwipeRefresh() {
binding.swipeRefreshSupplier.setOnRefreshListener(this::loadSupplierData); binding.swipeRefreshSupplier.setOnRefreshListener(() -> loadSupplierData(true));
} }
private void openSupplierDetails(int position) { private void openSupplierDetails(int position) {
@@ -126,15 +127,31 @@ public class SupplierFragment extends Fragment implements SupplierAdapter.OnSupp
} }
} }
private void loadSupplierData() { private void loadSupplierData(boolean reset) {
String query = binding.etSearchSupplier != null ? binding.etSearchSupplier.getText().toString().trim() : ""; String query = binding.etSearchSupplier != null ? binding.etSearchSupplier.getText().toString().trim() : "";
if (query.isEmpty()) query = null; if (query.isEmpty()) query = null;
viewModel.loadSuppliers(query); viewModel.loadSuppliers(reset, query);
} }
private void setupRecyclerView() { private void setupRecyclerView() {
adapter = new SupplierAdapter(supplierList, this); adapter = new SupplierAdapter(supplierList, this);
binding.recyclerViewSuppliers.setLayoutManager(new LinearLayoutManager(getContext())); binding.recyclerViewSuppliers.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewSuppliers.setAdapter(adapter); binding.recyclerViewSuppliers.setAdapter(adapter);
binding.recyclerViewSuppliers.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewSuppliers.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadSupplierData(false);
}
}
});
} }
} }

View File

@@ -114,9 +114,18 @@ public class ProductSupplierDetailFragment extends Fragment {
viewModel.setEditMode(productId, supplierId); viewModel.setEditMode(productId, supplierId);
preselectedProductId = productId; preselectedProductId = productId;
preselectedSupplierId = supplierId; preselectedSupplierId = supplierId;
binding.tvPSMode.setText("Edit Product Supplier"); binding.tvPSMode.setText("Edit Product Supplier");
binding.btnDeletePS.setVisibility(View.VISIBLE); binding.btnDeletePS.setVisibility(View.VISIBLE);
viewModel.loadProductSupplier().observe(getViewLifecycleOwner(), resource -> {
if (resource == null) return;
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
if (resource.data.getCost() != null) {
binding.etPSCost.setText(resource.data.getCost().toPlainString());
}
}
});
} else { } else {
binding.tvPSMode.setText("Add Product Supplier"); binding.tvPSMode.setText("Add Product Supplier");
binding.btnDeletePS.setVisibility(View.GONE); binding.btnDeletePS.setVisibility(View.GONE);

View File

@@ -1,6 +1,5 @@
package com.example.petstoremobile.fragments.listfragments.detailfragments; package com.example.petstoremobile.fragments.listfragments.detailfragments;
import android.graphics.Color;
import android.os.Bundle; import android.os.Bundle;
import android.view.*; import android.view.*;
import android.widget.Toast; import android.widget.Toast;
@@ -82,26 +81,6 @@ public class PurchaseOrderDetailFragment extends Fragment {
binding.tvPODetailSupplier.setText(po.getSupplierName()); binding.tvPODetailSupplier.setText(po.getSupplierName());
binding.tvPODetailStore.setText(po.getStoreName() != null ? po.getStoreName() : "N/A"); binding.tvPODetailStore.setText(po.getStoreName() != null ? po.getStoreName() : "N/A");
binding.tvPODetailDate.setText(po.getOrderDate()); binding.tvPODetailDate.setText(po.getOrderDate());
String status = po.getStatus() != null ? po.getStatus() : "";
binding.tvPODetailStatus.setText(status);
switch (status.toUpperCase()) {
case "RECEIVED":
binding.tvPODetailStatus.setTextColor(Color.parseColor("#4CAF50"));
break;
case "PLACED":
binding.tvPODetailStatus.setTextColor(Color.parseColor("#2196F3"));
break;
case "PENDING":
binding.tvPODetailStatus.setTextColor(Color.parseColor("#FF9800"));
break;
case "CANCELLED":
binding.tvPODetailStatus.setTextColor(Color.parseColor("#F44336"));
break;
default:
binding.tvPODetailStatus.setTextColor(Color.parseColor("#9E9E9E"));
break;
}
} else if (resource.status == Resource.Status.ERROR) { } else if (resource.status == Resource.Status.ERROR) {
Toast.makeText(getContext(), "Failed to load order: " + resource.message, Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Failed to load order: " + resource.message, Toast.LENGTH_SHORT).show();
} }

View File

@@ -21,7 +21,7 @@ public class ActivityLogRepository extends BaseRepository {
this.activityLogApi = activityLogApi; this.activityLogApi = activityLogApi;
} }
public LiveData<Resource<List<ActivityLogDTO>>> getActivityLogs(int limit, Long storeId, String role, String search) { public LiveData<Resource<List<ActivityLogDTO>>> getActivityLogs(int limit, Long storeId, String role, String search, String startDate, String endDate) {
return executeCall(activityLogApi.getActivityLogs(limit, storeId, role, search)); return executeCall(activityLogApi.getActivityLogs(limit, storeId, role, search, startDate, endDate));
} }
} }

View File

@@ -20,7 +20,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel;
@HiltViewModel @HiltViewModel
public class ActivityLogListViewModel extends ViewModel { public class ActivityLogListViewModel extends ViewModel {
private static final int LIMIT = 2000; private static final int PAGE_SIZE = 20;
private final ActivityLogRepository repository; private final ActivityLogRepository repository;
private final StoreRepository storeRepository; private final StoreRepository storeRepository;
@@ -32,6 +32,11 @@ public class ActivityLogListViewModel extends ViewModel {
private Long currentStoreId = null; private Long currentStoreId = null;
private String currentRole = null; private String currentRole = null;
private String currentSearch = null; private String currentSearch = null;
private String currentStartDate = null;
private String currentEndDate = null;
private int currentLimit = PAGE_SIZE;
private boolean isLastPage = false;
@Inject @Inject
public ActivityLogListViewModel(ActivityLogRepository repository, StoreRepository storeRepository) { public ActivityLogListViewModel(ActivityLogRepository repository, StoreRepository storeRepository) {
@@ -42,10 +47,11 @@ public class ActivityLogListViewModel extends ViewModel {
public LiveData<List<ActivityLogDTO>> getLogs() { return logs; } public LiveData<List<ActivityLogDTO>> getLogs() { return logs; }
public LiveData<List<DropdownDTO>> getStoreOptions() { return storeOptions; } public LiveData<List<DropdownDTO>> getStoreOptions() { return storeOptions; }
public LiveData<Boolean> getIsLoading() { return isLoading; } public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadInitialData() { public void loadInitialData() {
loadStores(); loadStores();
loadLogs(); loadLogs(true);
} }
private void loadStores() { private void loadStores() {
@@ -56,12 +62,24 @@ public class ActivityLogListViewModel extends ViewModel {
}); });
} }
public void loadLogs() { public void loadLogs(boolean reset) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentLimit = PAGE_SIZE;
isLastPage = false;
}
if (isLastPage) return;
isLoading.setValue(true); isLoading.setValue(true);
observeOnce(repository.getActivityLogs(LIMIT, currentStoreId, currentRole, currentSearch), resource -> { observeOnce(repository.getActivityLogs(currentLimit, currentStoreId, currentRole, currentSearch, currentStartDate, currentEndDate), resource -> {
if (resource != null) { if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) { if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
logs.setValue(resource.data); List<ActivityLogDTO> result = resource.data;
logs.setValue(result);
isLastPage = result.size() < currentLimit;
if (!isLastPage) currentLimit += PAGE_SIZE;
isLoading.setValue(false); isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) { } else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false); isLoading.setValue(false);
@@ -72,19 +90,26 @@ public class ActivityLogListViewModel extends ViewModel {
public void setRoleFilter(String role) { public void setRoleFilter(String role) {
currentRole = "All Roles".equals(role) ? null : role; currentRole = "All Roles".equals(role) ? null : role;
loadLogs(); loadLogs(true);
} }
public void setStoreFilter(Long storeId) { public void setStoreFilter(Long storeId) {
currentStoreId = storeId; currentStoreId = storeId;
loadLogs(); loadLogs(true);
} }
public void setSearchQuery(String query) { public void setSearchQuery(String query) {
currentSearch = (query == null || query.trim().isEmpty()) ? null : query.trim(); currentSearch = (query == null || query.trim().isEmpty()) ? null : query.trim();
loadLogs(); loadLogs(true);
} }
public void setDateRange(String startDate, String endDate) {
currentStartDate = startDate;
currentEndDate = endDate;
loadLogs(true);
}
private <T> void observeOnce(LiveData<Resource<T>> liveData, Observer<Resource<T>> handler) { private <T> void observeOnce(LiveData<Resource<T>> liveData, Observer<Resource<T>> handler) {
liveData.observeForever(new Observer<Resource<T>>() { liveData.observeForever(new Observer<Resource<T>>() {
@Override @Override

View File

@@ -52,6 +52,8 @@ public class AdoptionListViewModel extends ViewModel {
isLastPage = false; isLastPage = false;
} }
if (isLastPage) return;
if ("All Statuses".equals(status)) status = null; if ("All Statuses".equals(status)) status = null;
isLoading.setValue(true); isLoading.setValue(true);

View File

@@ -29,6 +29,10 @@ public class AppointmentListViewModel extends ViewModel {
private final MutableLiveData<List<StoreDTO>> stores = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData<List<StoreDTO>> stores = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false); private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
@Inject @Inject
public AppointmentListViewModel(AppointmentRepository appointmentRepository, StoreRepository storeRepository) { public AppointmentListViewModel(AppointmentRepository appointmentRepository, StoreRepository storeRepository) {
this.appointmentRepository = appointmentRepository; this.appointmentRepository = appointmentRepository;
@@ -38,13 +42,27 @@ public class AppointmentListViewModel extends ViewModel {
public LiveData<List<AppointmentDTO>> getAppointments() { return appointments; } public LiveData<List<AppointmentDTO>> getAppointments() { return appointments; }
public LiveData<List<StoreDTO>> getStores() { return stores; } public LiveData<List<StoreDTO>> getStores() { return stores; }
public LiveData<Boolean> getIsLoading() { return isLoading; } public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadAppointments(boolean reset, String query, String status, Long storeId, String date, Long employeeId) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadAppointments(String query, String status, Long storeId, String date, Long employeeId) {
isLoading.setValue(true); isLoading.setValue(true);
observeOnce(appointmentRepository.getAllAppointments(0, 500, query, status, storeId, date, employeeId, "appointmentId,desc"), resource -> { observeOnce(appointmentRepository.getAllAppointments(currentPage, PAGE_SIZE, query, status, storeId, date, employeeId, "appointmentId,desc"), resource -> {
if (resource != null) { if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) { if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
appointments.setValue(resource.data.getContent()); List<AppointmentDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(appointments.getValue());
currentList.addAll(resource.data.getContent());
appointments.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
isLoading.setValue(false); isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) { } else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false); isLoading.setValue(false);

View File

@@ -5,7 +5,6 @@ import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModel;
import com.example.petstoremobile.dtos.CouponDTO; import com.example.petstoremobile.dtos.CouponDTO;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.repositories.CouponRepository; import com.example.petstoremobile.repositories.CouponRepository;
import com.example.petstoremobile.utils.Resource; import com.example.petstoremobile.utils.Resource;
@@ -24,6 +23,10 @@ public class CouponListViewModel extends ViewModel {
private final MutableLiveData<List<CouponDTO>> coupons = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData<List<CouponDTO>> coupons = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false); private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
@Inject @Inject
public CouponListViewModel(CouponRepository repository) { public CouponListViewModel(CouponRepository repository) {
this.repository = repository; this.repository = repository;
@@ -31,13 +34,27 @@ public class CouponListViewModel extends ViewModel {
public LiveData<List<CouponDTO>> getCoupons() { return coupons; } public LiveData<List<CouponDTO>> getCoupons() { return coupons; }
public LiveData<Boolean> getIsLoading() { return isLoading; } public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadCoupons(boolean reset, Boolean active, String discountType, String sort) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadCoupons(int page, int size, Boolean active, String discountType, String sort) {
isLoading.setValue(true); isLoading.setValue(true);
observeOnce(repository.getAllCoupons(page, size, active, discountType, sort), resource -> { observeOnce(repository.getAllCoupons(currentPage, PAGE_SIZE, active, discountType, sort), resource -> {
if (resource != null) { if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) { if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
coupons.setValue(resource.data.getContent()); List<CouponDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(coupons.getValue());
currentList.addAll(resource.data.getContent());
coupons.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
isLoading.setValue(false); isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) { } else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false); isLoading.setValue(false);

View File

@@ -25,6 +25,10 @@ public class CustomerListViewModel extends ViewModel {
private final MutableLiveData<List<CustomerDTO>> filteredCustomers = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData<List<CustomerDTO>> filteredCustomers = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false); private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
private String lastQuery = ""; private String lastQuery = "";
private String lastStatus = "All Statuses"; private String lastStatus = "All Statuses";
@@ -35,14 +39,28 @@ public class CustomerListViewModel extends ViewModel {
public LiveData<List<CustomerDTO>> getFilteredCustomers() { return filteredCustomers; } public LiveData<List<CustomerDTO>> getFilteredCustomers() { return filteredCustomers; }
public LiveData<Boolean> getIsLoading() { return isLoading; } public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadCustomers(boolean reset) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadCustomers() {
isLoading.setValue(true); isLoading.setValue(true);
observeOnce(repository.getAllCustomers(0, 200), resource -> { observeOnce(repository.getAllCustomers(currentPage, PAGE_SIZE), resource -> {
if (resource != null) { if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) { if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
customers.setValue(resource.data.getContent()); List<CustomerDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(customers.getValue());
filter(lastQuery, lastStatus); currentList.addAll(resource.data.getContent());
customers.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
applyFilter(currentList);
isLoading.setValue(false); isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) { } else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false); isLoading.setValue(false);
@@ -66,23 +84,25 @@ public class CustomerListViewModel extends ViewModel {
public void filter(String query, String status) { public void filter(String query, String status) {
this.lastQuery = query; this.lastQuery = query;
this.lastStatus = status; this.lastStatus = status;
applyFilter(customers.getValue());
}
List<CustomerDTO> all = customers.getValue(); private void applyFilter(List<CustomerDTO> all) {
if (all == null) return; if (all == null) return;
List<CustomerDTO> filtered = new ArrayList<>(); List<CustomerDTO> filtered = new ArrayList<>();
String lowerQuery = query.toLowerCase(); String lowerQuery = lastQuery.toLowerCase();
for (CustomerDTO c : all) { for (CustomerDTO c : all) {
boolean matchesQuery = query.isEmpty() || boolean matchesQuery = lastQuery.isEmpty() ||
(c.getFullName() != null && c.getFullName().toLowerCase().contains(lowerQuery)) || (c.getFullName() != null && c.getFullName().toLowerCase().contains(lowerQuery)) ||
(c.getUsername() != null && c.getUsername().toLowerCase().contains(lowerQuery)) || (c.getUsername() != null && c.getUsername().toLowerCase().contains(lowerQuery)) ||
(c.getEmail() != null && c.getEmail().toLowerCase().contains(lowerQuery)) || (c.getEmail() != null && c.getEmail().toLowerCase().contains(lowerQuery)) ||
(c.getPhone() != null && c.getPhone().toLowerCase().contains(lowerQuery)); (c.getPhone() != null && c.getPhone().toLowerCase().contains(lowerQuery));
boolean matchesStatus = status.equals("All Statuses") || boolean matchesStatus = lastStatus.equals("All Statuses") ||
(status.equals("Active") && Boolean.TRUE.equals(c.getActive())) || (lastStatus.equals("Active") && Boolean.TRUE.equals(c.getActive())) ||
(status.equals("Inactive") && Boolean.FALSE.equals(c.getActive())); (lastStatus.equals("Inactive") && Boolean.FALSE.equals(c.getActive()));
if (matchesQuery && matchesStatus) { if (matchesQuery && matchesStatus) {
filtered.add(c); filtered.add(c);

View File

@@ -6,7 +6,6 @@ import androidx.lifecycle.ViewModel;
import com.example.petstoremobile.dtos.BulkDeleteRequest; import com.example.petstoremobile.dtos.BulkDeleteRequest;
import com.example.petstoremobile.dtos.DropdownDTO; import com.example.petstoremobile.dtos.DropdownDTO;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.PetDTO; import com.example.petstoremobile.dtos.PetDTO;
import com.example.petstoremobile.dtos.StoreDTO; import com.example.petstoremobile.dtos.StoreDTO;
import com.example.petstoremobile.repositories.PetRepository; import com.example.petstoremobile.repositories.PetRepository;
@@ -32,6 +31,10 @@ public class PetListViewModel extends ViewModel {
private final MutableLiveData<List<String>> speciesOptions = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData<List<String>> speciesOptions = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false); private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
@Inject @Inject
public PetListViewModel(PetRepository petRepository, StoreRepository storeRepository) { public PetListViewModel(PetRepository petRepository, StoreRepository storeRepository) {
this.petRepository = petRepository; this.petRepository = petRepository;
@@ -42,16 +45,32 @@ public class PetListViewModel extends ViewModel {
public LiveData<List<StoreDTO>> getStores() { return stores; } public LiveData<List<StoreDTO>> getStores() { return stores; }
public LiveData<List<String>> getSpeciesOptions() { return speciesOptions; } public LiveData<List<String>> getSpeciesOptions() { return speciesOptions; }
public LiveData<Boolean> getIsLoading() { return isLoading; } public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadPets(boolean reset, String query, String status, String species, Long storeId) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadPets(String query, String status, String species, Long storeId) {
if ("All Statuses".equals(status)) status = null; if ("All Statuses".equals(status)) status = null;
if ("All Species".equals(species)) species = null; if ("All Species".equals(species)) species = null;
isLoading.setValue(true); isLoading.setValue(true);
observeOnce(petRepository.getAllPets(0, 100, query, status, species, storeId, null, "petName"), resource -> { final String finalStatus = status;
final String finalSpecies = species;
observeOnce(petRepository.getAllPets(currentPage, PAGE_SIZE, query, finalStatus, finalSpecies, storeId, null, "petName"), resource -> {
if (resource != null) { if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) { if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
pets.setValue(resource.data.getContent()); List<PetDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(pets.getValue());
currentList.addAll(resource.data.getContent());
pets.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
isLoading.setValue(false); isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) { } else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false); isLoading.setValue(false);

View File

@@ -28,6 +28,10 @@ public class ProductListViewModel extends ViewModel {
private final MutableLiveData<List<DropdownDTO>> categories = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData<List<DropdownDTO>> categories = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false); private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
@Inject @Inject
public ProductListViewModel(ProductRepository productRepository, CategoryRepository categoryRepository) { public ProductListViewModel(ProductRepository productRepository, CategoryRepository categoryRepository) {
this.productRepository = productRepository; this.productRepository = productRepository;
@@ -37,13 +41,27 @@ public class ProductListViewModel extends ViewModel {
public LiveData<List<ProductDTO>> getProducts() { return products; } public LiveData<List<ProductDTO>> getProducts() { return products; }
public LiveData<List<DropdownDTO>> getCategories() { return categories; } public LiveData<List<DropdownDTO>> getCategories() { return categories; }
public LiveData<Boolean> getIsLoading() { return isLoading; } public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadProducts(boolean reset, String query, Long categoryId) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadProducts(String query, Long categoryId) {
isLoading.setValue(true); isLoading.setValue(true);
observeOnce(productRepository.getAllProducts(query, categoryId, 0, 100, "prodName"), resource -> { observeOnce(productRepository.getAllProducts(query, categoryId, currentPage, PAGE_SIZE, "prodName"), resource -> {
if (resource != null) { if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) { if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
products.setValue(resource.data.getContent()); List<ProductDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(products.getValue());
currentList.addAll(resource.data.getContent());
products.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
isLoading.setValue(false); isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) { } else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false); isLoading.setValue(false);

View File

@@ -46,6 +46,10 @@ public class ProductSupplierDetailViewModel extends ViewModel {
this.editSupplierId = supplierId; this.editSupplierId = supplierId;
} }
public LiveData<Resource<ProductSupplierDTO>> loadProductSupplier() {
return psRepository.getProductSupplierById(editProductId, editSupplierId);
}
public boolean isEditing() { return isEditing; } public boolean isEditing() { return isEditing; }
public long getEditProductId() { return editProductId; } public long getEditProductId() { return editProductId; }
public long getEditSupplierId() { return editSupplierId; } public long getEditSupplierId() { return editSupplierId; }

View File

@@ -33,6 +33,10 @@ public class ProductSupplierListViewModel extends ViewModel {
private final MutableLiveData<List<SupplierDTO>> suppliers = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData<List<SupplierDTO>> suppliers = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false); private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
@Inject @Inject
public ProductSupplierListViewModel(ProductSupplierRepository psRepository, ProductRepository productRepository, SupplierRepository supplierRepository) { public ProductSupplierListViewModel(ProductSupplierRepository psRepository, ProductRepository productRepository, SupplierRepository supplierRepository) {
this.psRepository = psRepository; this.psRepository = psRepository;
@@ -44,13 +48,27 @@ public class ProductSupplierListViewModel extends ViewModel {
public LiveData<List<ProductDTO>> getProducts() { return products; } public LiveData<List<ProductDTO>> getProducts() { return products; }
public LiveData<List<SupplierDTO>> getSuppliers() { return suppliers; } public LiveData<List<SupplierDTO>> getSuppliers() { return suppliers; }
public LiveData<Boolean> getIsLoading() { return isLoading; } public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadProductSuppliers(boolean reset, String query, Long productId, Long supplierId) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadProductSuppliers(String query, Long productId, Long supplierId) {
isLoading.setValue(true); isLoading.setValue(true);
observeOnce(psRepository.getAllProductSuppliers(0, 100, query, productId, supplierId, "productName"), resource -> { observeOnce(psRepository.getAllProductSuppliers(currentPage, PAGE_SIZE, query, productId, supplierId, "productName"), resource -> {
if (resource != null) { if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) { if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
productSuppliers.setValue(resource.data.getContent()); List<ProductSupplierDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(productSuppliers.getValue());
currentList.addAll(resource.data.getContent());
productSuppliers.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
isLoading.setValue(false); isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) { } else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false); isLoading.setValue(false);

View File

@@ -28,6 +28,10 @@ public class PurchaseOrderListViewModel extends ViewModel {
private final MutableLiveData<List<StoreDTO>> stores = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData<List<StoreDTO>> stores = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false); private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
@Inject @Inject
public PurchaseOrderListViewModel(PurchaseOrderRepository purchaseOrderRepository, StoreRepository storeRepository) { public PurchaseOrderListViewModel(PurchaseOrderRepository purchaseOrderRepository, StoreRepository storeRepository) {
this.purchaseOrderRepository = purchaseOrderRepository; this.purchaseOrderRepository = purchaseOrderRepository;
@@ -37,13 +41,27 @@ public class PurchaseOrderListViewModel extends ViewModel {
public LiveData<List<PurchaseOrderDTO>> getPurchaseOrders() { return purchaseOrders; } public LiveData<List<PurchaseOrderDTO>> getPurchaseOrders() { return purchaseOrders; }
public LiveData<List<StoreDTO>> getStores() { return stores; } public LiveData<List<StoreDTO>> getStores() { return stores; }
public LiveData<Boolean> getIsLoading() { return isLoading; } public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadPurchaseOrders(boolean reset, String query, Long storeId) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadPurchaseOrders(String query, Long storeId) {
isLoading.setValue(true); isLoading.setValue(true);
observeOnce(purchaseOrderRepository.getAllPurchaseOrders(0, 100, query, storeId, "purchaseOrderId,desc"), resource -> { observeOnce(purchaseOrderRepository.getAllPurchaseOrders(currentPage, PAGE_SIZE, query, storeId, "purchaseOrderId,desc"), resource -> {
if (resource != null) { if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) { if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
purchaseOrders.setValue(resource.data.getContent()); List<PurchaseOrderDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(purchaseOrders.getValue());
currentList.addAll(resource.data.getContent());
purchaseOrders.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
isLoading.setValue(false); isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) { } else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false); isLoading.setValue(false);

View File

@@ -28,6 +28,11 @@ public class StaffListViewModel extends ViewModel {
private final MutableLiveData<List<EmployeeDTO>> filteredEmployees = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData<List<EmployeeDTO>> filteredEmployees = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<List<StoreDTO>> stores = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData<List<StoreDTO>> stores = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false); private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
private String lastQuery = ""; private String lastQuery = "";
private Long lastStoreId = null; private Long lastStoreId = null;
private String lastStatus = "All Statuses"; private String lastStatus = "All Statuses";
@@ -41,14 +46,28 @@ public class StaffListViewModel extends ViewModel {
public LiveData<List<EmployeeDTO>> getFilteredEmployees() { return filteredEmployees; } public LiveData<List<EmployeeDTO>> getFilteredEmployees() { return filteredEmployees; }
public LiveData<List<StoreDTO>> getStores() { return stores; } public LiveData<List<StoreDTO>> getStores() { return stores; }
public LiveData<Boolean> getIsLoading() { return isLoading; } public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadStaff(boolean reset) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadStaff() {
isLoading.setValue(true); isLoading.setValue(true);
observeOnce(repository.getAllEmployees(0, 100), resource -> { observeOnce(repository.getAllEmployees(currentPage, PAGE_SIZE), resource -> {
if (resource != null) { if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) { if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
employees.setValue(resource.data.getContent()); List<EmployeeDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(employees.getValue());
filter(lastQuery, lastStoreId, lastStatus); currentList.addAll(resource.data.getContent());
employees.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
applyFilter(currentList);
isLoading.setValue(false); isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) { } else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false); isLoading.setValue(false);
@@ -81,28 +100,27 @@ public class StaffListViewModel extends ViewModel {
this.lastQuery = query; this.lastQuery = query;
this.lastStoreId = storeId; this.lastStoreId = storeId;
this.lastStatus = status; this.lastStatus = status;
applyFilter(employees.getValue());
List<EmployeeDTO> all = employees.getValue(); }
private void applyFilter(List<EmployeeDTO> all) {
if (all == null) return; if (all == null) return;
List<EmployeeDTO> filtered = new ArrayList<>(); List<EmployeeDTO> filtered = new ArrayList<>();
String lowerQuery = query.toLowerCase(); String lowerQuery = lastQuery.toLowerCase();
for (EmployeeDTO e : all) { for (EmployeeDTO e : all) {
// Search Query Filter boolean matchesQuery = lastQuery.isEmpty() ||
boolean matchesQuery = query.isEmpty() ||
(e.getFullName() != null && e.getFullName().toLowerCase().contains(lowerQuery)) || (e.getFullName() != null && e.getFullName().toLowerCase().contains(lowerQuery)) ||
(e.getUsername() != null && e.getUsername().toLowerCase().contains(lowerQuery)) || (e.getUsername() != null && e.getUsername().toLowerCase().contains(lowerQuery)) ||
(e.getEmail() != null && e.getEmail().toLowerCase().contains(lowerQuery)) || (e.getEmail() != null && e.getEmail().toLowerCase().contains(lowerQuery)) ||
(e.getPhone() != null && e.getPhone().toLowerCase().contains(lowerQuery)); (e.getPhone() != null && e.getPhone().toLowerCase().contains(lowerQuery));
// Store Filter boolean matchesStore = lastStoreId == null || (e.getPrimaryStoreId() != null && e.getPrimaryStoreId().equals(lastStoreId));
boolean matchesStore = storeId == null || (e.getPrimaryStoreId() != null && e.getPrimaryStoreId().equals(storeId));
// Status Filter boolean matchesStatus = lastStatus.equals("All Statuses") ||
boolean matchesStatus = status.equals("All Statuses") || (lastStatus.equals("Active") && Boolean.TRUE.equals(e.getActive())) ||
(status.equals("Active") && Boolean.TRUE.equals(e.getActive())) || (lastStatus.equals("Inactive") && Boolean.FALSE.equals(e.getActive()));
(status.equals("Inactive") && Boolean.FALSE.equals(e.getActive()));
if (matchesQuery && matchesStore && matchesStatus) { if (matchesQuery && matchesStore && matchesStatus) {
filtered.add(e); filtered.add(e);

View File

@@ -25,6 +25,10 @@ public class SupplierListViewModel extends ViewModel {
private final MutableLiveData<List<SupplierDTO>> suppliers = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData<List<SupplierDTO>> suppliers = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false); private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
@Inject @Inject
public SupplierListViewModel(SupplierRepository repository) { public SupplierListViewModel(SupplierRepository repository) {
this.repository = repository; this.repository = repository;
@@ -32,13 +36,27 @@ public class SupplierListViewModel extends ViewModel {
public LiveData<List<SupplierDTO>> getSuppliers() { return suppliers; } public LiveData<List<SupplierDTO>> getSuppliers() { return suppliers; }
public LiveData<Boolean> getIsLoading() { return isLoading; } public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadSuppliers(boolean reset, String query) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadSuppliers(String query) {
isLoading.setValue(true); isLoading.setValue(true);
observeOnce(repository.getAllSuppliers(0, 100, query, "supCompany"), resource -> { observeOnce(repository.getAllSuppliers(currentPage, PAGE_SIZE, query, "supCompany"), resource -> {
if (resource != null) { if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) { if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
suppliers.setValue(resource.data.getContent()); List<SupplierDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(suppliers.getValue());
currentList.addAll(resource.data.getContent());
suppliers.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
isLoading.setValue(false); isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) { } else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false); isLoading.setValue(false);

View File

@@ -88,6 +88,57 @@
</LinearLayout> </LinearLayout>
<!-- Date range pickers -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp">
<Button
android:id="@+id/btnStartDate"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:text="Start Date"
android:textSize="12sp"
android:backgroundTint="@color/white"
android:textColor="@color/text_dark"
android:ellipsize="end"
android:maxLines="1"/>
<View
android:layout_width="8dp"
android:layout_height="0dp"/>
<Button
android:id="@+id/btnEndDate"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:text="End Date"
android:textSize="12sp"
android:backgroundTint="@color/white"
android:textColor="@color/text_dark"
android:ellipsize="end"
android:maxLines="1"/>
<View
android:layout_width="8dp"
android:layout_height="0dp"/>
<Button
android:id="@+id/btnClearDates"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:text="Clear"
android:textSize="12sp"
android:backgroundTint="#e2e8f0"
android:textColor="@color/text_dark"
android:visibility="gone"/>
</LinearLayout>
<!-- Role and Store spinners --> <!-- Role and Store spinners -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -119,6 +170,7 @@
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout

View File

@@ -105,19 +105,6 @@
android:textSize="15sp" android:textSize="15sp"
android:layout_marginBottom="16dp"/> android:layout_marginBottom="16dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Status"
android:textColor="@color/text_light"
android:textSize="12sp"/>
<TextView
android:id="@+id/tvPODetailStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="15sp"
android:layout_marginBottom="16dp"/>
</LinearLayout> </LinearLayout>

View File

@@ -13,17 +13,17 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:layout_marginBottom="4dp"> android:gravity="center_vertical"
android:layout_marginBottom="2dp">
<TextView <TextView
android:id="@+id/tvLogActivity" android:id="@+id/tvLogActivity"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:textSize="13sp" android:textSize="14sp"
android:textColor="@color/text_dark" android:textColor="@color/text_dark"
android:textStyle="bold" android:textStyle="bold"/>
android:fontFamily="monospace"/>
<TextView <TextView
android:id="@+id/tvLogTimestamp" android:id="@+id/tvLogTimestamp"
@@ -36,18 +36,42 @@
</LinearLayout> </LinearLayout>
<TextView <TextView
android:id="@+id/tvLogUser" android:id="@+id/tvLogTechnical"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@color/text_dark"
android:layout_marginBottom="2dp"/>
<TextView
android:id="@+id/tvLogMeta"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textSize="11sp"
android:textColor="@color/text_light"/> android:textColor="@color/text_light"
android:fontFamily="monospace"
android:layout_marginBottom="6dp"
android:visibility="gone"/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#F0F0F0"
android:layout_marginBottom="6dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvLogUser"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="12sp"
android:textColor="@color/text_dark"/>
<TextView
android:id="@+id/tvLogMeta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="@color/text_light"/>
</LinearLayout>
</LinearLayout> </LinearLayout>

View File

@@ -27,19 +27,6 @@
android:textSize="18sp" android:textSize="18sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView
android:id="@+id/tvPOStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingTop="3dp"
android:paddingEnd="8dp"
android:paddingBottom="3dp"
android:text="Status"
android:textAllCaps="true"
android:textColor="@color/white"
android:textSize="11sp" />
</LinearLayout> </LinearLayout>
<TextView <TextView

View File

@@ -17,9 +17,9 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
@Component @Component
@Order(Ordered.LOWEST_PRECEDENCE - 20) @Order(Ordered.LOWEST_PRECEDENCE - 20)
public class ActivityLoggingFilter extends OncePerRequestFilter { public class ActivityLoggingFilter extends OncePerRequestFilter {
private final ActivityLogService activityLogService; private final ActivityLogService activityLogService;
@@ -30,13 +30,8 @@ import java.io.IOException;
@Override @Override
protected boolean shouldNotFilter(HttpServletRequest request) { protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI(); String uri = request.getRequestURI();
if (uri == null || uri.isBlank()) { if (uri == null || uri.isBlank()) return true;
return true; if (!uri.startsWith("/api/")) return true;
}
if (!uri.startsWith("/api/")) {
return true;
}
String lower = uri.toLowerCase(java.util.Locale.ROOT); String lower = uri.toLowerCase(java.util.Locale.ROOT);
return lower.startsWith("/api/v1/health") return lower.startsWith("/api/v1/health")
|| lower.startsWith("/api/v1/activity-logs") || lower.startsWith("/api/v1/activity-logs")
@@ -46,16 +41,15 @@ import java.io.IOException;
} }
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
recordActivity(request, response); recordActivity(request, response);
} }
private void recordActivity(HttpServletRequest request, HttpServletResponse response) { private void recordActivity(HttpServletRequest request, HttpServletResponse response) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) { if (authentication == null || !authentication.isAuthenticated()) return;
return;
}
Long userId = null; Long userId = null;
User.Role role = null; User.Role role = null;
@@ -69,11 +63,136 @@ import java.io.IOException;
role = appPrincipal.getRole(); role = appPrincipal.getRole();
} }
if (userId == null || role == null || role == User.Role.CUSTOMER) { if (userId == null || role == null) return;
return; if ("GET".equalsIgnoreCase(request.getMethod())) return;
}
String method = request.getMethod();
String uri = request.getRequestURI();
int status = response.getStatus();
String technical = method + " " + uri + "" + status;
String description = describe(method, uri, status);
String activity = description != null ? description + " | " + technical : technical;
String activity = String.format("%s %s -> %d", request.getMethod(), request.getRequestURI(), response.getStatus());
activityLogService.record(userId, activity); activityLogService.record(userId, activity);
} }
private String describe(String method, String rawUri, int status) {
String uri = rawUri.contains("?") ? rawUri.substring(0, rawUri.indexOf('?')) : rawUri;
String[] parts = uri.replaceFirst("^/+", "").split("/");
if (parts.length < 3) return null;
String r = parts[2];
String seg3 = parts.length > 3 ? parts[3] : null;
String seg4 = parts.length > 4 ? parts[4] : null;
String seg5 = parts.length > 5 ? parts[5] : null;
boolean seg3IsId = seg3 != null && seg3.matches("\\d+");
boolean seg4IsId = seg4 != null && seg4.matches("\\d+");
String id = seg3IsId ? seg3 : null;
String sub = seg3IsId ? seg4 : seg3;
String subsub = seg3IsId ? seg5 : seg4;
boolean isGet = "GET".equalsIgnoreCase(method);
boolean isPost = "POST".equalsIgnoreCase(method);
boolean isPut = "PUT".equalsIgnoreCase(method);
boolean isPatch = "PATCH".equalsIgnoreCase(method);
boolean isDelete = "DELETE".equalsIgnoreCase(method);
boolean failed = status >= 400;
switch (r) {
case "auth" -> {
if ("login".equals(seg3) && isPost) return failed ? "Failed login attempt" : "Logged in";
if ("logout".equals(seg3) && isPost) return "Logged out";
if ("register".equals(seg3) && isPost) return failed ? "Failed registration attempt" : "Registered a new account";
if ("forgot-password".equals(seg3) && isPost) return "Requested a password reset";
if ("reset-password".equals(seg3) && isPost) return "Reset their password";
if ("me".equals(seg3) && isPut) return "Updated their profile";
if ("me".equals(seg3) && "avatar".equals(sub) && isPost) return "Uploaded a profile picture";
if ("me".equals(seg3) && "avatar".equals(sub) && isDelete) return "Removed their profile picture";
}
case "cart" -> {
if ("add".equals(seg3) && isPost) return "Added an item to cart";
if ("update".equals(seg3) && isPut) return "Updated a cart item";
if ("remove".equals(seg3) && isDelete) return "Removed an item from cart";
if ("clear".equals(seg3) && isDelete) return "Cleared their cart";
if ("apply-coupon".equals(seg3) && isPost) return "Applied a coupon to cart";
if ("checkout".equals(seg3)) {
if ("complete".equals(seg4) && isPost) return "Completed a purchase";
if ("cancel".equals(seg4) && isPost) return "Cancelled checkout";
if (isPost) return "Started checkout";
}
}
case "chat" -> {
if ("conversations".equals(seg3) && isPost && seg4 == null) return "Started a new chat conversation";
if ("conversations".equals(seg3) && seg4IsId) {
String convId = seg4;
String chatSub = parts.length > 5 ? parts[5] : null;
if ("messages".equals(seg5) && isPost) return "Sent a chat message";
if ("attachments".equals(seg5) && isPost) return "Sent a file in chat";
if ("request-human".equals(seg5) && isPost) return "Requested human support in chat";
if (chatSub == null && isPut) return "Updated chat conversation #" + convId;
}
}
case "ai-chat" -> {
if ("message".equals(seg3) && isPost) return "Sent a message to the AI assistant";
}
case "product-suppliers" -> {
if (isPost && seg3 == null) return "Linked a product to a supplier";
if (seg3IsId && seg4IsId && isPut) return "Updated product-supplier link";
if (seg3IsId && seg4IsId && isDelete) return "Removed product-supplier link";
if (isDelete && !seg3IsId) return "Removed multiple product-supplier links";
}
}
String label = resourceLabel(r);
if (label == null) return null;
if ("image".equals(sub) && id != null) {
if (isPost) return "Uploaded image for " + label + " #" + id;
if (isDelete) return "Removed image from " + label + " #" + id;
}
if ("cancel".equals(sub) && id != null && isPatch) return "Cancelled " + label + " #" + id;
if (isPost && id == null) {
if ("request".equals(seg3)) return "Submitted an adoption request";
if ("bulk-delete".equals(seg3)) return "Deleted multiple " + plural(label);
return "Created a new " + label;
}
if ((isPut || isPatch) && id != null && sub == null) return "Updated " + label + " #" + id;
if (isDelete && id != null && sub == null) return "Deleted " + label + " #" + id;
if (isDelete && id == null) return "Deleted multiple " + plural(label);
return null;
}
private String resourceLabel(String resource) {
return switch (resource) {
case "products" -> "product";
case "categories" -> "category";
case "customers" -> "customer";
case "employees" -> "employee";
case "users" -> "user";
case "pets" -> "pet";
case "my-pets" -> "pet";
case "appointments" -> "appointment";
case "adoptions" -> "adoption";
case "sales" -> "sale";
case "refunds" -> "refund";
case "inventory" -> "inventory record";
case "services" -> "service";
case "suppliers" -> "supplier";
case "purchase-orders" -> "purchase order";
case "coupons" -> "coupon";
case "stores" -> "store";
default -> null;
};
}
private String plural(String label) {
if (label.endsWith("y")) return label.substring(0, label.length() - 1) + "ies";
if (label.endsWith("s")) return label + "es";
return label + "s";
}
} }

View File

@@ -137,7 +137,7 @@ public class AuthController {
String token = jwtUtil.generateToken(user); String token = jwtUtil.generateToken(user);
if (user.getRole() != User.Role.CUSTOMER) { if (user.getRole() != User.Role.CUSTOMER) {
activityLogService.record(user.getId(), "POST /api/v1/auth/login -> 200"); activityLogService.record(user.getId(), "Logged in | POST /api/v1/auth/login 200");
} }
return ResponseEntity.ok(new LoginResponse( return ResponseEntity.ok(new LoginResponse(

View File

@@ -158,10 +158,10 @@ public class EmailService {
.html(html) .html(html)
.build(); .build();
resend.emails().send(options); resend.emails().send(options);
activityLogService.record(recipientUserId, "Email sent: " + subject + "" + to); activityLogService.record(recipientUserId, "Sent an email | Email sent: " + subject + "" + to);
} catch (Exception ex) { } catch (Exception ex) {
log.error("Failed to send email '{}' to {}: {}", subject, to, ex.getMessage()); log.error("Failed to send email '{}' to {}: {}", subject, to, ex.getMessage());
activityLogService.record(recipientUserId, "Email failed: " + subject + "" + to); activityLogService.record(recipientUserId, "Failed to send email | Email failed: " + subject + "" + to);
} }
} }

View File

@@ -4,7 +4,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.example.petshopdesktop.auth.UserSession; import org.example.petshopdesktop.auth.UserSession;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.SequenceInputStream;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
@@ -150,26 +153,33 @@ public class ApiClient {
String mimeType = Files.probeContentType(filePath); String mimeType = Files.probeContentType(filePath);
if (mimeType == null || mimeType.isBlank()) mimeType = "application/octet-stream"; if (mimeType == null || mimeType.isBlank()) mimeType = "application/octet-stream";
byte[] fileBytes = Files.readAllBytes(filePath);
String fileName = filePath.getFileName().toString(); String fileName = filePath.getFileName().toString();
ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] fileHeader = ("--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + filePartName
out.write(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + filePartName
+ "\"; filename=\"" + fileName + "\"\r\nContent-Type: " + mimeType + "\r\n\r\n") + "\"; filename=\"" + fileName + "\"\r\nContent-Type: " + mimeType + "\r\n\r\n")
.getBytes(StandardCharsets.UTF_8)); .getBytes(StandardCharsets.UTF_8);
out.write(fileBytes);
out.write("\r\n".getBytes(StandardCharsets.UTF_8)); ByteArrayOutputStream tail = new ByteArrayOutputStream();
tail.write("\r\n".getBytes(StandardCharsets.UTF_8));
if (textContent != null && !textContent.isBlank()) { if (textContent != null && !textContent.isBlank()) {
out.write(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + textPartName tail.write(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + textPartName
+ "\"\r\n\r\n" + textContent + "\r\n").getBytes(StandardCharsets.UTF_8)); + "\"\r\n\r\n" + textContent + "\r\n").getBytes(StandardCharsets.UTF_8));
} }
out.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)); tail.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
InputStream body = new SequenceInputStream(
java.util.Collections.enumeration(java.util.Arrays.asList(
new ByteArrayInputStream(fileHeader),
Files.newInputStream(filePath),
new ByteArrayInputStream(tail.toByteArray())
))
);
HttpRequest.Builder builder = HttpRequest.newBuilder() HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + path)) .uri(URI.create(baseUrl + path))
.header("Content-Type", "multipart/form-data; boundary=" + boundary) .header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray())) .POST(HttpRequest.BodyPublishers.ofInputStream(() -> body))
.timeout(Duration.ofSeconds(30)); .timeout(Duration.ofSeconds(120));
addAuthHeader(builder); addAuthHeader(builder);
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
return handleResponse(response, responseClass); return handleResponse(response, responseClass);

View File

@@ -14,6 +14,7 @@ public class MessageResponse {
private Boolean isRead; private Boolean isRead;
private String attachmentName; private String attachmentName;
private String attachmentUrl; private String attachmentUrl;
private String attachmentMimeType;
public MessageResponse() { public MessageResponse() {
} }
@@ -105,4 +106,12 @@ public class MessageResponse {
public void setAttachmentUrl(String attachmentUrl) { public void setAttachmentUrl(String attachmentUrl) {
this.attachmentUrl = attachmentUrl; this.attachmentUrl = attachmentUrl;
} }
public String getAttachmentMimeType() {
return attachmentMimeType;
}
public void setAttachmentMimeType(String attachmentMimeType) {
this.attachmentMimeType = attachmentMimeType;
}
} }

View File

@@ -8,10 +8,14 @@ import javafx.event.ActionEvent;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.CheckBox; import javafx.scene.control.CheckBox;
import javafx.scene.control.ComboBox;
import javafx.scene.control.DatePicker; import javafx.scene.control.DatePicker;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn; import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView; import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.layout.VBox;
import org.example.petshopdesktop.api.dto.activity.ActivityLogResponse; import org.example.petshopdesktop.api.dto.activity.ActivityLogResponse;
import org.example.petshopdesktop.api.endpoints.ActivityLogApi; import org.example.petshopdesktop.api.endpoints.ActivityLogApi;
import org.example.petshopdesktop.util.ActivityLogger; import org.example.petshopdesktop.util.ActivityLogger;
@@ -20,73 +24,105 @@ import org.example.petshopdesktop.util.TableViewSupport;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import java.util.TreeSet;
public class ActivityLogController { public class ActivityLogController {
@FXML private static final String SEPARATOR_NEW = " | ";
private TableView<ActivityLogResponse> tvActivityLogs; private static final String SEPARATOR_OLD = " \u00b7 ";
private static final DateTimeFormatter DISPLAY_FORMATTER = DateTimeFormatter.ofPattern("MMM d, HH:mm");
private static final int DEFAULT_LIMIT = 2000;
@FXML @FXML private TableView<ActivityLogResponse> tvActivityLogs;
private TableColumn<ActivityLogResponse, Object> colTimestamp; @FXML private TableColumn<ActivityLogResponse, Object> colTimestamp;
@FXML private TableColumn<ActivityLogResponse, String> colUser;
@FXML @FXML private TableColumn<ActivityLogResponse, String> colRole;
private TableColumn<ActivityLogResponse, String> colUser; @FXML private TableColumn<ActivityLogResponse, String> colStore;
@FXML private TableColumn<ActivityLogResponse, String> colActivity;
@FXML @FXML private Button btnRefresh;
private TableColumn<ActivityLogResponse, String> colRole; @FXML private DatePicker dpStart;
@FXML private DatePicker dpEnd;
@FXML @FXML private CheckBox chkHideViewOnly;
private TableColumn<ActivityLogResponse, String> colStore; @FXML private TextField tfSearch;
@FXML private ComboBox<String> cbRoleFilter;
@FXML @FXML private ComboBox<String> cbStoreFilter;
private TableColumn<ActivityLogResponse, String> colActivity; @FXML private Label lblStatus;
@FXML private Label lblError;
@FXML
private Button btnRefresh;
@FXML
private DatePicker dpStart;
@FXML
private DatePicker dpEnd;
@FXML
private CheckBox chkHideViewOnly;
@FXML
private Label lblStatus;
@FXML
private Label lblError;
private final ObservableList<ActivityLogResponse> activityLogs = FXCollections.observableArrayList(); private final ObservableList<ActivityLogResponse> activityLogs = FXCollections.observableArrayList();
private FilteredList<ActivityLogResponse> filteredLogs; private FilteredList<ActivityLogResponse> filteredLogs;
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final int DEFAULT_LIMIT = 2000;
@FXML @FXML
public void initialize() { public void initialize() {
colTimestamp.setCellValueFactory(data -> new javafx.beans.property.SimpleObjectProperty<>(data.getValue().getLogTimestamp())); colTimestamp.setCellValueFactory(data -> new javafx.beans.property.SimpleObjectProperty<>(data.getValue().getLogTimestamp()));
colTimestamp.setCellFactory(column -> new javafx.scene.control.TableCell<>() { colTimestamp.setCellFactory(col -> new TableCell<>() {
@Override @Override
protected void updateItem(Object item, boolean empty) { protected void updateItem(Object item, boolean empty) {
super.updateItem(item, empty); super.updateItem(item, empty);
if (empty || item == null) { if (empty || item == null) {
setText(null); setText(null);
} else if (item instanceof java.time.LocalDateTime time) { } else if (item instanceof java.time.LocalDateTime time) {
setText(time.format(formatter)); setText(time.format(DISPLAY_FORMATTER));
} else { } else {
setText(item.toString()); setText(item.toString());
} }
} }
}); });
colUser.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(displayUser(data.getValue()))); colUser.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(displayUser(data.getValue())));
colRole.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(displayRole(data.getValue()))); colRole.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(formatRole(displayRole(data.getValue()))));
colStore.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(displayStore(data.getValue()))); colStore.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(displayStore(data.getValue())));
colActivity.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(nullToBlank(data.getValue().getActivity()))); colActivity.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(nullToBlank(data.getValue().getActivity())));
colActivity.setCellFactory(col -> new TableCell<>() {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null || item.isBlank()) {
setGraphic(null);
setText(null);
return;
}
int sep = item.indexOf(SEPARATOR_NEW);
int sepLen = SEPARATOR_NEW.length();
if (sep < 0) {
sep = item.indexOf(SEPARATOR_OLD);
sepLen = SEPARATOR_OLD.length();
}
if (sep >= 0) {
String description = item.substring(0, sep).trim();
String technical = item.substring(sep + sepLen).trim();
Label lblDesc = new Label(description);
lblDesc.setStyle("-fx-font-weight: bold; -fx-text-fill: #1f2937;");
lblDesc.setWrapText(true);
Label lblTech = new Label(technical);
lblTech.setStyle("-fx-font-size: 11px; -fx-text-fill: #94a3b8; -fx-font-family: monospace;");
lblTech.setWrapText(true);
VBox box = new VBox(2, lblDesc, lblTech);
setGraphic(box);
setText(null);
} else {
setGraphic(null);
setText(item);
}
}
});
tvActivityLogs.setFixedCellSize(52);
cbRoleFilter.setItems(FXCollections.observableArrayList("All Roles", "ADMIN", "STAFF", "CUSTOMER"));
cbRoleFilter.getSelectionModel().selectFirst();
cbRoleFilter.setOnAction(e -> applyFilters());
cbStoreFilter.setItems(FXCollections.observableArrayList("All Stores"));
cbStoreFilter.getSelectionModel().selectFirst();
cbStoreFilter.setOnAction(e -> applyFilters());
tfSearch.textProperty().addListener((obs, oldVal, newVal) -> applyFilters());
filteredLogs = new FilteredList<>(activityLogs, a -> true); filteredLogs = new FilteredList<>(activityLogs, a -> true);
TableViewSupport.bindSortedItems(tvActivityLogs, filteredLogs); TableViewSupport.bindSortedItems(tvActivityLogs, filteredLogs);
dpStart.setValue(LocalDate.now().minusDays(30)); dpStart.setValue(LocalDate.now().minusDays(30));
dpEnd.setValue(LocalDate.now()); dpEnd.setValue(LocalDate.now());
loadLogs(); loadLogs();
@@ -103,22 +139,59 @@ public class ActivityLogController {
} }
@FXML @FXML
void onHideViewOnlyChanged(ActionEvent event) { void onFilterChanged(ActionEvent event) {
applyViewOnlyFilter(); applyFilters();
} }
private void applyViewOnlyFilter() { private void applyFilters() {
boolean hide = chkHideViewOnly != null && chkHideViewOnly.isSelected(); String search = tfSearch != null && tfSearch.getText() != null ? tfSearch.getText().trim().toLowerCase() : "";
if (filteredLogs != null) { String role = cbRoleFilter != null ? cbRoleFilter.getValue() : null;
filteredLogs.setPredicate(hide String store = cbStoreFilter != null ? cbStoreFilter.getValue() : null;
? a -> { boolean hideViewOnly = chkHideViewOnly != null && chkHideViewOnly.isSelected();
String act = a.getActivity();
if (act == null) return true; if (filteredLogs == null) return;
String trimmed = act.stripLeading();
return !trimmed.startsWith("View"); filteredLogs.setPredicate(log -> {
if (hideViewOnly) {
String act = log.getActivity();
if (act != null && act.stripLeading().startsWith("View")) return false;
}
if (role != null && !role.equals("All Roles")) {
String logRole = displayRole(log);
if (!role.equalsIgnoreCase(logRole)) return false;
}
if (store != null && !store.equals("All Stores")) {
if (!store.equals(displayStore(log))) return false;
}
if (!search.isEmpty()) {
String activity = nullToBlank(log.getActivity()).toLowerCase();
String user = displayUser(log).toLowerCase();
String username = nullToBlank(log.getUsername()).toLowerCase();
String usernameSnap = nullToBlank(log.getUsernameSnapshot()).toLowerCase();
if (!activity.contains(search) && !user.contains(search)
&& !username.contains(search) && !usernameSnap.contains(search)) {
return false;
} }
: a -> true); }
return true;
});
}
private void populateStoreFilter(List<ActivityLogResponse> logs) {
TreeSet<String> stores = new TreeSet<>();
for (ActivityLogResponse log : logs) {
String s = displayStore(log);
if (!s.isBlank()) stores.add(s);
} }
String current = cbStoreFilter.getValue();
ObservableList<String> options = FXCollections.observableArrayList("All Stores");
options.addAll(stores);
cbStoreFilter.setItems(options);
cbStoreFilter.setValue(options.contains(current) ? current : "All Stores");
} }
private void loadLogs() { private void loadLogs() {
@@ -126,8 +199,8 @@ public class ActivityLogController {
tvActivityLogs.setDisable(true); tvActivityLogs.setDisable(true);
btnRefresh.setDisable(true); btnRefresh.setDisable(true);
java.time.LocalDate startDate = dpStart != null ? dpStart.getValue() : null; LocalDate startDate = dpStart != null ? dpStart.getValue() : null;
java.time.LocalDate endDate = dpEnd != null ? dpEnd.getValue() : null; LocalDate endDate = dpEnd != null ? dpEnd.getValue() : null;
new Thread(() -> { new Thread(() -> {
try { try {
@@ -136,7 +209,8 @@ public class ActivityLogController {
Platform.runLater(() -> { Platform.runLater(() -> {
activityLogs.setAll(safeContent); activityLogs.setAll(safeContent);
applyViewOnlyFilter(); populateStoreFilter(safeContent);
applyFilters();
tvActivityLogs.setDisable(false); tvActivityLogs.setDisable(false);
btnRefresh.setDisable(false); btnRefresh.setDisable(false);
TableViewSupport.flashStatus(lblStatus, "Refreshed"); TableViewSupport.flashStatus(lblStatus, "Refreshed");
@@ -145,8 +219,8 @@ public class ActivityLogController {
ActivityLogger.getInstance().logException("ActivityLogController.loadLogs", e, "Loading activity logs"); ActivityLogger.getInstance().logException("ActivityLogController.loadLogs", e, "Loading activity logs");
Platform.runLater(() -> { Platform.runLater(() -> {
lblError.setText(e.getMessage() == null || e.getMessage().isBlank() lblError.setText(e.getMessage() == null || e.getMessage().isBlank()
? "Could not load activity logs." ? "Could not load activity logs."
: "Could not load activity logs: " + e.getMessage()); : "Could not load activity logs: " + e.getMessage());
tvActivityLogs.setDisable(false); tvActivityLogs.setDisable(false);
btnRefresh.setDisable(false); btnRefresh.setDisable(false);
}); });
@@ -155,41 +229,33 @@ public class ActivityLogController {
} }
private String displayUser(ActivityLogResponse log) { private String displayUser(ActivityLogResponse log) {
if (log == null) { if (log == null) return "";
return ""; if (log.getFullNameSnapshot() != null && !log.getFullNameSnapshot().isBlank()) return log.getFullNameSnapshot();
} if (log.getFullName() != null && !log.getFullName().isBlank()) return log.getFullName();
if (log.getFullNameSnapshot() != null && !log.getFullNameSnapshot().isBlank()) { if (log.getUsernameSnapshot() != null && !log.getUsernameSnapshot().isBlank()) return log.getUsernameSnapshot();
return log.getFullNameSnapshot(); if (log.getUsername() != null && !log.getUsername().isBlank()) return log.getUsername();
}
if (log.getFullName() != null && !log.getFullName().isBlank()) {
return log.getFullName();
}
if (log.getUsernameSnapshot() != null && !log.getUsernameSnapshot().isBlank()) {
return log.getUsernameSnapshot();
}
if (log.getUsername() != null && !log.getUsername().isBlank()) {
return log.getUsername();
}
return log.getUserId() != null ? String.valueOf(log.getUserId()) : ""; return log.getUserId() != null ? String.valueOf(log.getUserId()) : "";
} }
private String displayRole(ActivityLogResponse log) { private String displayRole(ActivityLogResponse log) {
if (log == null) { if (log == null) return "";
return ""; if (log.getRoleSnapshot() != null && !log.getRoleSnapshot().isBlank()) return log.getRoleSnapshot();
}
if (log.getRoleSnapshot() != null && !log.getRoleSnapshot().isBlank()) {
return log.getRoleSnapshot();
}
return nullToBlank(log.getRole()); return nullToBlank(log.getRole());
} }
private String formatRole(String role) {
if (role == null) return "";
return switch (role.toUpperCase()) {
case "ADMIN" -> "Admin";
case "STAFF" -> "Staff";
case "CUSTOMER" -> "Customer";
default -> role;
};
}
private String displayStore(ActivityLogResponse log) { private String displayStore(ActivityLogResponse log) {
if (log == null) { if (log == null) return "";
return ""; if (log.getStoreNameSnapshot() != null && !log.getStoreNameSnapshot().isBlank()) return log.getStoreNameSnapshot();
}
if (log.getStoreNameSnapshot() != null && !log.getStoreNameSnapshot().isBlank()) {
return log.getStoreNameSnapshot();
}
return nullToBlank(log.getStoreName()); return nullToBlank(log.getStoreName());
} }

View File

@@ -19,6 +19,7 @@ import javafx.scene.layout.VBox;
import javafx.scene.paint.ImagePattern; import javafx.scene.paint.ImagePattern;
import javafx.scene.shape.Circle; import javafx.scene.shape.Circle;
import javafx.scene.image.Image; import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import org.example.petshopdesktop.api.ChatRealtimeClient; import org.example.petshopdesktop.api.ChatRealtimeClient;
import org.example.petshopdesktop.api.dto.chat.ConversationResponse; import org.example.petshopdesktop.api.dto.chat.ConversationResponse;
import org.example.petshopdesktop.api.dto.chat.MessageRequest; import org.example.petshopdesktop.api.dto.chat.MessageRequest;
@@ -203,11 +204,18 @@ public class ChatController {
} }
lblChatStatus.setText("Message sent"); lblChatStatus.setText("Message sent");
}); });
} catch (OutOfMemoryError e) {
Platform.runLater(() -> {
txtMessage.setText(content);
btnSend.setDisable(false);
lblChatStatus.setText("File too large to send");
});
} catch (Exception e) { } catch (Exception e) {
Platform.runLater(() -> { Platform.runLater(() -> {
txtMessage.setText(content); txtMessage.setText(content);
btnSend.setDisable(false); btnSend.setDisable(false);
lblChatStatus.setText("Chat send failed"); String msg = e.getMessage();
lblChatStatus.setText(msg != null && !msg.isBlank() ? msg : "Chat send failed");
ActivityLogger.getInstance().logException( ActivityLogger.getInstance().logException(
"ChatController.sendMessage", "ChatController.sendMessage",
e, e,
@@ -222,6 +230,16 @@ public class ChatController {
File file = org.example.petshopdesktop.util.FilePickerSupport.pickAnyFile(btnAttachment.getScene().getWindow()); File file = org.example.petshopdesktop.util.FilePickerSupport.pickAnyFile(btnAttachment.getScene().getWindow());
if (file == null) return; if (file == null) return;
long maxBytes = 5 * 1024 * 1024;
if (file.length() > maxBytes) {
javafx.scene.control.Alert alert = new javafx.scene.control.Alert(javafx.scene.control.Alert.AlertType.WARNING);
alert.setTitle("File Too Large");
alert.setHeaderText("The selected file exceeds the 5 MB limit.");
alert.setContentText("Please choose a smaller file and try again.");
alert.showAndWait();
return;
}
selectedAttachmentFile = file; selectedAttachmentFile = file;
btnAttachment.setText("📎 " + file.getName()); btnAttachment.setText("📎 " + file.getName());
btnAttachment.setStyle("-fx-background-color: #dcfce7; -fx-background-radius: 12; -fx-text-fill: #166534; -fx-cursor: hand;"); btnAttachment.setStyle("-fx-background-color: #dcfce7; -fx-background-radius: 12; -fx-text-fill: #166534; -fx-cursor: hand;");
@@ -539,10 +557,31 @@ public class ChatController {
bubble.getChildren().add(content); bubble.getChildren().add(content);
} }
if (message.getAttachmentName() != null && !message.getAttachmentName().isBlank()) { if (message.getAttachmentName() != null && !message.getAttachmentName().isBlank()) {
Label attachmentLabel = new Label("\uD83D\uDCCE " + message.getAttachmentName()); String mimeType = message.getAttachmentMimeType();
attachmentLabel.setStyle("-fx-text-fill: " + (mine ? "#dbeafe" : "#475569") + "; -fx-font-size: 12px;"); boolean isImage = mimeType != null && mimeType.startsWith("image/");
attachmentLabel.setWrapText(true); if (isImage && message.getId() != null) {
bubble.getChildren().add(attachmentLabel); ImageView imageView = new ImageView();
imageView.setFitWidth(280);
imageView.setFitHeight(280);
imageView.setPreserveRatio(true);
imageView.setSmooth(true);
bubble.getChildren().add(imageView);
String attachmentPath = "/api/v1/chat/messages/" + message.getId() + "/attachment";
new Thread(() -> {
try {
byte[] bytes = ApiClient.getInstance().getBytes(attachmentPath);
Image img = new Image(new java.io.ByteArrayInputStream(bytes));
if (!img.isError()) {
Platform.runLater(() -> imageView.setImage(img));
}
} catch (Exception ignored) {}
}).start();
} else {
Label attachmentLabel = new Label("\uD83D\uDCCE " + message.getAttachmentName());
attachmentLabel.setStyle("-fx-text-fill: " + (mine ? "#dbeafe" : "#475569") + "; -fx-font-size: 12px;");
attachmentLabel.setWrapText(true);
bubble.getChildren().add(attachmentLabel);
}
} }
bubble.getChildren().add(timestamp); bubble.getChildren().add(timestamp);

View File

@@ -3,57 +3,79 @@
<?import javafx.geometry.Insets?> <?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?> <?import javafx.scene.control.Button?>
<?import javafx.scene.control.CheckBox?> <?import javafx.scene.control.CheckBox?>
<?import javafx.scene.control.ComboBox?>
<?import javafx.scene.control.DatePicker?> <?import javafx.scene.control.DatePicker?>
<?import javafx.scene.control.Label?> <?import javafx.scene.control.Label?>
<?import javafx.scene.control.TableColumn?> <?import javafx.scene.control.TableColumn?>
<?import javafx.scene.control.TableView?> <?import javafx.scene.control.TableView?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.HBox?> <?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.Priority?>
<?import javafx.scene.layout.Region?>
<?import javafx.scene.layout.VBox?> <?import javafx.scene.layout.VBox?>
<?import javafx.scene.text.Font?> <?import javafx.scene.text.Font?>
<VBox spacing="18.0" style="-fx-font-size: 14px;" xmlns="http://javafx.com/javafx/25" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.ActivityLogController"> <VBox spacing="12.0" style="-fx-font-size: 14px;" xmlns="http://javafx.com/javafx/25" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.ActivityLogController">
<padding> <padding>
<Insets bottom="20.0" left="20.0" right="20.0" top="20.0" /> <Insets bottom="20.0" left="20.0" right="20.0" top="20.0" />
</padding> </padding>
<children> <children>
<HBox alignment="CENTER_LEFT" spacing="20.0">
<HBox alignment="CENTER_LEFT" spacing="12.0">
<children> <children>
<Label text="Activity Logs" textFill="#2c3e50"> <Label text="Activity Logs" textFill="#2c3e50">
<font> <font>
<Font name="System Bold" size="30.0" /> <Font name="System Bold" size="30.0" />
</font> </font>
</Label> </Label>
<Button fx:id="btnRefresh" mnemonicParsing="false" onAction="#btnRefreshClicked" prefHeight="44.0" prefWidth="118.0" style="-fx-background-color: #4ECDC4; -fx-cursor: hand; -fx-background-radius: 8;" text="Refresh" textFill="WHITE"> <Region HBox.hgrow="ALWAYS" />
<font> <Button fx:id="btnRefresh" mnemonicParsing="false" onAction="#btnRefreshClicked"
<Font name="System Bold" size="14.0" /> prefHeight="36.0" prefWidth="100.0"
</font> style="-fx-background-color: #4ECDC4; -fx-cursor: hand; -fx-background-radius: 8;"
<padding> text="Refresh" textFill="WHITE">
<Insets bottom="12.0" left="24.0" right="24.0" top="12.0" /> <font><Font name="System Bold" size="13.0" /></font>
</padding>
</Button> </Button>
<Label text="From" textFill="#64748b" />
<DatePicker fx:id="dpStart" onAction="#onDateChanged" promptText="Start date" prefWidth="140.0" />
<Label text="To" textFill="#64748b" />
<DatePicker fx:id="dpEnd" onAction="#onDateChanged" promptText="End date" prefWidth="140.0" />
<CheckBox fx:id="chkHideViewOnly" onAction="#onHideViewOnlyChanged" text="Hide view-only" />
</children> </children>
</HBox> </HBox>
<Label text="Showing activity for the selected date range" textFill="#64748b" /> <HBox alignment="CENTER_LEFT" spacing="10.0">
<children>
<TextField fx:id="tfSearch" promptText="Search activity or user…"
prefHeight="36.0" HBox.hgrow="ALWAYS"
style="-fx-background-radius: 8; -fx-border-radius: 8; -fx-border-color: #e2e8f0;" />
<ComboBox fx:id="cbRoleFilter" prefHeight="36.0" prefWidth="130.0"
style="-fx-background-radius: 8;" />
<ComboBox fx:id="cbStoreFilter" prefHeight="36.0" prefWidth="160.0"
style="-fx-background-radius: 8;" />
</children>
</HBox>
<HBox alignment="CENTER_LEFT" spacing="10.0">
<children>
<Label text="From" textFill="#64748b" />
<DatePicker fx:id="dpStart" onAction="#onDateChanged" promptText="Start date" prefWidth="140.0" prefHeight="36.0" />
<Label text="To" textFill="#64748b" />
<DatePicker fx:id="dpEnd" onAction="#onDateChanged" promptText="End date" prefWidth="140.0" prefHeight="36.0" />
<Region HBox.hgrow="ALWAYS" />
<CheckBox fx:id="chkHideViewOnly" onAction="#onFilterChanged" text="Hide view-only" />
</children>
</HBox>
<TableView fx:id="tvActivityLogs" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS"> <TableView fx:id="tvActivityLogs" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS">
<columns> <columns>
<TableColumn fx:id="colTimestamp" prefWidth="170.0" text="Timestamp" /> <TableColumn fx:id="colTimestamp" prefWidth="150.0" text="Timestamp" />
<TableColumn fx:id="colUser" prefWidth="170.0" text="User" /> <TableColumn fx:id="colUser" prefWidth="160.0" text="User" />
<TableColumn fx:id="colRole" prefWidth="90.0" text="Role" /> <TableColumn fx:id="colRole" prefWidth="90.0" text="Role" />
<TableColumn fx:id="colStore" prefWidth="160.0" text="Store" /> <TableColumn fx:id="colStore" prefWidth="140.0" text="Store" />
<TableColumn fx:id="colActivity" prefWidth="520.0" text="Activity" /> <TableColumn fx:id="colActivity" prefWidth="560.0" text="Activity" />
</columns> </columns>
</TableView> </TableView>
<Label fx:id="lblStatus" text="" textFill="#64748b" visible="false" managed="true" /> <Label fx:id="lblStatus" text="" textFill="#64748b" visible="false" managed="true" />
<Label fx:id="lblError" text="" textFill="#FF6B6B" wrapText="true" /> <Label fx:id="lblError" text="" textFill="#FF6B6B" wrapText="true" />
</children> </children>
</VBox> </VBox>