diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/ActivityLogAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/ActivityLogAdapter.java new file mode 100644 index 00000000..5a6b4d1c --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/ActivityLogAdapter.java @@ -0,0 +1,58 @@ +package com.example.petstoremobile.adapters; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.example.petstoremobile.R; +import com.example.petstoremobile.dtos.ActivityLogDTO; + +import java.util.List; + +public class ActivityLogAdapter extends RecyclerView.Adapter { + private final List items; + + public ActivityLogAdapter(List items) { + this.items = items; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_activity_log, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + ActivityLogDTO log = items.get(position); + holder.tvActivity.setText(log.getActivity()); + holder.tvUser.setText(log.getFullName() + " (" + log.getUsername() + ")"); + holder.tvMeta.setText(log.getStoreName() + " ยท " + log.getRole()); + String timestamp = log.getLogTimestamp(); + if (timestamp != null && timestamp.length() >= 16) { + timestamp = timestamp.substring(0, 10) + " " + timestamp.substring(11, 16); + } + holder.tvTimestamp.setText(timestamp); + } + + @Override + public int getItemCount() { return items.size(); } + + public static class ViewHolder extends RecyclerView.ViewHolder { + TextView tvActivity, tvUser, tvMeta, tvTimestamp; + + public ViewHolder(@NonNull View itemView) { + super(itemView); + tvActivity = itemView.findViewById(R.id.tvLogActivity); + tvUser = itemView.findViewById(R.id.tvLogUser); + tvMeta = itemView.findViewById(R.id.tvLogMeta); + tvTimestamp = itemView.findViewById(R.id.tvLogTimestamp); + } + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/api/ActivityLogApi.java b/android/app/src/main/java/com/example/petstoremobile/api/ActivityLogApi.java new file mode 100644 index 00000000..7aaedffe --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/api/ActivityLogApi.java @@ -0,0 +1,14 @@ +package com.example.petstoremobile.api; + +import com.example.petstoremobile.dtos.ActivityLogDTO; + +import java.util.List; + +import retrofit2.Call; +import retrofit2.http.GET; + +public interface ActivityLogApi { + + @GET("api/v1/activity-logs") + Call> getActivityLogs(); +} diff --git a/android/app/src/main/java/com/example/petstoremobile/di/NetworkModule.java b/android/app/src/main/java/com/example/petstoremobile/di/NetworkModule.java index 1a4ab665..8c9e2414 100644 --- a/android/app/src/main/java/com/example/petstoremobile/di/NetworkModule.java +++ b/android/app/src/main/java/com/example/petstoremobile/di/NetworkModule.java @@ -197,4 +197,10 @@ public class NetworkModule { public static CouponApi provideCouponApi(Retrofit retrofit) { return retrofit.create(CouponApi.class); } + + @Provides + @Singleton + public static ActivityLogApi provideActivityLogApi(Retrofit retrofit) { + return retrofit.create(ActivityLogApi.class); + } } \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/ActivityLogDTO.java b/android/app/src/main/java/com/example/petstoremobile/dtos/ActivityLogDTO.java new file mode 100644 index 00000000..2d4d0f6d --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/ActivityLogDTO.java @@ -0,0 +1,31 @@ +package com.example.petstoremobile.dtos; + +public class ActivityLogDTO { + private Long logId; + private String activity; + private String fullName; + private String fullNameSnapshot; + private String logTimestamp; + private String role; + private String roleSnapshot; + private Long storeId; + private String storeName; + private String storeNameSnapshot; + private Long userId; + private String username; + private String usernameSnapshot; + + public Long getLogId() { return logId; } + public String getActivity() { return activity; } + public String getFullName() { return fullName; } + public String getFullNameSnapshot() { return fullNameSnapshot; } + public String getLogTimestamp() { return logTimestamp; } + public String getRole() { return role; } + public String getRoleSnapshot() { return roleSnapshot; } + public Long getStoreId() { return storeId; } + public String getStoreName() { return storeName; } + public String getStoreNameSnapshot() { return storeNameSnapshot; } + public Long getUserId() { return userId; } + public String getUsername() { return username; } + public String getUsernameSnapshot() { return usernameSnapshot; } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/ListFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/ListFragment.java index 0aab247b..6f2c643b 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/ListFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/ListFragment.java @@ -92,6 +92,7 @@ public class ListFragment extends Fragment { binding.drawerCustomers.setOnClickListener(v -> navigateTo(R.id.nav_customer)); binding.drawerAnalytics.setOnClickListener(v -> navigateTo(R.id.nav_analytics)); binding.drawerCoupons.setOnClickListener(v -> navigateTo(R.id.nav_coupon)); + binding.drawerActivityLogs.setOnClickListener(v -> navigateTo(R.id.nav_activity_log)); return binding.getRoot(); } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ActivityLogFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ActivityLogFragment.java new file mode 100644 index 00000000..8ce58e95 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ActivityLogFragment.java @@ -0,0 +1,98 @@ +package com.example.petstoremobile.fragments.listfragments; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; + +import com.example.petstoremobile.adapters.ActivityLogAdapter; +import com.example.petstoremobile.databinding.FragmentActivityLogBinding; +import com.example.petstoremobile.dtos.ActivityLogDTO; +import com.example.petstoremobile.utils.SpinnerUtils; +import com.example.petstoremobile.utils.UIUtils; +import com.example.petstoremobile.viewmodels.ActivityLogListViewModel; + +import java.util.ArrayList; +import java.util.List; + +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint +public class ActivityLogFragment extends Fragment { + private FragmentActivityLogBinding binding; + private ActivityLogListViewModel viewModel; + private ActivityLogAdapter adapter; + private final List logList = new ArrayList<>(); + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + binding = FragmentActivityLogBinding.inflate(inflater, container, false); + viewModel = new ViewModelProvider(this).get(ActivityLogListViewModel.class); + + setupRecyclerView(); + setupFilters(); + observeViewModel(); + + binding.swipeRefreshActivityLog.setOnRefreshListener(() -> viewModel.loadLogs()); + UIUtils.setupHamburgerMenu(binding.btnHamburgerActivityLog, this); + + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + viewModel.loadLogs(); + } + + private void setupRecyclerView() { + adapter = new ActivityLogAdapter(logList); + binding.recyclerViewActivityLog.setLayoutManager(new LinearLayoutManager(getContext())); + binding.recyclerViewActivityLog.setAdapter(adapter); + } + + private void setupFilters() { + UIUtils.setupFilterToggle(binding.btnToggleFilter, binding.layoutFilter, + binding.etSearchLog, binding.spinnerRoleFilter, binding.spinnerStoreFilter); + + UIUtils.attachSearch(binding.etSearchLog, () -> + viewModel.setSearchQuery(binding.etSearchLog.getText().toString())); + + String[] roles = {"All Roles", "ADMIN", "STAFF"}; + SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerRoleFilter, roles, () -> + viewModel.setRoleFilter(binding.spinnerRoleFilter.getSelectedItem() != null + ? binding.spinnerRoleFilter.getSelectedItem().toString() : "All Roles")); + } + + private void observeViewModel() { + viewModel.getLogs().observe(getViewLifecycleOwner(), list -> { + logList.clear(); + logList.addAll(list); + adapter.notifyDataSetChanged(); + }); + + viewModel.getStoreOptions().observe(getViewLifecycleOwner(), options -> { + String[] arr = options.toArray(new String[0]); + SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStoreFilter, arr, () -> + viewModel.setStoreFilter(binding.spinnerStoreFilter.getSelectedItem() != null + ? binding.spinnerStoreFilter.getSelectedItem().toString() : "All Stores")); + }); + + viewModel.getIsLoading().observe(getViewLifecycleOwner(), loading -> { + binding.swipeRefreshActivityLog.setRefreshing(loading); + }); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/AppointmentDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/AppointmentDetailFragment.java index 05b83622..cbeb2125 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/AppointmentDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/AppointmentDetailFragment.java @@ -208,7 +208,7 @@ public class AppointmentDetailFragment extends Fragment { if (staff != null) SpinnerUtils.populateSpinner(requireContext(), binding.spinnerStaff, staff, DropdownDTO::getLabel, "-- Select Staff --", state.selectedStaffId, DropdownDTO::getId); - if (isStaff()) binding.spinnerStore.setEnabled(false); + if (isStaff()) UIUtils.setViewsEnabled(false, binding.spinnerStore); isUpdatingUI = false; } diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/ActivityLogRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/ActivityLogRepository.java new file mode 100644 index 00000000..53af40e5 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/ActivityLogRepository.java @@ -0,0 +1,27 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.ActivityLogApi; +import com.example.petstoremobile.dtos.ActivityLogDTO; +import com.example.petstoremobile.utils.Resource; + +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class ActivityLogRepository extends BaseRepository { + private final ActivityLogApi activityLogApi; + + @Inject + public ActivityLogRepository(ActivityLogApi activityLogApi) { + super("ActivityLogRepository"); + this.activityLogApi = activityLogApi; + } + + public LiveData>> getActivityLogs() { + return executeCall(activityLogApi.getActivityLogs()); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/ActivityLogListViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ActivityLogListViewModel.java new file mode 100644 index 00000000..33053cfc --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ActivityLogListViewModel.java @@ -0,0 +1,133 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.ActivityLogDTO; +import com.example.petstoremobile.repositories.ActivityLogRepository; +import com.example.petstoremobile.utils.Resource; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; + +@HiltViewModel +public class ActivityLogListViewModel extends ViewModel { + private final ActivityLogRepository repository; + + private final List allLogs = new ArrayList<>(); + private final MutableLiveData> logs = new MutableLiveData<>(new ArrayList<>()); + private final MutableLiveData> storeOptions = new MutableLiveData<>(new ArrayList<>()); + private final MutableLiveData isLoading = new MutableLiveData<>(false); + + private String searchQuery = ""; + private String roleFilter = "All Roles"; + private String storeFilter = "All Stores"; + + @Inject + public ActivityLogListViewModel(ActivityLogRepository repository) { + this.repository = repository; + } + + public LiveData> getLogs() { return logs; } + public LiveData> getStoreOptions() { return storeOptions; } + public LiveData getIsLoading() { return isLoading; } + + public void loadLogs() { + isLoading.setValue(true); + observeOnce(repository.getActivityLogs(), resource -> { + if (resource != null) { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + allLogs.clear(); + allLogs.addAll(resource.data); + buildStoreOptions(); + applyFilters(); + isLoading.setValue(false); + } else if (resource.status == Resource.Status.ERROR) { + isLoading.setValue(false); + } + } + }); + } + + public void setSearchQuery(String query) { + searchQuery = query == null ? "" : query.trim(); + applyFilters(); + } + + public void setRoleFilter(String role) { + roleFilter = role == null ? "All Roles" : role; + applyFilters(); + } + + public void setStoreFilter(String store) { + storeFilter = store == null ? "All Stores" : store; + applyFilters(); + } + + private void buildStoreOptions() { + LinkedHashSet names = new LinkedHashSet<>(); + for (ActivityLogDTO log : allLogs) { + String name = log.getStoreNameSnapshot() != null ? log.getStoreNameSnapshot() : log.getStoreName(); + if (name != null && !name.isEmpty()) names.add(name); + } + List options = new ArrayList<>(); + options.add("All Stores"); + options.addAll(names); + storeOptions.setValue(options); + } + + private void applyFilters() { + List filtered = new ArrayList<>(); + String query = searchQuery.toLowerCase(Locale.ROOT); + + for (ActivityLogDTO log : allLogs) { + // Role filter + if (!"All Roles".equals(roleFilter)) { + String role = log.getRoleSnapshot() != null ? log.getRoleSnapshot() : log.getRole(); + if (!roleFilter.equalsIgnoreCase(role)) continue; + } + + // Store filter + if (!"All Stores".equals(storeFilter)) { + String store = log.getStoreNameSnapshot() != null ? log.getStoreNameSnapshot() : log.getStoreName(); + if (!storeFilter.equals(store)) continue; + } + + // Search filter + if (!query.isEmpty()) { + String activity = log.getActivity() != null ? log.getActivity().toLowerCase(Locale.ROOT) : ""; + String fullName = log.getFullNameSnapshot() != null + ? log.getFullNameSnapshot().toLowerCase(Locale.ROOT) + : (log.getFullName() != null ? log.getFullName().toLowerCase(Locale.ROOT) : ""); + String username = log.getUsernameSnapshot() != null + ? log.getUsernameSnapshot().toLowerCase(Locale.ROOT) + : (log.getUsername() != null ? log.getUsername().toLowerCase(Locale.ROOT) : ""); + if (!activity.contains(query) && !fullName.contains(query) && !username.contains(query)) continue; + } + + filtered.add(log); + } + + logs.setValue(filtered); + } + + private void observeOnce(LiveData> liveData, Observer> handler) { + liveData.observeForever(new Observer>() { + @Override + public void onChanged(Resource resource) { + if (resource == null || resource.status != Resource.Status.LOADING) { + liveData.removeObserver(this); + } + handler.onChanged(resource); + } + }); + } +} diff --git a/android/app/src/main/res/layout/fragment_activity_log.xml b/android/app/src/main/res/layout/fragment_activity_log.xml new file mode 100644 index 00000000..b81dcea7 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_activity_log.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_list.xml b/android/app/src/main/res/layout/fragment_list.xml index 090a6a09..e220fad1 100644 --- a/android/app/src/main/res/layout/fragment_list.xml +++ b/android/app/src/main/res/layout/fragment_list.xml @@ -363,6 +363,23 @@ android:textSize="15sp"/> + + + + diff --git a/android/app/src/main/res/layout/item_activity_log.xml b/android/app/src/main/res/layout/item_activity_log.xml new file mode 100644 index 00000000..a2bad95c --- /dev/null +++ b/android/app/src/main/res/layout/item_activity_log.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/navigation/list_nav_graph.xml b/android/app/src/main/res/navigation/list_nav_graph.xml index 70c651e1..1e1d7ca6 100644 --- a/android/app/src/main/res/navigation/list_nav_graph.xml +++ b/android/app/src/main/res/navigation/list_nav_graph.xml @@ -175,6 +175,12 @@ android:label="Coupons" tools:layout="@layout/fragment_coupon" /> + +