diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/MessageAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/MessageAdapter.java index 0c3a51a0..eb9d9c19 100644 --- a/android/app/src/main/java/com/example/petstoremobile/adapters/MessageAdapter.java +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/MessageAdapter.java @@ -11,6 +11,7 @@ import com.bumptech.glide.Glide; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.model.GlideUrl; import com.bumptech.glide.load.model.LazyHeaders; +import com.bumptech.glide.signature.ObjectKey; import com.example.petstoremobile.R; import com.example.petstoremobile.databinding.ItemMessageReceivedBinding; import com.example.petstoremobile.databinding.ItemMessageSentBinding; @@ -35,6 +36,13 @@ public class MessageAdapter extends RecyclerView.Adapter messages, Long currentUserId) { this.messages = messages; this.currentUserId = currentUserId; + setHasStableIds(true); + } + + @Override + public long getItemId(int position) { + Message m = messages.get(position); + return m.getId() != null ? m.getId() : position; } public void setCurrentUserId(Long id) { @@ -150,6 +158,7 @@ public class MessageAdapter extends RecyclerView.Adapter { + int prevSize = messageList.size(); messageList.clear(); messageList.addAll(list); - messageAdapter.notifyDataSetChanged(); + if (prevSize > 0 && list.size() == prevSize + 1) { + messageAdapter.notifyItemInserted(list.size() - 1); + } else { + messageAdapter.notifyDataSetChanged(); + } scrollToBottom(); }); diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AnalyticsFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AnalyticsFragment.java index b8656b60..89aeb098 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AnalyticsFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AnalyticsFragment.java @@ -7,11 +7,13 @@ import android.widget.*; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; +import com.example.petstoremobile.api.auth.TokenManager; import com.example.petstoremobile.databinding.FragmentAnalyticsBinding; import com.example.petstoremobile.utils.SpinnerUtils; import com.example.petstoremobile.utils.UIUtils; import com.example.petstoremobile.viewmodels.AnalyticsViewModel; import dagger.hilt.android.AndroidEntryPoint; +import javax.inject.Inject; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.*; @@ -19,6 +21,9 @@ import java.util.*; @AndroidEntryPoint public class AnalyticsFragment extends Fragment { + @Inject + TokenManager tokenManager; + private FragmentAnalyticsBinding binding; private AnalyticsViewModel viewModel; private boolean filtersExpanded = false; @@ -33,6 +38,7 @@ public class AnalyticsFragment extends Fragment { viewModel = new ViewModelProvider(this).get(AnalyticsViewModel.class); setupFilterPanel(); + setupViewModeToggle(); observeViewModel(); viewModel.loadAnalytics(); @@ -42,6 +48,39 @@ public class AnalyticsFragment extends Fragment { return binding.getRoot(); } + private static final int COLOR_SELECTED = 0xFF4ECDC4; + private static final int COLOR_UNSELECTED = 0xFFCBD5E1; + + private void setupViewModeToggle() { + updateViewModeButtonStyles(viewModel.getViewMode()); + + binding.btnMyAnalytics.setOnClickListener(v -> { + viewModel.setViewMode("mine"); + updateViewModeButtonStyles("mine"); + updateStoreFilterVisibility("mine"); + }); + + binding.btnStoreAnalytics.setOnClickListener(v -> { + viewModel.setViewMode("store"); + updateViewModeButtonStyles("store"); + updateStoreFilterVisibility("store"); + }); + } + + private void updateViewModeButtonStyles(String mode) { + binding.btnMyAnalytics.setBackgroundTintList( + android.content.res.ColorStateList.valueOf(mode.equals("mine") ? COLOR_SELECTED : COLOR_UNSELECTED)); + binding.btnStoreAnalytics.setBackgroundTintList( + android.content.res.ColorStateList.valueOf(mode.equals("store") ? COLOR_SELECTED : COLOR_UNSELECTED)); + } + + private void updateStoreFilterVisibility(String mode) { + boolean isAdmin = "ADMIN".equalsIgnoreCase(tokenManager.getRole()); + int vis = (isAdmin && mode.equals("store")) ? View.VISIBLE : View.GONE; + binding.tvStoreFilterLabel.setVisibility(vis); + binding.spinnerFilterStore.setVisibility(vis); + } + // Filter Panel private void setupFilterPanel() { @@ -96,6 +135,9 @@ public class AnalyticsFragment extends Fragment { int topNPos = binding.spinnerTopN.getSelectedItemPosition(); filter.topN = (topNPos >= 0 && topNPos < TOP_N_VALUES.length) ? TOP_N_VALUES[topNPos] : 5; + Object store = binding.spinnerFilterStore.getSelectedItem(); + viewModel.setStoreFilter(store != null ? store.toString() : "All Stores"); + updateFilterSummary(); viewModel.applyFilter(filter); } @@ -104,8 +146,8 @@ public class AnalyticsFragment extends Fragment { binding.etFilterStartDate.setText(""); binding.etFilterEndDate.setText(""); binding.spinnerTopN.setSelection(0); - // Reset payment method to "All" SpinnerUtils.setSelectionByValue(binding.spinnerFilterPayment, "All"); + SpinnerUtils.setSelectionByValue(binding.spinnerFilterStore, "All Stores"); updateFilterSummary(); viewModel.resetFilter(); } @@ -162,6 +204,16 @@ public class AnalyticsFragment extends Fragment { methods.toArray(new String[0])); SpinnerUtils.setSelectionByValue(binding.spinnerFilterPayment, currentSelection); }); + + viewModel.getAvailableStores().observe(getViewLifecycleOwner(), stores -> { + if (stores == null || stores.isEmpty()) return; + String currentSelection = binding.spinnerFilterStore.getSelectedItem() != null + ? binding.spinnerFilterStore.getSelectedItem().toString() : "All Stores"; + SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerFilterStore, + stores.toArray(new String[0])); + SpinnerUtils.setSelectionByValue(binding.spinnerFilterStore, currentSelection); + updateStoreFilterVisibility(viewModel.getViewMode()); + }); } @Override @@ -224,17 +276,22 @@ public class AnalyticsFragment extends Fragment { } // Employee Performance - binding.llEmployeePerformance.removeAllViews(); - if (data.employeePerformance != null && !data.employeePerformance.isEmpty()) { - BigDecimal maxEmp = data.employeePerformance.get(0).getValue(); - if (maxEmp.compareTo(BigDecimal.ZERO) == 0) maxEmp = BigDecimal.ONE; - for (Map.Entry e : data.employeePerformance) { - addBarRow(binding.llEmployeePerformance, e.getKey(), - "$" + e.getValue().setScale(2, RoundingMode.HALF_UP), - e.getValue().floatValue() / maxEmp.floatValue(), "#1a759f"); + boolean showEmployeeSection = viewModel.getViewMode().equals("store"); + View empParent = (View) binding.llEmployeePerformance.getParent(); + if (empParent != null) empParent.setVisibility(showEmployeeSection ? View.VISIBLE : View.GONE); + if (showEmployeeSection) { + binding.llEmployeePerformance.removeAllViews(); + if (data.employeePerformance != null && !data.employeePerformance.isEmpty()) { + BigDecimal maxEmp = data.employeePerformance.get(0).getValue(); + if (maxEmp.compareTo(BigDecimal.ZERO) == 0) maxEmp = BigDecimal.ONE; + for (Map.Entry e : data.employeePerformance) { + addBarRow(binding.llEmployeePerformance, e.getKey(), + "$" + e.getValue().setScale(2, RoundingMode.HALF_UP), + e.getValue().floatValue() / maxEmp.floatValue(), "#1a759f"); + } + } else { + addEmptyRow(binding.llEmployeePerformance, "No data"); } - } else { - addEmptyRow(binding.llEmployeePerformance, "No data"); } // Daily Revenue diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SaleDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SaleDetailFragment.java index 8a4dff16..4963b272 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SaleDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SaleDetailFragment.java @@ -111,6 +111,7 @@ public class SaleDetailFragment extends Fragment { binding.llLoyaltyPoints.setVisibility(View.GONE); binding.cbUseLoyaltyPoints.setChecked(false); } + updateTotal(); }); } @@ -420,6 +421,15 @@ public class SaleDetailFragment extends Fragment { } binding.tvSaleDetailTotal.setText("Total: $" + String.format(Locale.getDefault(), "%.2f", total)); + + CustomerDTO customer = viewModel.getSelectedCustomerData().getValue(); + if (customer != null && !viewModel.isViewOnly()) { + int pointsToEarn = total.max(BigDecimal.ZERO).intValue(); + binding.tvPointsToEarn.setText("+" + pointsToEarn + " pts"); + binding.llPointsToEarn.setVisibility(View.VISIBLE); + } else { + binding.llPointsToEarn.setVisibility(View.GONE); + } } private void saveSale() { diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/InputValidator.java b/android/app/src/main/java/com/example/petstoremobile/utils/InputValidator.java index 0d45ad12..9ad2d5f4 100644 --- a/android/app/src/main/java/com/example/petstoremobile/utils/InputValidator.java +++ b/android/app/src/main/java/com/example/petstoremobile/utils/InputValidator.java @@ -100,13 +100,12 @@ public class InputValidator { return true; } - // Checks if the phone number is valid in (XXX) XXX-XXXX format + // Checks if the phone number is valid: XXX-XXX-XXXX, (XXX) XXX-XXXX, or XXXXXXXXXX public static boolean isValidPhone(EditText field) { String phone = field.getText().toString().trim(); - // Matches (XXX) XXX-XXXX format - String pattern = "^\\(\\d{3}\\) \\d{3}-\\d{4}$"; + String pattern = "^(\\(\\d{3}\\) \\d{3}-\\d{4}|\\d{3}-\\d{3}-\\d{4}|\\d{10})$"; if (phone.isEmpty() || !phone.matches(pattern)) { - field.setError("Enter a valid phone number: (XXX) XXX-XXXX"); + field.setError("Enter a valid phone number"); field.requestFocus(); return false; } diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/AnalyticsViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AnalyticsViewModel.java index 721dad5f..4a9b94d9 100644 --- a/android/app/src/main/java/com/example/petstoremobile/viewmodels/AnalyticsViewModel.java +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AnalyticsViewModel.java @@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; +import com.example.petstoremobile.api.auth.TokenManager; import com.example.petstoremobile.dtos.SaleDTO; import com.example.petstoremobile.repositories.SaleRepository; import com.example.petstoremobile.utils.Resource; @@ -21,6 +22,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.TreeMap; +import java.util.stream.Collectors; import javax.inject.Inject; @@ -29,24 +31,30 @@ import dagger.hilt.android.lifecycle.HiltViewModel; @HiltViewModel public class AnalyticsViewModel extends ViewModel { private final SaleRepository saleRepository; + private final TokenManager tokenManager; private final MutableLiveData analyticsData = new MutableLiveData<>(); private final MutableLiveData isLoading = new MutableLiveData<>(false); private final MutableLiveData errorMessage = new MutableLiveData<>(); private final MutableLiveData> availablePaymentMethods = new MutableLiveData<>(new ArrayList<>()); + private final MutableLiveData> availableStores = new MutableLiveData<>(new ArrayList<>()); private List cachedSales = new ArrayList<>(); private FilterState currentFilter = new FilterState(); + private String viewMode = "store"; + private String storeFilter = "All Stores"; @Inject - public AnalyticsViewModel(SaleRepository saleRepository) { + public AnalyticsViewModel(SaleRepository saleRepository, TokenManager tokenManager) { this.saleRepository = saleRepository; + this.tokenManager = tokenManager; } public LiveData getAnalyticsData() { return analyticsData; } public LiveData getIsLoading() { return isLoading; } public LiveData getErrorMessage() { return errorMessage; } public LiveData> getAvailablePaymentMethods() { return availablePaymentMethods; } + public LiveData> getAvailableStores() { return availableStores; } public void loadAnalytics() { isLoading.setValue(true); @@ -56,6 +64,7 @@ public class AnalyticsViewModel extends ViewModel { if (resource.status == Resource.Status.SUCCESS && resource.data != null) { cachedSales = resource.data.getContent(); derivePaymentMethods(); + deriveStores(); applyCurrentFilter(); isLoading.setValue(false); } else if (resource.status == Resource.Status.ERROR) { @@ -73,14 +82,61 @@ public class AnalyticsViewModel extends ViewModel { public void resetFilter() { currentFilter = new FilterState(); + storeFilter = "All Stores"; applyCurrentFilter(); } + public void setViewMode(String mode) { + viewMode = mode; + applyCurrentFilter(); + } + + public String getViewMode() { + return viewMode; + } + + public void setStoreFilter(String store) { + storeFilter = (store != null && !store.isEmpty()) ? store : "All Stores"; + applyCurrentFilter(); + } + + public String getStoreFilter() { + return storeFilter; + } + private void applyCurrentFilter() { - List filtered = filterSales(cachedSales, currentFilter); + List salesForMode; + if (viewMode.equals("mine")) { + String currentUser = tokenManager.getUsername(); + salesForMode = cachedSales.stream() + .filter(s -> currentUser != null && currentUser.equalsIgnoreCase(s.getEmployeeName() != null ? s.getEmployeeName() : "")) + .collect(Collectors.toList()); + } else { + salesForMode = cachedSales; + } + if (!storeFilter.equals("All Stores") && !storeFilter.isEmpty()) { + final String sf = storeFilter; + salesForMode = salesForMode.stream() + .filter(s -> sf.equalsIgnoreCase(s.getStoreName() != null ? s.getStoreName() : "")) + .collect(Collectors.toList()); + } + List filtered = filterSales(salesForMode, currentFilter); computeAnalytics(filtered, currentFilter); } + private void deriveStores() { + java.util.Set stores = new java.util.TreeSet<>(); + for (SaleDTO s : cachedSales) { + if (s.getStoreName() != null && !s.getStoreName().isEmpty()) { + stores.add(s.getStoreName()); + } + } + List result = new ArrayList<>(); + result.add("All Stores"); + result.addAll(stores); + availableStores.setValue(result); + } + private void derivePaymentMethods() { java.util.Set methods = new java.util.TreeSet<>(); for (SaleDTO s : cachedSales) { diff --git a/android/app/src/main/res/layout/fragment_analytics.xml b/android/app/src/main/res/layout/fragment_analytics.xml index 19a7a51b..9e59b7d7 100644 --- a/android/app/src/main/res/layout/fragment_analytics.xml +++ b/android/app/src/main/res/layout/fragment_analytics.xml @@ -45,6 +45,40 @@ + + + + + + + + + diff --git a/desktop/src/main/resources/org/example/petshopdesktop/modelviews/analytics-view.fxml b/desktop/src/main/resources/org/example/petshopdesktop/modelviews/analytics-view.fxml index 9aeb4fe9..4f86758a 100644 --- a/desktop/src/main/resources/org/example/petshopdesktop/modelviews/analytics-view.fxml +++ b/desktop/src/main/resources/org/example/petshopdesktop/modelviews/analytics-view.fxml @@ -8,6 +8,7 @@ + @@ -30,6 +31,16 @@ + + + + + + + + + +