diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7a998c43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.zip +.local/ +commit-patches/ +temp_photos/ +uploads/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 552480f0..caaf8e17 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,7 +1,25 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) + alias(libs.plugins.hilt) + alias(libs.plugins.navigation.safeargs) } +val localProperties = Properties().apply { + val file = rootProject.file("local.properties") + if (file.exists()) { + file.inputStream().use { load(it) } + } +} + +fun quoted(value: String): String = "\"$value\"" + +val emulatorBackendUrl = + (localProperties.getProperty("petstore.backend.emulatorUrl") ?: "http://10.0.2.2:8080/").trim() +val deviceBackendUrl = + (localProperties.getProperty("petstore.backend.deviceUrl") ?: "http://10.0.0.200:8080/").trim() + android { namespace = "com.example.petstoremobile" compileSdk = 36 @@ -14,6 +32,14 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "EMULATOR_BACKEND_URL", quoted(emulatorBackendUrl)) + buildConfigField("String", "DEVICE_BACKEND_URL", quoted(deviceBackendUrl)) + } + + buildFeatures { + buildConfig = true + viewBinding = true } buildTypes { @@ -32,34 +58,46 @@ android { } dependencies { + // Core AndroidX & UI implementation(libs.appcompat) implementation(libs.material) implementation(libs.activity) implementation(libs.constraintlayout) - - implementation("com.squareup.retrofit2:retrofit:2.9.0") - implementation("com.squareup.retrofit2:converter-gson:2.9.0") - implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") - implementation("com.squareup.okhttp3:okhttp:4.12.0") - - implementation("com.google.android.material:material:1.11.0") - implementation("androidx.viewpager2:viewpager2:1.1.0") - - implementation("androidx.camera:camera-core:1.4.0") - implementation("androidx.camera:camera-camera2:1.4.0") - implementation("androidx.camera:camera-lifecycle:1.4.0") - implementation("androidx.camera:camera-view:1.4.0") - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") implementation(libs.swiperefreshlayout) + implementation(libs.viewpager2) + // Hilt Dependency Injection + implementation(libs.hilt.android) + annotationProcessor(libs.hilt.compiler) + + // Navigation Component + implementation(libs.navigation.fragment) + implementation(libs.navigation.ui) + + // Networking + implementation(libs.retrofit) + implementation(libs.retrofit.gson) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + + // CameraX + implementation(libs.camera.core) + implementation(libs.camera.camera2) + implementation(libs.camera.lifecycle) + implementation(libs.camera.view) + + // Image Loading + implementation(libs.glide) + annotationProcessor(libs.glide.compiler) + + // Other Third-party Libraries implementation("com.github.NaikSoftware:StompProtocolAndroid:1.6.6") implementation("io.reactivex.rxjava2:rxjava:2.2.21") implementation("io.reactivex.rxjava2:rxandroid:2.1.1") + implementation("com.github.prolificinteractive:material-calendarview:2.0.1") - implementation("com.github.bumptech.glide:glide:4.16.0") - annotationProcessor("com.github.bumptech.glide:compiler:4.16.0") - + // Testing testImplementation(libs.junit) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/PetStoreApplication.java b/android/app/src/main/java/com/example/petstoremobile/PetStoreApplication.java index b041d54d..75def31b 100644 --- a/android/app/src/main/java/com/example/petstoremobile/PetStoreApplication.java +++ b/android/app/src/main/java/com/example/petstoremobile/PetStoreApplication.java @@ -1,8 +1,9 @@ package com.example.petstoremobile; import android.app.Application; -import com.example.petstoremobile.api.auth.TokenManager; +import dagger.hilt.android.HiltAndroidApp; +@HiltAndroidApp public class PetStoreApplication extends Application { @Override public void onCreate() { diff --git a/android/app/src/main/java/com/example/petstoremobile/activities/HomeActivity.java b/android/app/src/main/java/com/example/petstoremobile/activities/HomeActivity.java index ae7b8132..01b4ca24 100644 --- a/android/app/src/main/java/com/example/petstoremobile/activities/HomeActivity.java +++ b/android/app/src/main/java/com/example/petstoremobile/activities/HomeActivity.java @@ -10,23 +10,25 @@ import android.util.Log; import androidx.activity.EdgeToEdge; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; -import androidx.fragment.app.Fragment; +import androidx.navigation.NavController; +import androidx.navigation.fragment.NavHostFragment; +import androidx.navigation.ui.NavigationUI; import com.example.petstoremobile.R; -import com.example.petstoremobile.fragments.ChatFragment; -import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.ProfileFragment; +import com.example.petstoremobile.databinding.ActivityHomeBinding; import com.example.petstoremobile.services.ChatNotificationService; -import com.google.android.material.bottomnavigation.BottomNavigationView; +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint public class HomeActivity extends AppCompatActivity { - private BottomNavigationView bottomNav; + private ActivityHomeBinding binding; + private NavController navController; // Launcher to ask for notification permission private final ActivityResultLauncher requestPermissionLauncher = @@ -36,81 +38,73 @@ public class HomeActivity extends AppCompatActivity { } }); + /** + * Sets up the home screen, initializes bottom navigation, and handles incoming navigation intents. + */ @Override protected void onCreate(Bundle savedInstanceState) { + EdgeToEdge.enable(this); super.onCreate(savedInstanceState); - setContentView(R.layout.activity_home); + binding = ActivityHomeBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> { + Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); + return insets; + }); + + // Initialize Navigation Component + NavHostFragment navHostFragment = (NavHostFragment) getSupportFragmentManager() + .findFragmentById(R.id.nav_host_fragment); + if (navHostFragment != null) { + navController = navHostFragment.getNavController(); + NavigationUI.setupWithNavController(binding.bottomNavigation, navController); + } - //get the bottom navbar from the layout - bottomNav = findViewById(R.id.bottom_navigation); - //load the list fragment by default if it's a fresh start if (savedInstanceState == null) { handleIntent(getIntent()); } - //when an item in the bottom bar is selected, load the corresponding fragment - bottomNav.setOnItemSelectedListener(item -> { - if (item.getItemId() == R.id.nav_list) { - loadFragment(new ListFragment()); - return true; - } else if (item.getItemId() == R.id.nav_chat) { - loadFragment(new ChatFragment()); - return true; - } else if (item.getItemId() == R.id.nav_profile) { - loadFragment(new ProfileFragment()); - return true; - } - return false; - }); - // Start the notification service and request for notification permission startNotificationService(); requestNotificationPermission(); } - // Handle new intents when the activity is already running, - // like clicking a notification while the app is in use + /** + * Handles new intents received while the activity is already running (like notifications). + */ @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); + setIntent(intent); // Set the new intent so fragments can access updated extras handleIntent(intent); } - // Helper function to process intents for navigation. - // like clicking a notification or just launching the app from a fresh start + /** + * Processes the intent to determine if specific navigation (like opening a chat) is required. + */ private void handleIntent(Intent intent) { if (intent != null && "chat".equals(intent.getStringExtra("navigate_to"))) { - ChatFragment chatFragment = new ChatFragment(); - if (intent.hasExtra("conversation_id")) { - Bundle args = new Bundle(); - args.putLong("conversation_id", intent.getLongExtra("conversation_id", -1)); - chatFragment.setArguments(args); + if (binding.bottomNavigation != null) { + // Navigate by selecting the bottom nav item. + binding.bottomNavigation.setSelectedItemId(R.id.nav_chat); } - loadFragment(chatFragment); - bottomNav.setSelectedItemId(R.id.nav_chat); - } else { - new android.os.Handler().postDelayed(() -> { - loadFragment(new ListFragment()); - bottomNav.setSelectedItemId(R.id.nav_list); - }, 100); } } - // Helper function to start the notification service in the background - // to receive notifications when a new conversation is created + /** + * Starts the background service responsible for monitoring chat notifications. + */ private void startNotificationService() { - new Thread(() -> { - try { - Intent serviceIntent = new Intent(this, ChatNotificationService.class); - startService(serviceIntent); - } catch (Exception e) { - Log.e("HomeActivity", "Failed to start notification service: " + e.getMessage()); - } - }).start(); + Intent serviceIntent = new Intent(this, ChatNotificationService.class); + startService(serviceIntent); } - //Helper function to request for notification permission + /** + * Requests POST_NOTIFICATIONS permission from the user if running on Android 13 and above. + */ private void requestNotificationPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { @@ -118,12 +112,4 @@ public class HomeActivity extends AppCompatActivity { } } } - - //Helper function to load a fragment - private void loadFragment(Fragment fragment) { - getSupportFragmentManager() - .beginTransaction() - .replace(R.id.fragment_container, fragment) - .commit(); - } } diff --git a/android/app/src/main/java/com/example/petstoremobile/activities/MainActivity.java b/android/app/src/main/java/com/example/petstoremobile/activities/MainActivity.java index 8ce61921..f8c89342 100644 --- a/android/app/src/main/java/com/example/petstoremobile/activities/MainActivity.java +++ b/android/app/src/main/java/com/example/petstoremobile/activities/MainActivity.java @@ -2,10 +2,7 @@ package com.example.petstoremobile.activities; import android.content.Intent; import android.os.Bundle; -import android.util.Log; -import android.widget.Button; -import android.widget.EditText; -import android.widget.TextView; +import android.view.inputmethod.EditorInfo; import android.widget.Toast; import androidx.activity.EdgeToEdge; @@ -13,157 +10,134 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; +import androidx.lifecycle.ViewModelProvider; -import com.example.petstoremobile.R; -import com.example.petstoremobile.api.auth.AuthApi; import com.example.petstoremobile.api.auth.TokenManager; -import com.example.petstoremobile.api.RetrofitClient; -import com.example.petstoremobile.dtos.AuthDTO; -import com.example.petstoremobile.dtos.UserDTO; +import com.example.petstoremobile.databinding.ActivityMainBinding; +import com.example.petstoremobile.viewmodels.AuthViewModel; +import com.example.petstoremobile.utils.Resource; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; +import javax.inject.Inject; +import javax.inject.Named; + +import dagger.hilt.android.AndroidEntryPoint; //The login screen activity +@AndroidEntryPoint public class MainActivity extends AppCompatActivity { - private EditText etUser; - private EditText etPassword; - private Button btnLogin; - private TextView tvLoginStatus; + private ActivityMainBinding binding; + private AuthViewModel viewModel; + @Inject TokenManager tokenManager; + @Inject @Named("baseUrl") String baseUrl; + /** + * Initializes the activity, sets up the UI, and checks for an existing login session. + */ @Override protected void onCreate(Bundle savedInstanceState) { + EdgeToEdge.enable(this); super.onCreate(savedInstanceState); // Check if user is already logged in - TokenManager tokenManager = TokenManager.getInstance(this); if (tokenManager.isLoggedIn()) { if ("CUSTOMER".equalsIgnoreCase(tokenManager.getRole())) { // If a customer somehow remained logged in, clear them out tokenManager.clearLoginData(); } else { - Intent intent = new Intent(this, HomeActivity.class); - startActivity(intent); + startActivity(new Intent(this, HomeActivity.class)); finish(); return; } } - setContentView(R.layout.activity_main); + binding = ActivityMainBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + viewModel = new ViewModelProvider(this).get(AuthViewModel.class); + + ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> { + Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); + return insets; + }); - //get all controls from layout - tvLoginStatus = findViewById(R.id.tvLoginStatus); - etUser = findViewById(R.id.etUser); - etPassword = findViewById(R.id.etPassword); - btnLogin = findViewById(R.id.btnLogin); //clear login status - tvLoginStatus.setText(""); + binding.tvLoginStatus.setText(""); + + // Set editor action listener for password field to login on when enter is pressed + binding.etPassword.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_NULL) { + binding.btnLogin.performClick(); + return true; + } + return false; + }); //Set click listener for login button - btnLogin.setOnClickListener(v -> { + binding.btnLogin.setOnClickListener(v -> { //Get username and password from text fields - String username = etUser.getText().toString(); - String password = etPassword.getText().toString(); + String username = binding.etUser.getText().toString(); + String password = binding.etPassword.getText().toString(); //check if fields are empty if (username.isEmpty() || password.isEmpty()) { Toast.makeText(this, "Please enter username and password", Toast.LENGTH_SHORT).show(); - tvLoginStatus.setText("Please enter username and password"); + binding.tvLoginStatus.setText("Please enter username and password"); return; } - AuthApi authApi = RetrofitClient.getAuthApi(this); + performLogin(username, password); + }); + } - //Call login from api and get response - authApi.login(new AuthDTO.LoginRequest(username,password)).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful() && response.body() != null) { - String role = response.body().getRole(); + /** + * Executes the login process using the AuthViewModel and handles the authentication response. + */ + private void performLogin(String username, String password) { + viewModel.login(username, password).observe(this, resource -> { + if (resource == null) return; - // Check if the user is a CUSTOMER and deny login if so + switch (resource.status) { + case LOADING: + binding.btnLogin.setEnabled(false); + binding.tvLoginStatus.setText("Logging in..."); + break; + case SUCCESS: + if (resource.data != null) { + String role = resource.data.getRole(); if ("CUSTOMER".equalsIgnoreCase(role)) { - Toast.makeText(MainActivity.this, "Access denied: Customers are not allowed to log in.", Toast.LENGTH_LONG).show(); - tvLoginStatus.setText("Customers are not allowed to log in"); - return; + binding.btnLogin.setEnabled(true); + binding.tvLoginStatus.setText("Customers are not allowed to log in"); + Toast.makeText(this, "Access denied: Customers are not allowed to log in.", Toast.LENGTH_LONG).show(); + } else { + tokenManager.saveLoginData(resource.data.getToken(), resource.data.getUsername(), role); + fetchUserIdAndNavigate(); } - - //save login data in shared preferences - TokenManager.getInstance(MainActivity.this).saveLoginData( - response.body().getToken(), - response.body().getUsername(), - role - ); - - //fetch user id from api then login to home activity - RetrofitClient.getAuthApi(MainActivity.this).getMe() - .enqueue(new Callback() { - @Override - public void onResponse(Call call, - Response response) { - if (response.isSuccessful() && response.body() != null) { - TokenManager.getInstance(MainActivity.this) - .saveUserId(response.body().getId()); - } - - Toast.makeText(MainActivity.this, "Login successful", Toast.LENGTH_SHORT).show(); - startActivity(new Intent(MainActivity.this, HomeActivity.class)); - finish(); - } - - @Override - public void onFailure(Call call, - Throwable t) { - Log.e("MainActivity", "Failed to fetch userId", t); - - Toast.makeText(MainActivity.this, "Login successful", Toast.LENGTH_SHORT).show(); - startActivity(new Intent(MainActivity.this, HomeActivity.class)); - finish(); - } - }); - } else { - String errorMessage; - switch (response.code()) { - case 401: - errorMessage = "Invalid username or password"; - break; - case 500: - errorMessage = "Server error. Please try again later."; - break; - case 503: - errorMessage = "Service unavailable. Backend may be starting up."; - break; - default: - errorMessage = "Login failed (Error " + response.code() + ")"; - } - Toast.makeText(MainActivity.this, errorMessage, Toast.LENGTH_LONG).show(); - tvLoginStatus.setText(errorMessage); } + break; + case ERROR: + binding.btnLogin.setEnabled(true); + binding.tvLoginStatus.setText(resource.message); + Toast.makeText(this, resource.message, Toast.LENGTH_LONG).show(); + break; + } + }); + } + + /** + * Retrieves the logged-in user's profile information to save their ID before navigating to the home screen. + */ + private void fetchUserIdAndNavigate() { + viewModel.getMe().observe(this, resource -> { + if (resource != null && resource.status != Resource.Status.LOADING) { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + tokenManager.saveUserId(resource.data.getId()); } - - @Override - public void onFailure(Call call, Throwable t) { - Log.e("MainActivity", "Login request failed", t); - - String errorMessage; - if (t instanceof java.net.ConnectException || - t instanceof java.net.SocketTimeoutException || - t instanceof java.net.UnknownHostException) { - errorMessage = "Cannot connect to server at " + RetrofitClient.BASE_URL + - ". Please check if the backend is running."; - } else if (t instanceof java.io.IOException) { - errorMessage = "Network error. Please check your connection."; - } else { - errorMessage = "Login failed: " + t.getMessage(); - } - - Toast.makeText(MainActivity.this, errorMessage, Toast.LENGTH_LONG).show(); - tvLoginStatus.setText(errorMessage); - } - }); + Toast.makeText(this, "Login successful", Toast.LENGTH_SHORT).show(); + startActivity(new Intent(this, HomeActivity.class)); + finish(); + } }); } } diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/AdoptionAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/AdoptionAdapter.java index 5ee0adb3..cb3eeaf2 100644 --- a/android/app/src/main/java/com/example/petstoremobile/adapters/AdoptionAdapter.java +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/AdoptionAdapter.java @@ -1,81 +1,128 @@ package com.example.petstoremobile.adapters; import android.graphics.Color; -import android.view.*; -import android.widget.TextView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import com.example.petstoremobile.R; +import com.example.petstoremobile.databinding.ItemAdoptionBinding; import com.example.petstoremobile.dtos.AdoptionDTO; +import com.example.petstoremobile.utils.BulkDeleteHandler; +import com.example.petstoremobile.utils.SelectionHelper; import java.util.List; -public class AdoptionAdapter extends RecyclerView.Adapter { +public class AdoptionAdapter extends RecyclerView.Adapter implements BulkDeleteHandler.SelectableAdapter { private List adoptionList; private OnAdoptionClickListener listener; + private final SelectionHelper selectionHelper; public interface OnAdoptionClickListener { void onAdoptionClick(int position); + void onSelectionChanged(int count); } public AdoptionAdapter(List adoptionList, OnAdoptionClickListener listener) { this.adoptionList = adoptionList; this.listener = listener; + this.selectionHelper = new SelectionHelper(new SelectionHelper.SelectionListener() { + @Override + public void onSelectionChanged(int count) { + listener.onSelectionChanged(count); + } + + @Override + public void onSelectionModeToggle(boolean selectionMode) { + notifyDataSetChanged(); + } + }); + } + + @Override + public List getSelectedKeys() { + return selectionHelper.getSelectedKeys(); + } + + @Override + public void clearSelection() { + selectionHelper.clearSelection(); } public static class AdoptionViewHolder extends RecyclerView.ViewHolder { - TextView tvCustomerName, tvPetName, tvDate, tvEmployee, tvFee, tvStatus; + final ItemAdoptionBinding binding; - public AdoptionViewHolder(@NonNull View v) { - super(v); - tvCustomerName = v.findViewById(R.id.tvAdoptionCustomerName); - tvPetName = v.findViewById(R.id.tvAdoptionPetName); - tvDate = v.findViewById(R.id.tvAdoptionDate); - tvEmployee = v.findViewById(R.id.tvAdoptionEmployee); - tvFee = v.findViewById(R.id.tvAdoptionFee); - tvStatus = v.findViewById(R.id.tvAdoptionStatus); + public AdoptionViewHolder(@NonNull ItemAdoptionBinding binding) { + super(binding.getRoot()); + this.binding = binding; } } @NonNull @Override public AdoptionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View v = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_adoption, parent, false); - return new AdoptionViewHolder(v); + ItemAdoptionBinding binding = ItemAdoptionBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); + return new AdoptionViewHolder(binding); } @Override public void onBindViewHolder(@NonNull AdoptionViewHolder holder, int position) { AdoptionDTO a = adoptionList.get(position); + ItemAdoptionBinding binding = holder.binding; + + binding.tvAdoptionCustomerName.setText(a.getCustomerName() != null ? a.getCustomerName() : ""); + binding.tvAdoptionPetName.setText("Pet: " + (a.getPetName() != null ? a.getPetName() : "")); + binding.tvAdoptionStaffName.setText("Staff: " + (a.getEmployeeName() != null ? a.getEmployeeName() : "N/A")); + binding.tvAdoptionDate.setText("Date: " + (a.getAdoptionDate() != null ? a.getAdoptionDate() : "")); + binding.tvAdoptionFee.setText(a.getAdoptionFee() != null ? "$" + a.getAdoptionFee() : ""); + - holder.tvCustomerName.setText(a.getCustomerName() != null ? a.getCustomerName() : ""); - holder.tvPetName.setText("Pet: " + (a.getPetName() != null ? a.getPetName() : "")); - holder.tvDate.setText("Date: " + (a.getAdoptionDate() != null ? a.getAdoptionDate() : "")); - holder.tvFee.setText(a.getAdoptionFee() != null ? "$" + a.getAdoptionFee() : ""); - holder.tvEmployee.setText("Staff: " + - (a.getEmployeeName() != null ? a.getEmployeeName() : "Unassigned")); String status = a.getAdoptionStatus() != null ? a.getAdoptionStatus() : ""; - holder.tvStatus.setText(status); + binding.tvAdoptionStatus.setText(status); switch (status) { - case "Approved": - holder.tvStatus.setBackgroundColor(Color.parseColor("#4CAF50")); + case "Completed": + binding.tvAdoptionStatus.setBackgroundColor(Color.parseColor("#4CAF50")); break; case "Pending": - holder.tvStatus.setBackgroundColor(Color.parseColor("#FF9800")); + binding.tvAdoptionStatus.setBackgroundColor(Color.parseColor("#FF9800")); break; - case "Rejected": - holder.tvStatus.setBackgroundColor(Color.parseColor("#F44336")); + case "Cancelled": + binding.tvAdoptionStatus.setBackgroundColor(Color.parseColor("#F44336")); break; default: - holder.tvStatus.setBackgroundColor(Color.parseColor("#9E9E9E")); + binding.tvAdoptionStatus.setBackgroundColor(Color.parseColor("#9E9E9E")); break; } - holder.itemView.setOnClickListener(v -> listener.onAdoptionClick(position)); + String key = String.valueOf(a.getAdoptionId()); + + // Bulk delete selection mode + if (selectionHelper.isInSelectionMode()) { + binding.cbSelectAdoption.setVisibility(View.VISIBLE); + binding.cbSelectAdoption.setChecked(selectionHelper.isSelected(key)); + } else { + binding.cbSelectAdoption.setVisibility(View.GONE); + binding.cbSelectAdoption.setChecked(false); + } + + holder.itemView.setOnClickListener(v -> { + if (selectionHelper.isInSelectionMode()) { + selectionHelper.toggleSelection(key); + notifyItemChanged(position); + } else { + listener.onAdoptionClick(position); + } + }); + + holder.itemView.setOnLongClickListener(v -> { + if (!selectionHelper.isInSelectionMode()) { + selectionHelper.startSelection(key); + } + return true; + }); } @Override public int getItemCount() { return adoptionList.size(); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/AppointmentAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/AppointmentAdapter.java index 237cc154..cb292c1a 100644 --- a/android/app/src/main/java/com/example/petstoremobile/adapters/AppointmentAdapter.java +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/AppointmentAdapter.java @@ -4,79 +4,124 @@ import android.graphics.Color; 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.databinding.ItemAppointmentBinding; import com.example.petstoremobile.dtos.AppointmentDTO; +import com.example.petstoremobile.utils.BulkDeleteHandler; +import com.example.petstoremobile.utils.SelectionHelper; import java.util.List; -public class AppointmentAdapter extends RecyclerView.Adapter { +public class AppointmentAdapter extends RecyclerView.Adapter implements BulkDeleteHandler.SelectableAdapter { private List appointmentList; private OnAppointmentClickListener appointmentClickListener; + private final SelectionHelper selectionHelper; public interface OnAppointmentClickListener { void onAppointmentClick(int position); + void onSelectionChanged(int count); } public AppointmentAdapter(List appointmentList, OnAppointmentClickListener appointmentClickListener) { this.appointmentList = appointmentList; this.appointmentClickListener = appointmentClickListener; + this.selectionHelper = new SelectionHelper(new SelectionHelper.SelectionListener() { + @Override + public void onSelectionChanged(int count) { + appointmentClickListener.onSelectionChanged(count); + } + + @Override + public void onSelectionModeToggle(boolean selectionMode) { + notifyDataSetChanged(); + } + }); + } + + @Override + public List getSelectedKeys() { + return selectionHelper.getSelectedKeys(); + } + + @Override + public void clearSelection() { + selectionHelper.clearSelection(); } public static class AppointmentViewHolder extends RecyclerView.ViewHolder { - TextView tvCustomerName, tvPetName, tvServiceType, tvDateTime,tvEmployee, tvAppointmentStatus; + private final ItemAppointmentBinding binding; - public AppointmentViewHolder(@NonNull View v) { - super(v); - tvCustomerName = v.findViewById(R.id.tvCustomerName); - tvPetName = v.findViewById(R.id.tvApptPetName); - tvServiceType = v.findViewById(R.id.tvServiceType); - tvDateTime = v.findViewById(R.id.tvDateTime); - - tvAppointmentStatus = v.findViewById(R.id.tvAppointmentStatus); + public AppointmentViewHolder(@NonNull ItemAppointmentBinding binding) { + super(binding.getRoot()); + this.binding = binding; } } @NonNull @Override public AppointmentViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_appointment, parent, false); - return new AppointmentViewHolder(v); + ItemAppointmentBinding binding = ItemAppointmentBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); + return new AppointmentViewHolder(binding); } @Override public void onBindViewHolder(@NonNull AppointmentViewHolder holder, int position) { AppointmentDTO a = appointmentList.get(position); + ItemAppointmentBinding binding = holder.binding; - holder.tvCustomerName.setText(a.getCustomerName() != null ? a.getCustomerName() : ""); - holder.tvPetName.setText("Pet: " + (a.getPetName() != null ? a.getPetName() : "")); - holder.tvServiceType.setText(a.getServiceType() != null ? a.getServiceType() : ""); - holder.tvDateTime.setText((a.getAppointmentDate() != null ? a.getAppointmentDate() : "") + + binding.tvCustomerName.setText(a.getCustomerName() != null ? a.getCustomerName() : ""); + binding.tvApptPetName.setText("Pet: " + (a.getPetName() != null ? a.getPetName() : "")); + binding.tvServiceType.setText(a.getServiceType() != null ? a.getServiceType() : ""); + binding.tvStaffName.setText("Staff: " + (a.getEmployeeName() != null ? a.getEmployeeName() : "Unassigned")); + binding.tvDateTime.setText((a.getAppointmentDate() != null ? a.getAppointmentDate() : "") + " at " + (a.getAppointmentTime() != null ? a.getAppointmentTime() : "")); - holder.tvEmployee.setText("Staff: " + (a.getEmployeeName() != null ? a.getEmployeeName() : "")); String status = a.getStatus() != null ? a.getStatus() : ""; - holder.tvAppointmentStatus.setText(status); + binding.tvAppointmentStatus.setText(status); - switch (status) { - case "Booked": - holder.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#2196F3")); // blue + switch (status.toUpperCase()) { + case "BOOKED": + binding.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#2196F3")); // blue break; - case "Completed": - holder.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#4CAF50")); // green + case "COMPLETED": + binding.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#4CAF50")); // green break; - case "Cancelled": - holder.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#F44336")); // red + case "CANCELLED": + binding.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#F44336")); // red break; default: - holder.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#9E9E9E")); // gray + binding.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#9E9E9E")); // gray break; } - holder.itemView.setOnClickListener(v -> appointmentClickListener.onAppointmentClick(position)); + String key = String.valueOf(a.getAppointmentId()); + + // Bulk delete selection mode + if (selectionHelper.isInSelectionMode()) { + binding.cbSelectAppointment.setVisibility(View.VISIBLE); + binding.cbSelectAppointment.setChecked(selectionHelper.isSelected(key)); + } else { + binding.cbSelectAppointment.setVisibility(View.GONE); + binding.cbSelectAppointment.setChecked(false); + } + + holder.itemView.setOnClickListener(v -> { + if (selectionHelper.isInSelectionMode()) { + selectionHelper.toggleSelection(key); + notifyItemChanged(position); + } else { + appointmentClickListener.onAppointmentClick(position); + } + }); + + holder.itemView.setOnLongClickListener(v -> { + if (!selectionHelper.isInSelectionMode()) { + selectionHelper.startSelection(key); + } + return true; + }); } @Override diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/BlackTextArrayAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/BlackTextArrayAdapter.java new file mode 100644 index 00000000..22af59de --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/BlackTextArrayAdapter.java @@ -0,0 +1,44 @@ +package com.example.petstoremobile.adapters; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import com.example.petstoremobile.R; +import java.util.List; + +// A class that overrides the arrayAdapter so the text color changes based on theme +public class BlackTextArrayAdapter extends ArrayAdapter { + public BlackTextArrayAdapter(@NonNull Context context, int resource, @NonNull T[] objects) { + super(context, resource, objects); + } + + public BlackTextArrayAdapter(@NonNull Context context, int resource, @NonNull List objects) { + super(context, resource, objects); + } + + @NonNull + @Override + public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { + View view = super.getView(position, convertView, parent); + view.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.white)); + if (view instanceof TextView) { + ((TextView) view).setTextColor(ContextCompat.getColor(getContext(), R.color.spinner_text)); + } + return view; + } + + @Override + public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { + View view = super.getDropDownView(position, convertView, parent); + view.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.white)); + if (view instanceof TextView) { + ((TextView) view).setTextColor(ContextCompat.getColor(getContext(), R.color.spinner_text)); + } + return view; + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/ChatAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/ChatAdapter.java index 93e6d902..972ac56d 100644 --- a/android/app/src/main/java/com/example/petstoremobile/adapters/ChatAdapter.java +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/ChatAdapter.java @@ -1,14 +1,12 @@ 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.databinding.ItemChatBinding; import com.example.petstoremobile.models.Chat; import java.util.List; @@ -30,15 +28,15 @@ public class ChatAdapter extends RecyclerView.Adapter listener.onChatClick(chat)); } @@ -48,12 +46,11 @@ public class ChatAdapter extends RecyclerView.Adapter { +public class InventoryAdapter extends RecyclerView.Adapter implements BulkDeleteHandler.SelectableAdapter { private final List inventoryList; private final OnInventoryClickListener clickListener; - private final List selectedIds = new ArrayList<>(); - private boolean selectionMode = false; + private final SelectionHelper selectionHelper; public interface OnInventoryClickListener { void onInventoryClick(int position); @@ -32,117 +30,97 @@ public class InventoryAdapter extends RecyclerView.Adapter inventoryList, OnInventoryClickListener clickListener) { this.inventoryList = inventoryList; this.clickListener = clickListener; + this.selectionHelper = new SelectionHelper(new SelectionHelper.SelectionListener() { + @Override + public void onSelectionChanged(int count) { + clickListener.onSelectionChanged(count); + } + + @Override + public void onSelectionModeToggle(boolean selectionMode) { + notifyDataSetChanged(); + } + }); + } + + @Override + public List getSelectedKeys() { + return selectionHelper.getSelectedKeys(); + } + + @Override + public void clearSelection() { + selectionHelper.clearSelection(); } public static class InventoryViewHolder extends RecyclerView.ViewHolder { - // Matches desktop table columns: Inventory ID, Product ID, Product Name, - // Quantity - TextView tvInventoryId, tvProdId, tvProductName, tvQuantity; - CheckBox checkBox; + final ItemInventoryBinding binding; - public InventoryViewHolder(@NonNull View v) { - super(v); - tvInventoryId = v.findViewById(R.id.tvInventoryId); - tvProdId = v.findViewById(R.id.tvProdId); - tvProductName = v.findViewById(R.id.tvProductName); - tvQuantity = v.findViewById(R.id.tvQuantity); - checkBox = v.findViewById(R.id.cbSelectInventory); + public InventoryViewHolder(@NonNull ItemInventoryBinding binding) { + super(binding.getRoot()); + this.binding = binding; } } @NonNull @Override public InventoryViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View v = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_inventory, parent, false); - return new InventoryViewHolder(v); + ItemInventoryBinding binding = ItemInventoryBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); + return new InventoryViewHolder(binding); } @Override public void onBindViewHolder(@NonNull InventoryViewHolder holder, int position) { InventoryDTO inv = inventoryList.get(position); - - // Column: Inventory ID - holder.tvInventoryId.setText(String.valueOf(inv.getInventoryId() != null ? inv.getInventoryId() : "—")); - - // Column: Product ID - holder.tvProdId.setText(String.valueOf(inv.getProdId() != null ? inv.getProdId() : "—")); + ItemInventoryBinding binding = holder.binding; // Column: Product Name - holder.tvProductName.setText(inv.getProductName() != null ? inv.getProductName() : "—"); + binding.tvProductName.setText(inv.getProductName() != null ? inv.getProductName() : "—"); + + // Column: Store Name + binding.tvInventoryStore.setText("Store: " + (inv.getStoreName() != null ? inv.getStoreName() : "—")); // Column: Quantity int qty = inv.getQuantity() != null ? inv.getQuantity() : 0; - holder.tvQuantity.setText(String.valueOf(qty)); + binding.tvQuantity.setText("Stock: " + qty); // Low stock = red, normal = green (like desktop reorder concept) if (qty <= 5) { - holder.tvQuantity.setTextColor(Color.parseColor("#F44336")); + binding.tvQuantity.setTextColor(Color.parseColor("#F44336")); } else { - holder.tvQuantity.setTextColor(Color.parseColor("#4CAF50")); + binding.tvQuantity.setTextColor(Color.parseColor("#4CAF50")); } + String key = String.valueOf(inv.getInventoryId()); + // Bulk delete selection mode - if (selectionMode) { - holder.checkBox.setVisibility(View.VISIBLE); - holder.checkBox.setChecked(inv.getInventoryId() != null - && selectedIds.contains(inv.getInventoryId())); + if (selectionHelper.isInSelectionMode()) { + binding.cbSelectInventory.setVisibility(View.VISIBLE); + binding.cbSelectInventory.setChecked(selectionHelper.isSelected(key)); } else { - holder.checkBox.setVisibility(View.GONE); - holder.checkBox.setChecked(false); + binding.cbSelectInventory.setVisibility(View.GONE); + binding.cbSelectInventory.setChecked(false); } holder.itemView.setOnClickListener(v -> { - if (selectionMode) { - toggleSelection(inv.getInventoryId(), holder.checkBox); + if (selectionHelper.isInSelectionMode()) { + selectionHelper.toggleSelection(key); + notifyItemChanged(position); } else { clickListener.onInventoryClick(holder.getAdapterPosition()); } }); holder.itemView.setOnLongClickListener(v -> { - if (!selectionMode) { - selectionMode = true; - toggleSelection(inv.getInventoryId(), holder.checkBox); - notifyDataSetChanged(); + if (!selectionHelper.isInSelectionMode()) { + selectionHelper.startSelection(key); } return true; }); } - private void toggleSelection(Long id, CheckBox checkBox) { - if (id == null) - return; - if (selectedIds.contains(id)) { - selectedIds.remove(id); - checkBox.setChecked(false); - } else { - selectedIds.add(id); - checkBox.setChecked(true); - } - clickListener.onSelectionChanged(selectedIds.size()); - if (selectedIds.isEmpty()) { - selectionMode = false; - notifyDataSetChanged(); - } - } - - public List getSelectedIds() { - return new ArrayList<>(selectedIds); - } - - public void clearSelection() { - selectedIds.clear(); - selectionMode = false; - notifyDataSetChanged(); - } - - public boolean isInSelectionMode() { - return selectionMode; - } - @Override public int getItemCount() { return inventoryList.size(); } -} \ No newline at end of file +} 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 0354a1fd..ee58941a 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 @@ -3,10 +3,17 @@ package com.example.petstoremobile.adapters; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; +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.example.petstoremobile.R; +import com.example.petstoremobile.databinding.ItemMessageReceivedBinding; +import com.example.petstoremobile.databinding.ItemMessageSentBinding; import com.example.petstoremobile.models.Message; import java.util.List; @@ -17,6 +24,7 @@ public class MessageAdapter extends RecyclerView.Adapter messages; private Long currentUserId; + private String token; public MessageAdapter(List messages, Long currentUserId) { this.messages = messages; @@ -28,6 +36,10 @@ public class MessageAdapter extends RecyclerView.Adapter { +public class PetAdapter extends RecyclerView.Adapter implements BulkDeleteHandler.SelectableAdapter { private List petList; private OnPetClickListener petClickListener; + private String baseUrl; + private String token; + private final SelectionHelper selectionHelper; // Interface for pet click on recycler view public interface OnPetClickListener { void onPetClick(int position); + void onSelectionChanged(int selectedCount); } //Constructor public PetAdapter(List petList, OnPetClickListener petClickListener) { this.petList = petList; this.petClickListener = petClickListener; + this.selectionHelper = new SelectionHelper(new SelectionHelper.SelectionListener() { + @Override + public void onSelectionChanged(int count) { + petClickListener.onSelectionChanged(count); + } + + @Override + public void onSelectionModeToggle(boolean selectionMode) { + notifyDataSetChanged(); + } + }); + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public void setToken(String token) { + this.token = token; + } + + @Override + public List getSelectedKeys() { + return selectionHelper.getSelectedKeys(); + } + + @Override + public void clearSelection() { + selectionHelper.clearSelection(); } // Get the controls of each row in recycler view public static class PetViewHolder extends RecyclerView.ViewHolder { - TextView tvPetName, tvPetSpeciesBreed, tvPetAge, tvPetPrice, tvPetStatus; + private final ItemPetBinding binding; - public PetViewHolder(@NonNull View v) { - super(v); - tvPetName = v.findViewById(R.id.tvPetName); - tvPetSpeciesBreed = v.findViewById(R.id.tvPetSpeciesBreed); - tvPetAge = v.findViewById(R.id.tvPetAge); - tvPetPrice = v.findViewById(R.id.tvPetPrice); - tvPetStatus = v.findViewById(R.id.tvPetStatus); + public PetViewHolder(@NonNull ItemPetBinding binding) { + super(binding.getRoot()); + this.binding = binding; } } @@ -45,41 +80,75 @@ public class PetAdapter extends RecyclerView.Adapter { @NonNull @Override public PetViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_pet, parent, false); - return new PetViewHolder(v); + ItemPetBinding binding = ItemPetBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); + return new PetViewHolder(binding); } //populate the row with pet data @Override public void onBindViewHolder(@NonNull PetViewHolder holder, int position) { PetDTO pet = petList.get(position); + ItemPetBinding binding = holder.binding; - holder.tvPetName.setText(pet.getPetName()); - holder.tvPetSpeciesBreed.setText(pet.getPetSpecies() + " - " + pet.getPetBreed()); - holder.tvPetAge.setText("Age: " + pet.getPetAge() + " yr(s)"); + binding.tvPetName.setText(pet.getPetName()); + binding.tvPetSpeciesBreed.setText(pet.getPetSpecies() + " - " + pet.getPetBreed()); + binding.tvPetAge.setText("Age: " + pet.getPetAge() + " yr(s)"); - try { - double price = Double.parseDouble(pet.getPetPrice()); - holder.tvPetPrice.setText("$" + String.format("%.2f", price)); - } catch (Exception e) { - holder.tvPetPrice.setText("$" + pet.getPetPrice()); + Double price = pet.getPetPrice(); + if (price != null) { + binding.tvPetPrice.setText("$" + String.format("%.2f", price)); + } else { + binding.tvPetPrice.setText("$0.00"); } - holder.tvPetStatus.setText(pet.getPetStatus()); + binding.tvPetStatus.setText(pet.getPetStatus()); //Set the status color depending on availability. If available, green, otherwise red if (pet.getPetStatus() != null && pet.getPetStatus().equals("Available")) { - holder.tvPetStatus.setBackgroundColor(Color.parseColor("#4CAF50")); + binding.tvPetStatus.setBackgroundColor(Color.parseColor("#4CAF50")); } else { - holder.tvPetStatus.setBackgroundColor(Color.parseColor("#F44336")); + binding.tvPetStatus.setBackgroundColor(Color.parseColor("#F44336")); + } + + // Load pet image using Glide + if (baseUrl != null) { + String imageUrl = baseUrl + String.format(PetApi.PET_IMAGE_PATH, pet.getPetId()); + GlideUtils.loadImageWithTokenCircle(holder.itemView.getContext(), binding.ivPetProfile, imageUrl, token, R.drawable.placeholder); + } else { + binding.ivPetProfile.setImageResource(R.drawable.placeholder); + } + + String key = String.valueOf(pet.getPetId()); + + // Bulk delete selection mode + if (selectionHelper.isInSelectionMode()) { + binding.cbSelectPet.setVisibility(View.VISIBLE); + binding.cbSelectPet.setChecked(selectionHelper.isSelected(key)); + } else { + binding.cbSelectPet.setVisibility(View.GONE); + binding.cbSelectPet.setChecked(false); } //when a row is clicked, open the detail view - holder.itemView.setOnClickListener(v -> petClickListener.onPetClick(position)); + holder.itemView.setOnClickListener(v -> { + if (selectionHelper.isInSelectionMode()) { + selectionHelper.toggleSelection(key); + notifyItemChanged(position); + } else { + petClickListener.onPetClick(position); + } + }); + + holder.itemView.setOnLongClickListener(v -> { + if (!selectionHelper.isInSelectionMode()) { + selectionHelper.startSelection(key); + } + return true; + }); } @Override public int getItemCount() { return petList.size(); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/ProductAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/ProductAdapter.java index 300b7e57..f5f897cc 100644 --- a/android/app/src/main/java/com/example/petstoremobile/adapters/ProductAdapter.java +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/ProductAdapter.java @@ -1,17 +1,22 @@ package com.example.petstoremobile.adapters; import android.view.*; -import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; + import com.example.petstoremobile.R; +import com.example.petstoremobile.api.ProductApi; +import com.example.petstoremobile.databinding.ItemProductBinding; import com.example.petstoremobile.dtos.ProductDTO; +import com.example.petstoremobile.utils.GlideUtils; import java.util.List; public class ProductAdapter extends RecyclerView.Adapter { private List productList; private OnProductClickListener listener; + private String baseUrl; + private String token; public interface OnProductClickListener { void onProductClick(int position); @@ -22,33 +27,48 @@ public class ProductAdapter extends RecyclerView.Adapter listener.onProductClick(position)); } diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/ProductSupplierAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/ProductSupplierAdapter.java index 4c6377e1..231af741 100644 --- a/android/app/src/main/java/com/example/petstoremobile/adapters/ProductSupplierAdapter.java +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/ProductSupplierAdapter.java @@ -1,55 +1,109 @@ package com.example.petstoremobile.adapters; -import android.view.*; -import android.widget.TextView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import com.example.petstoremobile.R; + +import com.example.petstoremobile.databinding.ItemProductSupplierBinding; import com.example.petstoremobile.dtos.ProductSupplierDTO; +import com.example.petstoremobile.utils.BulkDeleteHandler; +import com.example.petstoremobile.utils.SelectionHelper; + import java.util.List; -public class ProductSupplierAdapter extends RecyclerView.Adapter { +public class ProductSupplierAdapter extends RecyclerView.Adapter implements BulkDeleteHandler.SelectableAdapter { - private List list; - private OnProductSupplierClickListener listener; + private final List list; + private final OnProductSupplierClickListener listener; + private final SelectionHelper selectionHelper; public interface OnProductSupplierClickListener { void onProductSupplierClick(int position); + void onSelectionChanged(int count); } public ProductSupplierAdapter(List list, OnProductSupplierClickListener listener) { this.list = list; this.listener = listener; + this.selectionHelper = new SelectionHelper(new SelectionHelper.SelectionListener() { + @Override + public void onSelectionChanged(int count) { + listener.onSelectionChanged(count); + } + + @Override + public void onSelectionModeToggle(boolean selectionMode) { + notifyDataSetChanged(); + } + }); + } + + @Override + public List getSelectedKeys() { + return selectionHelper.getSelectedKeys(); + } + + @Override + public void clearSelection() { + selectionHelper.clearSelection(); } public static class PSViewHolder extends RecyclerView.ViewHolder { - TextView tvProductName, tvSupplierName, tvCost; + final ItemProductSupplierBinding binding; - public PSViewHolder(@NonNull View v) { - super(v); - tvProductName = v.findViewById(R.id.tvPSProductName); - tvSupplierName = v.findViewById(R.id.tvPSSupplierName); - tvCost = v.findViewById(R.id.tvPSCost); + public PSViewHolder(@NonNull ItemProductSupplierBinding binding) { + super(binding.getRoot()); + this.binding = binding; } } @NonNull @Override public PSViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View v = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_product_supplier, parent, false); - return new PSViewHolder(v); + ItemProductSupplierBinding binding = ItemProductSupplierBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); + return new PSViewHolder(binding); } @Override public void onBindViewHolder(@NonNull PSViewHolder holder, int position) { ProductSupplierDTO ps = list.get(position); - holder.tvProductName.setText(ps.getProductName() != null ? ps.getProductName() : ""); - holder.tvSupplierName.setText("Supplier: " + (ps.getSupplierName() != null ? ps.getSupplierName() : "")); - holder.tvCost.setText(ps.getCost() != null ? "Cost: $" + ps.getCost() : ""); - holder.itemView.setOnClickListener(v -> listener.onProductSupplierClick(position)); + ItemProductSupplierBinding binding = holder.binding; + + binding.tvPSProductName.setText(ps.getProductName() != null ? ps.getProductName() : ""); + binding.tvPSSupplierName.setText("Supplier: " + (ps.getSupplierName() != null ? ps.getSupplierName() : "")); + binding.tvPSCost.setText(ps.getCost() != null ? "Cost: $" + ps.getCost() : ""); + + String key = ps.getProductId() + "-" + ps.getSupplierId(); + + // Bulk delete selection mode + if (selectionHelper.isInSelectionMode()) { + binding.cbSelectProductSupplier.setVisibility(View.VISIBLE); + binding.cbSelectProductSupplier.setChecked(selectionHelper.isSelected(key)); + } else { + binding.cbSelectProductSupplier.setVisibility(View.GONE); + binding.cbSelectProductSupplier.setChecked(false); + } + + holder.itemView.setOnClickListener(v -> { + if (selectionHelper.isInSelectionMode()) { + selectionHelper.toggleSelection(key); + notifyItemChanged(position); + } else { + listener.onProductSupplierClick(position); + } + }); + + holder.itemView.setOnLongClickListener(v -> { + if (!selectionHelper.isInSelectionMode()) { + selectionHelper.startSelection(key); + } + return true; + }); } @Override public int getItemCount() { return list.size(); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/PurchaseOrderAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/PurchaseOrderAdapter.java index 2d66e672..a31a3d0a 100644 --- a/android/app/src/main/java/com/example/petstoremobile/adapters/PurchaseOrderAdapter.java +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/PurchaseOrderAdapter.java @@ -1,11 +1,11 @@ package com.example.petstoremobile.adapters; import android.graphics.Color; -import android.view.*; -import android.widget.TextView; +import android.view.LayoutInflater; +import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import com.example.petstoremobile.R; +import com.example.petstoremobile.databinding.ItemPurchaseOrderBinding; import com.example.petstoremobile.dtos.PurchaseOrderDTO; import java.util.List; @@ -24,47 +24,49 @@ public class PurchaseOrderAdapter extends RecyclerView.Adapter listener.onSaleClick(position)); @@ -67,4 +67,4 @@ public class SaleAdapter extends RecyclerView.Adapter { +/** + * Adapter class for displaying a list of services in a RecyclerView. + */ +public class ServiceAdapter extends RecyclerView.Adapter implements BulkDeleteHandler.SelectableAdapter { - private List serviceList; - private OnServiceClickListener serviceClickListener; + private final List serviceList; + private final OnServiceClickListener clickListener; + private final SelectionHelper selectionHelper; - // Interface for service click on recycler view + /** + * Interface for handling clicks on service items. + */ public interface OnServiceClickListener { void onServiceClick(int position); + void onSelectionChanged(int count); } - //Constructor - public ServiceAdapter(List serviceList, OnServiceClickListener serviceClickListener) { - this.serviceList = serviceList; - this.serviceClickListener = serviceClickListener; + public ServiceAdapter(List serviceList, OnServiceClickListener clickListener) { + this.serviceList = serviceList; + this.clickListener = clickListener; + this.selectionHelper = new SelectionHelper(new SelectionHelper.SelectionListener() { + @Override + public void onSelectionChanged(int count) { + clickListener.onSelectionChanged(count); + } + + @Override + public void onSelectionModeToggle(boolean selectionMode) { + notifyDataSetChanged(); + } + }); } - // Get the controls of each row in recycler view + @Override + public List getSelectedKeys() { + return selectionHelper.getSelectedKeys(); + } + + @Override + public void clearSelection() { + selectionHelper.clearSelection(); + } + + /** + * ViewHolder class for service items. + */ public static class ServiceViewHolder extends RecyclerView.ViewHolder { - TextView tvServiceName, tvServiceDesc, tvServiceDuration, tvServicePrice; + final ItemServiceBinding binding; - public ServiceViewHolder(@NonNull View v) { - super(v); - tvServiceName = v.findViewById(R.id.tvServiceName); - tvServiceDesc = v.findViewById(R.id.tvServiceDesc); - tvServiceDuration = v.findViewById(R.id.tvServiceDuration); - tvServicePrice = v.findViewById(R.id.tvServicePrice); + public ServiceViewHolder(@NonNull ItemServiceBinding binding) { + super(binding.getRoot()); + this.binding = binding; } } @@ -43,26 +73,51 @@ public class ServiceAdapter extends RecyclerView.Adapter serviceClickListener.onServiceClick(position)); + String key = String.valueOf(service.getServiceId()); + + // Bulk delete selection mode + if (selectionHelper.isInSelectionMode()) { + binding.cbSelectService.setVisibility(View.VISIBLE); + binding.cbSelectService.setChecked(selectionHelper.isSelected(key)); + } else { + binding.cbSelectService.setVisibility(View.GONE); + binding.cbSelectService.setChecked(false); + } + + holder.itemView.setOnClickListener(v -> { + if (selectionHelper.isInSelectionMode()) { + selectionHelper.toggleSelection(key); + notifyItemChanged(position); + } else { + clickListener.onServiceClick(position); + } + }); + + holder.itemView.setOnLongClickListener(v -> { + if (!selectionHelper.isInSelectionMode()) { + selectionHelper.startSelection(key); + } + return true; + }); } @Override public int getItemCount() { return serviceList.size(); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/SupplierAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/SupplierAdapter.java index e134f5b2..980dc071 100644 --- a/android/app/src/main/java/com/example/petstoremobile/adapters/SupplierAdapter.java +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/SupplierAdapter.java @@ -3,39 +3,63 @@ 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.databinding.ItemSupplierBinding; import com.example.petstoremobile.dtos.SupplierDTO; +import com.example.petstoremobile.utils.BulkDeleteHandler; +import com.example.petstoremobile.utils.SelectionHelper; + import java.util.List; -public class SupplierAdapter extends RecyclerView.Adapter { +public class SupplierAdapter extends RecyclerView.Adapter implements BulkDeleteHandler.SelectableAdapter { - private List supplierList; - private OnSupplierClickListener supplierClickListener; + private final List supplierList; + private final OnSupplierClickListener supplierClickListener; + private final SelectionHelper selectionHelper; // Interface for supplier click on recycler view public interface OnSupplierClickListener { void onSupplierClick(int position); + void onSelectionChanged(int count); } //Constructor public SupplierAdapter(List supplierList, OnSupplierClickListener supplierClickListener) { this.supplierList = supplierList; this.supplierClickListener = supplierClickListener; + this.selectionHelper = new SelectionHelper(new SelectionHelper.SelectionListener() { + @Override + public void onSelectionChanged(int count) { + supplierClickListener.onSelectionChanged(count); + } + + @Override + public void onSelectionModeToggle(boolean selectionMode) { + notifyDataSetChanged(); + } + }); + } + + @Override + public List getSelectedKeys() { + return selectionHelper.getSelectedKeys(); + } + + @Override + public void clearSelection() { + selectionHelper.clearSelection(); } // Get the controls of each row in recycler view public static class SupplierViewHolder extends RecyclerView.ViewHolder { - TextView tvSupCompany, tvSupContactName, tvSupEmail, tvSupPhone; + final ItemSupplierBinding binding; - public SupplierViewHolder(@NonNull View v) { - super(v); - tvSupCompany = v.findViewById(R.id.tvSupCompany); - tvSupContactName = v.findViewById(R.id.tvSupContactName); - tvSupEmail = v.findViewById(R.id.tvSupEmail); - tvSupPhone = v.findViewById(R.id.tvSupPhone); + public SupplierViewHolder(@NonNull ItemSupplierBinding binding) { + super(binding.getRoot()); + this.binding = binding; } } @@ -43,26 +67,52 @@ public class SupplierAdapter extends RecyclerView.Adapter supplierClickListener.onSupplierClick(position)); + holder.itemView.setOnClickListener(v -> { + if (selectionHelper.isInSelectionMode()) { + selectionHelper.toggleSelection(key); + notifyItemChanged(position); + } else { + supplierClickListener.onSupplierClick(position); + } + }); + + holder.itemView.setOnLongClickListener(v -> { + if (!selectionHelper.isInSelectionMode()) { + selectionHelper.startSelection(key); + } + return true; + }); } @Override public int getItemCount() { return supplierList.size(); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/WhiteTextArrayAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/WhiteTextArrayAdapter.java new file mode 100644 index 00000000..42042207 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/WhiteTextArrayAdapter.java @@ -0,0 +1,47 @@ +package com.example.petstoremobile.adapters; + +import android.content.Context; +import android.graphics.Color; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import com.example.petstoremobile.R; +import java.util.List; + +/** + * A class that overrides the arrayAdapter so the text color is white and background is transparent. + */ +public class WhiteTextArrayAdapter extends ArrayAdapter { + public WhiteTextArrayAdapter(@NonNull Context context, int resource, @NonNull T[] objects) { + super(context, resource, objects); + } + + public WhiteTextArrayAdapter(@NonNull Context context, int resource, @NonNull List objects) { + super(context, resource, objects); + } + + @NonNull + @Override + public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { + View view = super.getView(position, convertView, parent); + view.setBackgroundColor(Color.TRANSPARENT); + if (view instanceof TextView) { + ((TextView) view).setTextColor(Color.WHITE); + } + return view; + } + + @Override + public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { + View view = super.getDropDownView(position, convertView, parent); + view.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.primary_dark)); + if (view instanceof TextView) { + ((TextView) view).setTextColor(Color.WHITE); + } + return view; + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/api/AdoptionApi.java b/android/app/src/main/java/com/example/petstoremobile/api/AdoptionApi.java index 2f704a41..ec397909 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/AdoptionApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/AdoptionApi.java @@ -1,12 +1,14 @@ package com.example.petstoremobile.api; import com.example.petstoremobile.dtos.AdoptionDTO; +import com.example.petstoremobile.dtos.BulkDeleteRequest; import com.example.petstoremobile.dtos.PageResponse; import retrofit2.Call; import retrofit2.http.Body; import retrofit2.http.DELETE; import retrofit2.http.GET; +import retrofit2.http.HTTP; import retrofit2.http.POST; import retrofit2.http.PUT; import retrofit2.http.Path; @@ -17,7 +19,12 @@ public interface AdoptionApi { @GET("api/v1/adoptions") Call> getAllAdoptions( @Query("page") int page, - @Query("size") int size); + @Query("size") int size, + @Query("q") String query, + @Query("status") String status, + @Query("storeId") Long storeId, + @Query("date") String date, + @Query("employeeId") Long employeeId); @GET("api/v1/adoptions/{id}") Call getAdoptionById(@Path("id") Long id); @@ -30,5 +37,7 @@ public interface AdoptionApi { @DELETE("api/v1/adoptions/{id}") Call deleteAdoption(@Path("id") Long id); -} + @HTTP(method = "DELETE", path = "api/v1/adoptions", hasBody = true) + Call bulkDeleteAdoptions(@Body BulkDeleteRequest request); +} diff --git a/android/app/src/main/java/com/example/petstoremobile/api/AppointmentApi.java b/android/app/src/main/java/com/example/petstoremobile/api/AppointmentApi.java index 5d7044cf..5b8a37a7 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/AppointmentApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/AppointmentApi.java @@ -1,12 +1,14 @@ package com.example.petstoremobile.api; import com.example.petstoremobile.dtos.AppointmentDTO; +import com.example.petstoremobile.dtos.BulkDeleteRequest; import com.example.petstoremobile.dtos.PageResponse; import retrofit2.Call; import retrofit2.http.Body; import retrofit2.http.DELETE; import retrofit2.http.GET; +import retrofit2.http.HTTP; import retrofit2.http.POST; import retrofit2.http.PUT; import retrofit2.http.Path; @@ -17,7 +19,12 @@ public interface AppointmentApi { @GET("api/v1/appointments") Call> getAllAppointments( @Query("page") int page, - @Query("size") int size); + @Query("size") int size, + @Query("q") String query, + @Query("status") String status, + @Query("storeId") Long storeId, + @Query("date") String date, + @Query("employeeId") Long employeeId); @GET("api/v1/appointments/{id}") Call getAppointmentById(@Path("id") Long id); @@ -30,4 +37,7 @@ public interface AppointmentApi { @DELETE("api/v1/appointments/{id}") Call deleteAppointment(@Path("id") Long id); + + @HTTP(method = "DELETE", path = "api/v1/appointments", hasBody = true) + Call bulkDeleteAppointments(@Body BulkDeleteRequest request); } \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/api/InventoryApi.java b/android/app/src/main/java/com/example/petstoremobile/api/InventoryApi.java index f54616ee..fa1e17b4 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/InventoryApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/InventoryApi.java @@ -2,13 +2,13 @@ package com.example.petstoremobile.api; import com.example.petstoremobile.dtos.BulkDeleteRequest; import com.example.petstoremobile.dtos.InventoryDTO; -import com.example.petstoremobile.dtos.InventoryRequest; import com.example.petstoremobile.dtos.PageResponse; import retrofit2.Call; import retrofit2.http.Body; import retrofit2.http.DELETE; import retrofit2.http.GET; +import retrofit2.http.HTTP; import retrofit2.http.POST; import retrofit2.http.PUT; import retrofit2.http.Path; @@ -16,12 +16,12 @@ import retrofit2.http.Query; public interface InventoryApi { - // GET /api/v1/inventory?q=...&page=...&size=... @GET("api/v1/inventory") Call> getAllInventory( - @Query("q") String query, @Query("page") int page, @Query("size") int size, + @Query("q") String query, + @Query("storeId") Long storeId, @Query("sort") String sort); // GET /api/v1/inventory/{id} @@ -30,17 +30,17 @@ public interface InventoryApi { // POST /api/v1/inventory @POST("api/v1/inventory") - Call createInventory(@Body InventoryRequest request); + Call createInventory(@Body InventoryDTO request); // PUT /api/v1/inventory/{id} @PUT("api/v1/inventory/{id}") - Call updateInventory(@Path("id") Long id, @Body InventoryRequest request); + Call updateInventory(@Path("id") Long id, @Body InventoryDTO request); // DELETE /api/v1/inventory/{id} @DELETE("api/v1/inventory/{id}") Call deleteInventory(@Path("id") Long id); // DELETE /api/v1/inventory (bulk delete) - @DELETE("api/v1/inventory") + @HTTP(method = "DELETE", path = "api/v1/inventory", hasBody = true) Call bulkDeleteInventory(@Body BulkDeleteRequest request); } diff --git a/android/app/src/main/java/com/example/petstoremobile/api/PetApi.java b/android/app/src/main/java/com/example/petstoremobile/api/PetApi.java index e2eb3090..acae1b51 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/PetApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/PetApi.java @@ -1,5 +1,6 @@ package com.example.petstoremobile.api; +import com.example.petstoremobile.dtos.BulkDeleteRequest; import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.dtos.PetDTO; @@ -8,6 +9,7 @@ import retrofit2.Call; import retrofit2.http.Body; import retrofit2.http.DELETE; import retrofit2.http.GET; +import retrofit2.http.HTTP; import retrofit2.http.Multipart; import retrofit2.http.POST; import retrofit2.http.PUT; @@ -20,11 +22,16 @@ public interface PetApi { // endpoint for downloading the pet's image file String PET_IMAGE_PATH = "api/v1/pets/%d/image"; - // Get all pets + // Get all pets with filters @GET("api/v1/pets") Call> getAllPets( @Query("page") int page, - @Query("size") int size + @Query("size") int size, + @Query("q") String query, + @Query("status") String status, + @Query("species") String species, + @Query("storeId") Long storeId, + @Query("sort") String sort ); // Get pet by id @@ -43,9 +50,17 @@ public interface PetApi { @DELETE("api/v1/pets/{id}") Call deletePet(@Path("id") Long id); + // Bulk delete pets + @HTTP(method = "DELETE", path = "api/v1/pets", hasBody = true) + Call bulkDeletePets(@Body BulkDeleteRequest request); + // Upload pet image @Multipart @POST("api/v1/pets/{id}/image") Call uploadPetImage(@Path("id") Long id, @Part MultipartBody.Part image); + // Delete pet image + @DELETE("api/v1/pets/{id}/image") + Call deletePetImage(@Path("id") Long id); + } diff --git a/android/app/src/main/java/com/example/petstoremobile/api/ProductApi.java b/android/app/src/main/java/com/example/petstoremobile/api/ProductApi.java index 422a39e5..1d46107b 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/ProductApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/ProductApi.java @@ -2,16 +2,20 @@ package com.example.petstoremobile.api; import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.dtos.ProductDTO; +import okhttp3.MultipartBody; import retrofit2.Call; import retrofit2.http.*; public interface ProductApi { + String PRODUCT_IMAGE_PATH = "api/v1/products/%d/image"; @GET("api/v1/products") Call> getAllProducts( @Query("q") String query, + @Query("categoryId") Long categoryId, @Query("page") int page, - @Query("size") int size); + @Query("size") int size, + @Query("sort") String sort); @GET("api/v1/products/{id}") Call getProductById(@Path("id") Long id); @@ -24,4 +28,11 @@ public interface ProductApi { @DELETE("api/v1/products/{id}") Call deleteProduct(@Path("id") Long id); + + @Multipart + @POST("api/v1/products/{id}/image") + Call uploadProductImage(@Path("id") Long id, @Part MultipartBody.Part image); + + @DELETE("api/v1/products/{id}/image") + Call deleteProductImage(@Path("id") Long id); } \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/api/ProductSupplierApi.java b/android/app/src/main/java/com/example/petstoremobile/api/ProductSupplierApi.java index 32810b12..b4414be5 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/ProductSupplierApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/ProductSupplierApi.java @@ -1,5 +1,6 @@ package com.example.petstoremobile.api; +import com.example.petstoremobile.dtos.BulkDeleteRequest; import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.dtos.ProductSupplierDTO; import retrofit2.Call; @@ -10,7 +11,16 @@ public interface ProductSupplierApi { @GET("api/v1/product-suppliers") Call> getAllProductSuppliers( @Query("page") int page, - @Query("size") int size); + @Query("size") int size, + @Query("q") String query, + @Query("productId") Long productId, + @Query("supplierId") Long supplierId, + @Query("sort") String sort); + + @GET("api/v1/product-suppliers/{productId}/{supplierId}") + Call getProductSupplierById( + @Path("productId") Long productId, + @Path("supplierId") Long supplierId); @POST("api/v1/product-suppliers") Call createProductSupplier(@Body ProductSupplierDTO dto); @@ -25,4 +35,6 @@ public interface ProductSupplierApi { Call deleteProductSupplier( @Path("productId") Long productId, @Path("supplierId") Long supplierId); + @HTTP(method = "DELETE", path = "api/v1/product-suppliers", hasBody = true) + Call bulkDeleteProductSuppliers(@Body BulkDeleteRequest request); } \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/api/PurchaseOrderApi.java b/android/app/src/main/java/com/example/petstoremobile/api/PurchaseOrderApi.java index 1e4f4ffe..ebb99139 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/PurchaseOrderApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/PurchaseOrderApi.java @@ -12,7 +12,10 @@ public interface PurchaseOrderApi { @GET("api/v1/purchase-orders") Call> getAllPurchaseOrders( @Query("page") int page, - @Query("size") int size); + @Query("size") int size, + @Query("q") String query, + @Query("storeId") Long storeId, + @Query("sort") String sort); @GET("api/v1/purchase-orders/{id}") Call getPurchaseOrderById(@Path("id") Long id); diff --git a/android/app/src/main/java/com/example/petstoremobile/api/RetrofitClient.java b/android/app/src/main/java/com/example/petstoremobile/api/RetrofitClient.java index 2ae969a4..0bdf14d1 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/RetrofitClient.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/RetrofitClient.java @@ -2,9 +2,12 @@ package com.example.petstoremobile.api; import android.content.Context; import android.os.Build; +import android.util.Log; +import com.example.petstoremobile.BuildConfig; import com.example.petstoremobile.api.auth.AuthApi; import com.example.petstoremobile.api.auth.AuthInterceptor; +import com.example.petstoremobile.api.auth.TokenManager; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; @@ -13,29 +16,30 @@ import retrofit2.converter.gson.GsonConverterFactory; import java.util.concurrent.TimeUnit; -//Retrofit client Used for API calls +//Retrofit client Used for API calls TODO: DELETE THIS FILE AFTER MERGE NOW THAT WE ARE USING HILT AND NETWORKMODULE public class RetrofitClient { + private static final String TAG = "RetrofitClient"; public static final String BASE_URL = getBaseUrl(); // Helper function to determine BASE_URL based on whether we are testing on an emulator or a real device private static String getBaseUrl() { - if (Build.FINGERPRINT.contains("generic") - || Build.FINGERPRINT.contains("unknown") + String url = isEmulator() ? BuildConfig.EMULATOR_BACKEND_URL : BuildConfig.DEVICE_BACKEND_URL; + Log.i(TAG, "Using backend URL: " + url + " (emulator=" + isEmulator() + ")"); + return url; + } + + private static boolean isEmulator() { + return Build.FINGERPRINT.startsWith("generic") + || Build.FINGERPRINT.startsWith("unknown") || Build.MODEL.contains("google_sdk") || Build.MODEL.contains("Emulator") || Build.MODEL.contains("Android SDK built for x86") || Build.MANUFACTURER.contains("Genymotion") - || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) - || "google_sdk".equals(Build.PRODUCT)) { - return "http://10.0.2.2:8080/"; //emulator testing - } - else { - return "http://10.0.0.200:8080/"; //Hardware testing - } - - /*else { - return "http://192.168.1.148:8080/"; //Hardware testing - } */ + || Build.HARDWARE.contains("goldfish") + || Build.HARDWARE.contains("ranchu") + || Build.PRODUCT.contains("sdk") + || Build.PRODUCT.contains("sdk_gphone") + || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")); } private static Retrofit retrofit = null; @@ -47,7 +51,7 @@ public class RetrofitClient { OkHttpClient client = new OkHttpClient.Builder() .addInterceptor(interceptor) - .addInterceptor(new AuthInterceptor(context)) + .addInterceptor(new AuthInterceptor(new TokenManager(context))) .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) @@ -131,8 +135,9 @@ public class RetrofitClient { public static RefundApi getRefundApi(Context context) { return getClient(context).create(RefundApi.class); } + public static EmployeeApi getEmployeeApi(Context context) { return getClient(context).create(EmployeeApi.class); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/api/SaleApi.java b/android/app/src/main/java/com/example/petstoremobile/api/SaleApi.java index c89ead10..72bfd8f4 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/SaleApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/SaleApi.java @@ -2,8 +2,13 @@ package com.example.petstoremobile.api; import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.dtos.SaleDTO; + import retrofit2.Call; -import retrofit2.http.*; +import retrofit2.http.Body; +import retrofit2.http.GET; +import retrofit2.http.POST; +import retrofit2.http.Path; +import retrofit2.http.Query; public interface SaleApi { diff --git a/android/app/src/main/java/com/example/petstoremobile/api/ServiceApi.java b/android/app/src/main/java/com/example/petstoremobile/api/ServiceApi.java index a8e4ed32..43014a53 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/ServiceApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/ServiceApi.java @@ -1,5 +1,6 @@ package com.example.petstoremobile.api; +import com.example.petstoremobile.dtos.BulkDeleteRequest; import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.dtos.ServiceDTO; @@ -7,6 +8,7 @@ import retrofit2.Call; import retrofit2.http.Body; import retrofit2.http.DELETE; import retrofit2.http.GET; +import retrofit2.http.HTTP; import retrofit2.http.POST; import retrofit2.http.PUT; import retrofit2.http.Path; @@ -18,7 +20,9 @@ public interface ServiceApi { @GET("api/v1/services") Call> getAllServices( @Query("page") int page, - @Query("size") int size + @Query("size") int size, + @Query("q") String query, + @Query("sort") String sort ); // Get service by id @@ -36,4 +40,8 @@ public interface ServiceApi { // Delete service @DELETE("api/v1/services/{id}") Call deleteService(@Path("id") Long id); + + // Bulk delete services + @HTTP(method = "DELETE", path = "api/v1/services", hasBody = true) + Call bulkDeleteServices(@Body BulkDeleteRequest request); } diff --git a/android/app/src/main/java/com/example/petstoremobile/api/SupplierApi.java b/android/app/src/main/java/com/example/petstoremobile/api/SupplierApi.java index 47d4e1e3..38e7675c 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/SupplierApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/SupplierApi.java @@ -1,5 +1,6 @@ package com.example.petstoremobile.api; +import com.example.petstoremobile.dtos.BulkDeleteRequest; import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.dtos.SupplierDTO; @@ -7,6 +8,7 @@ import retrofit2.Call; import retrofit2.http.Body; import retrofit2.http.DELETE; import retrofit2.http.GET; +import retrofit2.http.HTTP; import retrofit2.http.POST; import retrofit2.http.PUT; import retrofit2.http.Path; @@ -18,7 +20,9 @@ public interface SupplierApi { @GET("api/v1/suppliers") Call> getAllSuppliers( @Query("page") int page, - @Query("size") int size + @Query("size") int size, + @Query("q") String query, + @Query("sort") String sort ); // Get supplier by id @@ -36,4 +40,8 @@ public interface SupplierApi { // Delete supplier @DELETE("api/v1/suppliers/{id}") Call deleteSupplier(@Path("id") Long id); + + // Bulk delete suppliers + @HTTP(method = "DELETE", path = "api/v1/suppliers", hasBody = true) + Call bulkDeleteSuppliers(@Body BulkDeleteRequest request); } diff --git a/android/app/src/main/java/com/example/petstoremobile/api/UserApi.java b/android/app/src/main/java/com/example/petstoremobile/api/UserApi.java new file mode 100644 index 00000000..53611e66 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/api/UserApi.java @@ -0,0 +1,13 @@ +package com.example.petstoremobile.api; + +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.UserDTO; + +import retrofit2.Call; +import retrofit2.http.GET; +import retrofit2.http.Query; + +public interface UserApi { + @GET("api/v1/users") + Call> getUsers(@Query("role") String role, @Query("page") int page, @Query("size") int size); +} diff --git a/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java b/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java index 75605083..c0d5c1fe 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java @@ -1,6 +1,7 @@ package com.example.petstoremobile.api.auth; import com.example.petstoremobile.dtos.AuthDTO; +import com.example.petstoremobile.dtos.AvatarUploadResponse; import com.example.petstoremobile.dtos.UserDTO; import java.util.Map; @@ -8,6 +9,7 @@ import java.util.Map; import okhttp3.MultipartBody; import retrofit2.Call; import retrofit2.http.Body; +import retrofit2.http.DELETE; import retrofit2.http.GET; import retrofit2.http.Multipart; import retrofit2.http.POST; @@ -35,6 +37,10 @@ public interface AuthApi { //upload avatar endpoint @Multipart @POST("api/v1/auth/me/avatar") - Call uploadAvatar(@Part MultipartBody.Part avatar); + Call uploadAvatar(@Part MultipartBody.Part avatar); + + //delete avatar endpoint + @DELETE("api/v1/auth/me/avatar") + Call deleteAvatar(); } diff --git a/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthInterceptor.java b/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthInterceptor.java index dd17fffd..02bbe3c0 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthInterceptor.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthInterceptor.java @@ -1,7 +1,5 @@ package com.example.petstoremobile.api.auth; -import android.content.Context; - import androidx.annotation.NonNull; import java.io.IOException; @@ -15,8 +13,8 @@ public class AuthInterceptor implements Interceptor { private final TokenManager tokenManager; - public AuthInterceptor(Context context) { - this.tokenManager = TokenManager.getInstance(context); + public AuthInterceptor(TokenManager tokenManager) { + this.tokenManager = tokenManager; } @NonNull diff --git a/android/app/src/main/java/com/example/petstoremobile/api/auth/TokenManager.java b/android/app/src/main/java/com/example/petstoremobile/api/auth/TokenManager.java index b0f90508..aa9ab363 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/auth/TokenManager.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/auth/TokenManager.java @@ -3,7 +3,12 @@ package com.example.petstoremobile.api.auth; import android.content.Context; import android.content.SharedPreferences; -//Store login token in shared preferences +import javax.inject.Inject; +import javax.inject.Singleton; + +import dagger.hilt.android.qualifiers.ApplicationContext; + +@Singleton public class TokenManager { private static final String TOKEN_KEY = "token"; private static final String USERNAME_KEY = "username"; @@ -11,20 +16,13 @@ public class TokenManager { private static final String PREFS_NAME = "auth_prefs"; private static final String USER_ID_KEY = "user_id"; - private static TokenManager instance; private SharedPreferences prefs; - private TokenManager(Context context) { + @Inject + public TokenManager(@ApplicationContext Context context) { prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); } - public static TokenManager getInstance(Context context) { - if (instance == null) { - instance = new TokenManager(context); - } - return instance; - } - //save login data after login public void saveLoginData(String token, String username, String role) { prefs.edit() @@ -65,6 +63,4 @@ public class TokenManager { public void clearLoginData() { prefs.edit().clear().apply(); } - - -} +} \ No newline at end of file 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 new file mode 100644 index 00000000..b5f1ca64 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/di/NetworkModule.java @@ -0,0 +1,182 @@ +package com.example.petstoremobile.di; + +import android.content.Context; +import android.os.Build; + +import com.example.petstoremobile.BuildConfig; +import com.example.petstoremobile.api.*; +import com.example.petstoremobile.api.auth.AuthApi; +import com.example.petstoremobile.api.auth.AuthInterceptor; +import com.example.petstoremobile.api.auth.TokenManager; + +import java.util.concurrent.TimeUnit; + +import javax.inject.Named; +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; +import dagger.hilt.InstallIn; +import dagger.hilt.android.qualifiers.ApplicationContext; +import dagger.hilt.components.SingletonComponent; +import okhttp3.OkHttpClient; +import okhttp3.logging.HttpLoggingInterceptor; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; + +//Module to provide dependencies injection for the api +@Module +@InstallIn(SingletonComponent.class) +public class NetworkModule { + + @Provides + @Singleton + @Named("baseUrl") + public static String provideBaseUrl() { + return isEmulator() ? BuildConfig.EMULATOR_BACKEND_URL : BuildConfig.DEVICE_BACKEND_URL; + } + + // Check if the device is an emulator + private static boolean isEmulator() { + return Build.FINGERPRINT.startsWith("generic") + || Build.FINGERPRINT.startsWith("unknown") + || Build.MODEL.contains("google_sdk") + || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for x86") + || Build.MANUFACTURER.contains("Genymotion") + || Build.HARDWARE.contains("goldfish") + || Build.HARDWARE.contains("ranchu") + || Build.PRODUCT.contains("sdk") + || Build.PRODUCT.contains("sdk_gphone") + || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")); + } + + @Provides + @Singleton + public static OkHttpClient provideOkHttpClient(TokenManager tokenManager) { + HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor(); + interceptor.setLevel(HttpLoggingInterceptor.Level.BODY); + + return new OkHttpClient.Builder() + .addInterceptor(interceptor) + .addInterceptor(new AuthInterceptor(tokenManager)) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + } + + //build the retrofit instance + @Provides + @Singleton + public static Retrofit provideRetrofit(@Named("baseUrl") String baseUrl, OkHttpClient client) { + return new Retrofit.Builder() + .baseUrl(baseUrl) + .addConverterFactory(GsonConverterFactory.create()) + .client(client) + .build(); + } + + //associate the api with the retrofit instance + @Provides + @Singleton + public static PetApi providePetApi(Retrofit retrofit) { + return retrofit.create(PetApi.class); + } + + @Provides + @Singleton + public static ServiceApi provideServiceApi(Retrofit retrofit) { + return retrofit.create(ServiceApi.class); + } + + @Provides + @Singleton + public static SupplierApi provideSupplierApi(Retrofit retrofit) { + return retrofit.create(SupplierApi.class); + } + + @Provides + @Singleton + public static AdoptionApi provideAdoptionApi(Retrofit retrofit) { + return retrofit.create(AdoptionApi.class); + } + + @Provides + @Singleton + public static AppointmentApi provideAppointmentApi(Retrofit retrofit) { + return retrofit.create(AppointmentApi.class); + } + + @Provides + @Singleton + public static ProductApi provideProductApi(Retrofit retrofit) { + return retrofit.create(ProductApi.class); + } + + @Provides + @Singleton + public static SaleApi provideSaleApi(Retrofit retrofit) { + return retrofit.create(SaleApi.class); + } + + @Provides + @Singleton + public static PurchaseOrderApi providePurchaseOrderApi(Retrofit retrofit) { + return retrofit.create(PurchaseOrderApi.class); + } + + @Provides + @Singleton + public static ProductSupplierApi provideProductSupplierApi(Retrofit retrofit) { + return retrofit.create(ProductSupplierApi.class); + } + + @Provides + @Singleton + public static InventoryApi provideInventoryApi(Retrofit retrofit) { + return retrofit.create(InventoryApi.class); + } + + @Provides + @Singleton + public static AuthApi provideAuthApi(Retrofit retrofit) { + return retrofit.create(AuthApi.class); + } + + @Provides + @Singleton + public static ChatApi provideChatApi(Retrofit retrofit) { + return retrofit.create(ChatApi.class); + } + + @Provides + @Singleton + public static CustomerApi provideCustomerApi(Retrofit retrofit) { + return retrofit.create(CustomerApi.class); + } + + @Provides + @Singleton + public static MessageApi provideMessageApi(Retrofit retrofit) { + return retrofit.create(MessageApi.class); + } + + @Provides + @Singleton + public static StoreApi provideStoreApi(Retrofit retrofit) { + return retrofit.create(StoreApi.class); + } + + @Provides + @Singleton + public static CategoryApi provideCategoryApi(Retrofit retrofit) { + return retrofit.create(CategoryApi.class); + } + + @Provides + @Singleton + public static UserApi provideUserApi(Retrofit retrofit) { + return retrofit.create(UserApi.class); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/AdoptionDTO.java b/android/app/src/main/java/com/example/petstoremobile/dtos/AdoptionDTO.java index d013288c..d48bc942 100644 --- a/android/app/src/main/java/com/example/petstoremobile/dtos/AdoptionDTO.java +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/AdoptionDTO.java @@ -9,88 +9,68 @@ public class AdoptionDTO { private String petName; private Long customerId; private String customerName; + private Long employeeId; + private String employeeName; + private Long sourceStoreId; + private String sourceStoreName; private String adoptionDate; private String adoptionStatus; private BigDecimal adoptionFee; - private Long employeeId; - private String employeeName; private String createdAt; private String updatedAt; - // Constructor for create/update requests - public AdoptionDTO(Long petId, Long customerId, String adoptionDate, String adoptionStatus, Long employeeId) { + public AdoptionDTO() {} + + public AdoptionDTO(Long petId, Long customerId, Long employeeId, Long sourceStoreId, + String adoptionDate, String adoptionStatus, BigDecimal adoptionFee) { this.petId = petId; this.customerId = customerId; + this.employeeId = employeeId; + this.sourceStoreId = sourceStoreId; this.adoptionDate = adoptionDate; this.adoptionStatus = adoptionStatus; - this.employeeId = employeeId; + this.adoptionFee = adoptionFee; } - public Long getAdoptionId() { + public Long getAdoptionId() { return adoptionId; } + public void setAdoptionId(Long adoptionId) { this.adoptionId = adoptionId; } - return adoptionId; - } + public Long getPetId() { return petId; } + public void setPetId(Long petId) { this.petId = petId; } - public Long getPetId() { + public String getPetName() { return petName; } + public void setPetName(String petName) { this.petName = petName; } - return petId; - } + public Long getCustomerId() { return customerId; } + public void setCustomerId(Long customerId) { this.customerId = customerId; } - public String getPetName() { + public String getCustomerName() { return customerName; } + public void setCustomerName(String customerName) { this.customerName = customerName; } - return petName; - } + public Long getEmployeeId() { return employeeId; } + public void setEmployeeId(Long employeeId) { this.employeeId = employeeId; } - public Long getCustomerId() { + public String getEmployeeName() { return employeeName; } + public void setEmployeeName(String employeeName) { this.employeeName = employeeName; } - return customerId; - } + public Long getSourceStoreId() { return sourceStoreId; } + public void setSourceStoreId(Long sourceStoreId) { this.sourceStoreId = sourceStoreId; } - public String getCustomerName() { + public String getSourceStoreName() { return sourceStoreName; } + public void setSourceStoreName(String sourceStoreName) { this.sourceStoreName = sourceStoreName; } - return customerName; - } + public String getAdoptionDate() { return adoptionDate; } + public void setAdoptionDate(String adoptionDate) { this.adoptionDate = adoptionDate; } - public String getAdoptionDate() { + public String getAdoptionStatus() { return adoptionStatus; } + public void setAdoptionStatus(String adoptionStatus) { this.adoptionStatus = adoptionStatus; } - return adoptionDate; - } + public BigDecimal getAdoptionFee() { return adoptionFee; } + public void setAdoptionFee(BigDecimal adoptionFee) { this.adoptionFee = adoptionFee; } - public String getEmployeeName() { + public String getCreatedAt() { return createdAt; } + public void setCreatedAt(String createdAt) { this.createdAt = createdAt; } - return employeeName; - } - - public Long getEmployeeId() { - return employeeId; - } - - public String getAdoptionStatus() { - - return adoptionStatus; - } - - public String getStatus() { - - return adoptionStatus; - } - - public BigDecimal getAdoptionFee() { - - return adoptionFee; - } - - public String getCreatedAt() { - - return createdAt; - } - - public String getUpdatedAt() { - - return updatedAt; - } - - - - -} \ No newline at end of file + public String getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(String updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/AppointmentDTO.java b/android/app/src/main/java/com/example/petstoremobile/dtos/AppointmentDTO.java index 1a472cd0..37f6640f 100644 --- a/android/app/src/main/java/com/example/petstoremobile/dtos/AppointmentDTO.java +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/AppointmentDTO.java @@ -1,10 +1,7 @@ package com.example.petstoremobile.dtos; -import java.math.BigDecimal; -import java.util.List; - public class AppointmentDTO { - // Response fields (from server) + private Long appointmentId; private Long customerId; private String customerName; @@ -12,143 +9,72 @@ public class AppointmentDTO { private String storeName; private Long serviceId; private String serviceName; - private Long employeeId; private String employeeName; private String appointmentDate; private String appointmentTime; private String appointmentStatus; - private List petNames; - private List petIds; - + private String petName; + private Long petId; private String createdAt; private String updatedAt; - - - // Constructor for CREATE/UPDATE request body - // Matches AppointmentRequest exactly public AppointmentDTO(Long customerId, Long storeId, Long serviceId, String appointmentDate, String appointmentTime, - String appointmentStatus, List petIds) { + String appointmentStatus, Long petId) { + this(customerId, storeId, serviceId, null, appointmentDate, appointmentTime, appointmentStatus, petId); + } + + public AppointmentDTO(Long customerId, Long storeId, Long serviceId, Long employeeId, + String appointmentDate, String appointmentTime, + String appointmentStatus, Long petId) { this.customerId = customerId; this.storeId = storeId; this.serviceId = serviceId; + this.employeeId = employeeId; this.appointmentDate = appointmentDate; this.appointmentTime = appointmentTime; this.appointmentStatus = appointmentStatus; - this.petIds = petIds; - this.employeeId = employeeId; + this.petId = petId; } - // Getters - public Long getAppointmentId() { + public Long getAppointmentId() { return appointmentId; } - return appointmentId; - } + public Long getCustomerId() { return customerId; } - public Long getCustomerId() { + public String getCustomerName() { return customerName; } - return customerId; - } + public Long getStoreId() { return storeId; } - public String getCustomerName() { + public String getStoreName() { return storeName; } - return customerName; - } + public Long getServiceId() { return serviceId; } - public Long getStoreId() { + public String getServiceName() { return serviceName; } - return storeId; - } + public Long getEmployeeId() { return employeeId; } - public String getStoreName() { + public String getEmployeeName() { return employeeName; } - return storeName; - } + public String getAppointmentDate() { return appointmentDate; } - public Long getServiceId() { + public String getAppointmentTime() { return appointmentTime; } - return serviceId; - } + public String getAppointmentStatus() { return appointmentStatus; } - public String getServiceName() { + public String getPetName() { return petName; } - return serviceName; - } + public Long getPetId() { return petId; } - public String getAppointmentDate() { + public String getCreatedAt() { return createdAt; } - return appointmentDate; - } + public String getUpdatedAt() { return updatedAt; } - public String getAppointmentTime() { + public Long getPetID() { return petId; } - return appointmentTime; - } - - public String getAppointmentStatus() { - - return appointmentStatus; - } - - public List getPetNames() { - - return petNames; - } - - public List getPetIds() { - - return petIds; - } - - - - public String getCreatedAt() { - - return createdAt; - } - - public String getUpdatedAt() { - - return updatedAt; - } - - // Convenience getters for adapter/list display - public String getPetName() { - return (petNames != null && !petNames.isEmpty()) ? petNames.get(0) : ""; - } - - public Long getPetID() { - - return (petIds != null && !petIds.isEmpty()) ? petIds.get(0) : null; - } - - public Long getPetId() { - - return getPetID(); - } - - // Keep old name so adapter doesn't break - public String getServiceType() { - return serviceName; - } - - public Long getServiceID() { - - return serviceId; - } - - public String getEmployeeName() { - - return employeeName; - } - - // Status alias - public String getStatus() { - - return appointmentStatus; - } + public String getServiceType() { return serviceName; } + public Long getServiceID() { return serviceId; } + public String getStatus() { return appointmentStatus; } } diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/AvatarUploadResponse.java b/android/app/src/main/java/com/example/petstoremobile/dtos/AvatarUploadResponse.java new file mode 100644 index 00000000..194be1f4 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/AvatarUploadResponse.java @@ -0,0 +1,25 @@ +package com.example.petstoremobile.dtos; + +public class AvatarUploadResponse { + private String avatarUrl; + private String message; + + public AvatarUploadResponse() { + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/BulkDeleteRequest.java b/android/app/src/main/java/com/example/petstoremobile/dtos/BulkDeleteRequest.java index 49f92f06..e53c8369 100644 --- a/android/app/src/main/java/com/example/petstoremobile/dtos/BulkDeleteRequest.java +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/BulkDeleteRequest.java @@ -3,20 +3,20 @@ package com.example.petstoremobile.dtos; import java.util.List; public class BulkDeleteRequest { - private List ids; + private List ids; public BulkDeleteRequest() { } - public BulkDeleteRequest(List ids) { + public BulkDeleteRequest(List ids) { this.ids = ids; } - public List getIds() { + public List getIds() { return ids; } - public void setIds(List ids) { + public void setIds(List ids) { this.ids = ids; } } diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/CustomerDTO.java b/android/app/src/main/java/com/example/petstoremobile/dtos/CustomerDTO.java index 178b0033..21376a63 100644 --- a/android/app/src/main/java/com/example/petstoremobile/dtos/CustomerDTO.java +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/CustomerDTO.java @@ -1,6 +1,9 @@ package com.example.petstoremobile.dtos; +import com.google.gson.annotations.SerializedName; + public class CustomerDTO { + @SerializedName("id") private Long customerId; private String firstName; private String lastName; @@ -12,18 +15,34 @@ public class CustomerDTO { return customerId; } + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + public String getFirstName() { return firstName; } + public void setFirstName(String firstName) { + this.firstName = firstName; + } + public String getLastName() { return lastName; } + public void setLastName(String lastName) { + this.lastName = lastName; + } + public String getEmail() { return email; } + public void setEmail(String email) { + this.email = email; + } + public String getFullName() { return firstName + " " + lastName; } @@ -32,7 +51,15 @@ public class CustomerDTO { return createdAt; } + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + public String getUpdatedAt() { return updatedAt; } -} \ No newline at end of file + + public void setUpdatedAt(String updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/InventoryDTO.java b/android/app/src/main/java/com/example/petstoremobile/dtos/InventoryDTO.java index fe2ec542..ddafd045 100644 --- a/android/app/src/main/java/com/example/petstoremobile/dtos/InventoryDTO.java +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/InventoryDTO.java @@ -6,6 +6,8 @@ public class InventoryDTO { private Long prodId; private String productName; private String categoryName; + private Long storeId; + private String storeName; private Integer quantity; private String createdAt; private String updatedAt; @@ -14,8 +16,9 @@ public class InventoryDTO { } // Constructor for create/update requests (matches InventoryRequest) - public InventoryDTO(Long prodId, Integer quantity) { + public InventoryDTO(Long prodId, Long storeId, Integer quantity) { this.prodId = prodId; + this.storeId = storeId; this.quantity = quantity; } @@ -51,6 +54,22 @@ public class InventoryDTO { this.categoryName = categoryName; } + public Long getStoreId() { + return storeId; + } + + public void setStoreId(Long storeId) { + this.storeId = storeId; + } + + public String getStoreName() { + return storeName; + } + + public void setStoreName(String storeName) { + this.storeName = storeName; + } + public Integer getQuantity() { return quantity; } diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/InventoryRequest.java b/android/app/src/main/java/com/example/petstoremobile/dtos/InventoryRequest.java deleted file mode 100644 index f84dfb5f..00000000 --- a/android/app/src/main/java/com/example/petstoremobile/dtos/InventoryRequest.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.petstoremobile.dtos; - -public class InventoryRequest { - private Long prodId; - private Integer quantity; - - public InventoryRequest() { - } - - public InventoryRequest(Long prodId, Integer quantity) { - this.prodId = prodId; - this.quantity = quantity; - } - - public Long getProdId() { - return prodId; - } - - public void setProdId(Long prodId) { - this.prodId = prodId; - } - - public Integer getQuantity() { - return quantity; - } - - public void setQuantity(Integer quantity) { - this.quantity = quantity; - } -} - diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/MessageDTO.java b/android/app/src/main/java/com/example/petstoremobile/dtos/MessageDTO.java index 1080b600..fea4cf66 100644 --- a/android/app/src/main/java/com/example/petstoremobile/dtos/MessageDTO.java +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/MessageDTO.java @@ -22,6 +22,15 @@ public class MessageDTO { @SerializedName("isRead") private Boolean isRead; + @SerializedName("attachmentUrl") + private String attachmentUrl; + + @SerializedName("attachmentName") + private String attachmentName; + + @SerializedName("attachmentType") + private String attachmentType; + public MessageDTO() {} public Long getId() { return id; } @@ -41,4 +50,13 @@ public class MessageDTO { public Boolean getIsRead() { return isRead; } public void setIsRead(Boolean isRead) { this.isRead = isRead; } + + public String getAttachmentUrl() { return attachmentUrl; } + public void setAttachmentUrl(String attachmentUrl) { this.attachmentUrl = attachmentUrl; } + + public String getAttachmentName() { return attachmentName; } + public void setAttachmentName(String attachmentName) { this.attachmentName = attachmentName; } + + public String getAttachmentType() { return attachmentType; } + public void setAttachmentType(String attachmentType) { this.attachmentType = attachmentType; } } \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/PetDTO.java b/android/app/src/main/java/com/example/petstoremobile/dtos/PetDTO.java index d76a8509..0e9a0b3f 100644 --- a/android/app/src/main/java/com/example/petstoremobile/dtos/PetDTO.java +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/PetDTO.java @@ -7,9 +7,13 @@ public class PetDTO { private String petBreed; private Integer petAge; private String petStatus; - private String petPrice; + private Double petPrice; private String createdAt; private String updatedAt; + private Long customerId; + private String customerName; + private Long storeId; + private String storeName; public Long getPetId() { return petId; } public void setPetId(Long petId) { this.petId = petId; } @@ -29,12 +33,24 @@ public class PetDTO { public String getPetStatus() { return petStatus; } public void setPetStatus(String petStatus) { this.petStatus = petStatus; } - public String getPetPrice() { return petPrice; } - public void setPetPrice(String petPrice) { this.petPrice = petPrice; } + public Double getPetPrice() { return petPrice; } + public void setPetPrice(Double petPrice) { this.petPrice = petPrice; } public String getCreatedAt() { return createdAt; } public void setCreatedAt(String createdAt) { this.createdAt = createdAt; } public String getUpdatedAt() { return updatedAt; } public void setUpdatedAt(String updatedAt) { this.updatedAt = updatedAt; } + + public Long getCustomerId() { return customerId; } + public void setCustomerId(Long customerId) { this.customerId = customerId; } + + public String getCustomerName() { return customerName; } + public void setCustomerName(String customerName) { this.customerName = customerName; } + + public Long getStoreId() { return storeId; } + public void setStoreId(Long storeId) { this.storeId = storeId; } + + public String getStoreName() { return storeName; } + public void setStoreName(String storeName) { this.storeName = storeName; } } diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/PurchaseOrderDTO.java b/android/app/src/main/java/com/example/petstoremobile/dtos/PurchaseOrderDTO.java index d7a392ea..813633c9 100644 --- a/android/app/src/main/java/com/example/petstoremobile/dtos/PurchaseOrderDTO.java +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/PurchaseOrderDTO.java @@ -4,6 +4,8 @@ public class PurchaseOrderDTO { private Long purchaseOrderId; private Long supId; private String supplierName; + private Long storeId; + private String storeName; private String orderDate; private String status; private String createdAt; @@ -21,6 +23,14 @@ public class PurchaseOrderDTO { return supplierName; } + public Long getStoreId() { + return storeId; + } + + public String getStoreName() { + return storeName; + } + public String getOrderDate() { return orderDate; } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/ChatFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/ChatFragment.java index 0e405c63..b13edd67 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/ChatFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/ChatFragment.java @@ -1,47 +1,53 @@ package com.example.petstoremobile.fragments; +import android.app.Activity; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; import android.os.Bundle; +import android.provider.OpenableColumns; import android.util.Log; import android.view.*; -import android.widget.*; +import android.view.inputmethod.EditorInfo; +import android.widget.Toast; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.core.view.GravityCompat; -import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; +import com.bumptech.glide.Glide; import com.example.petstoremobile.R; import com.example.petstoremobile.adapters.ChatAdapter; import com.example.petstoremobile.adapters.MessageAdapter; import com.example.petstoremobile.api.auth.TokenManager; -import com.example.petstoremobile.api.ChatApi; -import com.example.petstoremobile.api.CustomerApi; -import com.example.petstoremobile.api.MessageApi; -import com.example.petstoremobile.api.RetrofitClient; +import com.example.petstoremobile.databinding.FragmentChatBinding; import com.example.petstoremobile.dtos.ConversationDTO; -import com.example.petstoremobile.dtos.CustomerDTO; import com.example.petstoremobile.dtos.MessageDTO; -import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.dtos.SendMessageRequest; import com.example.petstoremobile.models.Chat; import com.example.petstoremobile.models.Message; import com.example.petstoremobile.services.ChatNotificationService; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.viewmodels.ChatViewModel; import com.example.petstoremobile.websocket.StompChatManager; -import java.util.*; -import java.util.stream.Collectors; -import retrofit2.*; +import java.util.*; + +import javax.inject.Inject; +import javax.inject.Named; + +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickListener, StompChatManager.MessageListener, StompChatManager.ConversationListener, StompChatManager.ConnectionListener { private static final String TAG = "ChatFragment"; - // View - private DrawerLayout drawerLayout; - private RecyclerView rvChatList, rvMessages; - private EditText etMessage; - private Button btnSend; - private TextView tvChatTitle; + private FragmentChatBinding binding; + private ChatViewModel viewModel; // Adapters private ChatAdapter chatAdapter; @@ -51,71 +57,108 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis private final List chatList = new ArrayList<>(); private final List messageList = new ArrayList<>(); private final Map customerNames = new HashMap<>(); + private Uri pendingAttachmentUri; - // APIs - private ChatApi chatApi; - private CustomerApi customerApi; - private MessageApi messageApi; + @Inject TokenManager tokenManager; + @Inject @Named("baseUrl") String baseUrl; // chat private Long currentUserId; private Long activeConversationId; private StompChatManager stompChatManager; + private ActivityResultLauncher attachmentLauncher; + /** + * Initializes the attachment launcher to handle file selection from the gallery. + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(ChatViewModel.class); + attachmentLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + Uri uri = result.getData().getData(); + if (uri != null) { + showAttachmentPreview(uri); + } + } + } + ); + } + + /** + * Inflates the layout, initializes UI components, and sets up click listeners for messaging. + */ @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_chat, container, false); + binding = FragmentChatBinding.inflate(inflater, container, false); - chatApi = RetrofitClient.getChatApi(requireContext()); - customerApi = RetrofitClient.getCustomerApi(requireContext()); - messageApi = RetrofitClient.getMessageApi(requireContext()); + binding.btnHamburger.setOnClickListener(v -> binding.chatDrawerLayout.openDrawer(GravityCompat.START)); - drawerLayout = view.findViewById(R.id.chatDrawerLayout); - rvChatList = view.findViewById(R.id.rvChatList); - rvMessages = view.findViewById(R.id.rvMessages); - etMessage = view.findViewById(R.id.etMessage); - btnSend = view.findViewById(R.id.btnSend); - tvChatTitle = view.findViewById(R.id.tvChatTitle); + // Set editor action listener for message field to send when enter is pressed + binding.etMessage.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_SEND || actionId == EditorInfo.IME_NULL) { + binding.btnSend.performClick(); + return true; + } + return false; + }); - ImageButton hamburger = view.findViewById(R.id.btnHamburger); - hamburger.setOnClickListener(v -> drawerLayout.openDrawer(GravityCompat.START)); - btnSend.setOnClickListener(v -> sendMessage()); + //When the send button is clicked check if there is an attachment and send using the correct helper function + binding.btnSend.setOnClickListener(v -> { + if (pendingAttachmentUri != null) { + sendWithAttachment(pendingAttachmentUri); + } else { + sendMessage(); + } + }); + + //When the attachment button is clicked open the file picker + binding.btnAttach.setOnClickListener(v -> selectAttachment()); + binding.btnRemoveAttachment.setOnClickListener(v -> removeAttachment()); setupRecyclerViews(); loadInitialData(); - return view; + return binding.getRoot(); } + /** + * Configures the RecyclerViews for the conversation list and the message history. + */ private void setupRecyclerViews() { // Set up Drawer menu to select conversation chatAdapter = new ChatAdapter(chatList, this); - rvChatList.setLayoutManager(new LinearLayoutManager(getContext())); - rvChatList.setAdapter(chatAdapter); + binding.rvChatList.setLayoutManager(new LinearLayoutManager(getContext())); + binding.rvChatList.setAdapter(chatAdapter); // set up RecyclerView for selected chat to show messages messageAdapter = new MessageAdapter(messageList, null); LinearLayoutManager lm = new LinearLayoutManager(getContext()); lm.setStackFromEnd(true); - rvMessages.setLayoutManager(lm); - rvMessages.setAdapter(messageAdapter); + binding.rvMessages.setLayoutManager(lm); + binding.rvMessages.setAdapter(messageAdapter); setConversationActive(false); } - //Helper function to load token and user id then connect to websocket + /** + * Loads authentication tokens and user info, then initializes the Stomp WebSocket connection. + */ private void loadInitialData() { - TokenManager tm = TokenManager.getInstance(requireContext()); - String token = tm.getToken(); - currentUserId = tm.getUserId(); - String role = tm.getRole(); + String token = tokenManager.getToken(); + currentUserId = tokenManager.getUserId(); + String role = tokenManager.getRole(); messageAdapter.setCurrentUserId(currentUserId); + messageAdapter.setToken(token); // if token exist then connect to websocket if (token != null) { - stompChatManager = new StompChatManager(token, role); + stompChatManager = new StompChatManager(token, role, baseUrl); stompChatManager.setMessageListener(this); stompChatManager.setConversationListener(this); stompChatManager.setConnectionListener(this); @@ -126,89 +169,74 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis if (getArguments() != null && getArguments().containsKey("conversation_id")) { activeConversationId = getArguments().getLong("conversation_id"); + } else if (getActivity() != null && getActivity().getIntent().hasExtra("conversation_id")) { + activeConversationId = getActivity().getIntent().getLongExtra("conversation_id", -1); + getActivity().getIntent().removeExtra("conversation_id"); + getActivity().getIntent().removeExtra("navigate_to"); } loadCustomers(); } - //Helper function to load customer names for it to be displayed on drawer menu + /** + * Fetches a list of customers from the ViewModel to display customer names for the chat list. + */ private void loadCustomers() { - customerApi.getAllCustomers(0, 100).enqueue(new Callback>() { - @Override - public void onResponse(@NonNull Call> call, - @NonNull Response> response) { - if (response.isSuccessful() && response.body() != null) { - for (CustomerDTO c : response.body().getContent()) { - customerNames.put(c.getCustomerId(), c.getFullName()); - } - } - loadConversations(); - } - - @Override - public void onFailure(@NonNull Call> call, - @NonNull Throwable t) { + viewModel.getAllCustomers(0, 100).observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) { + resource.data.getContent().forEach(c -> customerNames.put(c.getCustomerId(), c.getFullName())); loadConversations(); } }); } - //helper function to load conversations entities to display with customer names in drawer menu + /** + * Retrieves all conversations for the current user through the ViewModel and populates the chat drawer. + */ private void loadConversations() { - chatApi.getAllConversations().enqueue(new Callback>() { - @Override - public void onResponse(@NonNull Call> call, - @NonNull Response> response) { - if (response.isSuccessful() && response.body() != null) { - chatList.clear(); - List loaded = response.body().stream() - .map(dto -> { - String name = customerNames.getOrDefault( - dto.getCustomerId(), "Customer #" + dto.getCustomerId()); - return new Chat(String.valueOf(dto.getId()), - name, dto.getLastMessage(), - dto.getCustomerId(), dto.getStaffId()); - }) - .collect(Collectors.toList()); - chatList.addAll(loaded); - chatAdapter.notifyDataSetChanged(); - - if (activeConversationId != null) { - setConversationActive(true); - // Update title to customer name of active conversation - for (Chat chat : chatList) { - if (chat.getChatId().equals(String.valueOf(activeConversationId))) { - tvChatTitle.setText(chat.getCustomerName()); - break; - } - } - if (stompChatManager != null) { - stompChatManager.subscribeToConversation(activeConversationId); - } - loadMessageHistory(activeConversationId); - } else { - messageList.clear(); - messageAdapter.notifyDataSetChanged(); - setConversationActive(false); - } + viewModel.getAllConversations().observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) { + chatList.clear(); + for (ConversationDTO dto : resource.data) { + String name = customerNames.getOrDefault( + dto.getCustomerId(), "Customer #" + dto.getCustomerId()); + chatList.add(new Chat(String.valueOf(dto.getId()), + name, dto.getLastMessage(), + dto.getCustomerId(), dto.getStaffId())); + } + chatAdapter.notifyDataSetChanged(); + + if (activeConversationId != null) { + setConversationActive(true); + // Update title to customer name of active conversation + for (Chat chat : chatList) { + if (chat.getChatId().equals(String.valueOf(activeConversationId))) { + binding.tvChatTitle.setText(chat.getCustomerName()); + break; + } + } + if (stompChatManager != null) { + stompChatManager.subscribeToConversation(activeConversationId); + } + loadMessageHistory(activeConversationId); + } else { + messageList.clear(); + messageAdapter.notifyDataSetChanged(); + setConversationActive(false); } - } - @Override - public void onFailure(@NonNull Call> call, - @NonNull Throwable t) { - Log.e(TAG, "Error loading conversations", t); } }); } - // Called when user taps a chat in the drawer - // Loads messages for that chat selected + /** + * Handles selection of a chat from the drawer, updating the UI and subscribing to the WebSocket. + */ @Override public void onChatClick(Chat chat) { activeConversationId = Long.parseLong(chat.getChatId()); setConversationActive(true); - tvChatTitle.setText(chat.getCustomerName()); - drawerLayout.closeDrawer(GravityCompat.START); + binding.tvChatTitle.setText(chat.getCustomerName()); + binding.chatDrawerLayout.closeDrawer(GravityCompat.START); if (stompChatManager != null) { stompChatManager.subscribeToConversation(activeConversationId); @@ -217,63 +245,127 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis loadMessageHistory(activeConversationId); } - //helper function to load messages for selected chat + /** + * Fetches the full message history for a specific conversation from the ViewModel. + */ private void loadMessageHistory(Long conversationId) { - messageApi.getMessages(conversationId).enqueue(new Callback>() { - @Override - public void onResponse(@NonNull Call> call, - @NonNull Response> response) { - if (response.isSuccessful() && response.body() != null) { - messageList.clear(); - for (MessageDTO dto : response.body()) { - messageList.add(dtoToModel(dto)); - } - messageAdapter.notifyDataSetChanged(); - scrollToBottom(); + viewModel.getMessages(conversationId).observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) { + messageList.clear(); + for (MessageDTO dto : resource.data) { + messageList.add(dtoToModel(dto)); } - } - @Override - public void onFailure(@NonNull Call> call, - @NonNull Throwable t) { - Log.e(TAG, "Error loading messages", t); + messageAdapter.notifyDataSetChanged(); + scrollToBottom(); } }); } - //Helper function to send a message to the chat + /** + * Sends a plain text message to the currently active conversation through the ViewModel. + */ private void sendMessage() { //check if a chat is selected if (activeConversationId == null) return; //get the message from text field - String text = etMessage.getText().toString().trim(); + String text = binding.etMessage.getText().toString().trim(); if (text.isEmpty()) return; //clear text field after sending - etMessage.setText(""); + binding.etMessage.setText(""); - //calls api to send the message - messageApi.sendMessage(activeConversationId, new SendMessageRequest(text)) - .enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, - @NonNull Response response) { - if (response.isSuccessful() && response.body() != null) { - messageList.add(dtoToModel(response.body())); - messageAdapter.notifyItemInserted(messageList.size() - 1); - scrollToBottom(); - loadConversations(); - } - } - @Override - public void onFailure(@NonNull Call call, - @NonNull Throwable t) { - Log.e(TAG, "Send failed", t); - } - }); + //calls viewmodel to send the message + viewModel.sendMessage(activeConversationId, new SendMessageRequest(text)).observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) { + messageList.add(dtoToModel(resource.data)); + messageAdapter.notifyItemInserted(messageList.size() - 1); + scrollToBottom(); + loadConversations(); + } + }); } - // When a message is received updates the chat preview + /** + * Launches a file picker intent to select an attachment for the message. + */ + private void selectAttachment() { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("*/*"); + attachmentLauncher.launch(intent); + } + + /** + * Displays a preview of the selected attachment in the UI. + */ + private void showAttachmentPreview(Uri uri) { + pendingAttachmentUri = uri; + binding.layoutAttachmentPreview.setVisibility(View.VISIBLE); + + String mimeType = requireContext().getContentResolver().getType(uri); + String fileName = getFileName(uri); + binding.tvPreviewName.setText(fileName); + + // If the file is an image, display a thumbnail of the image as well + if (mimeType != null && mimeType.startsWith("image/")) { + binding.ivPreview.setVisibility(View.VISIBLE); + Glide.with(this).load(uri).into(binding.ivPreview); + } else { + binding.ivPreview.setVisibility(View.GONE); + } + } + + /** + * Clears the current attachment selection and hides the preview UI. + */ + private void removeAttachment() { + pendingAttachmentUri = null; + binding.layoutAttachmentPreview.setVisibility(View.GONE); + } + + /** + * Show the display name of the file from its Uri. + */ + private String getFileName(Uri uri) { + String result = null; + if (uri.getScheme().equals("content")) { + try (Cursor cursor = requireContext().getContentResolver().query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (index != -1) { + result = cursor.getString(index); + } + } + } + } + if (result == null) { + result = uri.getPath(); + int cut = result.lastIndexOf('/'); + if (cut != -1) { + result = result.substring(cut + 1); + } + } + return result; + } + + /** + * Handles sending a message that includes a file attachment via the ViewModel. + */ + private void sendWithAttachment(Uri uri) { + if (activeConversationId == null) return; + String text = binding.etMessage.getText().toString().trim(); + binding.etMessage.setText(""); + removeAttachment(); + + if (!text.isEmpty()) { + binding.etMessage.setText(text); + } + Toast.makeText(requireContext(), "File attachments are not supported", Toast.LENGTH_SHORT).show(); + } + + /** + * Callback triggered when a new message is received via the WebSocket. + */ @Override public void onMessageReceived(MessageDTO dto) { //if there is no active selected conversation or the message received is for another chat, then just update the preview of last message @@ -287,81 +379,102 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis //else add the message to the active chat if it's not from the current user messageList.add(dtoToModel(dto)); - messageAdapter.notifyItemInserted(messageList.size() - 1); - scrollToBottom(); + requireActivity().runOnUiThread(() -> { + messageAdapter.notifyItemInserted(messageList.size() - 1); + scrollToBottom(); + }); } - // When a new conversation is added, updates the chat preview + /** + * Callback triggered when a conversation is created or updated via the WebSocket. + */ @Override public void onConversationUpdated(ConversationDTO dto) { - boolean updated = false; - String name = customerNames.getOrDefault( - dto.getCustomerId(), "Customer #" + dto.getCustomerId()); + requireActivity().runOnUiThread(() -> { + boolean updated = false; + String name = customerNames.getOrDefault( + dto.getCustomerId(), "Customer #" + dto.getCustomerId()); - for (int i = 0; i < chatList.size(); i++) { - Chat existing = chatList.get(i); - if (existing.getChatId().equals(String.valueOf(dto.getId()))) { - chatList.set(i, new Chat( + for (int i = 0; i < chatList.size(); i++) { + Chat existing = chatList.get(i); + if (existing.getChatId().equals(String.valueOf(dto.getId()))) { + chatList.set(i, new Chat( + String.valueOf(dto.getId()), + name, + dto.getLastMessage(), + dto.getCustomerId(), + dto.getStaffId() + )); + chatAdapter.notifyItemChanged(i); + updated = true; + break; + } + } + + if (!updated) { + chatList.add(0, new Chat( String.valueOf(dto.getId()), name, dto.getLastMessage(), dto.getCustomerId(), dto.getStaffId() )); - chatAdapter.notifyItemChanged(i); - updated = true; - break; + chatAdapter.notifyItemInserted(0); } - } - if (!updated) { - chatList.add(0, new Chat( - String.valueOf(dto.getId()), - name, - dto.getLastMessage(), - dto.getCustomerId(), - dto.getStaffId() - )); - chatAdapter.notifyItemInserted(0); - } - - if (activeConversationId != null && activeConversationId.equals(dto.getId())) { - setConversationActive(true); - tvChatTitle.setText(name); - } + if (activeConversationId != null && activeConversationId.equals(dto.getId())) { + setConversationActive(true); + binding.tvChatTitle.setText(name); + } + }); } + /** + * Callback triggered when the WebSocket connection is successfully opened. + */ @Override public void onSocketOpened() { if (!isAdded()) { return; } - loadConversations(); - if (activeConversationId != null) { - loadMessageHistory(activeConversationId); - } + requireActivity().runOnUiThread(() -> { + loadConversations(); + if (activeConversationId != null) { + loadMessageHistory(activeConversationId); + } + }); } + /** + * Callback triggered when the WebSocket connection is closed. + */ @Override public void onSocketClosed() { if (!isAdded()) { return; } - loadConversations(); + requireActivity().runOnUiThread(this::loadConversations); } + /** + * Callback triggered when a WebSocket connection error occurs. + */ @Override public void onSocketError() { if (!isAdded()) { return; } - loadConversations(); - if (activeConversationId != null) { - loadMessageHistory(activeConversationId); - } + requireActivity().runOnUiThread(() -> { + loadConversations(); + if (activeConversationId != null) { + loadMessageHistory(activeConversationId); + } + }); } - // Helper function to convert DTO to message + /** + * Converts a MessageDTO into a Message object. + */ private Message dtoToModel(MessageDTO dto) { Message m = new Message(); m.setId(dto.getId()); @@ -370,64 +483,80 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis m.setContent(dto.getContent()); m.setTimestamp(dto.getTimestamp()); m.setIsRead(dto.getIsRead()); + m.setAttachmentUrl(dto.getAttachmentUrl()); + m.setAttachmentName(dto.getAttachmentName()); + m.setAttachmentType(dto.getAttachmentType()); return m; } - //Helper function to scroll to bottom of the chat + /** + * Scrolls the message history RecyclerView to the most recent message. + */ private void scrollToBottom() { if (!messageList.isEmpty()) { - rvMessages.post(() -> - rvMessages.smoothScrollToPosition(messageList.size() - 1)); + binding.rvMessages.post(() -> + binding.rvMessages.smoothScrollToPosition(messageList.size() - 1)); } } - // Helper function to update the chat preview last message + /** + * Updates the preview snippet of the last message for a specific conversation in the drawer. + */ private void updateConversationPreview(Long conversationId, String lastMessage) { if (conversationId == null) { return; } - for (int i = 0; i < chatList.size(); i++) { - Chat existing = chatList.get(i); - if (existing.getChatId().equals(String.valueOf(conversationId))) { - Chat updated = new Chat( - existing.getChatId(), - existing.getCustomerName(), - lastMessage, - existing.getCustomerId(), - existing.getStaffId() - ); - chatList.set(i, updated); - chatAdapter.notifyItemChanged(i); - return; + requireActivity().runOnUiThread(() -> { + for (int i = 0; i < chatList.size(); i++) { + Chat existing = chatList.get(i); + if (existing.getChatId().equals(String.valueOf(conversationId))) { + Chat updated = new Chat( + existing.getChatId(), + existing.getCustomerName(), + lastMessage, + existing.getCustomerId(), + existing.getStaffId() + ); + chatList.set(i, updated); + chatAdapter.notifyItemChanged(i); + return; + } } - } + }); } - //Helper function to enable or disable the send button when there is no active chat + /** + * Toggles the UI state based on whether a conversation is currently selected. + */ private void setConversationActive(boolean active) { - btnSend.setEnabled(active); - etMessage.setEnabled(active); + binding.btnSend.setEnabled(active); + binding.etMessage.setEnabled(active); + binding.btnAttach.setEnabled(active); if (!active) { activeConversationId = null; ChatNotificationService.activeConversationIdInUi = null; - if (tvChatTitle != null) tvChatTitle.setText("Customer Chat"); + removeAttachment(); + if (binding != null && binding.tvChatTitle != null) binding.tvChatTitle.setText("Customer Chat"); if (stompChatManager != null) { stompChatManager.clearConversationSubscription(); } messageList.clear(); messageAdapter.notifyDataSetChanged(); - etMessage.setText(""); - etMessage.setHint("Select a chat to start messaging"); + binding.etMessage.setText(""); + binding.etMessage.setHint("Select a chat to start messaging"); } else { - etMessage.setHint("Type a message..."); + binding.etMessage.setHint("Type a message..."); ChatNotificationService.activeConversationIdInUi = activeConversationId; } } - // When fragment is destroyed, disconnect from websocket + /** + * Disconnects the WebSocket manager when the fragment view is destroyed. + */ @Override public void onDestroyView() { super.onDestroyView(); + binding = null; ChatNotificationService.activeConversationIdInUi = null; if (stompChatManager != null) stompChatManager.disconnect(); } 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 93d6c18b..901a2af5 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 @@ -2,110 +2,74 @@ package com.example.petstoremobile.fragments; import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.view.GravityCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.Fragment; +import androidx.navigation.NavController; +import androidx.navigation.fragment.NavHostFragment; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.LinearLayout; import com.example.petstoremobile.R; - import com.example.petstoremobile.api.auth.TokenManager; -import com.example.petstoremobile.fragments.listfragments.PetFragment; -import com.example.petstoremobile.fragments.listfragments.ServiceFragment; -import com.example.petstoremobile.fragments.listfragments.StaffFragment; -import com.example.petstoremobile.fragments.listfragments.SupplierFragment; -import com.example.petstoremobile.fragments.listfragments.AdoptionFragment; -import com.example.petstoremobile.fragments.listfragments.AppointmentFragment; -import com.example.petstoremobile.fragments.listfragments.InventoryFragment; -import com.example.petstoremobile.fragments.listfragments.ProductFragment; -import com.example.petstoremobile.fragments.listfragments.ProductSupplierFragment; -import com.example.petstoremobile.fragments.listfragments.PurchaseOrderFragment; -import com.example.petstoremobile.fragments.listfragments.SaleFragment; -import com.example.petstoremobile.fragments.listfragments.AnalyticsFragment; +import com.example.petstoremobile.databinding.FragmentListBinding; +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; //The Fragment for the displaying the list of entities to be viewed +@AndroidEntryPoint public class ListFragment extends Fragment { - private DrawerLayout drawerLayout; - private LinearLayout drawerPets, drawerServices, drawerSuppliers; - private View touchBlocker; - - // Adoptions, Appointments, Inventory, Products - - private LinearLayout drawerAdoptions, drawerAppointments, drawerInventory, drawerProducts, drawerProductSupplier, drawerPurchaseOrderView, drawerSale; - - private LinearLayout drawerAnalytics; - - //Staff - - private LinearLayout drawerStaff; + private FragmentListBinding binding; + private NavController innerNavController; + @Inject TokenManager tokenManager; + /** + * Inflates the fragment layout, initializes navigation drawers, and applies role-based access control. + */ @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_list, container, false); - - //get controls from the layout - drawerLayout = view.findViewById(R.id.drawerLayout); - drawerPets = view.findViewById(R.id.drawerPets); - drawerServices = view.findViewById(R.id.drawerServices); - drawerSuppliers = view.findViewById(R.id.drawerSuppliers); - drawerAdoptions = view.findViewById(R.id.drawerAdoptions); - drawerAppointments = view.findViewById(R.id.drawerAppointments); - drawerInventory = view.findViewById(R.id.drawerInventory); - drawerProducts = view.findViewById(R.id.drawerProducts); - drawerProductSupplier=view.findViewById(R.id.drawerProductSupplier); - drawerSale=view.findViewById(R.id.drawerSale); - drawerAnalytics = view.findViewById(R.id.drawerAnalytics); - drawerPurchaseOrderView=view.findViewById(R.id.drawerPurchaseOrderView); - drawerStaff = view.findViewById(R.id.drawerStaff); - + binding = FragmentListBinding.inflate(inflater, container, false); // Check user role and restrict access for STAFF - String role = TokenManager.getInstance(requireContext()).getRole(); + String role = tokenManager.getRole(); if ("STAFF".equalsIgnoreCase(role)) { - drawerSuppliers.setVisibility(View.GONE); - drawerInventory.setVisibility(View.GONE); - } - - //needed to disable touches on the innerContainer while the drawer is open - touchBlocker = view.findViewById(R.id.touchBlocker); - - //Display pets fragment by default - if (savedInstanceState == null) { - loadFragment(new PetFragment()); + binding.drawerSuppliers.setVisibility(View.GONE); + binding.drawerInventory.setVisibility(View.GONE); } // Only show for ADMIN if ("ADMIN".equalsIgnoreCase(role)) { - drawerStaff.setVisibility(View.VISIBLE); + binding.drawerStaff.setVisibility(View.VISIBLE); } else { - drawerStaff.setVisibility(View.GONE); + binding.drawerStaff.setVisibility(View.GONE); } //add Listeners to the drawer so user won't be able to interact with the innerContainer (the list fragments) //while the drawer is open - drawerLayout.addDrawerListener(new DrawerLayout.DrawerListener() { + binding.drawerLayout.addDrawerListener(new DrawerLayout.DrawerListener() { //When the drawer is opened, disable touches on the background @Override public void onDrawerOpened(View drawerView) { - touchBlocker.setVisibility(View.VISIBLE); - touchBlocker.setClickable(true); + binding.touchBlocker.setVisibility(View.VISIBLE); + binding.touchBlocker.setClickable(true); } //When the drawer is closed, enable touches again @Override public void onDrawerClosed(View drawerView) { - touchBlocker.setVisibility(View.GONE); - touchBlocker.setClickable(false); + binding.touchBlocker.setVisibility(View.GONE); + binding.touchBlocker.setClickable(false); } //unused methods @@ -116,99 +80,55 @@ public class ListFragment extends Fragment { }); // Click listeners for each drawer - //Pets - drawerPets.setOnClickListener(v -> { - loadFragment(new PetFragment()); - drawerLayout.closeDrawers(); - }); + binding.drawerPets.setOnClickListener(v -> navigateTo(R.id.nav_pet)); + binding.drawerServices.setOnClickListener(v -> navigateTo(R.id.nav_service)); + binding.drawerSuppliers.setOnClickListener(v -> navigateTo(R.id.nav_supplier)); + binding.drawerAdoptions.setOnClickListener(v -> navigateTo(R.id.nav_adoption)); + binding.drawerAppointments.setOnClickListener(v -> navigateTo(R.id.nav_appointment)); + binding.drawerInventory.setOnClickListener(v -> navigateTo(R.id.nav_inventory)); + binding.drawerProducts.setOnClickListener(v -> navigateTo(R.id.nav_product)); + binding.drawerProductSupplier.setOnClickListener(v -> navigateTo(R.id.nav_product_supplier)); + binding.drawerPurchaseOrderView.setOnClickListener(v -> navigateTo(R.id.nav_purchase_order)); + binding.drawerSale.setOnClickListener(v -> navigateTo(R.id.nav_sale)); + binding.drawerStaff.setOnClickListener(v -> navigateTo(R.id.nav_staff)); + binding.drawerAnalytics.setOnClickListener(v -> navigateTo(R.id.nav_analytics)); - //Services - drawerServices.setOnClickListener(v -> { - loadFragment(new ServiceFragment()); - drawerLayout.closeDrawers(); - }); - - //Suppliers - drawerSuppliers.setOnClickListener(v -> { - loadFragment(new SupplierFragment()); - drawerLayout.closeDrawers(); - }); - - //Adoptions - - drawerAdoptions.setOnClickListener(v -> { - loadFragment(new AdoptionFragment()); - drawerLayout.closeDrawers(); - }); - - //Appointment - drawerAppointments.setOnClickListener(v -> { - loadFragment(new AppointmentFragment()); - drawerLayout.closeDrawers(); - }); - - //Inventory - drawerInventory.setOnClickListener(v -> { - loadFragment(new InventoryFragment()); - drawerLayout.closeDrawers(); - }); - - //Products - drawerProducts.setOnClickListener(v -> { - loadFragment(new ProductFragment()); - drawerLayout.closeDrawers(); - }); - - //ProductSupplier - - drawerProductSupplier.setOnClickListener(v -> { - loadFragment(new ProductSupplierFragment()); - drawerLayout.closeDrawers(); - }); - - //Purchase - - drawerPurchaseOrderView.setOnClickListener(v -> { - loadFragment(new PurchaseOrderFragment()); - drawerLayout.closeDrawers(); - }); - - //Sale - - drawerSale.setOnClickListener(v -> { - loadFragment(new SaleFragment()); - drawerLayout.closeDrawers(); - }); - - //Analytics - - drawerAnalytics.setOnClickListener(v -> { - loadFragment(new AnalyticsFragment()); - drawerLayout.closeDrawers(); - }); - - // Click listener - drawerStaff.setOnClickListener(v -> { - loadFragment(new StaffFragment()); - drawerLayout.closeDrawers(); - }); - - - - return view; + return binding.getRoot(); } - //helper function to open the drawer + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + /** + * Initializes the NavController for the internal fragment container. + */ + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + NavHostFragment navHostFragment = (NavHostFragment) getChildFragmentManager() + .findFragmentById(R.id.inner_nav_host_fragment); + if (navHostFragment != null) { + innerNavController = navHostFragment.getNavController(); + } + } + + /** + * Navigates to a specific inner destination and closes all drawers. + */ + private void navigateTo(int destinationId) { + if (innerNavController != null) { + innerNavController.navigate(destinationId); + } + binding.drawerLayout.closeDrawers(); + } + + /** + * Programmatically opens the navigation drawer. + */ public void openDrawer() { - drawerLayout.openDrawer(GravityCompat.START); - } - - // helper function to load the fragment into the display - public void loadFragment(Fragment fragment) { - getChildFragmentManager() - .beginTransaction() - .replace(R.id.inner_fragment_container, fragment) - .addToBackStack(null) - .commit(); + binding.drawerLayout.openDrawer(GravityCompat.START); } } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java index c7253c70..af469295 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java @@ -1,186 +1,109 @@ package com.example.petstoremobile.fragments; -import android.Manifest; -import android.app.Activity; -import android.content.Intent; -import android.content.pm.PackageManager; import android.net.Uri; import android.os.Bundle; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; -import androidx.core.content.ContextCompat; -import androidx.core.content.FileProvider; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; -import android.provider.MediaStore; -import android.text.InputType; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.Button; import android.widget.EditText; -import android.widget.ImageView; -import android.widget.TextView; import android.widget.Toast; -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.example.petstoremobile.R; import com.example.petstoremobile.activities.MainActivity; -import com.example.petstoremobile.api.RetrofitClient; import com.example.petstoremobile.api.auth.AuthApi; import com.example.petstoremobile.api.auth.TokenManager; -import com.example.petstoremobile.dtos.ErrorResponse; +import com.example.petstoremobile.databinding.FragmentProfileBinding; import com.example.petstoremobile.dtos.UserDTO; import com.example.petstoremobile.services.ChatNotificationService; +import com.example.petstoremobile.utils.FileUtils; +import com.example.petstoremobile.utils.GlideUtils; +import com.example.petstoremobile.utils.ImagePickerHelper; import com.example.petstoremobile.utils.InputValidator; -import com.google.gson.Gson; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.UIUtils; +import com.example.petstoremobile.viewmodels.AuthViewModel; import java.io.File; -import java.io.FileOutputStream; -import java.io.InputStream; import java.util.HashMap; import java.util.Map; +import javax.inject.Inject; +import javax.inject.Named; + +import dagger.hilt.android.AndroidEntryPoint; import okhttp3.MediaType; import okhttp3.MultipartBody; import okhttp3.RequestBody; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; +/** + * Fragment that displays and allows editing of the user's profile information. + */ +@AndroidEntryPoint public class ProfileFragment extends Fragment { - //initialize the view/controls - private ImageView imgProfile; - private TextView tvProfileName, tvProfileEmail, tvProfilePhone, tvProfileRole; - private Uri photoUri; + private FragmentProfileBinding binding; private UserDTO currentUser; + private AuthViewModel viewModel; + private boolean hasImage = false; - //Initialize the launchers for camera and gallery - private ActivityResultLauncher galleryLauncher; - private ActivityResultLauncher cameraLauncher; - private ActivityResultLauncher permissionLauncher; + @Inject TokenManager tokenManager; + @Inject @Named("baseUrl") String baseUrl; - //Called when the fragment is created, sets up the launchers is set profile image + private ImagePickerHelper imagePickerHelper; + + /** + * Initializes activity launchers and the ImagePickerHelper for camera and gallary. + */ @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(AuthViewModel.class); - // Launcher to open gallery to select profile image - galleryLauncher = registerForActivityResult( - //open gallery - new ActivityResultContracts.StartActivityForResult(), - result -> { - //if the user selects an image and its not null - if (result.getResultCode() == Activity.RESULT_OK - && result.getData() != null) { - //get the selected image and set the image to the profile - Uri selectedImage = result.getData().getData(); - uploadAvatar(selectedImage); - } - } - ); + imagePickerHelper = new ImagePickerHelper(this, "profile_photo.jpg", new ImagePickerHelper.ImagePickerListener() { + @Override + public void onImagePicked(Uri uri) { + uploadAvatar(uri); + } - // Launcher for camera to open and capture profile image - cameraLauncher = registerForActivityResult( - //open camera - new ActivityResultContracts.TakePicture(), - success -> { - //if a photo is taken set the image profile to it otherwise do nothing - if (success) { - uploadAvatar(photoUri); - } - } - ); - - // Launcher to request camera permission - permissionLauncher = registerForActivityResult( - //ask user for camera permission - new ActivityResultContracts.RequestPermission(), - granted -> { - //if the permission is granted launch the camera - if (granted) { - launchCamera(); - } - else { - //if the permission is denied then tell the user to grant it - new AlertDialog.Builder(requireContext()) - .setTitle("Permission Permission Required") - .setMessage("Please grant camera permission to use this feature") - .setPositiveButton("Open Settings", (dialog, which) ->{ - //open the settings page to grant the permission when they click open settings - Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - intent.setData(Uri.fromParts("package", requireContext().getPackageName(), null)); - startActivity(intent); - }) - //close the dialog when the user clicks cancel - .setNegativeButton("Cancel", null) - .show(); - } - } - ); + @Override + public void onImageRemoved() { + deleteAvatar(); + } + }); } + /** + * Inflates the fragment layout and sets up listeners for profile. + */ @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_profile, container, false); - - //get all the controls from the view - imgProfile = view.findViewById(R.id.imgProfile); - tvProfileName = view.findViewById(R.id.tvProfileName); - tvProfileEmail = view.findViewById(R.id.tvProfileEmail); - tvProfilePhone = view.findViewById(R.id.tvProfilePhone); - tvProfileRole = view.findViewById(R.id.tvProfileRole); - Button btnChangePhoto = view.findViewById(R.id.btnChangePhoto); - Button btnEditEmail = view.findViewById(R.id.btnEditEmail); - Button btnEditPhone = view.findViewById(R.id.btnEditPhone); - Button btnLogout = view.findViewById(R.id.btnLogout); + binding = FragmentProfileBinding.inflate(inflater, container, false); //Load Profile Data from backend loadProfileData(); //Set up listeners for the buttons //Change photo button - btnChangePhoto.setOnClickListener(v -> { - //Show alert dialog to user to select from gallery or camera - new AlertDialog.Builder(requireContext()) - .setTitle("Change Profile Photo") - //set the options for the alert dialog - .setItems(new String[]{"Take Photo", "Choose from Gallery"}, (dialog, which) -> { - if (which == 0) { - // Choose Camera - //Checks if the user has granted the camera permission already - if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { - //if the permission is already granted then launch the camera - launchCamera(); - } else { - //otherwise request the permission - permissionLauncher.launch(Manifest.permission.CAMERA); - } - } else { - // Choose Gallery - Intent intent = new Intent(Intent.ACTION_PICK, - MediaStore.Images.Media.EXTERNAL_CONTENT_URI); - galleryLauncher.launch(intent); - } - }) - .show(); + binding.btnChangePhoto.setOnClickListener(v -> { + imagePickerHelper.showImagePickerDialog("Change Profile Photo", hasImage); }); //Edit email button //When clicked open a dialog to change email - btnEditEmail.setOnClickListener(v -> { + binding.btnEditEmail.setOnClickListener(v -> { //Make a text field for the user to enter the new email EditText input = new EditText(requireContext()); input.setPadding(30,30,30,30); - input.setText(tvProfileEmail.getText().toString()); + input.setText(binding.tvProfileEmail.getText().toString()); //set input type to email input.setInputType(android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS); @@ -203,18 +126,17 @@ public class ProfileFragment extends Fragment { //Edit phone button //When clicked open a dialog to change phone - btnEditPhone.setOnClickListener(v -> { + binding.btnEditPhone.setOnClickListener(v -> { //Make a text field for the user to enter the new email EditText input = new EditText(requireContext()); input.setPadding(30,30,30,30); - input.setText(tvProfilePhone.getText().toString()); + input.setText(binding.tvProfilePhone.getText().toString()); //set input type to phone number - input.setInputType(InputType.TYPE_CLASS_PHONE); + input.setInputType(android.view.inputmethod.EditorInfo.TYPE_CLASS_PHONE); - //add canada phone number formatting to input (XXX) XXX-XXXX - input.addTextChangedListener(new android.telephony.PhoneNumberFormattingTextWatcher("CA")); - input.setFilters(new android.text.InputFilter[]{new android.text.InputFilter.LengthFilter(14)}); + //add canada phone number formatting to input + UIUtils.formatPhoneInput(input); //Show alert dialog to user to enter new phone @@ -233,93 +155,71 @@ public class ProfileFragment extends Fragment { }); //Logout button - btnLogout.setOnClickListener(v -> { + binding.btnLogout.setOnClickListener(v -> { // Stop notification service before logging out so notifications stop - Intent serviceIntent = new Intent(requireContext(), ChatNotificationService.class); + android.content.Intent serviceIntent = new android.content.Intent(requireContext(), ChatNotificationService.class); requireContext().stopService(serviceIntent); - TokenManager.getInstance(requireContext()).clearLoginData(); // clear the token for next login + tokenManager.clearLoginData(); // clear the token for next login //get the intent to the main activity and clear the back stack so the back button won't allow the user to go back to the previous screen - Intent intent = new Intent(getActivity(), MainActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); + android.content.Intent intent = new android.content.Intent(getActivity(), MainActivity.class); + intent.addFlags(android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP | android.content.Intent.FLAG_ACTIVITY_NEW_TASK); //start the activity to go to login page and finish the current activity startActivity(intent); requireActivity().finish(); }); - return view; + return binding.getRoot(); } - //Helper function create a file in the cache directory to store the photo in then launch the camera to capture the photo - private void launchCamera() { - //create a file in the cache directory to store the photo in - File photoFile = new File(requireContext().getCacheDir(), "profile_photo.jpg"); - //get the uri for the file made - photoUri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".fileprovider", photoFile); - //launch the camera to capture the photo and save the photo to photoUri - cameraLauncher.launch(photoUri); + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; } - //Helper function to call the backend to get profile data and load it to the view + /** + * Fetches current user profile data from the API and then updates the UI. + */ private void loadProfileData() { - AuthApi authApi = RetrofitClient.getAuthApi(requireContext()); + viewModel.getMe().observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + currentUser = resource.data; - authApi.getMe().enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - //if the response is successful and the body is not null then set the user to the view - if (response.isSuccessful() && response.body() != null) { - currentUser = response.body(); + //set the user data to the view + binding.tvProfileName.setText(currentUser.getFullName()); + binding.tvProfileEmail.setText(currentUser.getEmail()); + binding.tvProfilePhone.setText(currentUser.getPhone()); + binding.tvProfileRole.setText(currentUser.getRole()); - //set the user data to the view - tvProfileName.setText(currentUser.getFullName()); - tvProfileEmail.setText(currentUser.getEmail()); - tvProfilePhone.setText(currentUser.getPhone()); - tvProfileRole.setText(currentUser.getRole()); + // get the avatar endpoint to load profile image and the token for authorization + String avatarUrl = baseUrl + AuthApi.AVATAR_FILE_PATH; + String token = tokenManager.getToken(); - // get the avatar endpoint to load profile image and the token for authorization - String avatarUrl = RetrofitClient.BASE_URL + AuthApi.AVATAR_FILE_PATH; - String token = TokenManager.getInstance(requireContext()).getToken(); - - if (token != null) { - // Create GlideUrl with token to fetch the image - GlideUrl glideUrl = new GlideUrl(avatarUrl, new LazyHeaders.Builder() - .addHeader("Authorization", "Bearer " + token) - .build()); - - // Load image using Glide - Glide.with(ProfileFragment.this) - .load(glideUrl) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .skipMemoryCache(true) - .placeholder(R.drawable.placeholder) - .error(R.drawable.placeholder) - .into(imgProfile); - } else { - // load placeholder image if token is null - Glide.with(ProfileFragment.this) - .load(R.drawable.placeholder) - .into(imgProfile); + GlideUtils.loadImageWithToken(requireContext(), binding.imgProfile, avatarUrl, token, R.drawable.placeholder, new GlideUtils.ImageLoadListener() { + @Override + public void onResourceReady() { + hasImage = true; } - } - else { - Log.e("onResponse: ", response.message()); - Toast.makeText(getContext(), "Failed to load profile: ", Toast.LENGTH_SHORT).show(); - } - } - @Override - public void onFailure(Call call, Throwable t) { - Log.e("PROFILE", "onFailure: " + t.getMessage()); - Toast.makeText(getContext(), "Network error: could not load profile", Toast.LENGTH_SHORT).show(); + @Override + public void onLoadFailed() { + hasImage = false; + } + }); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Failed to load profile: " + resource.message, Toast.LENGTH_SHORT).show(); } }); } - //Helper function to call the backend to upload a profile image + /** + * Uploads the selected or captured image as the user's new avatar. + */ private void uploadAvatar(Uri uri) { try { - File file = getFileFromUri(uri); + File file = FileUtils.getFileFromUri(requireContext(), uri); if (file == null) return; // Create RequestBody for file upload @@ -327,24 +227,13 @@ public class ProfileFragment extends Fragment { MultipartBody.Part body = MultipartBody.Part.createFormData("avatar", file.getName(), requestFile); //Call the backend to upload the avatar - AuthApi authApi = RetrofitClient.getAuthApi(requireContext()); - authApi.uploadAvatar(body).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful() && response.body() != null) { - currentUser = response.body(); - Toast.makeText(requireContext(), "Avatar updated successfully", Toast.LENGTH_SHORT).show(); - // Reload image after successful upload - loadProfileData(); - } else { - Toast.makeText(requireContext(), "Failed to upload avatar", Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - Log.e("UPLOAD_AVATAR", "Failure: " + t.getMessage()); - Toast.makeText(requireContext(), "Network error", Toast.LENGTH_SHORT).show(); + viewModel.uploadAvatar(body).observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), "Avatar updated successfully", Toast.LENGTH_SHORT).show(); + loadProfileData(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Upload failed: " + resource.message, Toast.LENGTH_SHORT).show(); } }); } catch (Exception e) { @@ -352,58 +241,39 @@ public class ProfileFragment extends Fragment { } } - // Helper function to create a temporary File object from a Uri for uploading the avatar - private File getFileFromUri(Uri uri) { - try { - InputStream inputStream = requireContext().getContentResolver().openInputStream(uri); - File tempFile = new File(requireContext().getCacheDir(), "upload_avatar.jpg"); - FileOutputStream outputStream = new FileOutputStream(tempFile); - byte[] buffer = new byte[1024]; - int length; - while ((length = inputStream.read(buffer)) > 0) { - outputStream.write(buffer, 0, length); + /** + * Sends a request to the API to delete the current user's avatar image. + */ + private void deleteAvatar() { + viewModel.deleteAvatar().observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + if (resource.status == Resource.Status.SUCCESS) { + hasImage = false; + binding.imgProfile.setImageResource(R.drawable.placeholder); + Toast.makeText(getContext(), "Avatar removed successfully", Toast.LENGTH_SHORT).show(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Removal failed: " + resource.message, Toast.LENGTH_SHORT).show(); } - outputStream.close(); - inputStream.close(); - return tempFile; - } catch (Exception e) { - Log.e("FILE_UTILS", "Error creating temp file", e); - return null; - } + }); } - //Helper function to update a profile field in the backend + /** + * Updates a specific profile field (like email or phone) by sending a request to the API. + */ private void updateProfileField(String fieldName, String value) { - AuthApi authApi = RetrofitClient.getAuthApi(requireContext()); Map updates = new HashMap<>(); updates.put(fieldName, value); - authApi.updateMe(updates).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful() && response.body() != null) { - currentUser = response.body(); - // Update the view with the new data from backend - tvProfileEmail.setText(currentUser.getEmail()); - tvProfilePhone.setText(currentUser.getPhone()); - Toast.makeText(requireContext(), "Profile updated successfully", Toast.LENGTH_SHORT).show(); - } else { - try { - String errorJson = response.errorBody().string(); - ErrorResponse errorResponse = new Gson().fromJson(errorJson, ErrorResponse.class); - String errorMessage = errorResponse.getMessage(); - Toast.makeText(requireContext(), errorMessage, Toast.LENGTH_LONG).show(); - } catch (Exception e) { - Log.e("UPDATE_PROFILE", "Error parsing error body", e); - Toast.makeText(requireContext(), "Failed to update profile", Toast.LENGTH_SHORT).show(); - } - } - } - - @Override - public void onFailure(Call call, Throwable t) { - Log.e("UPDATE_PROFILE", "Failure: " + t.getMessage()); - Toast.makeText(requireContext(), "Network error", Toast.LENGTH_SHORT).show(); + viewModel.updateMe(updates).observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + currentUser = resource.data; + Toast.makeText(getContext(), "Profile updated successfully", Toast.LENGTH_SHORT).show(); + // Update the view with the new data from backend + binding.tvProfileEmail.setText(currentUser.getEmail()); + binding.tvProfilePhone.setText(currentUser.getPhone()); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Update failed: " + resource.message, Toast.LENGTH_SHORT).show(); } }); } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AppointmentFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AppointmentFragment.java index f8aa734f..f7aed714 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AppointmentFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AppointmentFragment.java @@ -1,259 +1,374 @@ package com.example.petstoremobile.fragments.listfragments; +import android.graphics.Color; import android.os.Bundle; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + import android.text.Editable; import android.text.TextWatcher; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.EditText; -import android.widget.ImageButton; import android.widget.Toast; import com.example.petstoremobile.R; import com.example.petstoremobile.adapters.AppointmentAdapter; -import com.example.petstoremobile.api.AppointmentApi; -import com.example.petstoremobile.api.PetApi; -import com.example.petstoremobile.api.ServiceApi; -import com.example.petstoremobile.api.RetrofitClient; +import com.example.petstoremobile.databinding.FragmentAppointmentBinding; import com.example.petstoremobile.dtos.AppointmentDTO; -import com.example.petstoremobile.dtos.ServiceDTO; -import com.example.petstoremobile.dtos.PageResponse; -import com.example.petstoremobile.dtos.PetDTO; +import com.example.petstoremobile.dtos.StoreDTO; import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.detailfragments.AppointmentDetailFragment; -import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.example.petstoremobile.utils.BulkDeleteHandler; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.SpinnerUtils; +import com.example.petstoremobile.viewmodels.AppointmentViewModel; +import com.example.petstoremobile.utils.EventDecorator; +import com.example.petstoremobile.viewmodels.AuthViewModel; +import com.example.petstoremobile.viewmodels.StoreViewModel; +import com.prolificinteractive.materialcalendarview.CalendarDay; +import com.prolificinteractive.materialcalendarview.CalendarMode; +import java.text.ParseException; +import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.HashSet; import java.util.List; +import java.util.Locale; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; +import dagger.hilt.android.AndroidEntryPoint; +@AndroidEntryPoint public class AppointmentFragment extends Fragment implements AppointmentAdapter.OnAppointmentClickListener { + private FragmentAppointmentBinding binding; private List appointmentList = new ArrayList<>(); - private List filteredList = new ArrayList<>(); - private List petList = new ArrayList<>(); - private List serviceList = new ArrayList<>(); + private List storeList = new ArrayList<>(); private AppointmentAdapter adapter; - private AppointmentApi api; - private SwipeRefreshLayout swipeRefreshLayout; - private EditText etSearch; - private ImageButton hamburger; + private AppointmentViewModel appointmentViewModel; + private StoreViewModel storeViewModel; + private AuthViewModel authViewModel; + private BulkDeleteHandler bulkDeleteHandler; + private CalendarDay selectedCalendarDay; + private boolean isMonthMode = false; + private Long currentUserId = null; + private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()); + + /** + * Initializes the fragment and its associated ViewModels. + */ @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + appointmentViewModel = new ViewModelProvider(this).get(AppointmentViewModel.class); + storeViewModel = new ViewModelProvider(this).get(StoreViewModel.class); + authViewModel = new ViewModelProvider(this).get(AuthViewModel.class); + } + + /** + * Sets up the fragment's UI, including RecyclerView, search, swipe-to-refresh, and calendar. + */ + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_appointment, container, false); + binding = FragmentAppointmentBinding.inflate(inflater, container, false); - api = RetrofitClient.getAppointmentApi(requireContext()); - hamburger = view.findViewById(R.id.btnHamburger); + setupRecyclerView(); + setupSearch(); + setupStatusFilter(); + setupStoreFilter(); + setupSwipeRefresh(); + setupCalendar(); + setupFilterToggle(); + setupMyAppointmentFilter(); + setupBulkDelete(); - setupRecyclerView(view); - setupSearch(view); - setupSwipeRefresh(view); - loadAppointmentData(); - loadPets(); - loadServices(); + binding.fabAddAppointment.setOnClickListener(v -> openAppointmentDetails(-1)); - - FloatingActionButton fabAdd = view.findViewById(R.id.fabAddAppointment); - fabAdd.setOnClickListener(v -> openAppointmentDetails(-1)); - - hamburger.setOnClickListener(v -> { - ListFragment listFragment = (ListFragment) getParentFragment(); - if (listFragment != null) - listFragment.openDrawer(); - }); - - return view; - } - - private void setupSearch(View view) { - etSearch = view.findViewById(R.id.etSearchAppointment); - etSearch.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - filterAppointments(s.toString()); - } - - @Override - public void afterTextChanged(Editable s) { - } - }); - } - - private void filterAppointments(String query) { - filteredList.clear(); - if (query.isEmpty()) { - filteredList.addAll(appointmentList); - } else { - String lower = query.toLowerCase(); - for (AppointmentDTO a : appointmentList) { - if ((a.getCustomerName() != null && a.getCustomerName().toLowerCase().contains(lower)) - || (a.getServiceType() != null && a.getServiceType().toLowerCase().contains(lower)) - || (a.getPetName() != null && a.getPetName().toLowerCase().contains(lower))) { - filteredList.add(a); + binding.btnHamburger.setOnClickListener(v -> { + Fragment parent = getParentFragment(); + if (parent != null) { + Fragment grandParent = parent.getParentFragment(); + if (grandParent instanceof ListFragment) { + ((ListFragment) grandParent).openDrawer(); } } + }); + + binding.btnToggleCalendarMode.setOnClickListener(v -> toggleCalendarMode()); + + loadCurrentUserInfo(); + + return binding.getRoot(); + } + + private void setupBulkDelete() { + bulkDeleteHandler = new BulkDeleteHandler( + this, + binding.layoutBulkDelete, + binding.tvSelectionCount, + binding.btnBulkDelete, + adapter, + "appointment", + appointmentViewModel::bulkDeleteAppointments, + this::loadAppointmentData + ); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + @Override + public void onResume() { + super.onResume(); + loadAppointmentData(); + loadStoreData(); + } + + /** + * Toggles the calendar between week and month display modes. + */ + private void toggleCalendarMode() { + isMonthMode = !isMonthMode; + binding.calendarView.state().edit() + .setCalendarDisplayMode(isMonthMode ? CalendarMode.MONTHS : CalendarMode.WEEKS) + .commit(); + } + + /** + * Sets up the "My Appointments" filter button. + */ + private void setupMyAppointmentFilter() { + binding.btnMyAppointments.setOnClickListener(v -> { + loadAppointmentData(); + }); + } + + /** + * Fetches current user info to get the employeeId. + */ + private void loadCurrentUserInfo() { + authViewModel.getMe().observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + currentUserId = resource.data.getId(); + } + }); + } + + /** + * Sets up the filter toggle button to show/hide the filter layout. + */ + private void setupFilterToggle() { + binding.btnToggleFilter.setOnClickListener(v -> { + if (binding.layoutFilter.getVisibility() == View.GONE) { + binding.layoutFilter.setVisibility(View.VISIBLE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + binding.layoutFilter.setVisibility(View.GONE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_search); + + // Reset filters when closing + binding.etSearchAppointment.setText(""); + binding.spinnerStatus.setSelection(0); + binding.spinnerStore.setSelection(0); + binding.btnMyAppointments.setChecked(false); + selectedCalendarDay = null; + binding.calendarView.clearSelection(); + } + }); + } + + /** + * Sets up the date selection listener for the calendar. + */ + private void setupCalendar() { + binding.calendarView.setOnDateChangedListener((widget, date, selected) -> { + if (selected) { + if (date.equals(selectedCalendarDay)) { + selectedCalendarDay = null; + binding.calendarView.clearSelection(); + } else { + selectedCalendarDay = date; + } + } else { + selectedCalendarDay = null; + } + loadAppointmentData(); + }); + } + + /** + * Updates calendar indicators to highlight dates that have scheduled appointments. + */ + private void updateCalendarDecorators() { + HashSet datesWithAppointments = new HashSet<>(); + for (AppointmentDTO appointment : appointmentList) { + try { + //Get the appointment date + Date date = dateFormat.parse(appointment.getAppointmentDate()); + //if the date is not null, add it to the hashset + if (date != null) { + Calendar cal = Calendar.getInstance(); + cal.setTime(date); + datesWithAppointments.add(CalendarDay.from(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH))); + } + } catch (ParseException e) { + Log.e("AppointmentFragment", "Error parsing date: " + appointment.getAppointmentDate()); + } } - adapter.notifyDataSetChanged(); + //update the indicators to the calendar + binding.calendarView.removeDecorators(); + binding.calendarView.addDecorator(new EventDecorator(Color.RED, datesWithAppointments)); } - private void setupSwipeRefresh(View view) { - swipeRefreshLayout = view.findViewById(R.id.swipeRefreshAppointment); - swipeRefreshLayout.setOnRefreshListener(this::loadAppointmentData); + /** + * Configures the search bar for filtering. + */ + private void setupSearch() { + binding.etSearchAppointment.addTextChangedListener(new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) { + loadAppointmentData(); + } + @Override public void afterTextChanged(Editable s) {} + }); } + /** + * Configures the status filter spinner. + */ + private void setupStatusFilter() { + String[] statuses = {"All Statuses", "Booked", "Completed", "Cancelled", "Missed"}; + SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatus, statuses, this::loadAppointmentData); + } + + /** + * Configures the store filter spinner. + */ + private void setupStoreFilter() { + SpinnerUtils.setupFilterSpinner(binding.spinnerStore, this::loadAppointmentData); + } + + /** + * Fetches store data to populate the store filter. + */ + private void loadStoreData() { + storeViewModel.getAllStores(0, 100).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + storeList = resource.data.getContent(); + SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerStore, storeList, + StoreDTO::getStoreName, "All Stores", -1L, StoreDTO::getStoreId); + } + }); + } + + /** + * Initializes the SwipeRefreshLayout to allow manual data refreshing. + */ + private void setupSwipeRefresh() { + binding.swipeRefreshAppointment.setOnRefreshListener(this::loadAppointmentData); + } + + /** + * Navigates to the appointment detail screen for editing or creating an appointment. + */ private void openAppointmentDetails(int position) { - AppointmentDetailFragment detailFragment = new AppointmentDetailFragment(); Bundle args = new Bundle(); - if (position != -1) { - AppointmentDTO a = filteredList.get(position); + AppointmentDTO a = appointmentList.get(position); args.putLong("appointmentId", a.getAppointmentId()); - args.putString("appointmentDate", a.getAppointmentDate()); - args.putString("appointmentTime", a.getAppointmentTime()); - args.putString("appointmentStatus", a.getAppointmentStatus()); - // IDs for pre-selecting spinners - if (a.getPetID() != null) args.putLong("petId", a.getPetID()); - if (a.getServiceId() != null) args.putLong("serviceId", a.getServiceId()); - if (a.getCustomerId() != null) args.putLong("customerId", a.getCustomerId()); - if (a.getStoreId() != null) args.putLong("storeId", a.getStoreId()); } - - detailFragment.setArguments(args); - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.loadFragment(detailFragment); - } - public void onAppointmentSaved(int position, AppointmentDTO appointment) { - if (position == -1) { - appointmentList.add(appointment); - } else { - appointmentList.set(position, appointment); - } - filterAppointments(etSearch.getText().toString()); - } - - public void onAppointmentDeleted(int position) { - appointmentList.remove(position); - filterAppointments(etSearch.getText().toString()); + NavHostFragment.findNavController(this).navigate(R.id.nav_appointment_detail, args); } + /** + * Handles item click in the appointment list. + */ @Override public void onAppointmentClick(int position) { openAppointmentDetails(position); } + @Override + public void onSelectionChanged(int count) { + if (bulkDeleteHandler != null) { + bulkDeleteHandler.onSelectionChanged(count); + } + } + + /** + * Fetches appointment data from the server with all active filters. + */ private void loadAppointmentData() { - if (swipeRefreshLayout != null) - swipeRefreshLayout.setRefreshing(true); - api.getAllAppointments(0, 100).enqueue(new Callback>() { - @Override - public void onResponse(Call> call, - Response> response) { - if (swipeRefreshLayout != null) - swipeRefreshLayout.setRefreshing(false); - if (response.isSuccessful() && response.body() != null) { - appointmentList.clear(); - appointmentList.addAll(response.body().getContent()); - filterAppointments(etSearch != null ? etSearch.getText().toString() : ""); - } else { - Log.e("AppointmentFragment", "Error: " + response.message()); - Toast.makeText(getContext(), "Failed to load appointments", Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call> call, Throwable t) { - if (swipeRefreshLayout != null) - swipeRefreshLayout.setRefreshing(false); - Toast.makeText(getContext(), "Network error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); - Log.e("AppointmentFragment", t.getMessage()); - } - }); - } - - - - // Load Pets - private void loadPets() { - PetApi petApi = RetrofitClient.getPetApi(requireContext()); - petApi.getAllPets(0,100).enqueue(new Callback>() { - - @Override - public void onResponse(Call> call, Response> response) { - if (response.isSuccessful() && response.body() !=null) { - petList.clear(); - petList.addAll(response.body().getContent()); - } - } - - @Override - public void onFailure(Call> call, Throwable t) { - - Log.e("AppointmentFragment", "Pet load error:" + t.getMessage()); - - } - }); - } - - // Load Services - - private void loadServices() { - ServiceApi serviceApi = RetrofitClient.getServiceApi(requireContext()); - - serviceApi.getAllServices(0,100).enqueue(new Callback>() { - @Override - public void onResponse(Call> call, Response> response) { - if (response.isSuccessful() && response.body() != null) { - serviceList.clear(); - serviceList.addAll(response.body().getContent()); - - } - } - - @Override - public void onFailure(Call> call, Throwable t) { - Log.e("AppointmentFragmnet", "Service load error: " + t.getMessage()); - - } - }); - } - - private String getPetName(Long id) { - for (PetDTO p : petList) { - if (p.getPetId().equals(id)) return p.getPetName(); - + String query = binding.etSearchAppointment.getText().toString().trim(); + String status = binding.spinnerStatus.getSelectedItem() != null ? binding.spinnerStatus.getSelectedItem().toString() : "All Statuses"; + + Long storeId = null; + if (binding.spinnerStore.getSelectedItemPosition() > 0 && !storeList.isEmpty()) { + storeId = storeList.get(binding.spinnerStore.getSelectedItemPosition() - 1).getStoreId(); } - return ""; - } - private String getServiceName(Long id) { - for (ServiceDTO s : serviceList) { - if (s.getServiceId().equals(id))return s.getServiceName(); + String selectedDateString = null; + if (selectedCalendarDay != null) { + selectedDateString = String.format(Locale.getDefault(), "%04d-%02d-%02d", + selectedCalendarDay.getYear(), selectedCalendarDay.getMonth(), selectedCalendarDay.getDay()); } - return ""; + + Long employeeId = null; + if (binding.btnMyAppointments.isChecked()) { + employeeId = currentUserId; + } + + if (status.equals("All Statuses")) status = null; + else status = status.toUpperCase(); + + appointmentViewModel.getAllAppointments(0, 500, query, status, storeId, selectedDateString, employeeId).observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + + // Check the status to see if the resource is loaded and display the data + switch (resource.status) { + case LOADING: + // Show loading indicator + binding.swipeRefreshAppointment.setRefreshing(true); + break; + case SUCCESS: + // Hide loading indicator and display data + binding.swipeRefreshAppointment.setRefreshing(false); + if (resource.data != null) { + appointmentList.clear(); + appointmentList.addAll(resource.data.getContent()); + updateCalendarDecorators(); + adapter.notifyDataSetChanged(); + } + break; + case ERROR: + // Hide loading indicator and toast error message + binding.swipeRefreshAppointment.setRefreshing(false); + Toast.makeText(getContext(), "Failed to load appointments: " + resource.message, Toast.LENGTH_SHORT).show(); + Log.e("AppointmentFragment", "Error loading appointments: " + resource.message); + break; + } + }); } - private void setupRecyclerView(View view) { - RecyclerView recyclerView = view.findViewById(R.id.recyclerViewAppointments); - adapter = new AppointmentAdapter(filteredList, this); - recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - recyclerView.setAdapter(adapter); + /** + * Initializes the RecyclerView for displaying appointments. + */ + private void setupRecyclerView() { + adapter = new AppointmentAdapter(appointmentList, this); + binding.recyclerViewAppointments.setLayoutManager(new LinearLayoutManager(getContext())); + binding.recyclerViewAppointments.setAdapter(adapter); } } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/InventoryFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/InventoryFragment.java index 6eb27fb4..bf78e2b8 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/InventoryFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/InventoryFragment.java @@ -1,213 +1,182 @@ package com.example.petstoremobile.fragments.listfragments; import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; import android.text.Editable; import android.text.TextWatcher; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; -import android.widget.Button; -import android.widget.EditText; -import android.widget.ImageButton; -import android.widget.Spinner; -import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.example.petstoremobile.R; import com.example.petstoremobile.adapters.InventoryAdapter; -import com.example.petstoremobile.api.CategoryApi; -import com.example.petstoremobile.api.InventoryApi; -import com.example.petstoremobile.api.RetrofitClient; -import com.example.petstoremobile.dtos.BulkDeleteRequest; -import com.example.petstoremobile.dtos.CategoryDTO; +import com.example.petstoremobile.databinding.FragmentInventoryBinding; import com.example.petstoremobile.dtos.InventoryDTO; -import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.StoreDTO; import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.detailfragments.InventoryDetailFragment; -import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.example.petstoremobile.utils.BulkDeleteHandler; +import com.example.petstoremobile.viewmodels.InventoryViewModel; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.SpinnerUtils; import java.util.ArrayList; import java.util.List; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; +import dagger.hilt.android.AndroidEntryPoint; +@AndroidEntryPoint public class InventoryFragment extends Fragment implements InventoryAdapter.OnInventoryClickListener { private static final String TAG = "InventoryFragment"; private static final int PAGE_SIZE = 20; + private FragmentInventoryBinding binding; private final List inventoryList = new ArrayList<>(); - private final List categoryList = new ArrayList<>(); + private List storeList = new ArrayList<>(); private InventoryAdapter adapter; - private InventoryApi inventoryApi; - private CategoryApi categoryApi; - - private SwipeRefreshLayout swipeRefreshLayout; - private EditText etSearch; - private Spinner spinnerCategory; - private ImageButton hamburger; - private Button btnBulkDelete; - private TextView tvSelectionCount; - - // Debounce search - private final Handler searchHandler = new Handler(Looper.getMainLooper()); - private Runnable searchRunnable; - private String currentQuery = ""; - - // Selected category filter — null means "All" - private String selectedCategory = null; + private InventoryViewModel viewModel; + private BulkDeleteHandler bulkDeleteHandler; // Pagination private int currentPage = 0; private boolean isLastPage = false; private boolean isLoading = false; - // Prevent spinner from firing on initial load - private boolean spinnerReady = false; + /** + * Initializes the fragment and its ViewModel. + */ + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(InventoryViewModel.class); + } + + /** + * Sets up the fragment's UI components, including the inventory list and search. + */ + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + binding = FragmentInventoryBinding.inflate(inflater, container, false); + + setupRecyclerView(); + setupSearch(); + setupStoreFilter(); + setupSwipeRefresh(); + setupFilterToggle(); + setupBulkDelete(); + loadInventory(true); + loadStoreData(); + + binding.fabAddInventory.setOnClickListener(v -> openDetail(null)); + + binding.btnHamburger.setOnClickListener(v -> { + Fragment parent = getParentFragment(); + if (parent != null) { + Fragment grandParent = parent.getParentFragment(); + if (grandParent instanceof ListFragment) { + ((ListFragment) grandParent).openDrawer(); + } + } + }); + + return binding.getRoot(); + } + + private void setupBulkDelete() { + bulkDeleteHandler = new BulkDeleteHandler( + this, + binding.layoutBulkDelete, + binding.tvSelectionCount, + binding.btnBulkDelete, + adapter, + "inventory item", + viewModel::bulkDeleteInventory, + () -> loadInventory(true) + ); + } @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_inventory, container, false); - - inventoryApi = RetrofitClient.getInventoryApi(requireContext()); - categoryApi = RetrofitClient.getCategoryApi(requireContext()); - - hamburger = view.findViewById(R.id.btnHamburger); - btnBulkDelete = view.findViewById(R.id.btnBulkDelete); - tvSelectionCount = view.findViewById(R.id.tvSelectionCount); - spinnerCategory = view.findViewById(R.id.spinnerCategory); - - setupRecyclerView(view); - setupSearch(view); - setupSwipeRefresh(view); - loadCategories(); // loads categories then triggers loadInventory - loadInventory(true); - - view.findViewById(R.id.fabAddInventory) - .setOnClickListener(v -> openDetail(null)); - - hamburger.setOnClickListener(v -> { - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) - lf.openDrawer(); - }); - - btnBulkDelete.setOnClickListener(v -> confirmBulkDelete()); - - return view; + public void onDestroyView() { + super.onDestroyView(); + binding = null; } - // Categories - private void loadCategories() { - categoryApi.getAllCategories(0, 100).enqueue(new Callback>() { - @Override - public void onResponse(Call> call, - Response> response) { - if (response.isSuccessful() && response.body() != null) { - categoryList.clear(); - categoryList.addAll(response.body().getContent()); - setupCategorySpinner(); - } - } + /** + * Sets up the filter toggle button to show/hide the filter layout. + */ + private void setupFilterToggle() { + binding.btnToggleFilter.setOnClickListener(v -> { + if (binding.layoutFilter.getVisibility() == View.GONE) { + binding.layoutFilter.setVisibility(View.VISIBLE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + binding.layoutFilter.setVisibility(View.GONE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_search); - @Override - public void onFailure(Call> call, Throwable t) { - Log.e(TAG, "Failed to load categories", t); - // Still setup spinner with just "All" - setupCategorySpinner(); + // Reset filters when closing + binding.etSearchInventory.setText(""); + binding.spinnerStore.setSelection(0); } }); } - private void setupCategorySpinner() { - // First item is always "All Categories" - List categoryNames = new ArrayList<>(); - categoryNames.add("All Categories"); - for (CategoryDTO c : categoryList) { - categoryNames.add(c.getCategoryName()); - } - - ArrayAdapter spinnerAdapter = new ArrayAdapter<>( - requireContext(), - android.R.layout.simple_spinner_item, - categoryNames); - spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - spinnerCategory.setAdapter(spinnerAdapter); - - spinnerCategory.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - if (!spinnerReady) { - // Skip the first automatic trigger on setup - spinnerReady = true; - return; - } - if (position == 0) { - selectedCategory = null; // "All Categories" - } else { - selectedCategory = categoryList.get(position - 1).getCategoryName(); - } + /** + * Sets up the search bar for filtering. + */ + private void setupSearch() { + binding.etSearchInventory.addTextChangedListener(new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) { loadInventory(true); } + @Override public void afterTextChanged(Editable s) {} + }); + } - @Override - public void onNothingSelected(AdapterView parent) { + /** + * Configures the store filter spinner. + */ + private void setupStoreFilter() { + SpinnerUtils.setupFilterSpinner(binding.spinnerStore, () -> loadInventory(true)); + } + + /** + * Fetches store data to populate the store filter. + */ + private void loadStoreData() { + viewModel.getAllStores(0, 100).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + storeList = resource.data.getContent(); + SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerStore, storeList, + StoreDTO::getStoreName, "All Stores", -1L, StoreDTO::getStoreId); } }); } - // Search - - private void setupSearch(View view) { - etSearch = view.findViewById(R.id.etSearchInventory); - etSearch.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int i, int i1, int i2) { - } - - @Override - public void afterTextChanged(Editable s) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - if (searchRunnable != null) - searchHandler.removeCallbacks(searchRunnable); - searchRunnable = () -> { - currentQuery = s.toString().trim(); - loadInventory(true); - }; - searchHandler.postDelayed(searchRunnable, 400); - } - }); - } - - // RecyclerView + infinite scroll - private void setupRecyclerView(View view) { - RecyclerView rv = view.findViewById(R.id.recyclerViewInventory); + /** + * Initializes the RecyclerView with a layout manager, and adapter. + */ + private void setupRecyclerView() { adapter = new InventoryAdapter(inventoryList, this); - rv.setLayoutManager(new LinearLayoutManager(getContext())); - rv.setAdapter(adapter); + binding.recyclerViewInventory.setLayoutManager(new LinearLayoutManager(getContext())); + binding.recyclerViewInventory.setAdapter(adapter); - rv.addOnScrollListener(new RecyclerView.OnScrollListener() { + binding.recyclerViewInventory.addOnScrollListener(new RecyclerView.OnScrollListener() { @Override - public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { if (dy <= 0) return; - LinearLayoutManager lm = (LinearLayoutManager) rv.getLayoutManager(); + LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewInventory.getLayoutManager(); if (lm == null) return; int visible = lm.getChildCount(); @@ -220,144 +189,90 @@ public class InventoryFragment extends Fragment implements InventoryAdapter.OnIn }); } - private void setupSwipeRefresh(View view) { - swipeRefreshLayout = view.findViewById(R.id.swipeRefreshInventory); - swipeRefreshLayout.setOnRefreshListener(() -> loadInventory(true)); + /** + * Sets up the SwipeRefreshLayout to reload the first page of inventory items. + */ + private void setupSwipeRefresh() { + binding.swipeRefreshInventory.setOnRefreshListener(() -> loadInventory(true)); } - // Load inventory + /** + * Fetches a page of inventory items from the API. + */ private void loadInventory(boolean reset) { - if (isLoading) - return; - isLoading = true; + if (isLoading) return; if (reset) { currentPage = 0; isLastPage = false; } - // Build query: combine search text + selected category - String q = buildQuery(); + // Search text from input + String query = binding.etSearchInventory != null ? binding.etSearchInventory.getText().toString().trim() : ""; + if (query.isEmpty()) query = null; - inventoryApi.getAllInventory(q, currentPage, PAGE_SIZE, "inventoryId,asc") - .enqueue(new Callback>() { - @Override - public void onResponse(Call> call, - Response> response) { - isLoading = false; - if (swipeRefreshLayout != null) - swipeRefreshLayout.setRefreshing(false); - - if (response.isSuccessful() && response.body() != null) { - PageResponse page = response.body(); - if (reset) - inventoryList.clear(); - inventoryList.addAll(page.getContent()); - adapter.notifyDataSetChanged(); - isLastPage = page.isLast(); - if (!isLastPage) - currentPage++; - } else { - Log.e(TAG, "Error " + response.code()); - Toast.makeText(getContext(), "Failed to load inventory", Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call> call, Throwable t) { - isLoading = false; - if (swipeRefreshLayout != null) - swipeRefreshLayout.setRefreshing(false); - Log.e(TAG, "Network error", t); - Toast.makeText(getContext(), "Network error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); - } - }); - } - - // Combines search text and category into one query string for ?q= - private String buildQuery() { - String q = null; - if (!currentQuery.isEmpty() && selectedCategory != null) { - // Both active — prioritize search text, category acts as context - q = currentQuery; - } else if (!currentQuery.isEmpty()) { - q = currentQuery; - } else if (selectedCategory != null) { - q = selectedCategory; + Long storeId = null; + if (binding.spinnerStore.getSelectedItemPosition() > 0 && !storeList.isEmpty()) { + storeId = storeList.get(binding.spinnerStore.getSelectedItemPosition() - 1).getStoreId(); } - return q; - } - // Bulk delete - private void confirmBulkDelete() { - List ids = adapter.getSelectedIds(); - if (ids.isEmpty()) - return; + //Load all inventory items from the backend using viewModel + viewModel.getAllInventory(query, storeId, currentPage, PAGE_SIZE, "product.prodName").observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; - new androidx.appcompat.app.AlertDialog.Builder(requireContext()) - .setTitle("Delete " + ids.size() + " item(s)?") - .setMessage("This cannot be undone.") - .setPositiveButton("Delete", (d, w) -> bulkDelete(ids)) - .setNegativeButton("Cancel", null) - .show(); - } - - private void bulkDelete(List ids) { - inventoryApi.bulkDeleteInventory(new BulkDeleteRequest(ids)) - .enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - adapter.clearSelection(); - hideBulkDeleteBar(); - loadInventory(true); - Toast.makeText(getContext(), ids.size() + " item(s) deleted", Toast.LENGTH_SHORT).show(); - } else { - Toast.makeText(getContext(), "Delete failed", Toast.LENGTH_SHORT).show(); - } + // Check the status to see if the resource is loaded and display the data + switch (resource.status) { + case LOADING: + // Show loading indicator + isLoading = true; + binding.swipeRefreshInventory.setRefreshing(true); + break; + case SUCCESS: + // Hide loading indicator and display data + isLoading = false; + binding.swipeRefreshInventory.setRefreshing(false); + if (resource.data != null) { + if (reset) inventoryList.clear(); + inventoryList.addAll(resource.data.getContent()); + adapter.notifyDataSetChanged(); + isLastPage = resource.data.isLast(); + if (!isLastPage) currentPage++; } - - @Override - public void onFailure(Call call, Throwable t) { - Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); - } - }); + break; + case ERROR: + // Hide loading indicator and toast error message + isLoading = false; + binding.swipeRefreshInventory.setRefreshing(false); + Log.e(TAG, "Error: " + resource.message); + Toast.makeText(getContext(), "Failed to load inventory: " + resource.message, Toast.LENGTH_SHORT).show(); + break; + } + }); } - private void hideBulkDeleteBar() { - if (btnBulkDelete != null) - btnBulkDelete.setVisibility(View.GONE); - if (tvSelectionCount != null) - tvSelectionCount.setVisibility(View.GONE); - } - - // Navigation + /** + * Navigates to the inventory detail screen for a specific item or to add a new one. + */ private void openDetail(InventoryDTO inv) { - InventoryDetailFragment detail = new InventoryDetailFragment(); Bundle args = new Bundle(); if (inv != null) { args.putLong("inventoryId", inv.getInventoryId()); - args.putLong("prodId", inv.getProdId() != null ? inv.getProdId() : -1); - args.putString("productName", inv.getProductName()); - args.putString("categoryName", inv.getCategoryName()); - args.putInt("quantity", inv.getQuantity() != null ? inv.getQuantity() : 0); } - detail.setArguments(args); - detail.setInventoryFragment(this); - - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) - lf.loadFragment(detail); + NavHostFragment.findNavController(this).navigate(R.id.nav_inventory_detail, args); } + /** + * Reloads inventory data when changes occur. + */ public void onInventoryChanged() { loadInventory(true); } - // Adapter callbacks - + /** + * Handles item click in the inventory list. + */ @Override public void onInventoryClick(int position) { if (position >= 0 && position < inventoryList.size()) { @@ -365,14 +280,13 @@ public class InventoryFragment extends Fragment implements InventoryAdapter.OnIn } } + /** + * Updates the bulk deletion UI visibility and count when items are selected or deselected. + */ @Override public void onSelectionChanged(int selectedCount) { - if (selectedCount > 0) { - btnBulkDelete.setVisibility(View.VISIBLE); - tvSelectionCount.setVisibility(View.VISIBLE); - tvSelectionCount.setText(selectedCount + " selected"); - } else { - hideBulkDeleteBar(); + if (bulkDeleteHandler != null) { + bulkDeleteHandler.onSelectionChanged(selectedCount); } } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/PetFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/PetFragment.java index 91257673..578022c3 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/PetFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/PetFragment.java @@ -2,10 +2,12 @@ package com.example.petstoremobile.fragments.listfragments; import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import android.text.Editable; import android.text.TextWatcher; @@ -13,219 +15,264 @@ import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; -import android.widget.EditText; -import android.widget.ImageButton; -import android.widget.Spinner; import android.widget.Toast; import com.example.petstoremobile.R; import com.example.petstoremobile.adapters.PetAdapter; -import com.example.petstoremobile.api.PetApi; -import com.example.petstoremobile.api.RetrofitClient; -import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.databinding.FragmentPetBinding; import com.example.petstoremobile.dtos.PetDTO; +import com.example.petstoremobile.dtos.StoreDTO; import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.detailfragments.PetDetailFragment; -import com.example.petstoremobile.fragments.listfragments.listprofilefragments.PetProfileFragment; -import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.example.petstoremobile.utils.BulkDeleteHandler; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.SpinnerUtils; +import com.example.petstoremobile.viewmodels.PetViewModel; +import com.example.petstoremobile.viewmodels.StoreViewModel; import java.util.ArrayList; import java.util.List; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; +import javax.inject.Inject; +import javax.inject.Named; +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint public class PetFragment extends Fragment implements PetAdapter.OnPetClickListener { + private FragmentPetBinding binding; private List petList = new ArrayList<>(); - private List filteredList = new ArrayList<>(); - private ImageButton hamburger; + private List storeList = new ArrayList<>(); private PetAdapter adapter; - private PetApi api; - private SwipeRefreshLayout swipeRefreshLayout; - private EditText etSearch; - private Spinner spinnerStatus; + private PetViewModel viewModel; + private StoreViewModel storeViewModel; + private BulkDeleteHandler bulkDeleteHandler; - //load pet view + @Inject @Named("baseUrl") String baseUrl; + + /** + * Initializes the fragment and its associated ViewModels. + */ @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(PetViewModel.class); + storeViewModel = new ViewModelProvider(this).get(StoreViewModel.class); + } + + /** + * Sets up the fragment's UI components, including RecyclerView, filters, and swipe-to-refresh. + */ + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_pet, container, false); + binding = FragmentPetBinding.inflate(inflater, container, false); - //get retrofit - api = RetrofitClient.getPetApi(requireContext()); + setupRecyclerView(); + setupSearch(); + setupStatusFilter(); + setupSpeciesFilter(); + setupStoreFilter(); + setupSwipeRefresh(); + setupFilterToggle(); + setupBulkDelete(); - hamburger = view.findViewById(R.id.btnHamburger); + binding.fabAddPet.setOnClickListener(v -> openPetDetails()); - setupRecyclerView(view); - setupSearch(view); - setupStatusFilter(view); - setupSwipeRefresh(view); - loadPetData(); - - - //Add button to opens the add dialog - FloatingActionButton fabAddPet = view.findViewById(R.id.fabAddPet); - fabAddPet.setOnClickListener(v -> openPetDetails(-1)); - - //Make the hamburger button open the drawer from listFragment - hamburger.setOnClickListener(v -> { - ListFragment listFragment = (ListFragment) getParentFragment(); - //if list fragment is found then use its helper function to open the drawer - if (listFragment != null) { - listFragment.openDrawer(); + binding.btnHamburger.setOnClickListener(v -> { + Fragment parent = getParentFragment(); + if (parent != null) { + Fragment grandParent = parent.getParentFragment(); + if (grandParent instanceof ListFragment) { + ((ListFragment) grandParent).openDrawer(); + } } }); - return view; + return binding.getRoot(); } - private void setupSearch(View view) { - etSearch = view.findViewById(R.id.etSearchPet); - etSearch.addTextChangedListener(new TextWatcher() { + private void setupBulkDelete() { + bulkDeleteHandler = new BulkDeleteHandler( + this, + binding.layoutBulkDelete, + binding.tvSelectionCount, + binding.btnBulkDelete, + adapter, + "pet", + viewModel::bulkDeletePets, + this::loadPetData + ); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + /** + * Reloads data every time the fragment becomes visible. + */ + @Override + public void onResume() { + super.onResume(); + loadPetData(); + loadStoreData(); + } + + /** + * Sets up the filter toggle button to show/hide the filter layout. + */ + private void setupFilterToggle() { + binding.btnToggleFilter.setOnClickListener(v -> { + if (binding.layoutFilter.getVisibility() == View.GONE) { + binding.layoutFilter.setVisibility(View.VISIBLE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + binding.layoutFilter.setVisibility(View.GONE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_search); + + // Reset filters when closing + binding.etSearchPet.setText(""); + binding.spinnerStatus.setSelection(0); + binding.spinnerSpecies.setSelection(0); + binding.spinnerStore.setSelection(0); + } + }); + } + + /** + * Configures the search bar. + */ + private void setupSearch() { + binding.etSearchPet.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} @Override public void onTextChanged(CharSequence s, int start, int before, int count) { - filterPets(); + loadPetData(); } @Override public void afterTextChanged(Editable s) {} }); } - //Setup the status filter spinner - private void setupStatusFilter(View view) { - spinnerStatus = view.findViewById(R.id.spinnerStatus); - String[] statuses = {"All Statuses", "Available", "Adopted"}; - ArrayAdapter adapter = new ArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_item, statuses); - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - spinnerStatus.setAdapter(adapter); + /** + * Configures the status filter spinner. + */ + private void setupStatusFilter() { + String[] statuses = {"All Statuses", "Available", "Adopted", "Owned"}; + SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatus, statuses, this::loadPetData); + } - spinnerStatus.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - filterPets(); + /** + * Configures the species filter spinner with species. + */ + private void setupSpeciesFilter() { + String[] species = {"All Species", "Dog", "Cat", "Bird", "Rabbit", "Fish", "Hamster"}; + SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerSpecies, species, this::loadPetData); + } + + /** + * Configures the store filter spinner. + */ + private void setupStoreFilter() { + SpinnerUtils.setupFilterSpinner(binding.spinnerStore, this::loadPetData); + } + + /** + * Fetches store data to populate the store filter. + */ + private void loadStoreData() { + storeViewModel.getAllStores(0, 100).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + storeList = resource.data.getContent(); + SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerStore, storeList, + StoreDTO::getStoreName, "All Stores", -1L, StoreDTO::getStoreId); } - - @Override - public void onNothingSelected(AdapterView parent) {} }); } - // Helper function to filter pets based on search and status filter - private void filterPets() { - String query = etSearch.getText().toString().toLowerCase(); - String selectedStatus = spinnerStatus.getSelectedItem().toString(); - - filteredList.clear(); - for (PetDTO p : petList) { - boolean matchesSearch = query.isEmpty() || - p.getPetName().toLowerCase().contains(query) || - p.getPetSpecies().toLowerCase().contains(query) || - p.getPetBreed().toLowerCase().contains(query); - - boolean matchesStatus = selectedStatus.equals("All Statuses") || - p.getPetStatus().equalsIgnoreCase(selectedStatus); - - if (matchesSearch && matchesStatus) { - filteredList.add(p); - } - } - adapter.notifyDataSetChanged(); + /** + * Sets up the SwipeRefreshLayout. + */ + private void setupSwipeRefresh() { + binding.swipeRefreshPet.setOnRefreshListener(this::loadPetData); } - private void setupSwipeRefresh(View view) { - swipeRefreshLayout = view.findViewById(R.id.swipeRefreshPet); - swipeRefreshLayout.setOnRefreshListener(() -> { - loadPetData(); - }); - } - - //Open pet profile + /** + * Navigates to the pet profile screen. + */ private void openPetProfile(int position) { - PetProfileFragment profileFragment = new PetProfileFragment(); - - //Make a bundle to pass data to the profile fragment Bundle args = new Bundle(); - PetDTO pet = filteredList.get(position); - args.putInt("petId", pet.getPetId().intValue()); - args.putString("petName", pet.getPetName()); - args.putString("petSpecies", pet.getPetSpecies()); - args.putString("petBreed", pet.getPetBreed()); - args.putInt("petAge", pet.getPetAge()); - args.putString("petStatus", pet.getPetStatus()); - - try { - args.putDouble("petPrice", Double.parseDouble(pet.getPetPrice())); - } catch (Exception e) { - args.putDouble("petPrice", 0.0); - } - - //send the bundle to the profile fragment to display - profileFragment.setArguments(args); - - //get ListFragment to load the the pet profile view - ListFragment listFragment = (ListFragment) getParentFragment(); - if (listFragment != null) { - listFragment.loadFragment(profileFragment); - } + PetDTO pet = petList.get(position); + args.putLong("petId", pet.getPetId()); + NavHostFragment.findNavController(this).navigate(R.id.nav_pet_profile, args); } - //Open the pet detail view for adding - private void openPetDetails(int position) { - PetDetailFragment detailFragment = new PetDetailFragment(); - - //get ListFragment to load the detail view - ListFragment listFragment = (ListFragment) getParentFragment(); - if (listFragment != null) { - listFragment.loadFragment(detailFragment); - } + /** + * Navigates to the pet detail screen. + */ + private void openPetDetails() { + NavHostFragment.findNavController(this).navigate(R.id.nav_pet_detail); } - // Called by PetAdapter when a row is clicked to open the details view @Override public void onPetClick(int position) { openPetProfile(position); } - // Helper function to get a list of all pets from the backend - private void loadPetData() { - if (swipeRefreshLayout != null) { - swipeRefreshLayout.setRefreshing(true); + @Override + public void onSelectionChanged(int selectedCount) { + if (bulkDeleteHandler != null) { + bulkDeleteHandler.onSelectionChanged(selectedCount); } - api.getAllPets(0, 100).enqueue(new Callback>() { - @Override - public void onResponse(Call> call, Response> response) { - if (swipeRefreshLayout != null) { - swipeRefreshLayout.setRefreshing(false); - } - if (response.isSuccessful() && response.body() != null) { - petList.clear(); - petList.addAll(response.body().getContent()); - filterPets(); + } - } else { - Log.e("onResponse: ", response.message()); - } - } + /** + * Fetches pet data from the server with all active filters. + */ + private void loadPetData() { + String query = binding.etSearchPet.getText().toString().trim(); + String status = binding.spinnerStatus.getSelectedItem() != null ? binding.spinnerStatus.getSelectedItem().toString() : "All Statuses"; + String species = binding.spinnerSpecies.getSelectedItem() != null ? binding.spinnerSpecies.getSelectedItem().toString() : "All Species"; + + Long storeId = null; + if (binding.spinnerStore.getSelectedItemPosition() > 0 && !storeList.isEmpty()) { + storeId = storeList.get(binding.spinnerStore.getSelectedItemPosition() - 1).getStoreId(); + } - @Override - public void onFailure(Call> call, Throwable t) { - if (swipeRefreshLayout != null) { - swipeRefreshLayout.setRefreshing(false); - } - Toast.makeText(getContext(), - "Failed to load pets", Toast.LENGTH_SHORT).show(); - Log.e("onFailure: ", t.getMessage()); + if (status.equals("All Statuses")) status = null; + if (species.equals("All Species")) species = null; + + viewModel.getAllPets(0, 100, query, status, species, storeId, "petName").observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + + switch (resource.status) { + case LOADING: + binding.swipeRefreshPet.setRefreshing(true); + break; + case SUCCESS: + binding.swipeRefreshPet.setRefreshing(false); + if (resource.data != null) { + petList.clear(); + petList.addAll(resource.data.getContent()); + adapter.notifyDataSetChanged(); + } + break; + case ERROR: + binding.swipeRefreshPet.setRefreshing(false); + Toast.makeText(getContext(), "Failed to load pets: " + resource.message, Toast.LENGTH_SHORT).show(); + Log.e("PetFragment", "Error loading pets: " + resource.message); + break; } }); } - //set up the recyclerview and adapter - private void setupRecyclerView(View view) { - RecyclerView recyclerView = view.findViewById(R.id.recyclerViewPets); - adapter = new PetAdapter(filteredList, this); - recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - recyclerView.setAdapter(adapter); + /** + * Initializes the RecyclerView. + */ + private void setupRecyclerView() { + adapter = new PetAdapter(petList, this); + adapter.setBaseUrl(baseUrl); + binding.recyclerViewPets.setLayoutManager(new LinearLayoutManager(getContext())); + binding.recyclerViewPets.setAdapter(adapter); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ProductFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ProductFragment.java index 4e72b6cd..b2c28fee 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ProductFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ProductFragment.java @@ -1,133 +1,226 @@ package com.example.petstoremobile.fragments.listfragments; import android.os.Bundle; -import android.text.*; -import android.util.Log; -import android.view.*; -import android.widget.*; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import android.text.Editable; +import android.text.TextWatcher; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + import com.example.petstoremobile.R; import com.example.petstoremobile.adapters.ProductAdapter; -import com.example.petstoremobile.api.RetrofitClient; -import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.databinding.FragmentProductBinding; +import com.example.petstoremobile.dtos.CategoryDTO; import com.example.petstoremobile.dtos.ProductDTO; import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.detailfragments.ProductDetailFragment; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import java.util.*; -import retrofit2.*; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.SpinnerUtils; +import com.example.petstoremobile.viewmodels.ProductViewModel; +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Named; + +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint public class ProductFragment extends Fragment implements ProductAdapter.OnProductClickListener { + private FragmentProductBinding binding; private List productList = new ArrayList<>(); - private List filteredList = new ArrayList<>(); + private List categoryList = new ArrayList<>(); private ProductAdapter adapter; - private SwipeRefreshLayout swipeRefresh; - private EditText etSearch; + private ProductViewModel viewModel; + @Inject @Named("baseUrl") String baseUrl; + + /** + * Initializes the fragment and its associated ProductViewModel. + */ @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(ProductViewModel.class); + } + + /** + * Sets up the fragment's UI components, including RecyclerView, search, and swipe-to-refresh. + */ + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_product, container, false); + binding = FragmentProductBinding.inflate(inflater, container, false); - setupRecyclerView(view); - setupSearch(view); - setupSwipeRefresh(view); - loadProducts(); + setupRecyclerView(); + setupSearch(); + setupCategoryFilter(); + setupSwipeRefresh(); + setupFilterToggle(); - FloatingActionButton fab = view.findViewById(R.id.fabAddProduct); - fab.setOnClickListener(v -> openDetail(-1)); + binding.fabAddProduct.setOnClickListener(v -> openProductDetails(-1)); - ImageButton hamburger = view.findViewById(R.id.btnHamburgerProduct); - hamburger.setOnClickListener(v -> { - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.openDrawer(); - }); - - return view; - } - - private void setupRecyclerView(View view) { - RecyclerView rv = view.findViewById(R.id.recyclerViewProducts); - adapter = new ProductAdapter(filteredList, this); - rv.setLayoutManager(new LinearLayoutManager(getContext())); - rv.setAdapter(adapter); - } - - private void setupSearch(View view) { - etSearch = view.findViewById(R.id.etSearchProduct); - etSearch.addTextChangedListener(new TextWatcher() { - public void beforeTextChanged(CharSequence s, int a, int b, int c) {} - public void afterTextChanged(Editable s) {} - public void onTextChanged(CharSequence s, int a, int b, int c) { - filter(s.toString()); - } - }); - } - - private void setupSwipeRefresh(View view) { - swipeRefresh = view.findViewById(R.id.swipeRefreshProduct); - swipeRefresh.setOnRefreshListener(this::loadProducts); - } - - private void filter(String query) { - filteredList.clear(); - if (query.isEmpty()) { - filteredList.addAll(productList); - } else { - String lower = query.toLowerCase(); - for (ProductDTO p : productList) { - if ((p.getProdName() != null && p.getProdName().toLowerCase().contains(lower)) - || (p.getCategoryName() != null && p.getCategoryName().toLowerCase().contains(lower))) { - filteredList.add(p); + binding.btnHamburgerProduct.setOnClickListener(v -> { + Fragment parent = getParentFragment(); + if (parent != null) { + Fragment grandParent = parent.getParentFragment(); + if (grandParent instanceof ListFragment) { + ((ListFragment) grandParent).openDrawer(); } } - } - adapter.notifyDataSetChanged(); - } + }); - private void loadProducts() { - if (swipeRefresh != null) swipeRefresh.setRefreshing(true); - RetrofitClient.getProductApi(requireContext()).getAllProducts(null, 0, 100) - .enqueue(new Callback>() { - public void onResponse(Call> c, - Response> r) { - if (swipeRefresh != null) swipeRefresh.setRefreshing(false); - if (r.isSuccessful() && r.body() != null) { - productList.clear(); - productList.addAll(r.body().getContent()); - filter(etSearch != null ? etSearch.getText().toString() : ""); - } else { - Toast.makeText(getContext(), "Failed to load products", - Toast.LENGTH_SHORT).show(); - } - } - public void onFailure(Call> c, Throwable t) { - if (swipeRefresh != null) swipeRefresh.setRefreshing(false); - Log.e("ProductFragment", t.getMessage()); - } - }); - } - - private void openDetail(int position) { - ProductDetailFragment detail = new ProductDetailFragment(); - Bundle args = new Bundle(); - if (position != -1) { - ProductDTO p = filteredList.get(position); - args.putLong("prodId", p.getProdId()); - args.putString("prodName", p.getProdName()); - args.putString("prodDesc", p.getProdDesc() != null ? p.getProdDesc() : ""); - args.putString("prodPrice", p.getProdPrice() != null ? p.getProdPrice().toString() : ""); - args.putLong("categoryId", p.getCategoryId() != null ? p.getCategoryId() : -1); - } - detail.setArguments(args); - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.loadFragment(detail); + return binding.getRoot(); } @Override - public void onProductClick(int position) { openDetail(position); } -} \ No newline at end of file + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + /** + * Reloads data every time the fragment becomes visible. + */ + @Override + public void onResume() { + super.onResume(); + loadProductData(); + loadCategoryData(); + } + + /** + * Sets up the filter toggle button to show/hide the filter layout. + */ + private void setupFilterToggle() { + binding.btnToggleFilter.setOnClickListener(v -> { + if (binding.layoutFilter.getVisibility() == View.GONE) { + binding.layoutFilter.setVisibility(View.VISIBLE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + binding.layoutFilter.setVisibility(View.GONE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_search); + + // Reset filters when closing + binding.etSearchProduct.setText(""); + binding.spinnerCategory.setSelection(0); + } + }); + } + + /** + * Configures the search bar for triggering data load from backend. + */ + private void setupSearch() { + binding.etSearchProduct.addTextChangedListener(new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) { + loadProductData(); + } + @Override public void afterTextChanged(Editable s) {} + }); + } + + /** + * Configures the category filter spinner. + */ + private void setupCategoryFilter() { + SpinnerUtils.setupFilterSpinner(binding.spinnerCategory, this::loadProductData); + } + + /** + * Fetches category data to populate the category filter. + */ + private void loadCategoryData() { + viewModel.getAllCategories(0, 100).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + categoryList = resource.data.getContent(); + SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerCategory, categoryList, + CategoryDTO::getCategoryName, "All Categories", -1L, CategoryDTO::getCategoryId); + } + }); + } + + /** + * Sets up the SwipeRefreshLayout. + */ + private void setupSwipeRefresh() { + binding.swipeRefreshProduct.setOnRefreshListener(this::loadProductData); + } + + /** + * Navigates to the product detail screen. + */ + private void openProductDetails(int position) { + Bundle args = new Bundle(); + if (position != -1) { + ProductDTO product = productList.get(position); + args.putLong("prodId", product.getProdId()); + } + NavHostFragment.findNavController(this).navigate(R.id.nav_product_detail, args); + } + + @Override + public void onProductClick(int position) { + openProductDetails(position); + } + + /** + * Fetches product data from the server with search query, category, and sorting. + */ + private void loadProductData() { + String query = binding.etSearchProduct.getText().toString().trim(); + if (query.isEmpty()) query = null; + + Long categoryId = null; + if (binding.spinnerCategory.getSelectedItemPosition() > 0 && !categoryList.isEmpty()) { + categoryId = categoryList.get(binding.spinnerCategory.getSelectedItemPosition() - 1).getCategoryId(); + } + + viewModel.getAllProducts(query, categoryId, 0, 100, "prodName").observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + + switch (resource.status) { + case LOADING: + binding.swipeRefreshProduct.setRefreshing(true); + break; + case SUCCESS: + binding.swipeRefreshProduct.setRefreshing(false); + if (resource.data != null) { + productList.clear(); + productList.addAll(resource.data.getContent()); + adapter.notifyDataSetChanged(); + } + break; + case ERROR: + binding.swipeRefreshProduct.setRefreshing(false); + if (getContext() != null) { + Toast.makeText(getContext(), "Failed to load products: " + resource.message, Toast.LENGTH_SHORT).show(); + } + Log.e("ProductFragment", "Error loading products: " + resource.message); + break; + } + }); + } + + /** + * Initializes the RecyclerView. + */ + private void setupRecyclerView() { + adapter = new ProductAdapter(productList, this); + adapter.setBaseUrl(baseUrl); + binding.recyclerViewProducts.setLayoutManager(new LinearLayoutManager(getContext())); + binding.recyclerViewProducts.setAdapter(adapter); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ProductSupplierFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ProductSupplierFragment.java index 4ff88f16..578aa7a9 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ProductSupplierFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ProductSupplierFragment.java @@ -1,134 +1,278 @@ package com.example.petstoremobile.fragments.listfragments; import android.os.Bundle; -import android.text.*; +import android.text.Editable; +import android.text.TextWatcher; import android.util.Log; -import android.view.*; -import android.widget.*; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + import com.example.petstoremobile.R; import com.example.petstoremobile.adapters.ProductSupplierAdapter; -import com.example.petstoremobile.api.RetrofitClient; -import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.databinding.FragmentProductSupplierBinding; +import com.example.petstoremobile.dtos.ProductDTO; import com.example.petstoremobile.dtos.ProductSupplierDTO; +import com.example.petstoremobile.dtos.SupplierDTO; import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.detailfragments.ProductSupplierDetailFragment; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import java.util.*; -import retrofit2.*; +import com.example.petstoremobile.utils.BulkDeleteHandler; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.SpinnerUtils; +import com.example.petstoremobile.viewmodels.ProductSupplierViewModel; +import com.example.petstoremobile.viewmodels.ProductViewModel; +import com.example.petstoremobile.viewmodels.SupplierViewModel; +import java.util.ArrayList; +import java.util.List; + +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint public class ProductSupplierFragment extends Fragment implements ProductSupplierAdapter.OnProductSupplierClickListener { + private FragmentProductSupplierBinding binding; private List psList = new ArrayList<>(); - private List filteredList = new ArrayList<>(); + private List productList = new ArrayList<>(); + private List supplierList = new ArrayList<>(); + private ProductSupplierAdapter adapter; - private SwipeRefreshLayout swipeRefresh; - private EditText etSearch; + private ProductSupplierViewModel viewModel; + private ProductViewModel productViewModel; + private SupplierViewModel supplierViewModel; + private BulkDeleteHandler bulkDeleteHandler; + /** + * Initializes the fragment and its associated ViewModels. + */ @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(ProductSupplierViewModel.class); + productViewModel = new ViewModelProvider(this).get(ProductViewModel.class); + supplierViewModel = new ViewModelProvider(this).get(SupplierViewModel.class); + } + + /** + * Sets up the fragment's UI components, including the RecyclerView, search, and swipe-to-refresh. + */ + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_product_supplier, container, false); + binding = FragmentProductSupplierBinding.inflate(inflater, container, false); - setupRecyclerView(view); - setupSearch(view); - setupSwipeRefresh(view); - loadData(); + setupRecyclerView(); + setupSearch(); + setupProductFilter(); + setupSupplierFilter(); + setupSwipeRefresh(); + setupFilterToggle(); + setupBulkDelete(); - FloatingActionButton fab = view.findViewById(R.id.fabAddPS); - fab.setOnClickListener(v -> openDetail(-1)); + binding.fabAddPS.setOnClickListener(v -> openDetail(-1)); - ImageButton hamburger = view.findViewById(R.id.btnHamburgerPS); - hamburger.setOnClickListener(v -> { - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.openDrawer(); - }); - - return view; - } - - private void setupRecyclerView(View view) { - RecyclerView rv = view.findViewById(R.id.recyclerViewPS); - adapter = new ProductSupplierAdapter(filteredList, this); - rv.setLayoutManager(new LinearLayoutManager(getContext())); - rv.setAdapter(adapter); - } - - private void setupSearch(View view) { - etSearch = view.findViewById(R.id.etSearchPS); - etSearch.addTextChangedListener(new TextWatcher() { - public void beforeTextChanged(CharSequence s, int a, int b, int c) {} - public void afterTextChanged(Editable s) {} - public void onTextChanged(CharSequence s, int a, int b, int c) { - filter(s.toString()); - } - }); - } - - private void setupSwipeRefresh(View view) { - swipeRefresh = view.findViewById(R.id.swipeRefreshPS); - swipeRefresh.setOnRefreshListener(this::loadData); - } - - private void filter(String query) { - filteredList.clear(); - if (query.isEmpty()) { - filteredList.addAll(psList); - } else { - String lower = query.toLowerCase(); - for (ProductSupplierDTO ps : psList) { - if ((ps.getProductName() != null && ps.getProductName().toLowerCase().contains(lower)) - || (ps.getSupplierName() != null && ps.getSupplierName().toLowerCase().contains(lower))) { - filteredList.add(ps); + binding.btnHamburgerPS.setOnClickListener(v -> { + Fragment parent = getParentFragment(); + if (parent != null) { + Fragment grandParent = parent.getParentFragment(); + if (grandParent instanceof ListFragment) { + ((ListFragment) grandParent).openDrawer(); } } - } - adapter.notifyDataSetChanged(); + }); + + return binding.getRoot(); } - private void loadData() { - if (swipeRefresh != null) swipeRefresh.setRefreshing(true); - RetrofitClient.getProductSupplierApi(requireContext()).getAllProductSuppliers(0, 100) - .enqueue(new Callback>() { - public void onResponse(Call> c, - Response> r) { - if (swipeRefresh != null) swipeRefresh.setRefreshing(false); - if (r.isSuccessful() && r.body() != null) { - psList.clear(); - psList.addAll(r.body().getContent()); - filter(etSearch != null ? etSearch.getText().toString() : ""); - } else { - Toast.makeText(getContext(), "Failed to load", - Toast.LENGTH_SHORT).show(); - } - } - public void onFailure(Call> c, Throwable t) { - if (swipeRefresh != null) swipeRefresh.setRefreshing(false); - Log.e("PSFragment", t.getMessage()); - } - }); - } - - private void openDetail(int position) { - ProductSupplierDetailFragment detail = new ProductSupplierDetailFragment(); - Bundle args = new Bundle(); - if (position != -1) { - ProductSupplierDTO ps = filteredList.get(position); - args.putLong("productId", ps.getProductId()); - args.putLong("supplierId", ps.getSupplierId()); - args.putString("productName", ps.getProductName()); - args.putString("supplierName", ps.getSupplierName()); - args.putString("cost", ps.getCost() != null ? ps.getCost().toString() : ""); - } - detail.setArguments(args); - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.loadFragment(detail); + private void setupBulkDelete() { + bulkDeleteHandler = new BulkDeleteHandler( + this, + binding.layoutBulkDelete, + binding.tvSelectionCount, + binding.btnBulkDelete, + adapter, + "relationship", + viewModel::bulkDeleteProductSuppliers, + this::loadData + ); } + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + /** + * Reloads data every time the fragment becomes visible. + */ + @Override + public void onResume() { + super.onResume(); + loadData(); + loadFilterData(); + } + + /** + * Sets up the filter toggle button to show/hide the filter layout. + */ + private void setupFilterToggle() { + binding.btnToggleFilter.setOnClickListener(v -> { + if (binding.layoutFilter.getVisibility() == View.GONE) { + binding.layoutFilter.setVisibility(View.VISIBLE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + binding.layoutFilter.setVisibility(View.GONE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_search); + + // Reset filters when closing + binding.etSearchPS.setText(""); + binding.spinnerProduct.setSelection(0); + binding.spinnerSupplier.setSelection(0); + } + }); + } + + /** + * Initializes the RecyclerView with a layout manager and adapter for product-supplier data. + */ + private void setupRecyclerView() { + adapter = new ProductSupplierAdapter(psList, this); + binding.recyclerViewPS.setLayoutManager(new LinearLayoutManager(getContext())); + binding.recyclerViewPS.setAdapter(adapter); + } + + /** + * Configures the search bar for filtering. + */ + private void setupSearch() { + binding.etSearchPS.addTextChangedListener(new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) { + loadData(); + } + @Override public void afterTextChanged(Editable s) {} + }); + } + + /** + * Configures the product filter spinner. + */ + private void setupProductFilter() { + SpinnerUtils.setupFilterSpinner(binding.spinnerProduct, this::loadData); + } + + /** + * Configures the supplier filter spinner. + */ + private void setupSupplierFilter() { + SpinnerUtils.setupFilterSpinner(binding.spinnerSupplier, this::loadData); + } + + /** + * Fetches products and suppliers to populate the filters. + */ + private void loadFilterData() { + productViewModel.getAllProducts(null, null, 0, 100, "prodName").observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + productList = resource.data.getContent(); + SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerProduct, productList, + ProductDTO::getProdName, "All Products", -1L, ProductDTO::getProdId); + } + }); + + supplierViewModel.getAllSuppliers(0, 100, null, "supCompany").observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + supplierList = resource.data.getContent(); + SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerSupplier, supplierList, + SupplierDTO::getSupCompany, "All Suppliers", -1L, SupplierDTO::getSupId); + } + }); + } + + /** + * Sets up the SwipeRefreshLayout to allow manual reloading of product-supplier data. + */ + private void setupSwipeRefresh() { + binding.swipeRefreshPS.setOnRefreshListener(this::loadData); + } + + /** + * Fetches product-supplier data from the server through the ViewModel with search query and filters. + */ + private void loadData() { + String query = binding.etSearchPS.getText().toString().trim(); + if (query.isEmpty()) query = null; + + Long productId = null; + if (binding.spinnerProduct.getSelectedItemPosition() > 0 && !productList.isEmpty()) { + productId = productList.get(binding.spinnerProduct.getSelectedItemPosition() - 1).getProdId(); + } + + Long supplierId = null; + if (binding.spinnerSupplier.getSelectedItemPosition() > 0 && !supplierList.isEmpty()) { + supplierId = supplierList.get(binding.spinnerSupplier.getSelectedItemPosition() - 1).getSupId(); + } + + viewModel.getAllProductSuppliers(0, 100, query, productId, supplierId, "productName").observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + + // Check the status to see if the resource is loaded and display the data + switch (resource.status) { + case LOADING: + // Show loading indicator + binding.swipeRefreshPS.setRefreshing(true); + break; + case SUCCESS: + // Hide loading indicator and display data + binding.swipeRefreshPS.setRefreshing(false); + if (resource.data != null) { + psList.clear(); + psList.addAll(resource.data.getContent()); + adapter.notifyDataSetChanged(); + } + break; + case ERROR: + // Hide loading indicator and toast error message + binding.swipeRefreshPS.setRefreshing(false); + Toast.makeText(getContext(), "Failed to load: " + resource.message, Toast.LENGTH_SHORT).show(); + Log.e("PSFragment", "Error loading: " + resource.message); + break; + } + }); + } + + /** + * Navigates to the product-supplier detail screen for a specific item or to add a new record. + */ + private void openDetail(int position) { + Bundle args = new Bundle(); + if (position != -1) { + ProductSupplierDTO ps = psList.get(position); + args.putLong("productId", ps.getProductId()); + args.putLong("supplierId", ps.getSupplierId()); + } + NavHostFragment.findNavController(this).navigate(R.id.nav_product_supplier_detail, args); + } + + /** + * Handles item click in the product-supplier list. + */ @Override public void onProductSupplierClick(int position) { openDetail(position); } -} \ No newline at end of file + + @Override + public void onSelectionChanged(int count) { + if (bulkDeleteHandler != null) { + bulkDeleteHandler.onSelectionChanged(count); + } + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/PurchaseOrderFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/PurchaseOrderFragment.java index 76527f09..9a758cb8 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/PurchaseOrderFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/PurchaseOrderFragment.java @@ -1,139 +1,224 @@ package com.example.petstoremobile.fragments.listfragments; import android.os.Bundle; -import android.text.*; +import android.text.Editable; +import android.text.TextWatcher; import android.util.Log; -import android.view.*; -import android.widget.*; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + import com.example.petstoremobile.R; import com.example.petstoremobile.adapters.PurchaseOrderAdapter; -import com.example.petstoremobile.api.RetrofitClient; -import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.databinding.FragmentPurchaseOrderBinding; import com.example.petstoremobile.dtos.PurchaseOrderDTO; +import com.example.petstoremobile.dtos.StoreDTO; import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.detailfragments.PurchaseOrderDetailFragment; -import java.util.*; -import retrofit2.*; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.SpinnerUtils; +import com.example.petstoremobile.viewmodels.PurchaseOrderViewModel; +import com.example.petstoremobile.viewmodels.StoreViewModel; +import java.util.ArrayList; +import java.util.List; + +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint public class PurchaseOrderFragment extends Fragment implements PurchaseOrderAdapter.OnPurchaseOrderClickListener { + private FragmentPurchaseOrderBinding binding; private List poList = new ArrayList<>(); - private List filteredList = new ArrayList<>(); + private List storeList = new ArrayList<>(); private PurchaseOrderAdapter adapter; - private SwipeRefreshLayout swipeRefresh; - private EditText etSearch; + private PurchaseOrderViewModel viewModel; + private StoreViewModel storeViewModel; + /** + * Initializes the fragment and its associated ViewModels. + */ @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(PurchaseOrderViewModel.class); + storeViewModel = new ViewModelProvider(this).get(StoreViewModel.class); + } + + /** + * Sets up the fragment's UI components, including RecyclerView, filters, and swipe-to-refresh. + */ + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_purchase_order, container, false); + binding = FragmentPurchaseOrderBinding.inflate(inflater, container, false); - setupRecyclerView(view); - setupSearch(view); - setupSwipeRefresh(view); - loadData(); + setupRecyclerView(); + setupSearch(); + setupStoreFilter(); + setupSwipeRefresh(); + setupFilterToggle(); - ImageButton hamburger = view.findViewById(R.id.btnHamburgerPO); - hamburger.setOnClickListener(v -> { - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) - lf.openDrawer(); - }); - - return view; - } - - private void setupRecyclerView(View view) { - RecyclerView rv = view.findViewById(R.id.recyclerViewPO); - adapter = new PurchaseOrderAdapter(filteredList, this); - rv.setLayoutManager(new LinearLayoutManager(getContext())); - rv.setAdapter(adapter); - } - - private void setupSearch(View view) { - etSearch = view.findViewById(R.id.etSearchPO); - etSearch.addTextChangedListener(new TextWatcher() { - public void beforeTextChanged(CharSequence s, int a, int b, int c) { - } - - public void afterTextChanged(Editable s) { - } - - public void onTextChanged(CharSequence s, int a, int b, int c) { - filter(s.toString()); - } - }); - } - - private void setupSwipeRefresh(View view) { - swipeRefresh = view.findViewById(R.id.swipeRefreshPO); - swipeRefresh.setOnRefreshListener(this::loadData); - } - - private void filter(String query) { - filteredList.clear(); - if (query.isEmpty()) { - filteredList.addAll(poList); - } else { - String lower = query.toLowerCase(); - for (PurchaseOrderDTO po : poList) { - if ((po.getSupplierName() != null && po.getSupplierName().toLowerCase().contains(lower)) - || (po.getStatus() != null && po.getStatus().toLowerCase().contains(lower))) { - filteredList.add(po); + binding.btnHamburgerPO.setOnClickListener(v -> { + Fragment parent = getParentFragment(); + if (parent != null) { + Fragment grandParent = parent.getParentFragment(); + if (grandParent instanceof ListFragment) { + ((ListFragment) grandParent).openDrawer(); } } - } - adapter.notifyDataSetChanged(); + }); + + return binding.getRoot(); } + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + /** + * Reloads data every time the fragment becomes visible. + */ + @Override + public void onResume() { + super.onResume(); + loadData(); + loadStoreData(); + } + + /** + * Sets up the filter toggle button to show/hide the filter layout. + */ + private void setupFilterToggle() { + binding.btnToggleFilter.setOnClickListener(v -> { + if (binding.layoutFilter.getVisibility() == View.GONE) { + binding.layoutFilter.setVisibility(View.VISIBLE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + binding.layoutFilter.setVisibility(View.GONE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_search); + + // Reset filters when closing + binding.etSearchPO.setText(""); + binding.spinnerStore.setSelection(0); + } + }); + } + + /** + * Configures the search bar for filtering. + */ + private void setupSearch() { + binding.etSearchPO.addTextChangedListener(new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) { + loadData(); + } + @Override public void afterTextChanged(Editable s) {} + }); + } + + /** + * Configures the store filter spinner. + */ + private void setupStoreFilter() { + SpinnerUtils.setupFilterSpinner(binding.spinnerStore, this::loadData); + } + + /** + * Fetches store data to populate the store filter. + */ + private void loadStoreData() { + storeViewModel.getAllStores(0, 100).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + storeList = resource.data.getContent(); + SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerStore, storeList, + StoreDTO::getStoreName, "All Stores", -1L, StoreDTO::getStoreId); + } + }); + } + + /** + * Initializes the RecyclerView with a layout manager and adapter. + */ + private void setupRecyclerView() { + adapter = new PurchaseOrderAdapter(poList, this); + binding.recyclerViewPO.setLayoutManager(new LinearLayoutManager(getContext())); + binding.recyclerViewPO.setAdapter(adapter); + } + + /** + * Sets up the SwipeRefreshLayout to allow manual reloading of purchase order data. + */ + private void setupSwipeRefresh() { + binding.swipeRefreshPO.setOnRefreshListener(this::loadData); + } + + /** + * Fetches purchase order data from the server with active filters and updates the UI. + */ private void loadData() { - if (swipeRefresh != null) - swipeRefresh.setRefreshing(true); - RetrofitClient.getPurchaseOrderApi(requireContext()).getAllPurchaseOrders(0, 100) - .enqueue(new Callback>() { - public void onResponse(Call> c, - Response> r) { - if (swipeRefresh != null) - swipeRefresh.setRefreshing(false); - if (r.isSuccessful() && r.body() != null) { - poList.clear(); - poList.addAll(r.body().getContent()); - filter(etSearch != null ? etSearch.getText().toString() : ""); - } else { - Toast.makeText(getContext(), "Failed to load purchase orders", - Toast.LENGTH_SHORT).show(); - } - } + String query = binding.etSearchPO != null ? binding.etSearchPO.getText().toString().trim() : ""; + if (query.isEmpty()) query = null; - public void onFailure(Call> c, Throwable t) { - if (swipeRefresh != null) - swipeRefresh.setRefreshing(false); - Log.e("POFragment", t.getMessage()); + Long storeId = null; + if (binding.spinnerStore.getSelectedItemPosition() > 0 && !storeList.isEmpty()) { + storeId = storeList.get(binding.spinnerStore.getSelectedItemPosition() - 1).getStoreId(); + } + + viewModel.getAllPurchaseOrders(0, 100, query, storeId, "purchaseOrderId,desc").observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + + // Check the status to see if the resource is loaded and display the data + switch (resource.status) { + case LOADING: + // Show loading indicator + binding.swipeRefreshPO.setRefreshing(true); + break; + case SUCCESS: + // Hide loading indicator and display data + binding.swipeRefreshPO.setRefreshing(false); + if (resource.data != null) { + poList.clear(); + poList.addAll(resource.data.getContent()); + adapter.notifyDataSetChanged(); } - }); + break; + case ERROR: + // Hide loading indicator and toast error message + binding.swipeRefreshPO.setRefreshing(false); + Toast.makeText(getContext(), "Failed to load purchase orders: " + resource.message, Toast.LENGTH_SHORT).show(); + Log.e("POFragment", "Error loading purchase orders: " + resource.message); + break; + } + }); } + /** + * Navigates to the purchase order detail screen for a specific record. + */ private void openDetail(int position) { - PurchaseOrderDetailFragment detail = new PurchaseOrderDetailFragment(); Bundle args = new Bundle(); - PurchaseOrderDTO po = filteredList.get(position); + PurchaseOrderDTO po = poList.get(position); args.putLong("purchaseOrderId", po.getPurchaseOrderId()); - args.putString("supplierName", po.getSupplierName()); - args.putString("orderDate", po.getOrderDate()); - args.putString("status", po.getStatus()); - detail.setArguments(args); - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) - lf.loadFragment(detail); + NavHostFragment.findNavController(this).navigate(R.id.nav_purchase_order_detail, args); } + /** + * Handles item click in the purchase order list. + */ @Override public void onPurchaseOrderClick(int position) { openDetail(position); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/SaleFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/SaleFragment.java index 0fdd2103..99f249db 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/SaleFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/SaleFragment.java @@ -1,97 +1,97 @@ package com.example.petstoremobile.fragments.listfragments; import android.os.Bundle; -import android.text.*; -import android.util.Log; -import android.view.*; -import android.widget.*; +import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; +import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + import com.example.petstoremobile.R; import com.example.petstoremobile.adapters.SaleAdapter; -import com.example.petstoremobile.api.RetrofitClient; -import com.example.petstoremobile.dtos.PageResponse; -import com.example.petstoremobile.dtos.SaleDTO; +import com.example.petstoremobile.api.SaleApi; +import com.example.petstoremobile.databinding.FragmentSaleBinding; import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.detailfragments.SaleDetailFragment; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.example.petstoremobile.fragments.listfragments.detailfragments.RefundFragment; -import java.util.*; -import retrofit2.*; +import com.example.petstoremobile.models.Sale; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickListener { - private List saleList = new ArrayList<>(); - private List filteredList = new ArrayList<>(); + private FragmentSaleBinding binding; + private List saleList = new ArrayList<>(); + private List filteredList = new ArrayList<>(); private SaleAdapter adapter; - private SwipeRefreshLayout swipeRefresh; - private EditText etSearch; + + @Inject SaleApi api; @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_sale, container, false); + binding = FragmentSaleBinding.inflate(inflater, container, false); - setupRecyclerView(view); - setupSearch(view); - setupSwipeRefresh(view); - loadSales(); + setupRecyclerView(); + loadSaleData(); + setupSearch(); + setupSwipeRefresh(); - FloatingActionButton fab = view.findViewById(R.id.fabAddSale); - fab.setOnClickListener(v -> openDetail(-1, null)); - - ImageButton hamburger = view.findViewById(R.id.btnHamburgerSale); - hamburger.setOnClickListener(v -> { - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.openDrawer(); + // Make the hamburger button open the drawer from listFragment + binding.btnHamburger.setOnClickListener(v -> { + Fragment parent = getParentFragment(); + if (parent != null) { + Fragment grandParent = parent.getParentFragment(); + if (grandParent instanceof ListFragment) { + ((ListFragment) grandParent).openDrawer(); + } + } }); - // ← moved inside onCreateView - Button btnRefund = view.findViewById(R.id.btnOpenRefund); - btnRefund.setOnClickListener(v -> { - RefundFragment refundFragment = new RefundFragment(); - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.loadFragment(refundFragment); - }); - - return view; + return binding.getRoot(); } - private void setupRecyclerView(View view) { - RecyclerView rv = view.findViewById(R.id.recyclerViewSales); - adapter = new SaleAdapter(filteredList, this); - rv.setLayoutManager(new LinearLayoutManager(getContext())); - rv.setAdapter(adapter); + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; } - private void setupSearch(View view) { - etSearch = view.findViewById(R.id.etSearchSale); - etSearch.addTextChangedListener(new TextWatcher() { - public void beforeTextChanged(CharSequence s, int a, int b, int c) {} - public void afterTextChanged(Editable s) {} - public void onTextChanged(CharSequence s, int a, int b, int c) { - filter(s.toString()); + private void setupSearch() { + binding.etSearchSale.addTextChangedListener(new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + filterSales(s.toString()); + } + + @Override + public void afterTextChanged(Editable s) { } }); } - private void setupSwipeRefresh(View view) { - swipeRefresh = view.findViewById(R.id.swipeRefreshSale); - swipeRefresh.setOnRefreshListener(this::loadSales); - } - - private void filter(String query) { + private void filterSales(String query) { filteredList.clear(); if (query.isEmpty()) { filteredList.addAll(saleList); } else { String lower = query.toLowerCase(); - for (SaleDTO s : saleList) { - if ((s.getEmployeeName() != null && s.getEmployeeName().toLowerCase().contains(lower)) - || (s.getStoreName() != null && s.getStoreName().toLowerCase().contains(lower)) - || (s.getPaymentMethod() != null && s.getPaymentMethod().toLowerCase().contains(lower))) { + for (Sale s : saleList) { + if (s.getItemName().toLowerCase().contains(lower) + || s.getEmployeeName().toLowerCase().contains(lower) + || s.getSaleDate().toLowerCase().contains(lower) + || s.getPaymentMethod().toLowerCase().contains(lower) + || String.valueOf(s.getSaleId()).contains(lower)) { filteredList.add(s); } } @@ -99,44 +99,47 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis adapter.notifyDataSetChanged(); } - public void loadSales() { - if (swipeRefresh != null) swipeRefresh.setRefreshing(true); - RetrofitClient.getSaleApi(requireContext()).getAllSales(0, 100) - .enqueue(new Callback>() { - public void onResponse(Call> c, - Response> r) { - if (swipeRefresh != null) swipeRefresh.setRefreshing(false); - if (r.isSuccessful() && r.body() != null) { - saleList.clear(); - saleList.addAll(r.body().getContent()); - filter(etSearch != null ? etSearch.getText().toString() : ""); - } else { - Toast.makeText(getContext(), "Failed to load sales", - Toast.LENGTH_SHORT).show(); - } - } - public void onFailure(Call> c, Throwable t) { - if (swipeRefresh != null) swipeRefresh.setRefreshing(false); - Log.e("SaleFragment", t.getMessage()); - } - }); - } - - public void openDetail(int position, SaleDTO sale) { - SaleDetailFragment detail = new SaleDetailFragment(); - Bundle args = new Bundle(); - if (position != -1 && sale != null) { - args.putLong("saleId", sale.getSaleId()); - args.putBoolean("isRefund", Boolean.TRUE.equals(sale.getIsRefund())); - args.putBoolean("viewOnly", true); - } - detail.setArguments(args); - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.loadFragment(detail); + private void setupSwipeRefresh() { + binding.swipeRefreshSale.setOnRefreshListener(() -> { + loadSaleData(); + binding.swipeRefreshSale.setRefreshing(false); + }); } + // When a sale row is clicked, open the refund screen for that sale @Override public void onSaleClick(int position) { - openDetail(position, filteredList.get(position)); + Sale sale = filteredList.get(position); + Bundle args = new Bundle(); + args.putInt("saleId", sale.getSaleId()); + args.putString("saleDate", sale.getSaleDate()); + args.putString("employeeName", sale.getEmployeeName()); + args.putDouble("total", sale.getTotal()); + args.putString("paymentMethod", sale.getPaymentMethod()); + + NavHostFragment.findNavController(this).navigate(R.id.nav_refund_detail, args); } -} \ No newline at end of file + + public void reloadSales() { + loadSaleData(); + } + + // TODO: Replace with actual API call - GET v1/sales + private void loadSaleData() { + saleList.clear(); + saleList.add(new Sale(1, "2026-03-01", "John Smith", "Premium Dog Food", 2, 45.99, 91.98, "Card", false)); + saleList.add(new Sale(2, "2026-03-02", "Jane Doe", "Cat Toy Bundle", 1, 19.99, 19.99, "Cash", false)); + saleList.add(new Sale(3, "2026-03-03", "John Smith", "Pet Shampoo", 3, 12.99, 38.97, "Card", false)); + saleList.add(new Sale(4, "2026-03-04", "Jane Doe", "Dog Bed - Large", 1, 89.99, 89.99, "Cash", true)); + filteredList.clear(); + filteredList.addAll(saleList); + if (adapter != null) + adapter.notifyDataSetChanged(); + } + + private void setupRecyclerView() { + adapter = new SaleAdapter(filteredList, this); + binding.recyclerViewSales.setLayoutManager(new LinearLayoutManager(getContext())); + binding.recyclerViewSales.setAdapter(adapter); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ServiceFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ServiceFragment.java index 1f204114..0923e4b1 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ServiceFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ServiceFragment.java @@ -1,189 +1,241 @@ package com.example.petstoremobile.fragments.listfragments; import android.os.Bundle; - -import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - import android.text.Editable; import android.text.TextWatcher; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.EditText; -import android.widget.ImageButton; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + import com.example.petstoremobile.R; import com.example.petstoremobile.adapters.ServiceAdapter; -import com.example.petstoremobile.api.RetrofitClient; -import com.example.petstoremobile.api.ServiceApi; -import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.databinding.FragmentServiceBinding; import com.example.petstoremobile.dtos.ServiceDTO; import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.detailfragments.ServiceDetailFragment; -import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.example.petstoremobile.utils.BulkDeleteHandler; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.viewmodels.ServiceViewModel; import java.util.ArrayList; import java.util.List; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; +import dagger.hilt.android.AndroidEntryPoint; +/** + * Fragment class for displaying a list of services in a RecyclerView. + */ +@AndroidEntryPoint public class ServiceFragment extends Fragment implements ServiceAdapter.OnServiceClickListener { - private List serviceList = new ArrayList<>(); - private List filteredList = new ArrayList<>(); + private static final String TAG = "ServiceFragment"; + private static final int PAGE_SIZE = 20; + + private FragmentServiceBinding binding; + private final List serviceList = new ArrayList<>(); private ServiceAdapter adapter; - private ImageButton hamburger; - private ServiceApi api; - private SwipeRefreshLayout swipeRefreshLayout; - private EditText etSearch; + private ServiceViewModel viewModel; + private BulkDeleteHandler bulkDeleteHandler; - //load service view + // Pagination + private int currentPage = 0; + private boolean isLastPage = false; + private boolean isLoading = false; + + /** + * Initializes the fragment and its associated ViewModel. + */ @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(ServiceViewModel.class); + } + + /** + * Sets up the fragment's UI components, including RecyclerView, search, and swipe-to-refresh. + */ + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_service, container, false); + binding = FragmentServiceBinding.inflate(inflater, container, false); - api = RetrofitClient.getServiceApi(requireContext()); - hamburger = view.findViewById(R.id.btnHamburger); + setupRecyclerView(); + setupSearch(); + setupSwipeRefresh(); + setupFilterToggle(); + setupBulkDelete(); + loadServices(true); - setupRecyclerView(view); - setupSearch(view); - setupSwipeRefresh(view); - loadServiceData(); + binding.fabAddService.setOnClickListener(v -> openDetail(null)); - //Add button to opens the add dialog - FloatingActionButton fabAddService = view.findViewById(R.id.fabAddService); - fabAddService.setOnClickListener(v -> openServiceDetails(-1)); - - //Make the hamburger button open the drawer from listFragment - hamburger.setOnClickListener(v -> { - ListFragment listFragment = (ListFragment) getParentFragment(); - //if list fragment is found then use its helper function to open the drawer - if (listFragment != null) { - listFragment.openDrawer(); + binding.btnHamburger.setOnClickListener(v -> { + Fragment parent = getParentFragment(); + if (parent != null) { + Fragment grandParent = parent.getParentFragment(); + if (grandParent instanceof ListFragment) { + ((ListFragment) grandParent).openDrawer(); + } } }); - return view; + return binding.getRoot(); } - private void setupSearch(View view) { - etSearch = view.findViewById(R.id.etSearchService); - etSearch.addTextChangedListener(new TextWatcher() { + private void setupBulkDelete() { + bulkDeleteHandler = new BulkDeleteHandler( + this, + binding.layoutBulkDelete, + binding.tvSelectionCount, + binding.btnBulkDelete, + adapter, + "service", + viewModel::bulkDeleteServices, + () -> loadServices(true) + ); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + /** + * Sets up the filter toggle button to show/hide the filter layout. + */ + private void setupFilterToggle() { + binding.btnToggleFilter.setOnClickListener(v -> { + if (binding.layoutFilter.getVisibility() == View.GONE) { + binding.layoutFilter.setVisibility(View.VISIBLE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + binding.layoutFilter.setVisibility(View.GONE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_search); + + // Reset filters when closing + binding.etSearchService.setText(""); + } + }); + } + + /** + * Sets up the search bar for filtering. + */ + private void setupSearch() { + binding.etSearchService.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} @Override public void onTextChanged(CharSequence s, int start, int before, int count) { - filterServices(s.toString()); + loadServices(true); } @Override public void afterTextChanged(Editable s) {} }); } - private void filterServices(String query) { - filteredList.clear(); - if (query.isEmpty()) { - filteredList.addAll(serviceList); - } else { - String lower = query.toLowerCase(); - for (ServiceDTO s : serviceList) { - if (s.getServiceName().toLowerCase().contains(lower) - || s.getServiceDesc().toLowerCase().contains(lower)) { - filteredList.add(s); + /** + * Initializes the RecyclerView with a layout manager and adapter. + */ + private void setupRecyclerView() { + adapter = new ServiceAdapter(serviceList, this); + binding.recyclerViewServices.setLayoutManager(new LinearLayoutManager(getContext())); + binding.recyclerViewServices.setAdapter(adapter); + + binding.recyclerViewServices.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + if (dy <= 0) return; + LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewServices.getLayoutManager(); + if (lm == null) return; + int visible = lm.getChildCount(); + int total = lm.getItemCount(); + int firstVis = lm.findFirstVisibleItemPosition(); + if (!isLoading && !isLastPage && (visible + firstVis) >= total - 3) { + loadServices(false); } } - } - adapter.notifyDataSetChanged(); - } - - private void setupSwipeRefresh(View view) { - swipeRefreshLayout = view.findViewById(R.id.swipeRefreshService); - swipeRefreshLayout.setOnRefreshListener(() -> { - loadServiceData(); }); } - //Open the service detail view depending on the mode - private void openServiceDetails(int position) { - ServiceDetailFragment detailFragment = new ServiceDetailFragment(); - - //Make a bundle to pass data to the detail fragment - Bundle args = new Bundle(); - args.putInt("position", position); - - //if editing a service, add the service data to the bundle - if (position != -1) { - ServiceDTO service = filteredList.get(position); - args.putInt("serviceId", service.getServiceId().intValue()); - args.putString("serviceName", service.getServiceName()); - args.putString("serviceDesc", service.getServiceDesc()); - args.putInt("serviceDuration", service.getServiceDuration()); - args.putDouble("servicePrice", service.getServicePrice()); - } - - //send the bundle to the detail fragment to display - detailFragment.setArguments(args); - //set the service fragment to the parent so we refer back to service view when save or delete is done - detailFragment.setServiceFragment(this); - - //get ListFragment to load the the detail view - ListFragment listFragment = (ListFragment) getParentFragment(); - if (listFragment != null) { - listFragment.loadFragment(detailFragment); - } + /** + * Sets up the SwipeRefreshLayout. + */ + private void setupSwipeRefresh() { + binding.swipeRefreshService.setOnRefreshListener(() -> loadServices(true)); + } + + /** + * Fetches a page of services from the API. + */ + private void loadServices(boolean reset) { + if (isLoading) return; + + if (reset) { + currentPage = 0; + isLastPage = false; + } + + String query = binding.etSearchService.getText().toString().trim(); + if (query.isEmpty()) query = null; + + viewModel.getAllServices(currentPage, PAGE_SIZE, query, "serviceName").observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + + switch (resource.status) { + case LOADING: + isLoading = true; + binding.swipeRefreshService.setRefreshing(true); + break; + case SUCCESS: + isLoading = false; + binding.swipeRefreshService.setRefreshing(false); + if (resource.data != null) { + if (reset) serviceList.clear(); + serviceList.addAll(resource.data.getContent()); + adapter.notifyDataSetChanged(); + isLastPage = resource.data.isLast(); + if (!isLastPage) currentPage++; + } + break; + case ERROR: + isLoading = false; + binding.swipeRefreshService.setRefreshing(false); + Log.e(TAG, "Error: " + resource.message); + Toast.makeText(getContext(), "Failed to load services: " + resource.message, Toast.LENGTH_SHORT).show(); + break; + } + }); + } + + /** + * Navigates to the service detail screen. + */ + private void openDetail(ServiceDTO service) { + Bundle args = new Bundle(); + if (service != null) { + args.putLong("serviceId", service.getServiceId()); + } + NavHostFragment.findNavController(this).navigate(R.id.nav_service_detail, args); } - // Called by ServiceAdapter when a row is clicked to open the details view @Override public void onServiceClick(int position) { - openServiceDetails(position); - } - - // Helper function to get a list of all services from the backend - private void loadServiceData() { - if (swipeRefreshLayout != null) { - swipeRefreshLayout.setRefreshing(true); + if (position >= 0 && position < serviceList.size()) { + openDetail(serviceList.get(position)); } - api.getAllServices(0, 100).enqueue(new Callback>() { - @Override - public void onResponse(Call> call, Response> response) { - if (swipeRefreshLayout != null) { - swipeRefreshLayout.setRefreshing(false); - } - if (response.isSuccessful() && response.body() != null) { - serviceList.clear(); - serviceList.addAll(response.body().getContent()); - filterServices(etSearch.getText().toString()); - - } else { - Log.e("onResponse: ", response.message()); - } - } - - @Override - public void onFailure(Call> call, Throwable t) { - if (swipeRefreshLayout != null) { - swipeRefreshLayout.setRefreshing(false); - } - if (getContext() != null) { - Toast.makeText(getContext(), - "Failed to load services", Toast.LENGTH_SHORT).show(); - } - Log.e("onFailure: ", t.getMessage()); - } - }); } - //set up the recyclerview and adapter - private void setupRecyclerView(View view) { - RecyclerView recyclerView = view.findViewById(R.id.recyclerViewServices); - adapter = new ServiceAdapter(filteredList, this); - recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - recyclerView.setAdapter(adapter); + @Override + public void onSelectionChanged(int count) { + if (bulkDeleteHandler != null) { + bulkDeleteHandler.onSelectionChanged(count); + } } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/SupplierFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/SupplierFragment.java index 0d75da78..6d6f28ac 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/SupplierFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/SupplierFragment.java @@ -2,10 +2,12 @@ package com.example.petstoremobile.fragments.listfragments; import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import android.text.Editable; import android.text.TextWatcher; @@ -13,180 +15,205 @@ import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.EditText; -import android.widget.ImageButton; import android.widget.Toast; import com.example.petstoremobile.R; import com.example.petstoremobile.adapters.SupplierAdapter; -import com.example.petstoremobile.api.RetrofitClient; -import com.example.petstoremobile.api.SupplierApi; -import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.databinding.FragmentSupplierBinding; import com.example.petstoremobile.dtos.SupplierDTO; import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.detailfragments.SupplierDetailFragment; -import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.example.petstoremobile.utils.BulkDeleteHandler; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.viewmodels.SupplierViewModel; import java.util.ArrayList; import java.util.List; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; +import dagger.hilt.android.AndroidEntryPoint; +@AndroidEntryPoint public class SupplierFragment extends Fragment implements SupplierAdapter.OnSupplierClickListener { + private FragmentSupplierBinding binding; private List supplierList = new ArrayList<>(); - private List filteredList = new ArrayList<>(); private SupplierAdapter adapter; - private ImageButton hamburger; - private SupplierApi api; - private SwipeRefreshLayout swipeRefreshLayout; - private EditText etSearch; + private SupplierViewModel viewModel; + private BulkDeleteHandler bulkDeleteHandler; - //load supplier view + /** + * Initializes the fragment and its associated SupplierViewModel. + */ @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(SupplierViewModel.class); + } + + /** + * Sets up the fragment's UI components, including RecyclerView, search, and swipe-to-refresh. + */ + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_supplier, container, false); + binding = FragmentSupplierBinding.inflate(inflater, container, false); - api = RetrofitClient.getSupplierApi(requireContext()); - hamburger = view.findViewById(R.id.btnHamburger); - - setupRecyclerView(view); - setupSearch(view); - setupSwipeRefresh(view); + setupRecyclerView(); + setupSearch(); + setupSwipeRefresh(); + setupFilterToggle(); + setupBulkDelete(); loadSupplierData(); //Add button to opens the add dialog - FloatingActionButton fabAddSupplier = view.findViewById(R.id.fabAddSupplier); - fabAddSupplier.setOnClickListener(v -> openSupplierDetails(-1)); + binding.fabAddSupplier.setOnClickListener(v -> openSupplierDetails(-1)); //Make the hamburger button open the drawer from listFragment - hamburger.setOnClickListener(v -> { - ListFragment listFragment = (ListFragment) getParentFragment(); - //if list fragment is found then use its helper function to open the drawer - if (listFragment != null) { - listFragment.openDrawer(); + binding.btnHamburger.setOnClickListener(v -> { + Fragment parent = getParentFragment(); + if (parent != null) { + Fragment grandParent = parent.getParentFragment(); + if (grandParent instanceof ListFragment) { + ((ListFragment) grandParent).openDrawer(); + } } }); - return view; + return binding.getRoot(); } - private void setupSearch(View view) { - etSearch = view.findViewById(R.id.etSearchSupplier); - etSearch.addTextChangedListener(new TextWatcher() { + private void setupBulkDelete() { + bulkDeleteHandler = new BulkDeleteHandler( + this, + binding.layoutBulkDelete, + binding.tvSelectionCount, + binding.btnBulkDelete, + adapter, + "supplier", + viewModel::bulkDeleteSuppliers, + this::loadSupplierData + ); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + /** + * Sets up the filter toggle button to show/hide the filter layout. + */ + private void setupFilterToggle() { + binding.btnToggleFilter.setOnClickListener(v -> { + if (binding.layoutFilter.getVisibility() == View.GONE) { + binding.layoutFilter.setVisibility(View.VISIBLE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + binding.layoutFilter.setVisibility(View.GONE); + binding.btnToggleFilter.setImageResource(android.R.drawable.ic_menu_search); + + // Reset search when closing + binding.etSearchSupplier.setText(""); + } + }); + } + + /** + * Configures the search bar for filtering. + */ + private void setupSearch() { + binding.etSearchSupplier.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} @Override public void onTextChanged(CharSequence s, int start, int before, int count) { - filterSuppliers(s.toString()); + loadSupplierData(); } @Override public void afterTextChanged(Editable s) {} }); } - private void filterSuppliers(String query) { - filteredList.clear(); - if (query.isEmpty()) { - filteredList.addAll(supplierList); - } else { - String lower = query.toLowerCase(); - for (SupplierDTO s : supplierList) { - if (s.getSupCompany().toLowerCase().contains(lower) - || s.getSupContactFirstName().toLowerCase().contains(lower) - || s.getSupContactLastName().toLowerCase().contains(lower)) { - filteredList.add(s); - } - } - } - adapter.notifyDataSetChanged(); + /** + * Sets up the SwipeRefreshLayout to allow manual reloading of supplier data. + */ + private void setupSwipeRefresh() { + binding.swipeRefreshSupplier.setOnRefreshListener(this::loadSupplierData); } - private void setupSwipeRefresh(View view) { - swipeRefreshLayout = view.findViewById(R.id.swipeRefreshSupplier); - swipeRefreshLayout.setOnRefreshListener(() -> { - loadSupplierData(); - }); - } - - //Open the supplier detail view depending on the mode + /** + * Navigates to the supplier detail screen for editing an existing record or adding a new one. + */ private void openSupplierDetails(int position) { - SupplierDetailFragment detailFragment = new SupplierDetailFragment(); - //Make a bundle to pass data to the detail fragment Bundle args = new Bundle(); - args.putInt("position", position); - //if editing a supplier, add the supplier data to the bundle + //if editing a supplier, add the supplier id to the bundle if (position != -1) { - SupplierDTO supplier = filteredList.get(position); - args.putInt("supId", supplier.getSupId().intValue()); - args.putString("supCompany", supplier.getSupCompany()); - args.putString("supContactFirstName", supplier.getSupContactFirstName()); - args.putString("supContactLastName", supplier.getSupContactLastName()); - args.putString("supEmail", supplier.getSupEmail()); - args.putString("supPhone", supplier.getSupPhone()); + SupplierDTO supplier = supplierList.get(position); + args.putLong("supId", supplier.getSupId()); } - //send the bundle to the detail fragment to display - detailFragment.setArguments(args); - //set the supplier fragment to the parent so we refer back to supplier view when save or delete is done - detailFragment.setSupplierFragment(this); - - //get ListFragment to load the the detail view - ListFragment listFragment = (ListFragment) getParentFragment(); - if (listFragment != null) { - listFragment.loadFragment(detailFragment); - } + NavHostFragment.findNavController(this).navigate(R.id.nav_supplier_detail, args); } - // Called by SupplierAdapter when a row is clicked to open the details view + /** + * Handles item click in the supplier list. + */ @Override public void onSupplierClick(int position) { openSupplierDetails(position); } - // Helper function to get a list of all suppliers from the backend - private void loadSupplierData() { - if (swipeRefreshLayout != null) { - swipeRefreshLayout.setRefreshing(true); + @Override + public void onSelectionChanged(int count) { + if (bulkDeleteHandler != null) { + bulkDeleteHandler.onSelectionChanged(count); } - api.getAllSuppliers(0, 100).enqueue(new Callback>() { - @Override - public void onResponse(Call> call, Response> response) { - if (swipeRefreshLayout != null) { - swipeRefreshLayout.setRefreshing(false); - } - if (response.isSuccessful() && response.body() != null) { - supplierList.clear(); - supplierList.addAll(response.body().getContent()); - filterSuppliers(etSearch.getText().toString()); + } - } else { - Log.e("onResponse: ", response.message()); - } - } + /** + * Fetches all supplier data from the server through the ViewModel and updates the UI. + */ + private void loadSupplierData() { + String query = binding.etSearchSupplier != null ? binding.etSearchSupplier.getText().toString().trim() : ""; + if (query.isEmpty()) query = null; - @Override - public void onFailure(Call> call, Throwable t) { - if (swipeRefreshLayout != null) { - swipeRefreshLayout.setRefreshing(false); - } - if (getContext() != null) { - Toast.makeText(getContext(), - "Failed to load suppliers", Toast.LENGTH_SHORT).show(); - } - Log.e("onFailure: ", t.getMessage()); + //Load suppliers from the backend with query and default sort + viewModel.getAllSuppliers(0, 100, query, "supCompany").observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + + // Check the status to see if the resource is loaded and display the data + switch (resource.status) { + case LOADING: + // Show loading indicator + binding.swipeRefreshSupplier.setRefreshing(true); + break; + case SUCCESS: + // Hide loading indicator and display data + binding.swipeRefreshSupplier.setRefreshing(false); + if (resource.data != null) { + supplierList.clear(); + supplierList.addAll(resource.data.getContent()); + adapter.notifyDataSetChanged(); + } + break; + case ERROR: + // Hide loading indicator and toast error message + binding.swipeRefreshSupplier.setRefreshing(false); + if (getContext() != null) { + Toast.makeText(getContext(), "Failed to load suppliers: " + resource.message, Toast.LENGTH_SHORT).show(); + } + Log.e("SupplierFragment", "Error loading suppliers: " + resource.message); + break; } }); } - //set up the recyclerview and adapter - private void setupRecyclerView(View view) { - RecyclerView recyclerView = view.findViewById(R.id.recyclerViewSuppliers); - adapter = new SupplierAdapter(filteredList, this); - recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - recyclerView.setAdapter(adapter); + /** + * Initializes the RecyclerView with a layout manager and adapter for displaying suppliers. + */ + private void setupRecyclerView() { + adapter = new SupplierAdapter(supplierList, this); + binding.recyclerViewSuppliers.setLayoutManager(new LinearLayoutManager(getContext())); + binding.recyclerViewSuppliers.setAdapter(adapter); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/AdoptionDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/AdoptionDetailFragment.java index 0b38920c..7098b795 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/AdoptionDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/AdoptionDetailFragment.java @@ -2,78 +2,109 @@ package com.example.petstoremobile.fragments.listfragments.detailfragments; import android.app.DatePickerDialog; import android.os.Bundle; -import android.util.Log; import android.view.*; import android.widget.*; import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; -import com.example.petstoremobile.R; -import com.example.petstoremobile.api.*; -import com.example.petstoremobile.dtos.*; -import com.example.petstoremobile.fragments.ListFragment; -import java.util.*; -import retrofit2.*; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; +import com.example.petstoremobile.databinding.FragmentAdoptionDetailBinding; +import com.example.petstoremobile.dtos.*; +import com.example.petstoremobile.utils.DialogUtils; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.SpinnerUtils; +import com.example.petstoremobile.viewmodels.AdoptionViewModel; +import com.example.petstoremobile.viewmodels.CustomerViewModel; +import com.example.petstoremobile.viewmodels.PetViewModel; +import com.example.petstoremobile.viewmodels.StoreViewModel; +import com.example.petstoremobile.viewmodels.UserViewModel; + +import java.math.BigDecimal; +import java.util.*; + +import dagger.hilt.android.AndroidEntryPoint; + +/** + * Fragment for displaying and editing adoption request details. + */ +@AndroidEntryPoint public class AdoptionDetailFragment extends Fragment { - private TextView tvMode, tvAdoptionId; - private EditText etAdoptionDate; - private Spinner spinnerPet, spinnerCustomer, spinnerEmployee, spinnerStatus; - private Button btnSave, btnDelete, btnBack; + private FragmentAdoptionDetailBinding binding; private long adoptionId = -1; private boolean isEditing = false; private long preselectedPetId = -1; private long preselectedCustomerId = -1; - - private List employeeList = new ArrayList<>(); + private long preselectedStoreId = -1; private long preselectedEmployeeId = -1; private List petList = new ArrayList<>(); private List customerList = new ArrayList<>(); + private List storeList = new ArrayList<>(); + private List employeeList = new ArrayList<>(); - private final String[] STATUSES = {"Pending", "Approved", "Rejected"}; + private final String[] STATUSES = {"Pending", "Completed", "Cancelled"}; + + private AdoptionViewModel adoptionViewModel; + private PetViewModel petViewModel; + private CustomerViewModel customerViewModel; + private StoreViewModel storeViewModel; + private UserViewModel userViewModel; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + adoptionViewModel = new ViewModelProvider(this).get(AdoptionViewModel.class); + petViewModel = new ViewModelProvider(this).get(PetViewModel.class); + customerViewModel = new ViewModelProvider(this).get(CustomerViewModel.class); + storeViewModel = new ViewModelProvider(this).get(StoreViewModel.class); + userViewModel = new ViewModelProvider(this).get(UserViewModel.class); + } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_adoption_detail, container, false); - initViews(view); + binding = FragmentAdoptionDetailBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); setupSpinners(); setupDatePicker(); - loadData(); + loadSpinnersData(); handleArguments(); - btnBack.setOnClickListener(v -> navigateBack()); - btnSave.setOnClickListener(v -> saveAdoption()); - btnDelete.setOnClickListener(v -> confirmDelete()); - return view; + binding.btnAdoptionBack.setOnClickListener(v -> navigateBack()); + binding.btnSaveAdoption.setOnClickListener(v -> saveAdoption()); + binding.btnDeleteAdoption.setOnClickListener(v -> confirmDelete()); } - private void initViews(View v) { - tvMode = v.findViewById(R.id.tvAdoptionMode); - tvAdoptionId = v.findViewById(R.id.tvAdoptionId); - etAdoptionDate = v.findViewById(R.id.etAdoptionDate); - spinnerPet = v.findViewById(R.id.spinnerAdoptionPet); - spinnerCustomer= v.findViewById(R.id.spinnerAdoptionCustomer); - spinnerEmployee = v.findViewById(R.id.spinnerAdoptionEmployee); - spinnerStatus = v.findViewById(R.id.spinnerAdoptionStatus); - btnSave = v.findViewById(R.id.btnSaveAdoption); - btnDelete = v.findViewById(R.id.btnDeleteAdoption); - btnBack = v.findViewById(R.id.btnAdoptionBack); + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; } + /** + * Configures the spinner for adoption status. + */ private void setupSpinners() { - spinnerStatus.setAdapter(new ArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, STATUSES)); + SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerAdoptionStatus, STATUSES); } + /** + * Configures the date picker dialog for the adoption date field. + */ private void setupDatePicker() { - etAdoptionDate.setOnClickListener(v -> { + binding.etAdoptionDate.setOnClickListener(v -> { Calendar c = Calendar.getInstance(); new DatePickerDialog(requireContext(), - (dp, y, m, d) -> etAdoptionDate.setText( + (dp, y, m, d) -> binding.etAdoptionDate.setText( String.format("%04d-%02d-%02d", y, m + 1, d)), c.get(Calendar.YEAR), c.get(Calendar.MONTH), @@ -81,217 +112,238 @@ public class AdoptionDetailFragment extends Fragment { }); } - private void loadData() { + /** + * Fetches required data for spinners from the backend. + */ + private void loadSpinnersData() { loadPets(); loadCustomers(); + loadStores(); loadEmployees(); } + /** + * Loads the list of pets from the API. + */ private void loadPets() { - RetrofitClient.getPetApi(requireContext()).getAllPets(0, 200) - .enqueue(new Callback>() { - public void onResponse(Call> c, - Response> r) { - if (r.isSuccessful() && r.body() != null) { - petList = r.body().getContent(); - populatePetSpinner(); - } - } - public void onFailure(Call> c, Throwable t) { - Log.e("ADOPTION", "Pet load failed: " + t.getMessage()); - } - }); - } - - private void populatePetSpinner() { - List names = new ArrayList<>(); - names.add("-- Select Pet --"); - for (PetDTO p : petList) names.add(p.getPetName()); - spinnerPet.setAdapter(new ArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, names)); - if (preselectedPetId != -1) { - for (int i = 0; i < petList.size(); i++) { - if (petList.get(i).getPetId().equals(preselectedPetId)) { - spinnerPet.setSelection(i + 1); break; - } + petViewModel.getAllPets(0, 200, null, null, null, null, "petName").observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + petList = resource.data.getContent(); + refreshPetSpinner(); } - } + }); } + /** + * Populates the pet selection spinner with data. + */ + private void refreshPetSpinner() { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerAdoptionPet, petList, + PetDTO::getPetName, "-- Select Pet --", + preselectedPetId, PetDTO::getPetId); + } + + /** + * Loads the list of customers from the API. + */ private void loadCustomers() { - RetrofitClient.getCustomerApi(requireContext()).getAllCustomers(0, 200) - .enqueue(new Callback>() { - public void onResponse(Call> c, - Response> r) { - if (r.isSuccessful() && r.body() != null) { - customerList = r.body().getContent(); - populateCustomerSpinner(); - } - } - public void onFailure(Call> c, Throwable t) { - Log.e("ADOPTION", "Customer load failed: " + t.getMessage()); - } - }); - } - - private void populateCustomerSpinner() { - List names = new ArrayList<>(); - names.add("-- Select Customer --"); - for (CustomerDTO c : customerList) - names.add(c.getFirstName() + " " + c.getLastName()); - spinnerCustomer.setAdapter(new ArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, names)); - if (preselectedCustomerId != -1) { - for (int i = 0; i < customerList.size(); i++) { - if (customerList.get(i).getCustomerId().equals(preselectedCustomerId)) { - spinnerCustomer.setSelection(i + 1); break; - } + customerViewModel.getAllCustomers(0, 200).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + customerList = resource.data.getContent(); + refreshCustomerSpinner(); } - } + }); } + /** + * Populates the customer selection spinner with data. + */ + private void refreshCustomerSpinner() { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerAdoptionCustomer, customerList, + item -> item.getFirstName() + " " + item.getLastName(), + "-- Select Customer --", + preselectedCustomerId, CustomerDTO::getCustomerId); + } + + /** + * Loads the list of stores from the API. + */ + private void loadStores() { + storeViewModel.getAllStores(0, 200).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + storeList = resource.data.getContent(); + refreshStoreSpinner(); + } + }); + } + + /** + * Populates the store selection spinner with data. + */ + private void refreshStoreSpinner() { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerAdoptionStore, storeList, + StoreDTO::getStoreName, "-- Select Store --", + preselectedStoreId, StoreDTO::getStoreId); + } + + /** + * Loads the list of employees from the API. + */ private void loadEmployees() { - RetrofitClient.getEmployeeApi(requireContext()).getAllEmployees(0, 100) - .enqueue(new Callback>() { - public void onResponse(Call> c, - Response> r) { - if (r.isSuccessful() && r.body() != null) { - employeeList = r.body().getContent(); - List names = new ArrayList<>(); - names.add("-- Select Employee --"); - for (EmployeeDTO e : employeeList) - names.add(e.getFullName() + " (" + e.getRole() + ")"); - spinnerEmployee.setAdapter(new ArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, names)); - if (preselectedEmployeeId != -1) { - for (int i = 0; i < employeeList.size(); i++) { - if (employeeList.get(i).getEmployeeId() == preselectedEmployeeId) { - spinnerEmployee.setSelection(i + 1); break; - } - } - } - } - } - public void onFailure(Call> c, Throwable t) { - Log.e("ADOPTION", "Employee load failed: " + t.getMessage()); - } - }); + userViewModel.getUsers("STAFF", 0, 100).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + employeeList = resource.data.getContent(); + refreshEmployeeSpinner(); + } + }); } + /** + * Populates the employee selection spinner with data. + */ + private void refreshEmployeeSpinner() { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerAdoptionEmployee, employeeList, + UserDTO::getFullName, "-- Select Staff --", + preselectedEmployeeId, UserDTO::getId); + } + + /** + * Handles arguments to determine if the fragment is in edit or add mode. + */ private void handleArguments() { Bundle a = getArguments(); if (a != null && a.containsKey("adoptionId")) { isEditing = true; - adoptionId = a.getLong("adoptionId"); - preselectedPetId = a.getLong("petId", -1); - preselectedCustomerId = a.getLong("customerId", -1); - preselectedEmployeeId = a.getLong("employeeId", -1); - tvMode.setText("Edit Adoption"); - tvAdoptionId.setText("ID: " + adoptionId); - tvAdoptionId.setVisibility(View.VISIBLE); - etAdoptionDate.setText(a.getString("adoptionDate")); - btnDelete.setVisibility(View.VISIBLE); - - // Pre-fill status - String status = a.getString("adoptionStatus", "Pending"); - for (int i = 0; i < STATUSES.length; i++) { - if (STATUSES[i].equals(status)) { - spinnerStatus.setSelection(i); break; - } - } + adoptionId = a.getLong("adoptionId"); + binding.tvAdoptionMode.setText("Edit Adoption"); + binding.tvAdoptionId.setText("ID: " + adoptionId); + binding.tvAdoptionId.setVisibility(View.VISIBLE); + binding.btnDeleteAdoption.setVisibility(View.VISIBLE); + loadAdoptionData(); } else { - tvMode.setText("Add Adoption"); - btnDelete.setVisibility(View.GONE); - tvAdoptionId.setVisibility(View.GONE); + binding.tvAdoptionMode.setText("Add Adoption"); + binding.btnDeleteAdoption.setVisibility(View.GONE); + binding.tvAdoptionId.setVisibility(View.GONE); } } + /** + * Fetches specific adoption details from the backend using the ID. + */ + private void loadAdoptionData() { + adoptionViewModel.getAdoptionById(adoptionId).observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + AdoptionDTO a = resource.data; + preselectedPetId = a.getPetId() != null ? a.getPetId() : -1; + preselectedCustomerId = a.getCustomerId() != null ? a.getCustomerId() : -1; + preselectedStoreId = a.getSourceStoreId() != null ? a.getSourceStoreId() : -1; + preselectedEmployeeId = a.getEmployeeId() != null ? a.getEmployeeId() : -1; + + binding.etAdoptionDate.setText(a.getAdoptionDate()); + binding.etAdoptionFee.setText(a.getAdoptionFee() != null ? a.getAdoptionFee().toString() : ""); + SpinnerUtils.setSelectionByValue(binding.spinnerAdoptionStatus, a.getAdoptionStatus()); + + refreshPetSpinner(); + refreshCustomerSpinner(); + refreshStoreSpinner(); + refreshEmployeeSpinner(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Failed to load adoption: " + resource.message, Toast.LENGTH_SHORT).show(); + } + }); + } + + /** + * Validates input and saves the adoption request to the backend. + */ private void saveAdoption() { - if (spinnerCustomer.getSelectedItemPosition() == 0) { + if (binding.spinnerAdoptionCustomer.getSelectedItemPosition() == 0) { Toast.makeText(getContext(), "Select a customer", Toast.LENGTH_SHORT).show(); return; } - if (spinnerPet.getSelectedItemPosition() == 0) { + if (binding.spinnerAdoptionPet.getSelectedItemPosition() == 0) { Toast.makeText(getContext(), "Select a pet", Toast.LENGTH_SHORT).show(); return; } - Long employeeId = null; - if (spinnerEmployee.getSelectedItemPosition() > 0) { - employeeId = employeeList - .get(spinnerEmployee.getSelectedItemPosition() - 1) - .getEmployeeId(); + if (binding.spinnerAdoptionStore.getSelectedItemPosition() == 0) { + Toast.makeText(getContext(), "Select a store", Toast.LENGTH_SHORT).show(); return; } - String date = etAdoptionDate.getText().toString().trim(); + String date = binding.etAdoptionDate.getText().toString().trim(); if (date.isEmpty()) { Toast.makeText(getContext(), "Select a date", Toast.LENGTH_SHORT).show(); return; } - CustomerDTO customer = customerList.get(spinnerCustomer.getSelectedItemPosition() - 1); - PetDTO pet = petList.get(spinnerPet.getSelectedItemPosition() - 1); - String status = STATUSES[spinnerStatus.getSelectedItemPosition()]; + BigDecimal fee = BigDecimal.ZERO; + String feeStr = binding.etAdoptionFee.getText().toString().trim(); + if (!feeStr.isEmpty()) { + try { + fee = new BigDecimal(feeStr); + } catch (NumberFormatException e) { + Toast.makeText(getContext(), "Invalid fee format", Toast.LENGTH_SHORT).show(); + return; + } + } + + CustomerDTO customer = customerList.get(binding.spinnerAdoptionCustomer.getSelectedItemPosition() - 1); + PetDTO pet = petList.get(binding.spinnerAdoptionPet.getSelectedItemPosition() - 1); + StoreDTO store = storeList.get(binding.spinnerAdoptionStore.getSelectedItemPosition() - 1); + + Long employeeId = null; + if (binding.spinnerAdoptionEmployee.getSelectedItemPosition() > 0) { + employeeId = employeeList.get(binding.spinnerAdoptionEmployee.getSelectedItemPosition() - 1).getId(); + } + + String status = STATUSES[binding.spinnerAdoptionStatus.getSelectedItemPosition()]; AdoptionDTO dto = new AdoptionDTO( pet.getPetId(), customer.getCustomerId(), + employeeId, + store.getStoreId(), date, status, - employeeId + fee ); - Log.d("ADOPTION_SAVE", "petId=" + pet.getPetId() - + " customerId=" + customer.getCustomerId() - + " date=" + date + " status=" + status); - - AdoptionApi api = RetrofitClient.getAdoptionApi(requireContext()); if (isEditing) { - api.updateAdoption(adoptionId, dto).enqueue(simpleCallback("Updated")); + adoptionViewModel.updateAdoption(adoptionId, dto).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), "Updated", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Error: " + resource.message, Toast.LENGTH_SHORT).show(); + } + }); } else { - api.createAdoption(dto).enqueue(simpleCallback("Saved")); + adoptionViewModel.createAdoption(dto).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), "Saved", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Error: " + resource.message, Toast.LENGTH_SHORT).show(); + } + }); } } - private Callback simpleCallback(String msg) { - return new Callback<>() { - public void onResponse(Call c, Response r) { - Log.d("ADOPTION_SAVE", "Response: " + r.code()); - if (r.isSuccessful()) { - Toast.makeText(getContext(), msg, Toast.LENGTH_SHORT).show(); - navigateBack(); - } else { - try { - String err = r.errorBody().string(); - Log.e("ADOPTION_SAVE", "Error: " + err); - Toast.makeText(getContext(), "Error " + r.code(), Toast.LENGTH_SHORT).show(); - } catch (Exception e) { - Log.e("ADOPTION_SAVE", "Failed to read error"); - } - } - } - public void onFailure(Call c, Throwable t) { - Log.e("ADOPTION_SAVE", "Failure: " + t.getMessage()); - Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); - } - }; - } - + /** + * Shows a confirmation dialog before deleting an adoption request. + */ private void confirmDelete() { - new AlertDialog.Builder(requireContext()) - .setTitle("Delete Adoption?") - .setPositiveButton("Yes", (d, w) -> - RetrofitClient.getAdoptionApi(requireContext()) - .deleteAdoption(adoptionId) - .enqueue(new Callback() { - public void onResponse(Call c, Response r) { - navigateBack(); - } - public void onFailure(Call c, Throwable t) { - Toast.makeText(getContext(), "Delete failed", - Toast.LENGTH_SHORT).show(); - } - })) - .setNegativeButton("No", null).show(); + DialogUtils.showDeleteConfirmDialog(requireContext(), "Adoption", () -> + adoptionViewModel.deleteAdoption(adoptionId).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), "Deleted", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Delete failed: " + resource.message, Toast.LENGTH_SHORT).show(); + } + })); } + /** + * Navigates back to the previous fragment. + */ private void navigateBack() { - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.getChildFragmentManager().popBackStack(); + NavHostFragment.findNavController(this).popBackStack(); } -} \ No newline at end of file +} 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 573e26e4..7757f156 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 @@ -6,22 +6,34 @@ import android.util.Log; import android.view.*; import android.widget.*; import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; -import com.example.petstoremobile.R; -import com.example.petstoremobile.api.*; -import com.example.petstoremobile.dtos.*; -import com.example.petstoremobile.fragments.ListFragment; -import java.util.*; -import retrofit2.*; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; +import com.example.petstoremobile.databinding.FragmentAppointmentDetailBinding; +import com.example.petstoremobile.dtos.*; +import com.example.petstoremobile.utils.DialogUtils; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.SpinnerUtils; +import com.example.petstoremobile.viewmodels.AppointmentViewModel; +import com.example.petstoremobile.viewmodels.CustomerViewModel; +import com.example.petstoremobile.viewmodels.PetViewModel; +import com.example.petstoremobile.viewmodels.ServiceViewModel; +import com.example.petstoremobile.viewmodels.StoreViewModel; +import com.example.petstoremobile.viewmodels.UserViewModel; + +import java.util.*; + +import dagger.hilt.android.AndroidEntryPoint; + +/** + * Fragment for displaying and editing appointment details. + */ +@AndroidEntryPoint public class AppointmentDetailFragment extends Fragment { - private TextView tvMode, tvAppointmentId; - private EditText etAppointmentDate; - private Spinner spinnerPet, spinnerService, spinnerStatus, spinnerHour, spinnerMinute; - private Spinner spinnerCustomer, spinnerStore; - private Button btnSave, btnDelete, btnBack; + private FragmentAppointmentDetailBinding binding; private long appointmentId = -1; private boolean isEditing = false; @@ -29,66 +41,82 @@ public class AppointmentDetailFragment extends Fragment { private long preselectedServiceId = -1; private long preselectedCustomerId = -1; private long preselectedStoreId = -1; + private long preselectedStaffId = -1; private List petList = new ArrayList<>(); private List serviceList = new ArrayList<>(); private List customerList = new ArrayList<>(); private List storeList = new ArrayList<>(); - private List allAppointments = new ArrayList<>(); + private List staffList = new ArrayList<>(); private final Integer[] HOURS = {9,10,11,12,13,14,15,16,17}; private final Integer[] MINUTES = {0,15,30,45}; - private final String[] STATUSES = {"Booked","Completed","Cancelled"}; + + private AppointmentViewModel appointmentViewModel; + private PetViewModel petViewModel; + private ServiceViewModel serviceViewModel; + private StoreViewModel storeViewModel; + private CustomerViewModel customerViewModel; + private UserViewModel userViewModel; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + appointmentViewModel = new ViewModelProvider(this).get(AppointmentViewModel.class); + petViewModel = new ViewModelProvider(this).get(PetViewModel.class); + serviceViewModel = new ViewModelProvider(this).get(ServiceViewModel.class); + storeViewModel = new ViewModelProvider(this).get(StoreViewModel.class); + customerViewModel = new ViewModelProvider(this).get(CustomerViewModel.class); + userViewModel = new ViewModelProvider(this).get(UserViewModel.class); + } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_appointment_detail, container, false); - initViews(view); + binding = FragmentAppointmentDetailBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); setupSpinners(); setupDatePicker(); - loadData(); + loadSpinnersData(); handleArguments(); - btnBack.setOnClickListener(v -> navigateBack()); - btnSave.setOnClickListener(v -> saveAppointment()); - btnDelete.setOnClickListener(v -> confirmDelete()); - return view; + binding.btnApptBack.setOnClickListener(v -> navigateBack()); + binding.btnSaveAppointment.setOnClickListener(v -> saveAppointment()); + binding.btnDeleteAppointment.setOnClickListener(v -> confirmDelete()); } - private void initViews(View v) { - tvMode = v.findViewById(R.id.tvApptMode); - tvAppointmentId = v.findViewById(R.id.tvAppointmentId); - etAppointmentDate= v.findViewById(R.id.etAppointmentDate); - spinnerPet = v.findViewById(R.id.spinnerPet); - spinnerService = v.findViewById(R.id.spinnerService); - spinnerStatus = v.findViewById(R.id.spinnerAppointmentStatus); - spinnerHour = v.findViewById(R.id.spinnerHour); - spinnerMinute = v.findViewById(R.id.spinnerMinute); - spinnerCustomer = v.findViewById(R.id.spinnerCustomer); - spinnerStore = v.findViewById(R.id.spinnerStore); - btnSave = v.findViewById(R.id.btnSaveAppointment); - btnDelete = v.findViewById(R.id.btnDeleteAppointment); - btnBack = v.findViewById(R.id.btnApptBack); + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; } + /** + * Configures the adapters for spinners. + */ private void setupSpinners() { - spinnerStatus.setAdapter(new ArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, STATUSES)); + SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerAppointmentStatus, + new String[]{"Booked", "Completed", "Cancelled", "Missed"}); String[] hours = new String[HOURS.length]; for (int i = 0; i < HOURS.length; i++) hours[i] = String.format("%02d:00", HOURS[i]); - spinnerHour.setAdapter(new ArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, hours)); - spinnerMinute.setAdapter(new ArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, new String[]{"00","15","30","45"})); + SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerHour, hours); + SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerMinute, new String[]{"00","15","30","45"}); } + /** + * Configures the date picker dialog for the appointment date field. + */ private void setupDatePicker() { - etAppointmentDate.setOnClickListener(v -> { + binding.etAppointmentDate.setOnClickListener(v -> { Calendar c = Calendar.getInstance(); DatePickerDialog d = new DatePickerDialog(requireContext(), - (dp,y,m,d1) -> etAppointmentDate.setText( + (dp,y,m,d1) -> binding.etAppointmentDate.setText( String.format("%04d-%02d-%02d", y, m+1, d1)), c.get(Calendar.YEAR), c.get(Calendar.MONTH), c.get(Calendar.DAY_OF_MONTH)); @@ -97,218 +125,233 @@ public class AppointmentDetailFragment extends Fragment { }); } - private void loadData() { + /** + * Fetches all required data for spinners from the backend. + */ + private void loadSpinnersData() { loadPets(); loadServices(); loadCustomers(); loadStores(); - loadAllAppointments(); + loadStaff(); } + /** + * Loads the list of pets from the ViewModel. + */ private void loadPets() { - RetrofitClient.getPetApi(requireContext()).getAllPets(0, 200) - .enqueue(new Callback>() { - public void onResponse(Call> c, Response> r) { - if (r.isSuccessful() && r.body() != null) { - petList = r.body().getContent(); - populatePetSpinner(); - } - } - public void onFailure(Call> c, Throwable t) { - Log.e("APPT", "Pet load failed: " + t.getMessage()); - } - }); - } - - private void populatePetSpinner() { - List names = new ArrayList<>(); - names.add("-- Select Pet --"); - for (PetDTO p : petList) names.add(p.getPetName()); - spinnerPet.setAdapter(new ArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, names)); - if (preselectedPetId != -1) { - for (int i = 0; i < petList.size(); i++) { - if (petList.get(i).getPetId().equals(preselectedPetId)) { - spinnerPet.setSelection(i + 1); break; - } + petViewModel.getAllPets(0, 200, null, null, null, null, "petName").observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + petList = resource.data.getContent(); + refreshPetSpinner(); } - } + }); } + /** + * Populates the pet selection spinner. + */ + private void refreshPetSpinner() { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerPet, petList, + PetDTO::getPetName, "-- Select Pet --", + preselectedPetId, PetDTO::getPetId); + } + + /** + * Loads the list of services from the API. + */ private void loadServices() { - RetrofitClient.getServiceApi(requireContext()).getAllServices(0, 200) - .enqueue(new Callback>() { - public void onResponse(Call> c, Response> r) { - if (r.isSuccessful() && r.body() != null) { - serviceList = r.body().getContent(); - populateServiceSpinner(); - } - } - public void onFailure(Call> c, Throwable t) { - Log.e("APPT", "Service load failed: " + t.getMessage()); - } - }); - } - - private void populateServiceSpinner() { - List names = new ArrayList<>(); - names.add("-- Select Service --"); - for (ServiceDTO s : serviceList) names.add(s.getServiceName()); - spinnerService.setAdapter(new ArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, names)); - if (preselectedServiceId != -1) { - for (int i = 0; i < serviceList.size(); i++) { - if (serviceList.get(i).getServiceId().equals(preselectedServiceId)) { - spinnerService.setSelection(i + 1); break; - } + serviceViewModel.getAllServices(0, 200, null, "serviceName").observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + serviceList = resource.data.getContent(); + refreshServiceSpinner(); } - } + }); } + /** + * Populates the service selection spinner. + */ + private void refreshServiceSpinner() { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerService, serviceList, + ServiceDTO::getServiceName, "-- Select Service --", + preselectedServiceId, ServiceDTO::getServiceId); + } + + /** + * Loads the list of customers from the API. + */ private void loadCustomers() { - RetrofitClient.getCustomerApi(requireContext()).getAllCustomers(0, 200) - .enqueue(new Callback>() { - public void onResponse(Call> c, Response> r) { - if (r.isSuccessful() && r.body() != null) { - customerList = r.body().getContent(); - populateCustomerSpinner(); - } - } - public void onFailure(Call> c, Throwable t) { - Log.e("APPT", "Customer load failed: " + t.getMessage()); - } - }); - } - - private void populateCustomerSpinner() { - List names = new ArrayList<>(); - names.add("-- Select Customer --"); - for (CustomerDTO c : customerList) - names.add(c.getFirstName() + " " + c.getLastName()); - spinnerCustomer.setAdapter(new ArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, names)); - if (preselectedCustomerId != -1) { - for (int i = 0; i < customerList.size(); i++) { - if (customerList.get(i).getCustomerId().equals(preselectedCustomerId)) { - spinnerCustomer.setSelection(i + 1); break; - } + customerViewModel.getAllCustomers(0, 200).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + customerList = resource.data.getContent(); + refreshCustomerSpinner(); } - } + }); } + /** + * Populates the customer selection spinner. + */ + private void refreshCustomerSpinner() { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerCustomer, customerList, + item -> item.getFirstName() + " " + item.getLastName(), + "-- Select Customer --", + preselectedCustomerId, CustomerDTO::getCustomerId); + } + + /** + * Loads the list of stores from the API. + */ private void loadStores() { - RetrofitClient.getStoreApi(requireContext()).getAllStores(0, 50) - .enqueue(new Callback>() { - public void onResponse(Call> c, Response> r) { - if (r.isSuccessful() && r.body() != null) { - storeList = r.body().getContent(); - populateStoreSpinner(); - } - } - public void onFailure(Call> c, Throwable t) { - Log.e("APPT", "Store load failed: " + t.getMessage()); - } - }); - } - - private void populateStoreSpinner() { - List names = new ArrayList<>(); - names.add("-- Select Store --"); - for (StoreDTO s : storeList) names.add(s.getStoreName()); - spinnerStore.setAdapter(new ArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, names)); - if (preselectedStoreId != -1) { - for (int i = 0; i < storeList.size(); i++) { - if (storeList.get(i).getStoreId().equals(preselectedStoreId)) { - spinnerStore.setSelection(i + 1); break; - } + storeViewModel.getAllStores(0, 50).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + storeList = resource.data.getContent(); + refreshStoreSpinner(); } - } + }); } - private void loadAllAppointments() { - RetrofitClient.getAppointmentApi(requireContext()).getAllAppointments(0, 500) - .enqueue(new Callback>() { - public void onResponse(Call> c, Response> r) { - if (r.isSuccessful() && r.body() != null) - allAppointments = r.body().getContent(); - } - public void onFailure(Call> c, Throwable t) {} - }); + /** + * Populates the store selection spinner. + */ + private void refreshStoreSpinner() { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerStore, storeList, + StoreDTO::getStoreName, "-- Select Store --", + preselectedStoreId, StoreDTO::getStoreId); } + /** + * Loads the list of staff from the API. + */ + private void loadStaff() { + userViewModel.getUsers("STAFF", 0, 100).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + staffList = resource.data.getContent(); + refreshStaffSpinner(); + } + }); + } + + /** + * Populates the staff selection spinner. + */ + private void refreshStaffSpinner() { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerStaff, staffList, + UserDTO::getFullName, "-- Select Staff --", + preselectedStaffId, UserDTO::getId); + } + + /** + * Handles arguments to determine if the fragment is in edit or add mode. + */ private void handleArguments() { Bundle a = getArguments(); if (a != null && a.containsKey("appointmentId")) { isEditing = true; - appointmentId = a.getLong("appointmentId"); - preselectedPetId = a.getLong("petId", -1); - preselectedServiceId= a.getLong("serviceId", -1); - preselectedCustomerId = a.getLong("customerId", -1); - preselectedStoreId = a.getLong("storeId", -1); - - tvMode.setText("Edit Appointment"); - tvAppointmentId.setText("ID: " + appointmentId); - tvAppointmentId.setVisibility(View.VISIBLE); - etAppointmentDate.setText(a.getString("appointmentDate")); - btnDelete.setVisibility(View.VISIBLE); - - // Pre-fill time spinners - String time = a.getString("appointmentTime", "09:00"); - if (time.length() > 5) time = time.substring(0, 5); - String[] parts = time.split(":"); - if (parts.length == 2) { - int hour = Integer.parseInt(parts[0]); - int min = Integer.parseInt(parts[1]); - for (int i = 0; i < HOURS.length; i++) - if (HOURS[i] == hour) { spinnerHour.setSelection(i); break; } - for (int i = 0; i < MINUTES.length; i++) - if (MINUTES[i] == min) { spinnerMinute.setSelection(i); break; } - } - - // Pre-fill status - String status = a.getString("appointmentStatus", "Booked"); - for (int i = 0; i < STATUSES.length; i++) - if (STATUSES[i].equals(status)) { spinnerStatus.setSelection(i); break; } - + appointmentId = a.getLong("appointmentId"); + binding.tvApptMode.setText("Edit Appointment"); + binding.tvAppointmentId.setText("ID: " + appointmentId); + binding.tvAppointmentId.setVisibility(View.VISIBLE); + binding.btnDeleteAppointment.setVisibility(View.VISIBLE); + loadAppointmentData(); } else { - tvMode.setText("Add Appointment"); - btnDelete.setVisibility(View.GONE); - tvAppointmentId.setVisibility(View.GONE); + binding.tvApptMode.setText("Add Appointment"); + binding.btnDeleteAppointment.setVisibility(View.GONE); + binding.tvAppointmentId.setVisibility(View.GONE); } } + /** + * Fetches specific appointment details from the backend using the ID. + */ + private void loadAppointmentData() { + appointmentViewModel.getAppointmentById(appointmentId).observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + AppointmentDTO a = resource.data; + preselectedPetId = (a.getPetId() != null) ? a.getPetId() : -1; + preselectedServiceId = (a.getServiceId() != null) ? a.getServiceId() : -1; + preselectedCustomerId = (a.getCustomerId() != null) ? a.getCustomerId() : -1; + preselectedStoreId = (a.getStoreId() != null) ? a.getStoreId() : -1; + preselectedStaffId = (a.getEmployeeId() != null) ? a.getEmployeeId() : -1; + + binding.etAppointmentDate.setText(a.getAppointmentDate()); + + // Pre-fill time spinners + String time = a.getAppointmentTime() != null ? a.getAppointmentTime() : "09:00"; + if (time.length() > 5) time = time.substring(0, 5); + String[] parts = time.split(":"); + if (parts.length == 2) { + try { + int hour = Integer.parseInt(parts[0]); + int min = Integer.parseInt(parts[1]); + for (int i = 0; i < HOURS.length; i++) + if (HOURS[i] == hour) { binding.spinnerHour.setSelection(i); break; } + for (int i = 0; i < MINUTES.length; i++) + if (MINUTES[i] == min) { binding.spinnerMinute.setSelection(i); break; } + } catch (NumberFormatException ignored) {} + } + + // Match Title labels with backend values + String status = a.getAppointmentStatus(); + if (status != null && !status.isEmpty()) { + String formattedStatus = status.substring(0, 1).toUpperCase() + status.substring(1).toLowerCase(); + SpinnerUtils.setSelectionByValue(binding.spinnerAppointmentStatus, formattedStatus); + } + + refreshPetSpinner(); + refreshServiceSpinner(); + refreshCustomerSpinner(); + refreshStoreSpinner(); + refreshStaffSpinner(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Failed to load appointment: " + resource.message, Toast.LENGTH_SHORT).show(); + } + }); + } + + /** + * Validates input and saves the appointment to the backend. + */ private void saveAppointment() { - if (spinnerCustomer.getSelectedItemPosition() == 0) { + if (binding.spinnerCustomer.getSelectedItemPosition() == 0) { Toast.makeText(getContext(), "Select a customer", Toast.LENGTH_SHORT).show(); return; } - if (spinnerStore.getSelectedItemPosition() == 0) { + if (binding.spinnerStore.getSelectedItemPosition() == 0) { Toast.makeText(getContext(), "Select a store", Toast.LENGTH_SHORT).show(); return; } - if (spinnerPet.getSelectedItemPosition() == 0) { + if (binding.spinnerPet.getSelectedItemPosition() == 0) { Toast.makeText(getContext(), "Select a pet", Toast.LENGTH_SHORT).show(); return; } - if (spinnerService.getSelectedItemPosition() == 0) { + if (binding.spinnerService.getSelectedItemPosition() == 0) { Toast.makeText(getContext(), "Select a service", Toast.LENGTH_SHORT).show(); return; } - String date = etAppointmentDate.getText().toString().trim(); + String date = binding.etAppointmentDate.getText().toString().trim(); if (date.isEmpty()) { Toast.makeText(getContext(), "Select a date", Toast.LENGTH_SHORT).show(); return; } - CustomerDTO customer = customerList.get(spinnerCustomer.getSelectedItemPosition() - 1); - StoreDTO store = storeList.get(spinnerStore.getSelectedItemPosition() - 1); - PetDTO pet = petList.get(spinnerPet.getSelectedItemPosition() - 1); - ServiceDTO service = serviceList.get(spinnerService.getSelectedItemPosition() - 1); + CustomerDTO customer = customerList.get(binding.spinnerCustomer.getSelectedItemPosition() - 1); + StoreDTO store = storeList.get(binding.spinnerStore.getSelectedItemPosition() - 1); + PetDTO pet = petList.get(binding.spinnerPet.getSelectedItemPosition() - 1); + ServiceDTO service = serviceList.get(binding.spinnerService.getSelectedItemPosition() - 1); + + Long employeeId = null; + if (binding.spinnerStaff.getSelectedItemPosition() > 0) { + employeeId = staffList.get(binding.spinnerStaff.getSelectedItemPosition() - 1).getId(); + } String time = String.format("%02d:%02d", - HOURS[spinnerHour.getSelectedItemPosition()], - MINUTES[spinnerMinute.getSelectedItemPosition()]); - String status = STATUSES[spinnerStatus.getSelectedItemPosition()]; + HOURS[binding.spinnerHour.getSelectedItemPosition()], + MINUTES[binding.spinnerMinute.getSelectedItemPosition()]); + + // Get status and convert to uppercase for backend + String status = binding.spinnerAppointmentStatus.getSelectedItem().toString().toUpperCase(); - // Validate future date+time if status is Booked - if ("Booked".equalsIgnoreCase(status)) { + // Validate future date+time if status is BOOKED + if ("BOOKED".equalsIgnoreCase(status)) { try { String[] dateParts = date.split("-"); String[] timeParts = time.split(":"); @@ -322,7 +365,7 @@ public class AppointmentDetailFragment extends Fragment { 0 ); if (selected.before(Calendar.getInstance())) { - showErrorDialog("Invalid Time", + DialogUtils.showInfoDialog(requireContext(), "Invalid Time", "Booked appointments must be in the future. " + "Please select a future date and time."); return; @@ -337,107 +380,80 @@ public class AppointmentDetailFragment extends Fragment { customer.getCustomerId(), store.getStoreId(), service.getServiceId(), + employeeId, date, time, status, - Collections.singletonList(pet.getPetId()) + pet.getPetId() ); - Log.d("APPT_SAVE", "customerId=" + customer.getCustomerId() - + " storeId=" + store.getStoreId() - + " serviceId=" + service.getServiceId() - + " petId=" + pet.getPetId() - + " date=" + date + " time=" + time); + androidx.lifecycle.Observer> observer = resource -> { + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), isEditing ? "Updated" : "Saved", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + handleSaveError(resource.message); + } + }; - AppointmentApi api = RetrofitClient.getAppointmentApi(requireContext()); if (isEditing) { - api.updateAppointment(appointmentId, dto).enqueue(simpleCallback("Updated")); + appointmentViewModel.updateAppointment(appointmentId, dto).observe(getViewLifecycleOwner(), observer); } else { - api.createAppointment(dto).enqueue(simpleCallback("Saved")); + appointmentViewModel.createAppointment(dto).observe(getViewLifecycleOwner(), observer); } } - private Callback simpleCallback(String msg) { - return new Callback<>() { - public void onResponse(Call c, Response r) { - Log.d("APPT_SAVE", "Response: " + r.code()); - if (r.isSuccessful()) { - Toast.makeText(getContext(), msg, Toast.LENGTH_SHORT).show(); - navigateBack(); - } else { - try { - String errorBody = r.errorBody().string(); - Log.e("APPT_SAVE", "Error: " + errorBody); - - // Show proper dialog based on error type - if (errorBody.toLowerCase().contains("future")) { - showErrorDialog("Invalid Date/Time", - "Booked appointments must be scheduled in the future. " + - "Please select a future date and time."); - //------------------------------------------ - } else if (errorBody.toLowerCase().contains("not available") || - errorBody.toLowerCase().contains("time is not available")) { - showNoAvailabilityDialog(); - } else if (r.code() == 404) { - showErrorDialog("Not Found", - "The selected pet, customer or service was not found."); - } else if (r.code() == 403) { - showErrorDialog("Access Denied", - "You don't have permission to perform this action."); - } else if (r.code() == 400) { - showErrorDialog("Invalid Request", errorBody); - } else { - showErrorDialog("Error", "Something went wrong. Please try again."); - } - //----------------------------- - } catch (Exception e) { - Log.e("APPT_SAVE", "Failed to read error body"); - showErrorDialog("Error", "Something went wrong. Please try again."); - } - } + /** + * Handles errors that occur during the saving process. + */ + private void handleSaveError(String errorMessage) { + if (errorMessage != null) { + Log.e("APPT_SAVE", "Error: " + errorMessage); + if (errorMessage.toLowerCase().contains("future")) { + DialogUtils.showInfoDialog(requireContext(), "Invalid Date/Time", + "Booked appointments must be scheduled in the future."); + } else if (errorMessage.toLowerCase().contains("not available")) { + showNoAvailabilityDialog(); + } else { + Toast.makeText(getContext(), "Operation failed", Toast.LENGTH_SHORT).show(); } - - public void onFailure(Call c, Throwable t) { - Log.e("APPT_SAVE", "Failure: " + t.getMessage()); - Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); - } - }; + } else { + Toast.makeText(getContext(), "Something went wrong", Toast.LENGTH_SHORT).show(); + } } + /** + * Shows a specialized dialog when a time slot is not available. + */ private void showNoAvailabilityDialog() { - new AlertDialog.Builder(requireContext()) + new androidx.appcompat.app.AlertDialog.Builder(requireContext()) .setTitle("No Availability") - .setMessage("This time slot is already booked for the selected service and store. Please choose a different time or date.") + .setMessage("This time slot is already booked. Please choose a different time or date.") .setPositiveButton("Change Time", (d, w) -> d.dismiss()) .setNegativeButton("Cancel Booking", (d, w) -> navigateBack()) .setCancelable(false) .show(); } - private void showErrorDialog(String title, String message) { - new AlertDialog.Builder(requireContext()) - .setTitle(title) - .setMessage(message) - .setPositiveButton("OK", null) - .show(); - } + /** + * Shows a confirmation dialog and handles the deletion of an appointment. + */ private void confirmDelete() { - new AlertDialog.Builder(requireContext()) - .setTitle("Delete Appointment?") - .setPositiveButton("Yes", (d, w) -> - RetrofitClient.getAppointmentApi(requireContext()) - .deleteAppointment(appointmentId) - .enqueue(new Callback() { - public void onResponse(Call c, Response r) { navigateBack(); } - public void onFailure(Call c, Throwable t) { - Toast.makeText(getContext(), "Delete failed", Toast.LENGTH_SHORT).show(); - } - })) - .setNegativeButton("No", null).show(); + DialogUtils.showDeleteConfirmDialog(requireContext(), "Appointment", () -> + appointmentViewModel.deleteAppointment(appointmentId).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), "Deleted", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Delete failed", Toast.LENGTH_SHORT).show(); + } + })); } + /** + * Navigates back to the previous screen. + */ private void navigateBack() { - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.getChildFragmentManager().popBackStack(); + NavHostFragment.findNavController(this).popBackStack(); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/InventoryDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/InventoryDetailFragment.java index 9846bb36..7a729b94 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/InventoryDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/InventoryDetailFragment.java @@ -1,291 +1,236 @@ package com.example.petstoremobile.fragments.listfragments.detailfragments; import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.text.Editable; -import android.text.TextWatcher; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.AutoCompleteTextView; -import android.widget.Button; -import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; -import com.example.petstoremobile.R; -import com.example.petstoremobile.api.InventoryApi; -import com.example.petstoremobile.api.ProductApi; -import com.example.petstoremobile.api.RetrofitClient; +import com.example.petstoremobile.databinding.FragmentInventoryDetailBinding; import com.example.petstoremobile.dtos.InventoryDTO; -import com.example.petstoremobile.dtos.InventoryRequest; -import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.dtos.ProductDTO; -import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.InventoryFragment; +import com.example.petstoremobile.dtos.StoreDTO; +import com.example.petstoremobile.utils.InputValidator; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.SpinnerUtils; +import com.example.petstoremobile.viewmodels.InventoryViewModel; +import com.example.petstoremobile.viewmodels.ProductViewModel; +import com.example.petstoremobile.viewmodels.StoreViewModel; import java.util.ArrayList; import java.util.List; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; +import dagger.hilt.android.AndroidEntryPoint; +/** + * Fragment for displaying and editing inventory item details. + */ +@AndroidEntryPoint public class InventoryDetailFragment extends Fragment { - private TextView tvMode, tvInventoryId, tvProductInfo; - private AutoCompleteTextView etProductSearch; - private android.widget.EditText etQuantity; - private Button btnSave, btnDelete, btnBack; + private FragmentInventoryDetailBinding binding; - private InventoryApi inventoryApi; - private ProductApi productApi; - private InventoryFragment inventoryFragment; + private InventoryViewModel inventoryViewModel; + private ProductViewModel productViewModel; + private StoreViewModel storeViewModel; private boolean isEditing = false; private long inventoryId = -1; + private long preselectedStoreId = -1; + private long preselectedProductId = -1; - // The product selected from the dropdown - private ProductDTO selectedProduct = null; + private List storeList = new ArrayList<>(); + private List productList = new ArrayList<>(); - // For debouncing product search - private final Handler searchHandler = new Handler(Looper.getMainLooper()); - private Runnable searchRunnable; + /** + * Initializes the view models. + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + inventoryViewModel = new ViewModelProvider(this).get(InventoryViewModel.class); + productViewModel = new ViewModelProvider(this).get(ProductViewModel.class); + storeViewModel = new ViewModelProvider(this).get(StoreViewModel.class); + } - // Dropdown list - private final List productSuggestions = new ArrayList<>(); - private ArrayAdapter dropdownAdapter; + /** + * Inflates the layout. + */ + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + binding = FragmentInventoryDetailBinding.inflate(inflater, container, false); + return binding.getRoot(); + } - public void setInventoryFragment(InventoryFragment fragment) { - this.inventoryFragment = fragment; + /** + * Sets up UI components after the view is created. + */ + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + loadSpinnersData(); + handleArguments(); + + binding.btnInventoryBack.setOnClickListener(v -> navigateBack()); + binding.btnSaveInventory.setOnClickListener(v -> saveInventory()); + binding.btnDeleteInventory.setOnClickListener(v -> confirmDelete()); } @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_inventory_detail, container, false); - - inventoryApi = RetrofitClient.getInventoryApi(requireContext()); - productApi = RetrofitClient.getProductApi(requireContext()); - - initViews(view); - setupProductSearch(); - handleArguments(); - - btnBack.setOnClickListener(v -> navigateBack()); - btnSave.setOnClickListener(v -> saveInventory()); - btnDelete.setOnClickListener(v -> confirmDelete()); - - return view; + public void onDestroyView() { + super.onDestroyView(); + binding = null; } - private void initViews(View view) { - tvMode = view.findViewById(R.id.tvInventoryMode); - tvInventoryId = view.findViewById(R.id.tvInventoryId); - tvProductInfo = view.findViewById(R.id.tvProductInfo); - etProductSearch = view.findViewById(R.id.etProductSearch); - etQuantity = view.findViewById(R.id.etQuantity); - btnSave = view.findViewById(R.id.btnSaveInventory); - btnDelete = view.findViewById(R.id.btnDeleteInventory); - btnBack = view.findViewById(R.id.btnInventoryBack); - - // Setup dropdown adapter - dropdownAdapter = new ArrayAdapter<>(requireContext(), - android.R.layout.simple_dropdown_item_1line, new ArrayList<>()); - etProductSearch.setAdapter(dropdownAdapter); - etProductSearch.setThreshold(1); // start showing after 1 character + /** + * Fetches required data for spinners from the backend. + */ + private void loadSpinnersData() { + loadStores(); + loadProducts(); } - // Product search dropdown - private void setupProductSearch() { - etProductSearch.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int i, int i1, int i2) { - } - - @Override - public void afterTextChanged(Editable s) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - // Clear selected product when user is typing again - selectedProduct = null; - tvProductInfo.setVisibility(View.GONE); - - if (searchRunnable != null) - searchHandler.removeCallbacks(searchRunnable); - String query = s.toString().trim(); - if (query.isEmpty()) - return; - - searchRunnable = () -> searchProducts(query); - searchHandler.postDelayed(searchRunnable, 400); - } - }); - - // When user picks an item from the dropdown - etProductSearch.setOnItemClickListener((parent, view, position, id) -> { - if (position < productSuggestions.size()) { - selectedProduct = productSuggestions.get(position); - // Show product details below the search box - tvProductInfo.setText( - "ID: " + selectedProduct.getProdId() - + " • " + selectedProduct.getCategoryName()); - tvProductInfo.setVisibility(View.VISIBLE); + /** + * Loads the list of stores for the spinner. + */ + private void loadStores() { + storeViewModel.getAllStores(0, 100).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + storeList = resource.data.getContent(); + refreshStoreSpinner(); } }); } - private void searchProducts(String query) { - productApi.getAllProducts(query, 0, 20).enqueue(new Callback>() { - @Override - public void onResponse(Call> call, - Response> response) { - if (response.isSuccessful() && response.body() != null) { - productSuggestions.clear(); - productSuggestions.addAll(response.body().getContent()); + private void refreshStoreSpinner() { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerInventoryStore, storeList, + StoreDTO::getStoreName, "-- Select Store --", + preselectedStoreId, StoreDTO::getStoreId); + } - // Build display strings: "Product Name (ID: X)" - List names = new ArrayList<>(); - for (ProductDTO p : productSuggestions) { - names.add(p.getProdName() + " (ID: " + p.getProdId() + ")"); - } - - dropdownAdapter.clear(); - dropdownAdapter.addAll(names); - dropdownAdapter.notifyDataSetChanged(); - etProductSearch.showDropDown(); - } - } - - @Override - public void onFailure(Call> call, Throwable t) { - Toast.makeText(getContext(), "Failed to load products", Toast.LENGTH_SHORT).show(); + /** + * Loads the list of products for the spinner. + */ + private void loadProducts() { + productViewModel.getAllProducts(null, null, 0, 500, "prodName").observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + productList = resource.data.getContent(); + refreshProductSpinner(); } }); } - // Arguments (edit mode) + private void refreshProductSpinner() { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerInventoryProduct, productList, + ProductDTO::getProdName, "-- Select Product --", + preselectedProductId, ProductDTO::getProdId); + } + /** + * Handles fragment arguments to determine if we are in edit or add mode. + */ private void handleArguments() { Bundle args = getArguments(); if (args != null && args.containsKey("inventoryId")) { isEditing = true; inventoryId = args.getLong("inventoryId"); - tvMode.setText("Edit Inventory"); - tvInventoryId.setText("Inventory ID: " + inventoryId); - tvInventoryId.setVisibility(View.VISIBLE); + binding.tvInventoryMode.setText("Edit Inventory"); + binding.tvInventoryId.setText("Inventory ID: " + inventoryId); + binding.tvInventoryId.setVisibility(View.VISIBLE); + binding.btnDeleteInventory.setVisibility(View.VISIBLE); + binding.btnSaveInventory.setText("Save"); - // Pre-fill search box with existing product name - String productName = args.getString("productName", ""); - long prodId = args.getLong("prodId", -1); - etProductSearch.setText(productName); - - // Show existing product info - if (prodId != -1) { - tvProductInfo.setText( - "ID: " + prodId - + " • " + args.getString("categoryName", "")); - tvProductInfo.setVisibility(View.VISIBLE); - - // Build a minimal ProductDTO so selectedProduct is not null on save - selectedProduct = new ProductDTO(productName, null, null, null); - selectedProduct.setProdId(prodId); - } - - etQuantity.setText(String.valueOf(args.getInt("quantity", 0))); - btnDelete.setVisibility(View.VISIBLE); - btnSave.setText("Save"); + loadInventoryData(); } else { isEditing = false; - tvMode.setText("Add Inventory"); - tvInventoryId.setVisibility(View.GONE); - tvProductInfo.setVisibility(View.GONE); - btnDelete.setVisibility(View.GONE); - btnSave.setText("Add"); + binding.tvInventoryMode.setText("Add Inventory"); + binding.tvInventoryId.setVisibility(View.GONE); + binding.btnDeleteInventory.setVisibility(View.GONE); + binding.btnSaveInventory.setText("Add"); } } - // Save + /** + * Loads existing inventory data from the backend. + */ + private void loadInventoryData() { + inventoryViewModel.getInventoryById(inventoryId).observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + InventoryDTO inv = resource.data; + binding.etQuantity.setText(String.valueOf(inv.getQuantity())); + preselectedStoreId = inv.getStoreId() != null ? inv.getStoreId() : -1; + preselectedProductId = inv.getProdId() != null ? inv.getProdId() : -1; + + refreshStoreSpinner(); + refreshProductSpinner(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Failed to load inventory: " + resource.message, Toast.LENGTH_SHORT).show(); + } + }); + } + + /** + * Validates input and saves the current inventory item details to the backend. + */ private void saveInventory() { - if (selectedProduct == null) { - etProductSearch.setError("Please select a product from the list"); - etProductSearch.requestFocus(); + if (binding.spinnerInventoryStore.getSelectedItemPosition() == 0) { + Toast.makeText(getContext(), "Please select a store", Toast.LENGTH_SHORT).show(); + return; + } + if (binding.spinnerInventoryProduct.getSelectedItemPosition() == 0) { + Toast.makeText(getContext(), "Please select a product", Toast.LENGTH_SHORT).show(); return; } - String quantityStr = etQuantity.getText().toString().trim(); - if (quantityStr.isEmpty()) { - etQuantity.setError("Quantity is required"); - etQuantity.requestFocus(); + if (!InputValidator.isNotEmpty(binding.etQuantity, "Quantity") || + !InputValidator.isPositiveInteger(binding.etQuantity, "Quantity")) { return; } - int quantity; - try { - quantity = Integer.parseInt(quantityStr); - } catch (NumberFormatException e) { - etQuantity.setError("Invalid quantity"); - return; - } + int quantity = Integer.parseInt(binding.etQuantity.getText().toString().trim()); + StoreDTO store = storeList.get(binding.spinnerInventoryStore.getSelectedItemPosition() - 1); + ProductDTO product = productList.get(binding.spinnerInventoryProduct.getSelectedItemPosition() - 1); - if (quantity < 0) { - etQuantity.setError("Quantity must be 0 or more"); - etQuantity.requestFocus(); - return; - } - - InventoryRequest request = new InventoryRequest(selectedProduct.getProdId(), quantity); + InventoryDTO request = new InventoryDTO(product.getProdId(), store.getStoreId(), quantity); setButtonsEnabled(false); if (isEditing) { - inventoryApi.updateInventory(inventoryId, request).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - setButtonsEnabled(true); - if (response.isSuccessful()) { - Toast.makeText(getContext(), "Inventory updated", Toast.LENGTH_SHORT).show(); - notifyParentAndGoBack(); - } else { - Toast.makeText(getContext(), "Update failed: " + response.code(), Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - setButtonsEnabled(true); - Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); + inventoryViewModel.updateInventory(inventoryId, request).observe(getViewLifecycleOwner(), resource -> { + setButtonsEnabled(true); + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), "Inventory updated", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Error: " + resource.message, Toast.LENGTH_SHORT).show(); } }); } else { - inventoryApi.createInventory(request).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - setButtonsEnabled(true); - if (response.isSuccessful()) { - Toast.makeText(getContext(), "Inventory created", Toast.LENGTH_SHORT).show(); - notifyParentAndGoBack(); - } else { - Toast.makeText(getContext(), "Create failed: " + response.code(), Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - setButtonsEnabled(true); - Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); + inventoryViewModel.createInventory(request).observe(getViewLifecycleOwner(), resource -> { + setButtonsEnabled(true); + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), "Inventory created", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Error: " + resource.message, Toast.LENGTH_SHORT).show(); } }); } } - // Delete + /** + * Shows a confirmation dialog before deleting an inventory item. + */ private void confirmDelete() { new AlertDialog.Builder(requireContext()) .setTitle("Delete inventory item?") @@ -295,45 +240,35 @@ public class InventoryDetailFragment extends Fragment { .show(); } + /** + * Sends a request to the API to delete the inventory item. + */ private void deleteInventory() { setButtonsEnabled(false); - inventoryApi.deleteInventory(inventoryId).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - setButtonsEnabled(true); - if (response.isSuccessful()) { - Toast.makeText(getContext(), "Inventory deleted", Toast.LENGTH_SHORT).show(); - notifyParentAndGoBack(); - } else { - Toast.makeText(getContext(), "Delete failed: " + response.code(), Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - setButtonsEnabled(true); - Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); + inventoryViewModel.deleteInventory(inventoryId).observe(getViewLifecycleOwner(), resource -> { + setButtonsEnabled(true); + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), "Inventory deleted", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Delete failed: " + resource.message, Toast.LENGTH_SHORT).show(); } }); } - // Helpers - - private void notifyParentAndGoBack() { - if (inventoryFragment != null) - inventoryFragment.onInventoryChanged(); - navigateBack(); - } - + /** + * Navigates back to the previous fragment. + */ private void navigateBack() { - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) - lf.getChildFragmentManager().popBackStack(); + NavHostFragment.findNavController(this).popBackStack(); } + /** + * Enables or disables action buttons. + */ private void setButtonsEnabled(boolean enabled) { - btnSave.setEnabled(enabled); - btnDelete.setEnabled(enabled); - btnBack.setEnabled(enabled); + binding.btnSaveInventory.setEnabled(enabled); + binding.btnDeleteInventory.setEnabled(enabled); + binding.btnInventoryBack.setEnabled(enabled); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java index 70c19833..62d8bf42 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java @@ -4,83 +4,140 @@ import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.Button; -import android.widget.EditText; +import android.widget.AdapterView; import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; import com.example.petstoremobile.R; -import com.example.petstoremobile.api.PetApi; -import com.example.petstoremobile.api.RetrofitClient; +import com.example.petstoremobile.databinding.FragmentPetDetailBinding; +import com.example.petstoremobile.dtos.CustomerDTO; import com.example.petstoremobile.dtos.PetDTO; -import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.PetFragment; +import com.example.petstoremobile.dtos.StoreDTO; import com.example.petstoremobile.utils.ActivityLogger; +import com.example.petstoremobile.utils.DialogUtils; import com.example.petstoremobile.utils.InputValidator; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.SpinnerUtils; +import com.example.petstoremobile.viewmodels.CustomerViewModel; +import com.example.petstoremobile.viewmodels.PetViewModel; +import com.example.petstoremobile.viewmodels.StoreViewModel; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import dagger.hilt.android.AndroidEntryPoint; + +/** + * Fragment for displaying and editing pet details. + */ +@AndroidEntryPoint public class PetDetailFragment extends Fragment { - private TextView tvMode, tvPetId; - private EditText etPetName, etPetSpecies, etPetBreed, etPetAge, etPetPrice; - private Spinner spinnerPetStatus; - private Button btnSavePet, btnDeletePet, btnBack; - private int petId; + private FragmentPetDetailBinding binding; + private long petId; private boolean isEditing = false; - private PetFragment petFragment; - //set the pet fragment to the parent so we refer back to pet view when save or delete is done - public void setPetFragment(PetFragment fragment) { - this.petFragment = fragment; + private PetViewModel viewModel; + private CustomerViewModel customerViewModel; + private StoreViewModel storeViewModel; + private List customerList = new ArrayList<>(); + private List storeList = new ArrayList<>(); + private Long selectedCustomerId = null; + private Long selectedStoreId = null; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(PetViewModel.class); + customerViewModel = new ViewModelProvider(this).get(CustomerViewModel.class); + storeViewModel = new ViewModelProvider(this).get(StoreViewModel.class); } @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_pet_detail, container, false); + binding = FragmentPetDetailBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); - //set up spinner and get controls from layout and display the view depending on the mode - initViews(view); setupSpinner(); + loadCustomers(); + loadStores(); handleArguments(); //set button click listeners - btnBack.setOnClickListener(v -> navigateBack()); - btnSavePet.setOnClickListener(v -> savePet()); - btnDeletePet.setOnClickListener(v -> deletePet()); - - return view; + binding.btnBack.setOnClickListener(v -> navigateBack()); + binding.btnSavePet.setOnClickListener(v -> savePet()); + binding.btnDeletePet.setOnClickListener(v -> deletePet()); } - //Method to Update or Add a pet + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + /** + * Handles the saving of pet data (adding/updating). + */ private void savePet() { // Validates all fields using InputValidator - if (!InputValidator.isNotEmpty(etPetName, "Pet Name")) return; - if (!InputValidator.isNotEmpty(etPetSpecies, "Species")) return; - if (!InputValidator.isNotEmpty(etPetBreed, "Breed")) return; - if (!InputValidator.isPositiveInteger(etPetAge, "Age")) return; - if (!InputValidator.isPositiveDecimal(etPetPrice, "Price")) return; + if (!InputValidator.isNotEmpty(binding.etPetName, "Pet Name")) return; + if (!InputValidator.isNotEmpty(binding.etPetSpecies, "Species")) return; + if (!InputValidator.isNotEmpty(binding.etPetBreed, "Breed")) return; + if (!InputValidator.isPositiveInteger(binding.etPetAge, "Age")) return; + if (!InputValidator.isPositiveDecimal(binding.etPetPrice, "Price")) return; //get all the values from the fields - String name = etPetName.getText().toString().trim(); - String species = etPetSpecies.getText().toString().trim(); - String breed = etPetBreed.getText().toString().trim(); - int age = Integer.parseInt(etPetAge.getText().toString().trim()); - String priceStr = etPetPrice.getText().toString().trim(); - String status = spinnerPetStatus.getSelectedItem().toString(); + String name = binding.etPetName.getText().toString().trim(); + String species = binding.etPetSpecies.getText().toString().trim(); + String breed = binding.etPetBreed.getText().toString().trim(); + int age = Integer.parseInt(binding.etPetAge.getText().toString().trim()); + double price = Double.parseDouble(binding.etPetPrice.getText().toString().trim()); + String status = binding.spinnerPetStatus.getSelectedItem().toString(); + + // Get selected customer + Long customerId = null; + int customerPos = binding.spinnerCustomer.getSelectedItemPosition(); + if (customerPos > 0) { // 0 means no customer for pet + customerId = customerList.get(customerPos - 1).getCustomerId(); + } + + // Get selected store + Long storeId = null; + int storePos = binding.spinnerStore.getSelectedItemPosition(); + if (storePos > 0) { + storeId = storeList.get(storePos - 1).getStoreId(); + } + + // Validation: If status is Available, a store must be selected + if ("Available".equalsIgnoreCase(status)) { + if (!InputValidator.isSpinnerSelected(binding.spinnerStore, "Store")) return; + } + + // Validation: If status is Owned, an owner must be selected + if ("Owned".equalsIgnoreCase(status)) { + if (!InputValidator.isSpinnerSelected(binding.spinnerCustomer, "Owner")) return; + } + + // Validation: If status is Adopted, an owner and store must be selected + if ("Adopted".equalsIgnoreCase(status)) { + if (!InputValidator.isSpinnerSelected(binding.spinnerCustomer, "Owner")) return; + if (!InputValidator.isSpinnerSelected(binding.spinnerStore, "Store")) return; + } //create a pet object to send to the API PetDTO petDTO = new PetDTO(); @@ -88,167 +145,221 @@ public class PetDetailFragment extends Fragment { petDTO.setPetSpecies(species); petDTO.setPetBreed(breed); petDTO.setPetAge(age); - petDTO.setPetPrice(priceStr); + petDTO.setPetPrice(price); petDTO.setPetStatus(status); - - PetApi petApi = RetrofitClient.getPetApi(requireContext()); + petDTO.setCustomerId(customerId); + petDTO.setStoreId(storeId); //check if the pet is being edited or added if (isEditing) { // Update existing pet - petDTO.setPetId((long) petId); - petApi.updatePet((long) petId, petDTO).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - ActivityLogger.logChange(requireContext(), "Pet", "UPDATED", petId); - Toast.makeText(getContext(), "Pet updated successfully!", Toast.LENGTH_SHORT).show(); - navigateBack(); - } else { - Toast.makeText(getContext(), "Failed to update pet: " + response.code(), Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - ActivityLogger.logException(requireContext(), "PetDetailFragment.updatePet", new Exception(t)); - Log.e("PetDetailFragment", "Error updating pet", t); - Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + petDTO.setPetId(petId); + viewModel.updatePet(petId, petDTO).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + ActivityLogger.logChange(requireContext(), "Pet", "UPDATED", (int) petId); + Toast.makeText(getContext(), "Pet updated successfully!", Toast.LENGTH_SHORT).show(); + navigateToPetList(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Error: " + resource.message, Toast.LENGTH_SHORT).show(); } }); } else { // Add new pet - petApi.createPet(petDTO).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - ActivityLogger.log(requireContext(), "Added new Pet: " + name); - Toast.makeText(getContext(), "Pet added successfully!", Toast.LENGTH_SHORT).show(); - navigateBack(); - } else { - Toast.makeText(getContext(), "Failed to add pet: " + response.code(), Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - ActivityLogger.logException(requireContext(), "PetDetailFragment.createPet", new Exception(t)); - Log.e("PetDetailFragment", "Error adding pet", t); - Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + viewModel.createPet(petDTO).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + ActivityLogger.log(requireContext(), "Added new Pet: " + name); + Toast.makeText(getContext(), "Pet added successfully!", Toast.LENGTH_SHORT).show(); + navigateToPetList(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Error: " + resource.message, Toast.LENGTH_SHORT).show(); } }); } } - //Method to Delete a pet + /** + * Displays a confirmation dialog and handles the deletion of a pet. + */ private void deletePet() { - //Alert the user to confirm the delete - new AlertDialog.Builder(requireContext()) - .setTitle("Delete Pet") - .setMessage("Are you sure you want to delete " + etPetName.getText().toString() + "?") - .setPositiveButton("Delete", (dialog, which) -> { - PetApi petApi = RetrofitClient.getPetApi(requireContext()); - //if they say yes then delete the pet - petApi.deletePet((long) petId).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - ActivityLogger.logChange(requireContext(), "Pet", "DELETED", petId); - Toast.makeText(getContext(), "Pet deleted successfully!", Toast.LENGTH_SHORT).show(); - navigateBack(); - } else { - Toast.makeText(getContext(), "Failed to delete pet: " + response.code(), Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - ActivityLogger.logException(requireContext(), "PetDetailFragment.deletePet", new Exception(t)); - Log.e("PetDetailFragment", "Error deleting pet", t); - Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); - } - }); - }) - .setNegativeButton("Cancel", null) - .show(); + DialogUtils.showDeleteConfirmDialog(requireContext(), "Pet", () -> + viewModel.deletePet(petId).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + ActivityLogger.logChange(requireContext(), "Pet", "DELETED", (int) petId); + Toast.makeText(getContext(), "Pet deleted successfully!", Toast.LENGTH_SHORT).show(); + navigateToPetList(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Delete failed: " + resource.message, Toast.LENGTH_SHORT).show(); + } + })); } - //Helper method to navigate back to the list + /** + * Navigates back to the pet list screen. + */ + private void navigateToPetList() { + NavHostFragment.findNavController(this).popBackStack(R.id.nav_pet, false); + } + + /** + * Navigates back to the previous screen. + */ private void navigateBack() { - ListFragment listFragment = (ListFragment) getParentFragment(); - if (listFragment != null) { - // If editing pop back twice to get back to PetDetail Fragment instead of PetProfileFragment - if (isEditing) { - listFragment.getChildFragmentManager().popBackStack(); - } - listFragment.getChildFragmentManager().popBackStack(); - } + NavHostFragment.findNavController(this).popBackStack(); } - //helper function to check if pet is being edited or added and show the view accordingly + /** + * Handles arguments passed to the fragment to determine if it's in edit or add mode. + */ private void handleArguments() { // Pet is being edited if the bundle contains a petId if (getArguments() != null && getArguments().containsKey("petId")) { // Get pet data from arguments and populate fields isEditing = true; - petId = getArguments().getInt("petId"); - tvMode.setText("Edit Pet"); - tvPetId.setText("ID: " + petId); - etPetName.setText(getArguments().getString("petName")); - etPetSpecies.setText(getArguments().getString("petSpecies")); - etPetBreed.setText(getArguments().getString("petBreed")); - etPetAge.setText(String.valueOf(getArguments().getInt("petAge"))); - etPetPrice.setText(String.valueOf(getArguments().getDouble("petPrice"))); - String status = getArguments().getString("petStatus"); - if ("Available".equals(status)) { - spinnerPetStatus.setSelection(0); - } else { - spinnerPetStatus.setSelection(1); - } - btnDeletePet.setVisibility(View.VISIBLE); + petId = getArguments().getLong("petId"); + binding.tvMode.setText("Edit Pet"); + binding.tvPetId.setText("ID: " + petId); + binding.tvPetId.setVisibility(View.VISIBLE); + binding.btnDeletePet.setVisibility(View.VISIBLE); + loadPetData(); } else { // Pet is being added // Set default values for add a new pet isEditing = false; - tvMode.setText("Add Pet"); - tvPetId.setVisibility(View.GONE); - btnDeletePet.setVisibility(View.GONE); - btnSavePet.setText("Add"); + binding.tvMode.setText("Add Pet"); + binding.tvPetId.setVisibility(View.GONE); + binding.btnDeletePet.setVisibility(View.GONE); + binding.btnSavePet.setText("Add"); } } - //helper function to get controls from layout - private void initViews(View view) { - tvMode = view.findViewById(R.id.tvMode); - tvPetId = view.findViewById(R.id.tvPetId); - etPetName = view.findViewById(R.id.etPetName); - etPetSpecies = view.findViewById(R.id.etPetSpecies); - etPetBreed = view.findViewById(R.id.etPetBreed); - etPetAge = view.findViewById(R.id.etPetAge); - etPetPrice = view.findViewById(R.id.etPetPrice); - spinnerPetStatus = view.findViewById(R.id.spinnerPetStatus); - btnSavePet = view.findViewById(R.id.btnSavePet); - btnDeletePet = view.findViewById(R.id.btnDeletePet); - btnBack = view.findViewById(R.id.btnBack); - } + /** + * Fetches specific pet details from the backend using the ID. + */ + private void loadPetData() { + viewModel.getPetById(petId).observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + PetDTO p = resource.data; + binding.etPetName.setText(p.getPetName()); + binding.etPetSpecies.setText(p.getPetSpecies()); + binding.etPetBreed.setText(p.getPetBreed()); + binding.etPetAge.setText(String.valueOf(p.getPetAge())); + if (p.getPetPrice() != null) { + binding.etPetPrice.setText(String.format(Locale.getDefault(), "%.2f", p.getPetPrice())); + } + SpinnerUtils.setSelectionByValue(binding.spinnerPetStatus, p.getPetStatus()); + + selectedCustomerId = p.getCustomerId(); + updateCustomerSpinnerSelection(); - //helper function to set up the spinner menu for pet status - private void setupSpinner() { - ArrayAdapter adapter = new ArrayAdapter(requireContext(), - android.R.layout.simple_spinner_item, - new String[]{"Available", "Adopted"}) { - - //Override the getView method for the spinner to make the text color darker for more readability - @NonNull - @Override - public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { - View view = super.getView(position, convertView, parent); - ((TextView) view).setTextColor(ContextCompat.getColor(requireContext(), R.color.text_dark)); - return view; + selectedStoreId = p.getStoreId(); + updateStoreSpinnerSelection(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Failed to load pet: " + resource.message, Toast.LENGTH_SHORT).show(); } - }; - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - spinnerPetStatus.setAdapter(adapter); + }); } + /** + * Fetches the list of customers and populates the spinner. + */ + private void loadCustomers() { + customerViewModel.getAllCustomers(0, 1000).observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) { + customerList = resource.data.getContent(); + updateCustomerSpinnerSelection(); + } + }); + } + + /** + * Fetches the list of stores and populates the spinner. + */ + private void loadStores() { + storeViewModel.getAllStores(0, 1000).observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) { + storeList = resource.data.getContent(); + updateStoreSpinnerSelection(); + } + }); + } + + /** + * Updates the customer spinner with the current list and sets the selection if needed. + */ + private void updateCustomerSpinnerSelection() { + SpinnerUtils.populateSpinner( + requireContext(), + binding.spinnerCustomer, + customerList, + CustomerDTO::getFullName, + "No Owner", + selectedCustomerId, + CustomerDTO::getCustomerId + ); + } + + /** + * Updates the store spinner with the current list and sets the selection if needed. + */ + private void updateStoreSpinnerSelection() { + SpinnerUtils.populateSpinner( + requireContext(), + binding.spinnerStore, + storeList, + StoreDTO::getStoreName, + "None", + selectedStoreId, + StoreDTO::getStoreId + ); + } + + /** + * Initializes the spinner for pet status selection. + */ + private void setupSpinner() { + SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerPetStatus, + new String[]{"Available", "Adopted", "Owned"}); + + binding.spinnerPetStatus.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + String status = parent.getItemAtPosition(position).toString(); + + // Clear any existing error icons when status changes + clearSpinnerError(binding.spinnerCustomer); + clearSpinnerError(binding.spinnerStore); + + //Disable the customer spinner if the status is "Available" + if ("Available".equalsIgnoreCase(status)) { + binding.spinnerCustomer.setSelection(0); + binding.spinnerCustomer.setEnabled(false); + } else { + binding.spinnerCustomer.setEnabled(true); + } + + //Disable the store spinner if the status is "Owned" + if ("Owned".equalsIgnoreCase(status)) { + binding.spinnerStore.setSelection(0); + binding.spinnerStore.setEnabled(false); + } else { + binding.spinnerStore.setEnabled(true); + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + } + + /** + * Clears error messages from a Spinner's selected view. + */ + private void clearSpinnerError(Spinner spinner) { + View selectedView = spinner.getSelectedView(); + if (selectedView instanceof TextView) { + ((TextView) selectedView).setError(null); + } + } } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ProductDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ProductDetailFragment.java index 29e3473b..48a988d2 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ProductDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ProductDetailFragment.java @@ -1,190 +1,321 @@ package com.example.petstoremobile.fragments.listfragments.detailfragments; +import android.net.Uri; import android.os.Bundle; -import android.util.Log; import android.view.*; import android.widget.*; import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; + +import com.bumptech.glide.Glide; import com.example.petstoremobile.R; import com.example.petstoremobile.api.*; +import com.example.petstoremobile.api.auth.TokenManager; +import com.example.petstoremobile.databinding.FragmentProductDetailBinding; import com.example.petstoremobile.dtos.*; -import com.example.petstoremobile.fragments.ListFragment; +import com.example.petstoremobile.viewmodels.ProductViewModel; +import com.example.petstoremobile.utils.DialogUtils; +import com.example.petstoremobile.utils.FileUtils; +import com.example.petstoremobile.utils.GlideUtils; +import com.example.petstoremobile.utils.ImagePickerHelper; +import com.example.petstoremobile.utils.InputValidator; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.SpinnerUtils; + +import java.io.File; import java.math.BigDecimal; import java.util.*; -import retrofit2.*; +import javax.inject.Inject; + +import javax.inject.Named; + +import dagger.hilt.android.AndroidEntryPoint; +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.RequestBody; + +/** + * Fragment for displaying and editing product details, including image selection. + */ +@AndroidEntryPoint public class ProductDetailFragment extends Fragment { - private TextView tvMode, tvProductId; - private EditText etProductName, etProductDesc, etProductPrice; - private Spinner spinnerCategory; - private Button btnSave, btnDelete, btnBack; + private FragmentProductDetailBinding binding; private long prodId = -1; private boolean isEditing = false; private long preselectedCategoryId = -1; + private boolean hasImage = false; + private boolean isImageChanged = false; + private boolean isImageRemoved = false; private List categoryList = new ArrayList<>(); + private Uri photoUri; + private ProductViewModel viewModel; + private ImagePickerHelper imagePickerHelper; + @Inject @Named("baseUrl") String baseUrl; + @Inject TokenManager tokenManager; + + /** + * Initializes activity launchers and the ImagePickerHelper. + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(ProductViewModel.class); + + imagePickerHelper = new ImagePickerHelper(this, "product_photo.jpg", new ImagePickerHelper.ImagePickerListener() { + @Override + public void onImagePicked(Uri uri) { + photoUri = uri; + Glide.with(ProductDetailFragment.this).load(uri).into(binding.ivProductImage); + hasImage = true; + isImageChanged = true; + isImageRemoved = false; + } + + @Override + public void onImageRemoved() { + photoUri = null; + hasImage = false; + isImageChanged = false; + isImageRemoved = true; + binding.ivProductImage.setImageResource(R.drawable.placeholder2); + } + }); + } + + /** + * Inflates the layout. + */ @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_product_detail, container, false); - initViews(view); + binding = FragmentProductDetailBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + /** + * Sets up UI components and listeners after the view is created. + */ + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + loadCategories(); handleArguments(); - btnBack.setOnClickListener(v -> navigateBack()); - btnSave.setOnClickListener(v -> saveProduct()); - btnDelete.setOnClickListener(v -> confirmDelete()); - return view; + binding.btnProductBack.setOnClickListener(v -> navigateBack()); + binding.btnSaveProduct.setOnClickListener(v -> saveProduct()); + binding.btnDeleteProduct.setOnClickListener(v -> confirmDelete()); + binding.ivProductImage.setOnClickListener(v -> imagePickerHelper.showImagePickerDialog("Select Product Image", hasImage)); } - private void initViews(View v) { - tvMode = v.findViewById(R.id.tvProductMode); - tvProductId = v.findViewById(R.id.tvProductId); - etProductName = v.findViewById(R.id.etProductName); - etProductDesc = v.findViewById(R.id.etProductDesc); - etProductPrice = v.findViewById(R.id.etProductPrice); - spinnerCategory = v.findViewById(R.id.spinnerProductCategory); - btnSave = v.findViewById(R.id.btnSaveProduct); - btnDelete = v.findViewById(R.id.btnDeleteProduct); - btnBack = v.findViewById(R.id.btnProductBack); + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; } + /** + * Fetches all product categories for the selection spinner. + */ private void loadCategories() { - RetrofitClient.getCategoryApi(requireContext()).getAllCategories(0, 100) - .enqueue(new Callback>() { - public void onResponse(Call> c, - Response> r) { - if (r.isSuccessful() && r.body() != null) { - categoryList = r.body().getContent(); - populateCategorySpinner(); - } - } - public void onFailure(Call> c, Throwable t) { - Log.e("ProductDetail", "Category load failed: " + t.getMessage()); - } - }); - } - - private void populateCategorySpinner() { - List names = new ArrayList<>(); - names.add("-- Select Category --"); - for (CategoryDTO c : categoryList) names.add(c.getCategoryName()); - spinnerCategory.setAdapter(new ArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, names)); - if (preselectedCategoryId != -1) { - for (int i = 0; i < categoryList.size(); i++) { - if (categoryList.get(i).getCategoryId().equals(preselectedCategoryId)) { - spinnerCategory.setSelection(i + 1); break; - } + viewModel.getAllCategories(0, 100).observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) { + categoryList = resource.data.getContent(); + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerProductCategory, categoryList, + CategoryDTO::getCategoryName, "-- Select Category --", + preselectedCategoryId, CategoryDTO::getCategoryId); } - } + }); } + /** + * Checks if the fragment was opened with existing product data for editing. + */ private void handleArguments() { Bundle a = getArguments(); if (a != null && a.containsKey("prodId")) { isEditing = true; prodId = a.getLong("prodId"); - preselectedCategoryId = a.getLong("categoryId", -1); - - tvMode.setText("Edit Product"); - tvProductId.setText("ID: " + prodId); - tvProductId.setVisibility(View.VISIBLE); - etProductName.setText(a.getString("prodName")); - etProductDesc.setText(a.getString("prodDesc")); - etProductPrice.setText(a.getString("prodPrice")); - btnDelete.setVisibility(View.VISIBLE); + binding.tvProductMode.setText("Edit Product"); + binding.tvProductId.setText("ID: " + prodId); + binding.tvProductId.setVisibility(View.VISIBLE); + binding.btnDeleteProduct.setVisibility(View.VISIBLE); + loadProductData(); + loadProductImage(); } else { - tvMode.setText("Add Product"); - btnDelete.setVisibility(View.GONE); - tvProductId.setVisibility(View.GONE); + binding.tvProductMode.setText("Add Product"); + binding.btnDeleteProduct.setVisibility(View.GONE); + binding.tvProductId.setVisibility(View.GONE); + hasImage = false; } } - private void saveProduct() { - String name = etProductName.getText().toString().trim(); - String desc = etProductDesc.getText().toString().trim(); - String priceStr = etProductPrice.getText().toString().trim(); + /** + * Loads the product data from the backend. + */ + private void loadProductData() { + viewModel.getProductById(prodId).observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + ProductDTO p = resource.data; + binding.etProductName.setText(p.getProdName()); + binding.etProductDesc.setText(p.getProdDesc()); + binding.etProductPrice.setText(p.getProdPrice() != null ? p.getProdPrice().toString() : ""); + preselectedCategoryId = p.getCategoryId() != null ? p.getCategoryId() : -1; + + // Refresh spinner selection once data is loaded + if (!categoryList.isEmpty()) { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerProductCategory, categoryList, + CategoryDTO::getCategoryName, "-- Select Category --", + preselectedCategoryId, CategoryDTO::getCategoryId); + } + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Failed to load product: " + resource.message, Toast.LENGTH_SHORT).show(); + } + }); + } - if (name.isEmpty()) { - etProductName.setError("Enter product name"); return; + /** + * Loads the product image from the backend. + */ + private void loadProductImage() { + String imageUrl = baseUrl + String.format(Locale.US, ProductApi.PRODUCT_IMAGE_PATH, prodId); + String token = tokenManager.getToken(); + + GlideUtils.loadImageWithToken(requireContext(), binding.ivProductImage, imageUrl, token, R.drawable.placeholder2, new GlideUtils.ImageLoadListener() { + @Override + public void onResourceReady() { + hasImage = true; + } + + @Override + public void onLoadFailed() { + hasImage = false; + } + }); + } + + /** + * Performs image related actions (upload/delete) after product details are saved. + */ + private void performPendingImageActions(String successMsg) { + if (isImageRemoved) { + viewModel.deleteProductImage(prodId).observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status != Resource.Status.LOADING) { + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), successMsg, Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(getContext(), successMsg + " (but image removal failed)", Toast.LENGTH_SHORT).show(); + } + navigateBack(); + } + }); + } else if (isImageChanged && photoUri != null) { + uploadProductImageAndNavigate(photoUri, successMsg); + } else { + Toast.makeText(getContext(), successMsg, Toast.LENGTH_SHORT).show(); + navigateBack(); } - if (spinnerCategory.getSelectedItemPosition() == 0) { + } + + /** + * Uploads the selected image file to the server. + */ + private void uploadProductImageAndNavigate(Uri uri, String successMsg) { + File file = FileUtils.getFileFromUri(requireContext(), uri); + if (file == null) { + Toast.makeText(getContext(), successMsg, Toast.LENGTH_SHORT).show(); + navigateBack(); + return; + } + + RequestBody requestFile = RequestBody.create(file, MediaType.parse(requireContext().getContentResolver().getType(uri))); + MultipartBody.Part body = MultipartBody.Part.createFormData("image", file.getName(), requestFile); + + viewModel.uploadProductImage(prodId, body).observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status != Resource.Status.LOADING) { + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), successMsg, Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(getContext(), successMsg + " (but image upload failed)", Toast.LENGTH_SHORT).show(); + } + navigateBack(); + } + }); + } + + /** + * Validates input fields and saves product information to the backend. + */ + private void saveProduct() { + if (!InputValidator.isNotEmpty(binding.etProductName, "Product Name")) return; + + if (binding.spinnerProductCategory.getSelectedItemPosition() == 0) { Toast.makeText(getContext(), "Select a category", Toast.LENGTH_SHORT).show(); return; } - if (priceStr.isEmpty()) { - etProductPrice.setError("Enter price"); return; + + if (!InputValidator.isNotEmpty(binding.etProductPrice, "Price") || + !InputValidator.isPositiveDecimal(binding.etProductPrice, "Price")) { + return; } - CategoryDTO category = categoryList.get(spinnerCategory.getSelectedItemPosition() - 1); - BigDecimal price; - try { - price = new BigDecimal(priceStr); - } catch (Exception e) { - etProductPrice.setError("Invalid price"); return; - } + String name = binding.etProductName.getText().toString().trim(); + String desc = binding.etProductDesc.getText().toString().trim(); + BigDecimal price = new BigDecimal(binding.etProductPrice.getText().toString().trim()); + CategoryDTO category = categoryList.get(binding.spinnerProductCategory.getSelectedItemPosition() - 1); ProductDTO dto = new ProductDTO(name, category.getCategoryId(), desc, price); - Log.d("PRODUCT_SAVE", "name=" + name + " categoryId=" + category.getCategoryId() - + " price=" + price); - - ProductApi api = RetrofitClient.getProductApi(requireContext()); if (isEditing) { - api.updateProduct(prodId, dto).enqueue(simpleCallback("Updated")); + viewModel.updateProduct(prodId, dto).observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status != Resource.Status.LOADING) { + if (resource.status == Resource.Status.SUCCESS) { + performPendingImageActions("Updated"); + } else { + Toast.makeText(getContext(), "Error: " + resource.message, Toast.LENGTH_SHORT).show(); + } + } + }); } else { - api.createProduct(dto).enqueue(simpleCallback("Saved")); + viewModel.createProduct(dto).observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status != Resource.Status.LOADING) { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + prodId = resource.data.getProdId(); + performPendingImageActions("Saved"); + } else { + Toast.makeText(getContext(), "Error saving: " + resource.message, Toast.LENGTH_SHORT).show(); + } + } + }); } } - private Callback simpleCallback(String msg) { - return new Callback<>() { - public void onResponse(Call c, Response r) { - if (r.isSuccessful()) { - Toast.makeText(getContext(), msg, Toast.LENGTH_SHORT).show(); - navigateBack(); - } else { - try { - String err = r.errorBody().string(); - Log.e("PRODUCT_SAVE", "Error: " + err); - Toast.makeText(getContext(), "Error " + r.code(), Toast.LENGTH_SHORT).show(); - } catch (Exception e) { - Log.e("PRODUCT_SAVE", "Failed to read error"); - } - } - } - public void onFailure(Call c, Throwable t) { - Log.e("PRODUCT_SAVE", "Failure: " + t.getMessage()); - Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); - } - }; - } - + /** + * Displays a confirmation dialog before deleting the product. + */ private void confirmDelete() { - new AlertDialog.Builder(requireContext()) - .setTitle("Delete Product?") - .setPositiveButton("Yes", (d, w) -> - RetrofitClient.getProductApi(requireContext()) - .deleteProduct(prodId) - .enqueue(new Callback() { - public void onResponse(Call c, Response r) { - navigateBack(); - } - public void onFailure(Call c, Throwable t) { - Toast.makeText(getContext(), "Delete failed", - Toast.LENGTH_SHORT).show(); - } - })) - .setNegativeButton("No", null).show(); + DialogUtils.showDeleteConfirmDialog(requireContext(), "Product", () -> + viewModel.deleteProduct(prodId).observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status == Resource.Status.SUCCESS) { + navigateBack(); + } else if (resource != null && resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Delete failed: " + resource.message, Toast.LENGTH_SHORT).show(); + } + })); } + /** + * Navigates back to the previous fragment. + */ private void navigateBack() { - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.getChildFragmentManager().popBackStack(); + NavHostFragment.findNavController(this).popBackStack(); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ProductSupplierDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ProductSupplierDetailFragment.java index 729ae49d..aa570d13 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ProductSupplierDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ProductSupplierDetailFragment.java @@ -1,26 +1,36 @@ package com.example.petstoremobile.fragments.listfragments.detailfragments; import android.os.Bundle; -import android.util.Log; import android.view.*; import android.widget.*; import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; -import com.example.petstoremobile.R; -import com.example.petstoremobile.api.*; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; + +import com.example.petstoremobile.databinding.FragmentProductSupplierDetailBinding; import com.example.petstoremobile.dtos.*; -import com.example.petstoremobile.fragments.ListFragment; +import com.example.petstoremobile.utils.DialogUtils; +import com.example.petstoremobile.utils.InputValidator; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.SpinnerUtils; +import com.example.petstoremobile.viewmodels.ProductSupplierViewModel; +import com.example.petstoremobile.viewmodels.ProductViewModel; +import com.example.petstoremobile.viewmodels.SupplierViewModel; + import java.math.BigDecimal; import java.util.*; -import retrofit2.*; +import dagger.hilt.android.AndroidEntryPoint; + +/** + * Fragment for displaying and editing the relationship between products and suppliers. + */ +@AndroidEntryPoint public class ProductSupplierDetailFragment extends Fragment { - private TextView tvMode; - private Spinner spinnerProduct, spinnerSupplier; - private EditText etCost; - private Button btnSave, btnDelete, btnBack; + private FragmentProductSupplierDetailBinding binding; private boolean isEditing = false; private long editProductId = -1; @@ -31,190 +41,171 @@ public class ProductSupplierDetailFragment extends Fragment { private List productList = new ArrayList<>(); private List supplierList = new ArrayList<>(); + private ProductSupplierViewModel psViewModel; + private ProductViewModel productViewModel; + private SupplierViewModel supplierViewModel; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + psViewModel = new ViewModelProvider(this).get(ProductSupplierViewModel.class); + productViewModel = new ViewModelProvider(this).get(ProductViewModel.class); + supplierViewModel = new ViewModelProvider(this).get(SupplierViewModel.class); + } + @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_product_supplier_detail, container, false); - initViews(view); - loadData(); + binding = FragmentProductSupplierDetailBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + loadSpinnersData(); handleArguments(); - btnBack.setOnClickListener(v -> navigateBack()); - btnSave.setOnClickListener(v -> save()); - btnDelete.setOnClickListener(v -> confirmDelete()); - return view; + binding.btnPSBack.setOnClickListener(v -> navigateBack()); + binding.btnSavePS.setOnClickListener(v -> save()); + binding.btnDeletePS.setOnClickListener(v -> confirmDelete()); } - private void initViews(View v) { - tvMode = v.findViewById(R.id.tvPSMode); - spinnerProduct = v.findViewById(R.id.spinnerPSProduct); - spinnerSupplier = v.findViewById(R.id.spinnerPSSupplier); - etCost = v.findViewById(R.id.etPSCost); - btnSave = v.findViewById(R.id.btnSavePS); - btnDelete = v.findViewById(R.id.btnDeletePS); - btnBack = v.findViewById(R.id.btnPSBack); + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; } - private void loadData() { + /** + * Fetches products and suppliers to populate the spinners. + */ + private void loadSpinnersData() { loadProducts(); loadSuppliers(); } + /** + * Loads the list of products from the API. + */ private void loadProducts() { - RetrofitClient.getProductApi(requireContext()).getAllProducts(null, 0, 200) - .enqueue(new Callback>() { - public void onResponse(Call> c, - Response> r) { - if (r.isSuccessful() && r.body() != null) { - productList = r.body().getContent(); - populateProductSpinner(); - } - } - public void onFailure(Call> c, Throwable t) { - Log.e("PSDetail", "Product load failed: " + t.getMessage()); - } - }); - } - - private void populateProductSpinner() { - List names = new ArrayList<>(); - names.add("-- Select Product --"); - for (ProductDTO p : productList) names.add(p.getProdName()); - spinnerProduct.setAdapter(new ArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, names)); - if (preselectedProductId != -1) { - for (int i = 0; i < productList.size(); i++) { - if (productList.get(i).getProdId().equals(preselectedProductId)) { - spinnerProduct.setSelection(i + 1); break; - } + productViewModel.getAllProducts(null, null, 0, 200, "prodName").observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + productList = resource.data.getContent(); + refreshProductSpinner(); } - } + }); } + private void refreshProductSpinner() { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerPSProduct, productList, + ProductDTO::getProdName, "-- Select Product --", + preselectedProductId, ProductDTO::getProdId); + } + + /** + * Loads the list of suppliers from the API. + */ private void loadSuppliers() { - RetrofitClient.getSupplierApi(requireContext()).getAllSuppliers(0, 200) - .enqueue(new Callback>() { - public void onResponse(Call> c, - Response> r) { - if (r.isSuccessful() && r.body() != null) { - supplierList = r.body().getContent(); - populateSupplierSpinner(); - } - } - public void onFailure(Call> c, Throwable t) { - Log.e("PSDetail", "Supplier load failed: " + t.getMessage()); - } - }); - } - - private void populateSupplierSpinner() { - List names = new ArrayList<>(); - names.add("-- Select Supplier --"); - for (SupplierDTO s : supplierList) names.add(s.getSupCompany()); - spinnerSupplier.setAdapter(new ArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, names)); - if (preselectedSupplierId != -1) { - for (int i = 0; i < supplierList.size(); i++) { - if (supplierList.get(i).getSupId().equals(preselectedSupplierId)) { - spinnerSupplier.setSelection(i + 1); break; - } + supplierViewModel.getAllSuppliers(0, 200, null, "supCompany").observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + supplierList = resource.data.getContent(); + refreshSupplierSpinner(); } - } + }); } + private void refreshSupplierSpinner() { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerPSSupplier, supplierList, + SupplierDTO::getSupCompany, "-- Select Supplier --", + preselectedSupplierId, SupplierDTO::getSupId); + } + + /** + * Handles arguments to determine if the fragment is in edit or add mode. + */ private void handleArguments() { Bundle a = getArguments(); - if (a != null && a.containsKey("productId")) { + if (a != null && a.containsKey("productId") && a.containsKey("supplierId")) { isEditing = true; - editProductId = a.getLong("productId"); - editSupplierId = a.getLong("supplierId"); - preselectedProductId = editProductId; + editProductId = a.getLong("productId"); + editSupplierId = a.getLong("supplierId"); + preselectedProductId = editProductId; preselectedSupplierId = editSupplierId; - etCost.setText(a.getString("cost")); - tvMode.setText("Edit Product Supplier"); - btnDelete.setVisibility(View.VISIBLE); + + binding.tvPSMode.setText("Edit Product Supplier"); + binding.btnDeletePS.setVisibility(View.VISIBLE); + } else { - tvMode.setText("Add Product Supplier"); - btnDelete.setVisibility(View.GONE); + binding.tvPSMode.setText("Add Product Supplier"); + binding.btnDeletePS.setVisibility(View.GONE); } } + + /** + * Validates input and saves the product-supplier to the backend. + */ private void save() { - if (spinnerProduct.getSelectedItemPosition() == 0) { + if (binding.spinnerPSProduct.getSelectedItemPosition() == 0) { Toast.makeText(getContext(), "Select a product", Toast.LENGTH_SHORT).show(); return; } - if (spinnerSupplier.getSelectedItemPosition() == 0) { + if (binding.spinnerPSSupplier.getSelectedItemPosition() == 0) { Toast.makeText(getContext(), "Select a supplier", Toast.LENGTH_SHORT).show(); return; } - String costStr = etCost.getText().toString().trim(); - if (costStr.isEmpty()) { - etCost.setError("Enter cost"); return; + + if (!InputValidator.isNotEmpty(binding.etPSCost, "Cost") || + !InputValidator.isPositiveDecimal(binding.etPSCost, "Cost")) { + return; } - ProductDTO product = productList.get(spinnerProduct.getSelectedItemPosition() - 1); - SupplierDTO supplier = supplierList.get(spinnerSupplier.getSelectedItemPosition() - 1); - BigDecimal cost; - try { - cost = new BigDecimal(costStr); - } catch (Exception e) { - etCost.setError("Invalid cost"); return; - } + ProductDTO product = productList.get(binding.spinnerPSProduct.getSelectedItemPosition() - 1); + SupplierDTO supplier = supplierList.get(binding.spinnerPSSupplier.getSelectedItemPosition() - 1); + BigDecimal cost = new BigDecimal(binding.etPSCost.getText().toString().trim()); ProductSupplierDTO dto = new ProductSupplierDTO( product.getProdId(), supplier.getSupId(), cost); - ProductSupplierApi api = RetrofitClient.getProductSupplierApi(requireContext()); if (isEditing) { - api.updateProductSupplier(editProductId, editSupplierId, dto) - .enqueue(simpleCallback("Updated")); + psViewModel.updateProductSupplier(editProductId, editSupplierId, dto).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), "Updated", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Error: " + resource.message, Toast.LENGTH_SHORT).show(); + } + }); } else { - api.createProductSupplier(dto).enqueue(simpleCallback("Saved")); + psViewModel.createProductSupplier(dto).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), "Saved", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Error: " + resource.message, Toast.LENGTH_SHORT).show(); + } + }); } } - private Callback simpleCallback(String msg) { - return new Callback<>() { - public void onResponse(Call c, Response r) { - if (r.isSuccessful()) { - Toast.makeText(getContext(), msg, Toast.LENGTH_SHORT).show(); - navigateBack(); - } else { - try { - String err = r.errorBody().string(); - Log.e("PS_SAVE", "Error: " + err); - Toast.makeText(getContext(), "Error " + r.code(), Toast.LENGTH_SHORT).show(); - } catch (Exception e) { - Log.e("PS_SAVE", "Failed to read error"); - } - } - } - public void onFailure(Call c, Throwable t) { - Log.e("PS_SAVE", "Failure: " + t.getMessage()); - Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); - } - }; - } - + /** + * Shows a confirmation dialog before deleting a product-supplier relationship. + */ private void confirmDelete() { - new AlertDialog.Builder(requireContext()) - .setTitle("Delete?") - .setPositiveButton("Yes", (d, w) -> - RetrofitClient.getProductSupplierApi(requireContext()) - .deleteProductSupplier(editProductId, editSupplierId) - .enqueue(new Callback() { - public void onResponse(Call c, Response r) { - navigateBack(); - } - public void onFailure(Call c, Throwable t) { - Toast.makeText(getContext(), "Delete failed", - Toast.LENGTH_SHORT).show(); - } - })) - .setNegativeButton("No", null).show(); + DialogUtils.showDeleteConfirmDialog(requireContext(), "Product Supplier", () -> + psViewModel.deleteProductSupplier(editProductId, editSupplierId).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), "Deleted", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Delete failed: " + resource.message, Toast.LENGTH_SHORT).show(); + } + })); } + /** + * Navigates back to the previous screen. + */ private void navigateBack() { - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.getChildFragmentManager().popBackStack(); + NavHostFragment.findNavController(this).popBackStack(); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PurchaseOrderDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PurchaseOrderDetailFragment.java index 47dae4d8..eb69bd16 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PurchaseOrderDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PurchaseOrderDetailFragment.java @@ -3,53 +3,107 @@ package com.example.petstoremobile.fragments.listfragments.detailfragments; import android.graphics.Color; import android.os.Bundle; import android.view.*; -import android.widget.*; -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import com.example.petstoremobile.R; -import com.example.petstoremobile.fragments.ListFragment; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; + +import com.example.petstoremobile.databinding.FragmentPurchaseOrderDetailBinding; +import com.example.petstoremobile.dtos.PurchaseOrderDTO; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.viewmodels.PurchaseOrderViewModel; + +import dagger.hilt.android.AndroidEntryPoint; + +/** + * Fragment for displaying the information of a purchase order. + */ +@AndroidEntryPoint public class PurchaseOrderDetailFragment extends Fragment { - private TextView tvId, tvSupplier, tvDate, tvStatus; - private Button btnBack; + private FragmentPurchaseOrderDetailBinding binding; + private PurchaseOrderViewModel viewModel; + private long purchaseOrderId; + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(PurchaseOrderViewModel.class); + } + + /** + * Inflates the layout. + */ @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_purchase_order_detail, container, false); + binding = FragmentPurchaseOrderDetailBinding.inflate(inflater, container, false); + return binding.getRoot(); + } - tvId = view.findViewById(R.id.tvPODetailId); - tvSupplier = view.findViewById(R.id.tvPODetailSupplier); - tvDate = view.findViewById(R.id.tvPODetailDate); - tvStatus = view.findViewById(R.id.tvPODetailStatus); - btnBack = view.findViewById(R.id.btnPOBack); + /** + * Initializes views and populates order data from backend after the view is created. + */ + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); - Bundle a = getArguments(); - if (a != null) { - tvId.setText("PO #" + a.getLong("purchaseOrderId")); - tvSupplier.setText(a.getString("supplierName")); - tvDate.setText(a.getString("orderDate")); + handleArguments(); - String status = a.getString("status", ""); - tvStatus.setText(status); - switch (status) { - case "Completed": - tvStatus.setTextColor(Color.parseColor("#4CAF50")); break; - case "Pending": - tvStatus.setTextColor(Color.parseColor("#FF9800")); break; - case "Cancelled": - tvStatus.setTextColor(Color.parseColor("#F44336")); break; - default: - tvStatus.setTextColor(Color.parseColor("#9E9E9E")); break; - } - } - - btnBack.setOnClickListener(v -> { - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.getChildFragmentManager().popBackStack(); + binding.btnPOBack.setOnClickListener(v -> { + NavHostFragment.findNavController(this).popBackStack(); }); + } - return view; + private void handleArguments() { + Bundle a = getArguments(); + if (a != null && a.containsKey("purchaseOrderId")) { + purchaseOrderId = a.getLong("purchaseOrderId"); + loadPurchaseOrderData(); + } + } + + private void loadPurchaseOrderData() { + viewModel.getPurchaseOrderById(purchaseOrderId).observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + PurchaseOrderDTO po = resource.data; + binding.tvPODetailId.setText("PO #" + po.getPurchaseOrderId()); + binding.tvPODetailSupplier.setText(po.getSupplierName()); + binding.tvPODetailStore.setText(po.getStoreName() != null ? po.getStoreName() : "N/A"); + 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) { + Toast.makeText(getContext(), "Failed to load order: " + resource.message, Toast.LENGTH_SHORT).show(); + } + }); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; } } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ServiceDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ServiceDetailFragment.java index c7b0a4c5..49c51141 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ServiceDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ServiceDetailFragment.java @@ -2,75 +2,87 @@ package com.example.petstoremobile.fragments.listfragments.detailfragments; import android.os.Bundle; -import androidx.appcompat.app.AlertDialog; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.Button; -import android.widget.EditText; -import android.widget.TextView; import android.widget.Toast; import com.example.petstoremobile.R; -import com.example.petstoremobile.api.RetrofitClient; -import com.example.petstoremobile.api.ServiceApi; +import com.example.petstoremobile.databinding.FragmentServiceDetailBinding; import com.example.petstoremobile.dtos.ServiceDTO; -import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.ServiceFragment; import com.example.petstoremobile.utils.ActivityLogger; +import com.example.petstoremobile.utils.DialogUtils; import com.example.petstoremobile.utils.InputValidator; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.viewmodels.ServiceViewModel; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; +import dagger.hilt.android.AndroidEntryPoint; +/** + * Fragment for displaying and editing service details. + */ +@AndroidEntryPoint public class ServiceDetailFragment extends Fragment { - private TextView tvMode, tvServiceId; - private EditText etServiceName, etServiceDesc, etServiceDuration, etServicePrice; - private Button btnSaveService, btnDeleteService, btnBack; - private int serviceId; + private FragmentServiceDetailBinding binding; + private long serviceId; private boolean isEditing = false; - private ServiceFragment serviceFragment; - //set the service fragment to the parent so we refer back to service view when save or delete is done - public void setServiceFragment(ServiceFragment fragment) { - this.serviceFragment = fragment; + private ServiceViewModel viewModel; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(ServiceViewModel.class); } @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_service_detail, container, false); + binding = FragmentServiceDetailBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); //get controls from layout and display the view depending on the mode - initViews(view); handleArguments(); //set button click listeners - btnBack.setOnClickListener(v -> navigateBack()); - btnSaveService.setOnClickListener(v -> saveService()); - btnDeleteService.setOnClickListener(v -> deleteService()); - - return view; + binding.btnBack.setOnClickListener(v -> navigateBack()); + binding.btnSaveService.setOnClickListener(v -> saveService()); + binding.btnDeleteService.setOnClickListener(v -> deleteService()); } - //Method to Update or Add a service + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + /** + * Handles the saving of service data (adding or updating). + */ private void saveService() { // Validates all fields using InputValidator - if (!InputValidator.isNotEmpty(etServiceName, "Service Name")) return; - if (!InputValidator.isNotEmpty(etServiceDesc, "Description")) return; - if (!InputValidator.isPositiveInteger(etServiceDuration, "Duration")) return; - if (!InputValidator.isPositiveDecimal(etServicePrice, "Price")) return; + if (!InputValidator.isNotEmpty(binding.etServiceName, "Service Name")) return; + if (!InputValidator.isNotEmpty(binding.etServiceDesc, "Description")) return; + if (!InputValidator.isPositiveInteger(binding.etServiceDuration, "Duration")) return; + if (!InputValidator.isPositiveDecimal(binding.etServicePrice, "Price")) return; //get all the values from the fields - String name = etServiceName.getText().toString().trim(); - String desc = etServiceDesc.getText().toString().trim(); - int duration = Integer.parseInt(etServiceDuration.getText().toString().trim()); - double price = Double.parseDouble(etServicePrice.getText().toString().trim()); + String name = binding.etServiceName.getText().toString().trim(); + String desc = binding.etServiceDesc.getText().toString().trim(); + int duration = Integer.parseInt(binding.etServiceDuration.getText().toString().trim()); + double price = Double.parseDouble(binding.etServicePrice.getText().toString().trim()); //create a service object to send to the API ServiceDTO serviceDTO = new ServiceDTO(); @@ -79,130 +91,94 @@ public class ServiceDetailFragment extends Fragment { serviceDTO.setServiceDuration(duration); serviceDTO.setServicePrice(price); - ServiceApi serviceApi = RetrofitClient.getServiceApi(requireContext()); - //check if the service is being edited or added if (isEditing) { // Update existing service - serviceDTO.setServiceId((long) serviceId); - serviceApi.updateService((long) serviceId, serviceDTO).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - ActivityLogger.logChange(requireContext(), "Service", "UPDATED", serviceId); - Toast.makeText(getContext(), "Service updated successfully!", Toast.LENGTH_SHORT).show(); - navigateBack(); - } else { - Toast.makeText(getContext(), "Failed to update service: " + response.code(), Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - ActivityLogger.logException(requireContext(), "ServiceDetailFragment.updateService", new Exception(t)); - Log.e("ServiceDetailFragment", "Error updating service", t); - Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + serviceDTO.setServiceId(serviceId); + viewModel.updateService(serviceId, serviceDTO).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + ActivityLogger.logChange(requireContext(), "Service", "UPDATED", (int) serviceId); + Toast.makeText(getContext(), "Service updated successfully!", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Error: " + resource.message, Toast.LENGTH_SHORT).show(); } }); } else { - // Add new service - serviceApi.createService(serviceDTO).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - ActivityLogger.log(requireContext(), "Added new Service: " + name); - Toast.makeText(getContext(), "Service added successfully!", Toast.LENGTH_SHORT).show(); - navigateBack(); - } else { - Toast.makeText(getContext(), "Failed to add service: " + response.code(), Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - ActivityLogger.logException(requireContext(), "ServiceDetailFragment.createService", new Exception(t)); - Log.e("ServiceDetailFragment", "Error adding service", t); - Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + viewModel.createService(serviceDTO).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + ActivityLogger.log(requireContext(), "Added new Service: " + name); + Toast.makeText(getContext(), "Service added successfully!", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Error: " + resource.message, Toast.LENGTH_SHORT).show(); } }); } } - //Method to Delete a service + /** + * Displays a confirmation dialog and handles the deletion of a service. + */ private void deleteService() { - //Alert the user to confirm the delete - new AlertDialog.Builder(requireContext()) - .setTitle("Delete Service") - .setMessage("Are you sure you want to delete " + etServiceName.getText().toString() + "?") - .setPositiveButton("Delete", (dialog, which) -> { - ServiceApi serviceApi = RetrofitClient.getServiceApi(requireContext()); - serviceApi.deleteService((long) serviceId).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - ActivityLogger.logChange(requireContext(), "Service", "DELETED", serviceId); - Toast.makeText(getContext(), "Service deleted successfully!", Toast.LENGTH_SHORT).show(); - navigateBack(); - } else { - Toast.makeText(getContext(), "Failed to delete service: " + response.code(), Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - ActivityLogger.logException(requireContext(), "ServiceDetailFragment.deleteService", new Exception(t)); - Log.e("ServiceDetailFragment", "Error deleting service", t); - Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); - } - }); - }) - .setNegativeButton("Cancel", null) - .show(); + DialogUtils.showDeleteConfirmDialog(requireContext(), "Service", () -> + viewModel.deleteService(serviceId).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + ActivityLogger.logChange(requireContext(), "Service", "DELETED", (int) serviceId); + Toast.makeText(getContext(), "Service deleted successfully!", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Delete failed: " + resource.message, Toast.LENGTH_SHORT).show(); + } + })); } - //Helper method to navigate back to the list + /** + * Navigates back to the previous screen. + */ private void navigateBack() { - ListFragment listFragment = (ListFragment) getParentFragment(); - if (listFragment != null) { - listFragment.getChildFragmentManager().popBackStack(); - } + NavHostFragment.findNavController(this).popBackStack(); } - //helper function to check if service is being edited or added and show the view accordingly + /** + * Handles arguments passed to the fragment to determine if it's in edit or add mode. + */ private void handleArguments() { // Service is being edited if the bundle contains a serviceId if (getArguments() != null && getArguments().containsKey("serviceId")) { // Get service data from arguments and populate fields isEditing = true; - serviceId = getArguments().getInt("serviceId"); - tvMode.setText("Edit Service"); - tvServiceId.setText("ID: " + serviceId); - etServiceName.setText(getArguments().getString("serviceName")); - etServiceDesc.setText(getArguments().getString("serviceDesc")); - etServiceDuration.setText(String.valueOf(getArguments().getInt("serviceDuration"))); - etServicePrice.setText(String.valueOf(getArguments().getDouble("servicePrice"))); - btnDeleteService.setVisibility(View.VISIBLE); + serviceId = getArguments().getLong("serviceId"); + binding.tvMode.setText("Edit Service"); + binding.tvServiceId.setText("ID: " + serviceId); + binding.btnDeleteService.setVisibility(View.VISIBLE); + loadServiceData(); } else { // Service is being added // Set default values for add a new service isEditing = false; - tvMode.setText("Add Service"); - tvServiceId.setVisibility(View.GONE); - btnDeleteService.setVisibility(View.GONE); - btnSaveService.setText("Add"); + binding.tvMode.setText("Add Service"); + binding.tvServiceId.setVisibility(View.GONE); + binding.btnDeleteService.setVisibility(View.GONE); + binding.btnSaveService.setText("Add"); } } - //helper function to get controls from layout - private void initViews(View view) { - tvMode = view.findViewById(R.id.tvMode); - tvServiceId = view.findViewById(R.id.tvServiceId); - etServiceName = view.findViewById(R.id.etServiceName); - etServiceDesc = view.findViewById(R.id.etServiceDesc); - etServiceDuration = view.findViewById(R.id.etServiceDuration); - etServicePrice = view.findViewById(R.id.etServicePrice); - btnSaveService = view.findViewById(R.id.btnSaveService); - btnDeleteService = view.findViewById(R.id.btnDeleteService); - btnBack = view.findViewById(R.id.btnBack); + /** + * Fetches specific service details from the backend using the ID. + */ + private void loadServiceData() { + viewModel.getServiceById(serviceId).observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + ServiceDTO s = resource.data; + binding.etServiceName.setText(s.getServiceName()); + binding.etServiceDesc.setText(s.getServiceDesc()); + binding.etServiceDuration.setText(String.valueOf(s.getServiceDuration())); + binding.etServicePrice.setText(String.valueOf(s.getServicePrice())); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Failed to load service: " + resource.message, Toast.LENGTH_SHORT).show(); + } + }); } } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SupplierDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SupplierDetailFragment.java index df5c5520..4935cb8b 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SupplierDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SupplierDetailFragment.java @@ -2,77 +2,91 @@ package com.example.petstoremobile.fragments.listfragments.detailfragments; import android.os.Bundle; -import androidx.appcompat.app.AlertDialog; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.Button; -import android.widget.EditText; -import android.widget.TextView; import android.widget.Toast; -import com.example.petstoremobile.R; -import com.example.petstoremobile.api.RetrofitClient; -import com.example.petstoremobile.api.SupplierApi; +import com.example.petstoremobile.databinding.FragmentSupplierDetailBinding; import com.example.petstoremobile.dtos.SupplierDTO; -import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.SupplierFragment; import com.example.petstoremobile.utils.ActivityLogger; +import com.example.petstoremobile.utils.DialogUtils; import com.example.petstoremobile.utils.InputValidator; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.UIUtils; +import com.example.petstoremobile.viewmodels.SupplierViewModel; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; +import dagger.hilt.android.AndroidEntryPoint; +/** + * Fragment for displaying and editing supplier details. + */ +@AndroidEntryPoint public class SupplierDetailFragment extends Fragment { - private TextView tvMode, tvSupId; - private EditText etSupCompany, etSupContactFirstName, etSupContactLastName, etSupEmail, etSupPhone; - private Button btnSaveSupplier, btnDeleteSupplier, btnBack; - private int supId; + private FragmentSupplierDetailBinding binding; + private long supId; private boolean isEditing = false; - private SupplierFragment supplierFragment; - //set the supplier fragment to the parent so we refer back to supplier view when save or delete is done - public void setSupplierFragment(SupplierFragment fragment) { - this.supplierFragment = fragment; + private SupplierViewModel viewModel; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(SupplierViewModel.class); } @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_supplier_detail, container, false); + binding = FragmentSupplierDetailBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + // Add phone number formatting (CA) and limit length to 14 characters + UIUtils.formatPhoneInput(binding.etSupPhone); - //get controls from layout and display the view depending on the mode - initViews(view); handleArguments(); //set button click listeners - btnBack.setOnClickListener(v -> navigateBack()); - btnSaveSupplier.setOnClickListener(v -> saveSupplier()); - btnDeleteSupplier.setOnClickListener(v -> deleteSupplier()); - - return view; + binding.btnBack.setOnClickListener(v -> navigateBack()); + binding.btnSaveSupplier.setOnClickListener(v -> saveSupplier()); + binding.btnDeleteSupplier.setOnClickListener(v -> deleteSupplier()); } - //Method to Update or Add a supplier + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + /** + * Handles the saving of supplier data (adding or updating). + */ private void saveSupplier() { // Validates all fields using InputValidator - if (!InputValidator.isNotEmpty(etSupCompany, "Company Name")) return; - if (!InputValidator.isNotEmpty(etSupContactFirstName, "First Name")) return; - if (!InputValidator.isNotEmpty(etSupContactLastName, "Last Name")) return; - if (!InputValidator.isValidEmail(etSupEmail)) return; - if (!InputValidator.isValidPhone(etSupPhone)) return; + if (!InputValidator.isNotEmpty(binding.etSupCompany, "Company Name")) return; + if (!InputValidator.isNotEmpty(binding.etSupContactFirstName, "First Name")) return; + if (!InputValidator.isNotEmpty(binding.etSupContactLastName, "Last Name")) return; + if (!InputValidator.isValidEmail(binding.etSupEmail)) return; + if (!InputValidator.isValidPhone(binding.etSupPhone)) return; //get all the values from the fields - String company = etSupCompany.getText().toString().trim(); - String firstName = etSupContactFirstName.getText().toString().trim(); - String lastName = etSupContactLastName.getText().toString().trim(); - String email = etSupEmail.getText().toString().trim(); - String phone = etSupPhone.getText().toString().trim(); + String company = binding.etSupCompany.getText().toString().trim(); + String firstName = binding.etSupContactFirstName.getText().toString().trim(); + String lastName = binding.etSupContactLastName.getText().toString().trim(); + String email = binding.etSupEmail.getText().toString().trim(); + String phone = binding.etSupPhone.getText().toString().trim(); //create a supplier object to send to the API SupplierDTO supplierDTO = new SupplierDTO(); @@ -82,137 +96,97 @@ public class SupplierDetailFragment extends Fragment { supplierDTO.setSupEmail(email); supplierDTO.setSupPhone(phone); - SupplierApi supplierApi = RetrofitClient.getSupplierApi(requireContext()); - //check if the supplier is being edited or added if (isEditing) { // Update existing supplier - supplierDTO.setSupId((long) supId); - supplierApi.updateSupplier((long) supId, supplierDTO).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - ActivityLogger.logChange(requireContext(), "Supplier", "UPDATED", supId); - Toast.makeText(getContext(), "Supplier updated successfully!", Toast.LENGTH_SHORT).show(); - navigateBack(); - } else { - Toast.makeText(getContext(), "Failed to update supplier: " + response.code(), Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - ActivityLogger.logException(requireContext(), "SupplierDetailFragment.updateSupplier", new Exception(t)); - Log.e("SupplierDetailFragment", "Error updating supplier", t); - Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + supplierDTO.setSupId(supId); + viewModel.updateSupplier(supId, supplierDTO).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + ActivityLogger.logChange(requireContext(), "Supplier", "UPDATED", (int) supId); + Toast.makeText(getContext(), "Supplier updated successfully!", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Error: " + resource.message, Toast.LENGTH_SHORT).show(); } }); } else { // Add new supplier - supplierApi.createSupplier(supplierDTO).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - ActivityLogger.log(requireContext(), "Added new Supplier: " + company); - Toast.makeText(getContext(), "Supplier added successfully!", Toast.LENGTH_SHORT).show(); - navigateBack(); - } else { - Toast.makeText(getContext(), "Failed to add supplier: " + response.code(), Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - ActivityLogger.logException(requireContext(), "SupplierDetailFragment.createSupplier", new Exception(t)); - Log.e("SupplierDetailFragment", "Error adding supplier", t); - Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + viewModel.createSupplier(supplierDTO).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + ActivityLogger.log(requireContext(), "Added new Supplier: " + company); + Toast.makeText(getContext(), "Supplier added successfully!", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Error: " + resource.message, Toast.LENGTH_SHORT).show(); } }); } } - //Method to Delete a supplier + /** + * Displays a confirmation dialog and handles the deletion of a supplier. + */ private void deleteSupplier() { - //Alert the user to confirm the delete - new AlertDialog.Builder(requireContext()) - .setTitle("Delete Supplier") - .setMessage("Are you sure you want to delete " + etSupCompany.getText().toString() + "?") - .setPositiveButton("Delete", (dialog, which) -> { - SupplierApi supplierApi = RetrofitClient.getSupplierApi(requireContext()); - supplierApi.deleteSupplier((long) supId).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - ActivityLogger.logChange(requireContext(), "Supplier", "DELETED", supId); - Toast.makeText(getContext(), "Supplier deleted successfully!", Toast.LENGTH_SHORT).show(); - navigateBack(); - } else { - Toast.makeText(getContext(), "Failed to delete supplier: " + response.code(), Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - ActivityLogger.logException(requireContext(), "SupplierDetailFragment.deleteSupplier", new Exception(t)); - Log.e("SupplierDetailFragment", "Error deleting supplier", t); - Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); - } - }); - }) - .setNegativeButton("Cancel", null) - .show(); + DialogUtils.showDeleteConfirmDialog(requireContext(), "Supplier", () -> + viewModel.deleteSupplier(supId).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS) { + ActivityLogger.logChange(requireContext(), "Supplier", "DELETED", (int) supId); + Toast.makeText(getContext(), "Supplier deleted successfully!", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Delete failed: " + resource.message, Toast.LENGTH_SHORT).show(); + } + })); } - //Helper method to navigate back to the list + /** + * Navigates back to the previous screen. + */ private void navigateBack() { - ListFragment listFragment = (ListFragment) getParentFragment(); - if (listFragment != null) { - listFragment.getChildFragmentManager().popBackStack(); - } + NavHostFragment.findNavController(this).popBackStack(); } - //helper function to check if supplier is being edited or added and show the view accordingly + /** + * Handles arguments passed to the fragment to determine if it's in edit or add mode. + */ private void handleArguments() { // Supplier is being edited if the bundle contains a supId if (getArguments() != null && getArguments().containsKey("supId")) { // Get supplier data from arguments and populate fields isEditing = true; - supId = getArguments().getInt("supId"); - tvMode.setText("Edit Supplier"); - tvSupId.setText("ID: " + supId); - etSupCompany.setText(getArguments().getString("supCompany")); - etSupContactFirstName.setText(getArguments().getString("supContactFirstName")); - etSupContactLastName.setText(getArguments().getString("supContactLastName")); - etSupEmail.setText(getArguments().getString("supEmail")); - etSupPhone.setText(getArguments().getString("supPhone")); - btnDeleteSupplier.setVisibility(View.VISIBLE); + supId = getArguments().getLong("supId"); + binding.tvMode.setText("Edit Supplier"); + binding.tvSupId.setText("ID: " + supId); + binding.tvSupId.setVisibility(View.VISIBLE); + binding.btnDeleteSupplier.setVisibility(View.VISIBLE); + loadSupplierData(); } else { // Supplier is being added // Set default values for add a new supplier isEditing = false; - tvMode.setText("Add Supplier"); - tvSupId.setVisibility(View.GONE); - btnDeleteSupplier.setVisibility(View.GONE); - btnSaveSupplier.setText("Add"); + binding.tvMode.setText("Add Supplier"); + binding.tvSupId.setVisibility(View.GONE); + binding.btnDeleteSupplier.setVisibility(View.GONE); + binding.btnSaveSupplier.setText("Add"); } } - //helper function to get controls from layout - private void initViews(View view) { - tvMode = view.findViewById(R.id.tvMode); - tvSupId = view.findViewById(R.id.tvSupId); - etSupCompany = view.findViewById(R.id.etSupCompany); - etSupContactFirstName = view.findViewById(R.id.etSupContactFirstName); - etSupContactLastName = view.findViewById(R.id.etSupContactLastName); - etSupEmail = view.findViewById(R.id.etSupEmail); - etSupPhone = view.findViewById(R.id.etSupPhone); - - // Add phone number formatting (CA) and limit length to 14 characters - etSupPhone.addTextChangedListener(new android.telephony.PhoneNumberFormattingTextWatcher("CA")); - etSupPhone.setFilters(new android.text.InputFilter[]{new android.text.InputFilter.LengthFilter(14)}); - - btnSaveSupplier = view.findViewById(R.id.btnSaveSupplier); - btnDeleteSupplier = view.findViewById(R.id.btnDeleteSupplier); - btnBack = view.findViewById(R.id.btnBack); + /** + * Fetches specific supplier details from the backend using the ID. + */ + private void loadSupplierData() { + viewModel.getSupplierById(supId).observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + SupplierDTO s = resource.data; + binding.etSupCompany.setText(s.getSupCompany()); + binding.etSupContactFirstName.setText(s.getSupContactFirstName()); + binding.etSupContactLastName.setText(s.getSupContactLastName()); + binding.etSupEmail.setText(s.getSupEmail()); + binding.etSupPhone.setText(s.getSupPhone()); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Failed to load supplier: " + resource.message, Toast.LENGTH_SHORT).show(); + } + }); } } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/listprofilefragments/PetProfileFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/listprofilefragments/PetProfileFragment.java index 454d263b..9e876c69 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/listprofilefragments/PetProfileFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/listprofilefragments/PetProfileFragment.java @@ -1,262 +1,210 @@ package com.example.petstoremobile.fragments.listfragments.listprofilefragments; -import android.Manifest; -import android.app.Activity; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.graphics.Color; import android.net.Uri; import android.os.Bundle; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.appcompat.app.AlertDialog; -import androidx.core.content.ContextCompat; -import androidx.core.content.FileProvider; +import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; -import android.provider.MediaStore; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.TextView; import android.widget.Toast; -import com.bumptech.glide.Glide; -import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.example.petstoremobile.R; import com.example.petstoremobile.api.PetApi; -import com.example.petstoremobile.api.RetrofitClient; -import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.detailfragments.PetDetailFragment; +import com.example.petstoremobile.api.auth.TokenManager; +import com.example.petstoremobile.databinding.FragmentPetProfileBinding; +import com.example.petstoremobile.dtos.PetDTO; +import com.example.petstoremobile.utils.FileUtils; +import com.example.petstoremobile.utils.GlideUtils; +import com.example.petstoremobile.utils.ImagePickerHelper; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.viewmodels.PetViewModel; import java.io.File; -import java.io.FileOutputStream; -import java.io.InputStream; import java.util.Locale; +import javax.inject.Inject; +import javax.inject.Named; + +import dagger.hilt.android.AndroidEntryPoint; import okhttp3.MediaType; import okhttp3.MultipartBody; import okhttp3.RequestBody; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; +@AndroidEntryPoint public class PetProfileFragment extends Fragment { - private TextView tvPetName, tvPetSpecies, tvPetBreed, tvPetAge, tvPetPrice; - private Button btnBack, btnEditPet, btnChangePhoto; - private ImageView imgPet; - private Uri photoUri; - private int petId; + private FragmentPetProfileBinding binding; + private long petId; + private boolean hasImage = false; - // launchers for camera and gallery - private ActivityResultLauncher galleryLauncher; - private ActivityResultLauncher cameraLauncher; - private ActivityResultLauncher permissionLauncher; + @Inject @Named("baseUrl") String baseUrl; + @Inject TokenManager tokenManager; + + private PetViewModel viewModel; + private ImagePickerHelper imagePickerHelper; + /** + * Initializes activity launchers for gallery, camera, and permissions. + */ @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(PetViewModel.class); - // Launcher to open gallery to select image - galleryLauncher = registerForActivityResult( - new ActivityResultContracts.StartActivityForResult(), - result -> { - if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { - Uri selectedImage = result.getData().getData(); - uploadPetImage(selectedImage); - } - } - ); + imagePickerHelper = new ImagePickerHelper(this, "pet_photo.jpg", new ImagePickerHelper.ImagePickerListener() { + @Override + public void onImagePicked(Uri uri) { + uploadPetImage(uri); + } - // Launcher for camera to open and capture image - cameraLauncher = registerForActivityResult( - new ActivityResultContracts.TakePicture(), - success -> { - if (success) { - uploadPetImage(photoUri); - } - } - ); - - // Launcher to request camera permission - permissionLauncher = registerForActivityResult( - new ActivityResultContracts.RequestPermission(), - granted -> { - if (granted) { - launchCamera(); - } else { - new AlertDialog.Builder(requireContext()) - .setTitle("Permission Required") - .setMessage("Please grant camera permission to use this feature") - .setPositiveButton("Open Settings", (dialog, which) -> { - Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - intent.setData(Uri.fromParts("package", requireContext().getPackageName(), null)); - startActivity(intent); - }) - .setNegativeButton("Cancel", null) - .show(); - } - } - ); + @Override + public void onImageRemoved() { + deletePetImage(); + } + }); } + /** + * Inflates the layout using view binding, initializes views, and sets up click listeners. + */ @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_pet_profile, container, false); - - // Initialize views - tvPetName = view.findViewById(R.id.tvPetName); - tvPetSpecies = view.findViewById(R.id.tvPetSpecies); - tvPetBreed = view.findViewById(R.id.tvPetBreed); - tvPetAge = view.findViewById(R.id.tvPetAge); - tvPetPrice = view.findViewById(R.id.tvPetPrice); - btnBack = view.findViewById(R.id.btnBack); - btnEditPet = view.findViewById(R.id.btnEditPet); - btnChangePhoto = view.findViewById(R.id.btnChangePhoto); - imgPet = view.findViewById(R.id.imgPet); - + binding = FragmentPetProfileBinding.inflate(inflater, container, false); // Set pet details to display if (getArguments() != null) { - petId = getArguments().getInt("petId"); - tvPetName.setText(getArguments().getString("petName")); - tvPetSpecies.setText(getArguments().getString("petSpecies")); - tvPetBreed.setText(getArguments().getString("petBreed")); - tvPetAge.setText(String.format(Locale.getDefault(), "%d yr(s)", getArguments().getInt("petAge"))); - tvPetPrice.setText(String.format(Locale.getDefault(), "$%.2f", getArguments().getDouble("petPrice"))); - - // Load pet image from backend - loadPetImage(petId); + petId = getArguments().getLong("petId"); + loadPetData(); + loadPetImage((int) petId); } //set button click listeners - btnBack.setOnClickListener(v -> { - //get the list fragment and pop the back stack to return to the previous view (PetFragment) - ListFragment listFragment = (ListFragment) getParentFragment(); - if (listFragment != null) { - listFragment.getChildFragmentManager().popBackStack(); - } + binding.btnBack.setOnClickListener(v -> { + NavHostFragment.findNavController(this).popBackStack(); }); //Make the edit button go to the pet detail view - btnEditPet.setOnClickListener(v -> { - if (getArguments() == null) return; - - PetDetailFragment detailFragment = new PetDetailFragment(); - //send the bundle to the pet detail fragment - detailFragment.setArguments(getArguments()); - - //get ListFragment to load the the detail view - ListFragment listFragment = (ListFragment) getParentFragment(); - if (listFragment != null) { - listFragment.loadFragment(detailFragment); - } + binding.btnEditPet.setOnClickListener(v -> { + Bundle args = new Bundle(); + args.putLong("petId", petId); + NavHostFragment.findNavController(this).navigate(R.id.nav_pet_detail, args); }); //Make change photo button ask user to select a new photo - btnChangePhoto.setOnClickListener(v -> { - new AlertDialog.Builder(requireContext()) - .setTitle("Change Pet Photo") - .setItems(new String[]{"Take Photo", "Choose from Gallery"}, (dialog, which) -> { - if (which == 0) { - // Choose Camera - //Checks if the user has granted the camera permission already - if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { - //if the permission is already granted then launch the camera - launchCamera(); - } else { - //otherwise request the permission - permissionLauncher.launch(Manifest.permission.CAMERA); - } - } else { - Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); - galleryLauncher.launch(intent); - } - }) - .show(); + binding.btnChangePhoto.setOnClickListener(v -> { + imagePickerHelper.showImagePickerDialog("Change Pet Photo", hasImage); }); - return view; + return binding.getRoot(); } - // Helper function to load pet image from backend + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + /** + * Fetches current pet data from the backend and updates the UI. + */ + private void loadPetData() { + viewModel.getPetById(petId).observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + PetDTO pet = resource.data; + binding.tvPetName.setText(pet.getPetName()); + binding.tvPetSpecies.setText(pet.getPetSpecies()); + binding.tvPetBreed.setText(pet.getPetBreed()); + binding.tvPetAge.setText(String.format(Locale.getDefault(), "%d yr(s)", pet.getPetAge())); + + if (pet.getPetPrice() != null) { + binding.tvPetPrice.setText(String.format(Locale.getDefault(), "$%.2f", pet.getPetPrice())); + } else { + binding.tvPetPrice.setText("$0.00"); + } + + // Display owner name if available, otherwise show No Owner + if (pet.getCustomerName() != null && !pet.getCustomerName().isEmpty()) { + binding.tvPetOwner.setText(pet.getCustomerName()); + } else { + binding.tvPetOwner.setText("No Owner"); + } + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Failed to load pet data: " + resource.message, Toast.LENGTH_SHORT).show(); + } + }); + } + + /** + * Fetches and displays the pet\'s image from the server. + */ private void loadPetImage(int petId) { - String imageUrl = RetrofitClient.BASE_URL + String.format(Locale.US, PetApi.PET_IMAGE_PATH, petId); + String imageUrl = baseUrl + String.format(Locale.US, PetApi.PET_IMAGE_PATH, petId); + String token = tokenManager.getToken(); - Glide.with(this) - .load(imageUrl) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .skipMemoryCache(true) - .placeholder(R.drawable.placeholder) - .error(R.drawable.placeholder) - .into(imgPet); + GlideUtils.loadImageWithToken(requireContext(), binding.imgPet, imageUrl, token, R.drawable.placeholder, new GlideUtils.ImageLoadListener() { + @Override + public void onResourceReady() { + hasImage = true; + } + + @Override + public void onLoadFailed() { + hasImage = false; + } + }); } - // Helper function to upload pet image to backend + /** + * Uploads a selected or captured image a pet photo through the ViewModel. + */ private void uploadPetImage(Uri uri) { try { - File file = getFileFromUri(uri); + File file = FileUtils.getFileFromUri(requireContext(), uri); if (file == null) return; // Create RequestBody for file upload RequestBody requestFile = RequestBody.create(file, MediaType.parse(requireContext().getContentResolver().getType(uri))); MultipartBody.Part body = MultipartBody.Part.createFormData("image", file.getName(), requestFile); - // Call the backend to upload the image - PetApi petApi = RetrofitClient.getPetApi(requireContext()); - petApi.uploadPetImage((long) petId, body).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - Toast.makeText(requireContext(), "Pet photo updated successfully", Toast.LENGTH_SHORT).show(); - // Reload image after successful upload - loadPetImage(petId); + // Use ViewModel to upload image + viewModel.uploadPetImage(petId, body).observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status != Resource.Status.LOADING) { + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), "Pet photo updated successfully", Toast.LENGTH_SHORT).show(); + loadPetImage((int) petId); } else { - Toast.makeText(requireContext(), "Failed to upload pet photo", Toast.LENGTH_SHORT).show(); + Toast.makeText(getContext(), "Upload failed: " + resource.message, Toast.LENGTH_SHORT).show(); } } - - @Override - public void onFailure(Call call, Throwable t) { - Log.e("UPLOAD_PET_IMAGE", "Failure: " + t.getMessage()); - Toast.makeText(requireContext(), "Network error", Toast.LENGTH_SHORT).show(); - } }); } catch (Exception e) { Log.e("UPLOAD_PET_IMAGE", "Error: " + e.getMessage()); } } - // Helper function to create a temporary File object from a Uri for uploading - private File getFileFromUri(Uri uri) { - try { - InputStream inputStream = requireContext().getContentResolver().openInputStream(uri); - File tempFile = new File(requireContext().getCacheDir(), "upload_pet_image.jpg"); - FileOutputStream outputStream = new FileOutputStream(tempFile); - byte[] buffer = new byte[1024]; - int length; - while ((length = inputStream.read(buffer)) > 0) { - outputStream.write(buffer, 0, length); + /** + * Sends a request to the ViewModel to remove the current pet photo. + */ + private void deletePetImage() { + viewModel.deletePetImage(petId).observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status != Resource.Status.LOADING) { + if (resource.status == Resource.Status.SUCCESS) { + Toast.makeText(getContext(), "Pet photo removed", Toast.LENGTH_SHORT).show(); + hasImage = false; + binding.imgPet.setImageResource(R.drawable.placeholder); + } else { + Toast.makeText(getContext(), "Delete failed: " + resource.message, Toast.LENGTH_SHORT).show(); + } } - outputStream.close(); - inputStream.close(); - return tempFile; - } catch (Exception e) { - Log.e("FILE_UTILS", "Error creating temp file", e); - return null; - } + }); } - - private void launchCamera() { - File photoFile = new File(requireContext().getCacheDir(), "pet_photo.jpg"); - photoUri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".fileprovider", photoFile); - cameraLauncher.launch(photoUri); - } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/models/Adoption.java b/android/app/src/main/java/com/example/petstoremobile/models/Adoption.java deleted file mode 100644 index e227bc9b..00000000 --- a/android/app/src/main/java/com/example/petstoremobile/models/Adoption.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.example.petstoremobile.models; - -public class Adoption { - private int adoptionId; - private String adopterName; - private String adopterEmail; - private String adopterPhone; - private String petName; - private String adoptionDate; - private String status; - - // Constructor - public Adoption(int adoptionId, String adopterName, String adopterEmail, String adopterPhone, String petName, String adoptionDate, String status) { - this.adoptionId = adoptionId; - this.adopterName = adopterName; - this.adopterEmail = adopterEmail; - this.adopterPhone = adopterPhone; - this.petName = petName; - this.adoptionDate = adoptionDate; - this.status = status; - } - - // Getters and setters - public int getAdoptionId() { - return adoptionId; - } - - public void setAdoptionId(int adoptionId) { - this.adoptionId = adoptionId; - } - - public String getAdopterName() { - return adopterName; - } - - public void setAdopterName(String adopterName) { - this.adopterName = adopterName; - } - - public String getAdopterEmail() { - return adopterEmail; - } - - public void setAdopterEmail(String adopterEmail) { - this.adopterEmail = adopterEmail; - } - - public String getAdopterPhone() { - return adopterPhone; - } - - public void setAdopterPhone(String adopterPhone) { - this.adopterPhone = adopterPhone; - } - - public String getPetName() { - return petName; - } - - public void setPetName(String petName) { - this.petName = petName; - } - - public String getAdoptionDate() { - return adoptionDate; - } - - public void setAdoptionDate(String adoptionDate) { - this.adoptionDate = adoptionDate; - } - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } -} diff --git a/android/app/src/main/java/com/example/petstoremobile/models/Appointment.java b/android/app/src/main/java/com/example/petstoremobile/models/Appointment.java deleted file mode 100644 index 38e8da10..00000000 --- a/android/app/src/main/java/com/example/petstoremobile/models/Appointment.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.example.petstoremobile.models; - - -public class Appointment { - private int appointmentId; - private String customerName; - private String petName; - private String serviceType; - private String appointmentDate; - private String appointmentTime; - private String status; - - // Constructor - public Appointment(int appointmentId, String customerName, String petName, String serviceType, String appointmentDate, String appointmentTime, String status) { - this.appointmentId = appointmentId; - this.customerName = customerName; - this.petName = petName; - this.serviceType = serviceType; - this.appointmentDate = appointmentDate; - this.appointmentTime = appointmentTime; - this.status = status; - } - - // Getters and setters - public int getAppointmentId() { - return appointmentId; - } - - public String getCustomerName() { - return customerName; - } - - public void setCustomerName(String customerName) { - this.customerName = customerName; - } - - public String getPetName() { - return petName; - } - - public void setPetName(String petName) { - this.petName = petName; - } - - public String getServiceType() { - return serviceType; - } - - public void setServiceType(String serviceType) { - this.serviceType = serviceType; - } - - public String getAppointmentDate() { - return appointmentDate; - } - - public void setAppointmentDate(String appointmentDate) { - this.appointmentDate = appointmentDate; - } - - public String getAppointmentTime() { - return appointmentTime; - } - - public void setAppointmentTime(String appointmentTime) { - this.appointmentTime = appointmentTime; - } - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } -} diff --git a/android/app/src/main/java/com/example/petstoremobile/models/Inventory.java b/android/app/src/main/java/com/example/petstoremobile/models/Inventory.java deleted file mode 100644 index 7aacd2df..00000000 --- a/android/app/src/main/java/com/example/petstoremobile/models/Inventory.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.example.petstoremobile.models; - - -public class Inventory { - private int inventoryId; - private String itemName; - private String category; - private int quantity; - private double unitPrice; - private String supplier; - - // Constructor - public Inventory(int inventoryId, String itemName, String category, int quantity, double unitPrice, String supplier) { - this.inventoryId = inventoryId; - this.itemName = itemName; - this.category = category; - this.quantity = quantity; - this.unitPrice = unitPrice; - this.supplier = supplier; - } - - // Getters and setters - public int getInventoryId() { - return inventoryId; - } - - public String getItemName() { - return itemName; - } - - public void setItemName(String itemName) { - this.itemName = itemName; - } - - public String getCategory() { - return category; - } - - public void setCategory(String category) { - this.category = category; - } - - public int getQuantity() { - return quantity; - } - - public void setQuantity(int quantity) { - this.quantity = quantity; - } - - public double getUnitPrice() { - return unitPrice; - } - - public void setUnitPrice(double unitPrice) { - this.unitPrice = unitPrice; - } - - public String getSupplier() { - return supplier; - } - - public void setSupplier(String supplier) { - this.supplier = supplier; - } -} diff --git a/android/app/src/main/java/com/example/petstoremobile/models/Message.java b/android/app/src/main/java/com/example/petstoremobile/models/Message.java index 18ec549a..bf76b4c4 100644 --- a/android/app/src/main/java/com/example/petstoremobile/models/Message.java +++ b/android/app/src/main/java/com/example/petstoremobile/models/Message.java @@ -7,6 +7,9 @@ public class Message { private String content; private String timestamp; private Boolean isRead; + private String attachmentUrl; + private String attachmentName; + private String attachmentType; public Message() {} @@ -33,4 +36,13 @@ public class Message { public Boolean getIsRead() { return isRead; } public void setIsRead(Boolean isRead) { this.isRead = isRead; } + + public String getAttachmentUrl() { return attachmentUrl; } + public void setAttachmentUrl(String attachmentUrl) { this.attachmentUrl = attachmentUrl; } + + public String getAttachmentName() { return attachmentName; } + public void setAttachmentName(String attachmentName) { this.attachmentName = attachmentName; } + + public String getAttachmentType() { return attachmentType; } + public void setAttachmentType(String attachmentType) { this.attachmentType = attachmentType; } } \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/models/Product.java b/android/app/src/main/java/com/example/petstoremobile/models/Product.java deleted file mode 100644 index 90a56eab..00000000 --- a/android/app/src/main/java/com/example/petstoremobile/models/Product.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.example.petstoremobile.models; - - -public class Product { - private int productId; - private String productName; - private String productDesc; - private String category; - private double productPrice; - private int stockQuantity; - - // Constructor - public Product(int productId, String productName, String productDesc, String category, double productPrice, int stockQuantity) { - this.productId = productId; - this.productName = productName; - this.productDesc = productDesc; - this.category = category; - this.productPrice = productPrice; - this.stockQuantity = stockQuantity; - } - - // Getters and setters - public int getProductId() { - return productId; - } - - public String getProductName() { - return productName; - } - - public void setProductName(String productName) { - this.productName = productName; - } - - public String getProductDesc() { - return productDesc; - } - - public void setProductDesc(String productDesc) { - this.productDesc = productDesc; - } - - public String getCategory() { - return category; - } - - public void setCategory(String category) { - this.category = category; - } - - public double getProductPrice() { - return productPrice; - } - - public void setProductPrice(double productPrice) { - this.productPrice = productPrice; - } - - public int getStockQuantity() { - return stockQuantity; - } - - public void setStockQuantity(int stockQuantity) { - this.stockQuantity = stockQuantity; - } -} diff --git a/android/app/src/main/java/com/example/petstoremobile/models/ProductSupplier.java b/android/app/src/main/java/com/example/petstoremobile/models/ProductSupplier.java deleted file mode 100644 index b624b5e4..00000000 --- a/android/app/src/main/java/com/example/petstoremobile/models/ProductSupplier.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.example.petstoremobile.models; - -public class ProductSupplier { - private int supId; - private int prodId; - private String supCompany; - private String prodName; - private double cost; - - public ProductSupplier(int supId, int prodId, String supCompany, String prodName, double cost) { - this.supId = supId; - this.prodId = prodId; - this.supCompany = supCompany; - this.prodName = prodName; - this.cost = cost; - } - - public int getSupId() { - return supId; - } - - public int getProdId() { - return prodId; - } - - public String getSupCompany() { - return supCompany; - } - - public String getProdName() { - return prodName; - } - - public double getCost() { - return cost; - } - - public void setSupCompany(String supCompany) { - this.supCompany = supCompany; - } - - public void setProdName(String prodName) { - this.prodName = prodName; - } - - public void setCost(double cost) { - this.cost = cost; - } -} diff --git a/android/app/src/main/java/com/example/petstoremobile/models/PurchaseOrder.java b/android/app/src/main/java/com/example/petstoremobile/models/PurchaseOrder.java deleted file mode 100644 index 971ff400..00000000 --- a/android/app/src/main/java/com/example/petstoremobile/models/PurchaseOrder.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.petstoremobile.models; - -public class PurchaseOrder { - private int purchaseOrderId; - private String supplierName; - private String orderDate; - private String status; - - public PurchaseOrder(int purchaseOrderId, String supplierName, String orderDate, String status) { - this.purchaseOrderId = purchaseOrderId; - this.supplierName = supplierName; - this.orderDate = orderDate; - this.status = status; - } - - public int getPurchaseOrderId() { - return purchaseOrderId; - } - - public String getSupplierName() { - return supplierName; - } - - public String getOrderDate() { - return orderDate; - } - - public String getStatus() { - return status; - } -} diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/AdoptionRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/AdoptionRepository.java new file mode 100644 index 00000000..f09f85b4 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/AdoptionRepository.java @@ -0,0 +1,65 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.AdoptionApi; +import com.example.petstoremobile.dtos.AdoptionDTO; +import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class AdoptionRepository extends BaseRepository { + private final AdoptionApi adoptionApi; + + @Inject + public AdoptionRepository(AdoptionApi adoptionApi) { + super("AdoptionRepository"); + this.adoptionApi = adoptionApi; + } + + /** + * Retrieves a paginated list of all adoptions from the API. + */ + public LiveData>> getAllAdoptions(int page, int size, String query, String status, Long storeId, String date, Long employeeId) { + return executeCall(adoptionApi.getAllAdoptions(page, size, query, status, storeId, date, employeeId)); + } + + /** + * Retrieves a specific adoption record by its ID from the API. + */ + public LiveData> getAdoptionById(Long id) { + return executeCall(adoptionApi.getAdoptionById(id)); + } + + /** + * Sends a request to the API to create a new adoption record. + */ + public LiveData> createAdoption(AdoptionDTO adoption) { + return executeCall(adoptionApi.createAdoption(adoption)); + } + + /** + * Sends a request to the API to update an existing adoption record by ID. + */ + public LiveData> updateAdoption(Long id, AdoptionDTO adoption) { + return executeCall(adoptionApi.updateAdoption(id, adoption)); + } + + /** + * Sends a request to the API to delete a specific adoption record. + */ + public LiveData> deleteAdoption(Long id) { + return executeCall(adoptionApi.deleteAdoption(id)); + } + + /** + * Sends a request to the API to delete multiple adoption records. + */ + public LiveData> bulkDeleteAdoptions(BulkDeleteRequest request) { + return executeCall(adoptionApi.bulkDeleteAdoptions(request)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/AppointmentRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/AppointmentRepository.java new file mode 100644 index 00000000..85083a25 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/AppointmentRepository.java @@ -0,0 +1,65 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.AppointmentApi; +import com.example.petstoremobile.dtos.AppointmentDTO; +import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class AppointmentRepository extends BaseRepository { + private final AppointmentApi appointmentApi; + + @Inject + public AppointmentRepository(AppointmentApi appointmentApi) { + super("AppointmentRepository"); + this.appointmentApi = appointmentApi; + } + + /** + * Retrieves a paginated list of all appointments from the API with filtering. + */ + public LiveData>> getAllAppointments(int page, int size, String query, String status, Long storeId, String date, Long employeeId) { + return executeCall(appointmentApi.getAllAppointments(page, size, query, status, storeId, date, employeeId)); + } + + /** + * Retrieves a specific appointment by its ID from the API. + */ + public LiveData> getAppointmentById(Long id) { + return executeCall(appointmentApi.getAppointmentById(id)); + } + + /** + * Sends a request to the API to create a new appointment record. + */ + public LiveData> createAppointment(AppointmentDTO appointment) { + return executeCall(appointmentApi.createAppointment(appointment)); + } + + /** + * Sends a request to the API to update an existing appointment record by ID. + */ + public LiveData> updateAppointment(Long id, AppointmentDTO appointment) { + return executeCall(appointmentApi.updateAppointment(id, appointment)); + } + + /** + * Sends a request to the API to delete a specific appointment record. + */ + public LiveData> deleteAppointment(Long id) { + return executeCall(appointmentApi.deleteAppointment(id)); + } + + /** + * Sends a request to the API to delete multiple appointment records. + */ + public LiveData> bulkDeleteAppointments(BulkDeleteRequest request) { + return executeCall(appointmentApi.bulkDeleteAppointments(request)); + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/AuthRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/AuthRepository.java new file mode 100644 index 00000000..6011bac8 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/AuthRepository.java @@ -0,0 +1,108 @@ +package com.example.petstoremobile.repositories; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.example.petstoremobile.api.auth.AuthApi; +import com.example.petstoremobile.api.auth.TokenManager; +import com.example.petstoremobile.dtos.AuthDTO; +import com.example.petstoremobile.dtos.AvatarUploadResponse; +import com.example.petstoremobile.dtos.UserDTO; +import com.example.petstoremobile.utils.ErrorUtils; +import com.example.petstoremobile.utils.Resource; + +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import okhttp3.MultipartBody; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +@Singleton +public class AuthRepository extends BaseRepository { + private final AuthApi authApi; + private final TokenManager tokenManager; + + @Inject + public AuthRepository(AuthApi authApi, TokenManager tokenManager) { + super("AuthRepository"); + this.authApi = authApi; + this.tokenManager = tokenManager; + } + + /** + * Authenticates the user and saves login data (token, username, role) upon success. + */ + public LiveData> login(AuthDTO.LoginRequest loginRequest) { + MutableLiveData> data = new MutableLiveData<>(); + data.setValue(Resource.loading(null)); + + authApi.login(loginRequest).enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful()) { + AuthDTO.LoginResponse result = response.body(); + if (result != null && result.getToken() != null) { + tokenManager.saveLoginData(result.getToken(), result.getUsername(), result.getRole()); + data.setValue(Resource.success(result)); + } else { + data.setValue(Resource.error("Login failed: Invalid response", null)); + } + } else { + String errorMsg = ErrorUtils.getErrorMessage(response, "Login failed"); + data.setValue(Resource.error(errorMsg, null)); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + data.setValue(Resource.error(ErrorUtils.getFailureMessage(t), null)); + } + }); + + return data; + } + + /** + * Retrieves the current user's profile information from the API. + */ + public LiveData> getMe() { + return executeCall(authApi.getMe()); + } + + /** + * Updates the current user's profile details. + */ + public LiveData> updateMe(Map updates) { + return executeCall(authApi.updateMe(updates)); + } + + /** + * Uploads a multipart image to be used as the current user's avatar. + */ + public LiveData> uploadAvatar(MultipartBody.Part avatar) { + return executeCall(authApi.uploadAvatar(avatar)); + } + + /** + * Sends a request to the API to remove the current user's avatar. + */ + public LiveData> deleteAvatar() { + return executeCall(authApi.deleteAvatar()); + } + + /** + * Clears all authentication and login data from storage. + */ + public void logout() { + tokenManager.clearLoginData(); + } + + public boolean isLoggedIn() { + return tokenManager.getToken() != null; + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/BaseRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/BaseRepository.java new file mode 100644 index 00000000..cf98dfe8 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/BaseRepository.java @@ -0,0 +1,29 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.RetrofitUtils; + +import retrofit2.Call; + +/** + * Base class for all repositories to provide common functionality for API calls. + */ +public abstract class BaseRepository { + protected final String TAG; + + protected BaseRepository(String tag) { + this.TAG = tag; + } + + /** + * Executes a Retrofit call and returns a LiveData containing the Resource. + */ + protected LiveData> executeCall(Call call) { + MutableLiveData> data = new MutableLiveData<>(); + RetrofitUtils.enqueue(call, data, TAG); + return data; + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/CategoryRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/CategoryRepository.java new file mode 100644 index 00000000..8d11511b --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/CategoryRepository.java @@ -0,0 +1,29 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.CategoryApi; +import com.example.petstoremobile.dtos.CategoryDTO; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class CategoryRepository extends BaseRepository { + private final CategoryApi categoryApi; + + @Inject + public CategoryRepository(CategoryApi categoryApi) { + super("CategoryRepository"); + this.categoryApi = categoryApi; + } + + /** + * Retrieves a paginated list of all product categories from the API. + */ + public LiveData>> getAllCategories(int page, int size) { + return executeCall(categoryApi.getAllCategories(page, size)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/ChatRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/ChatRepository.java new file mode 100644 index 00000000..c3a31ad4 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/ChatRepository.java @@ -0,0 +1,64 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.ChatApi; +import com.example.petstoremobile.api.CustomerApi; +import com.example.petstoremobile.api.MessageApi; +import com.example.petstoremobile.dtos.ConversationDTO; +import com.example.petstoremobile.dtos.CustomerDTO; +import com.example.petstoremobile.dtos.MessageDTO; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.SendMessageRequest; +import com.example.petstoremobile.utils.Resource; + +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Repository for handling chat-related data operations. + */ +@Singleton +public class ChatRepository extends BaseRepository { + private final ChatApi chatApi; + private final MessageApi messageApi; + private final CustomerApi customerApi; + + @Inject + public ChatRepository(ChatApi chatApi, MessageApi messageApi, CustomerApi customerApi) { + super("ChatRepository"); + this.chatApi = chatApi; + this.messageApi = messageApi; + this.customerApi = customerApi; + } + + /** + * Retrieves all chat conversations for the current user. + */ + public LiveData>> getAllConversations() { + return executeCall(chatApi.getAllConversations()); + } + + /** + * Retrieves the message history for a specific conversation. + */ + public LiveData>> getMessages(Long conversationId) { + return executeCall(messageApi.getMessages(conversationId)); + } + + /** + * Sends a plain text message to a conversation. + */ + public LiveData> sendMessage(Long conversationId, SendMessageRequest request) { + return executeCall(messageApi.sendMessage(conversationId, request)); + } + + /** + * Fetches a paginated list of customers. + */ + public LiveData>> getAllCustomers(int page, int size) { + return executeCall(customerApi.getAllCustomers(page, size)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/CustomerRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/CustomerRepository.java new file mode 100644 index 00000000..4006ae69 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/CustomerRepository.java @@ -0,0 +1,36 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.CustomerApi; +import com.example.petstoremobile.dtos.CustomerDTO; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class CustomerRepository extends BaseRepository { + private final CustomerApi customerApi; + + @Inject + public CustomerRepository(CustomerApi customerApi) { + super("CustomerRepository"); + this.customerApi = customerApi; + } + + /** + * Retrieves a paginated list of all customers from the API. + */ + public LiveData>> getAllCustomers(int page, int size) { + return executeCall(customerApi.getAllCustomers(page, size)); + } + + /** + * Retrieves a specific customer by their ID. + */ + public LiveData> getCustomerById(Long id) { + return executeCall(customerApi.getCustomerById(id)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/InventoryRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/InventoryRepository.java new file mode 100644 index 00000000..94526d25 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/InventoryRepository.java @@ -0,0 +1,59 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.InventoryApi; +import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.InventoryDTO; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class InventoryRepository extends BaseRepository { + private final InventoryApi inventoryApi; + + @Inject + public InventoryRepository(InventoryApi inventoryApi) { + super("InventoryRepository"); + this.inventoryApi = inventoryApi; + } + + /** + * Retrieves a paginated list of inventory items from the API with optional search, category, storeId and sort. + */ + public LiveData>> getAllInventory(String query, Long storeId, int page, int size, String sort) { + return executeCall(inventoryApi.getAllInventory(page, size, query, storeId, sort)); + } + + /** + * Retrieves a specific inventory item by its ID from the API. + */ + public LiveData> getInventoryById(Long id) { + return executeCall(inventoryApi.getInventoryById(id)); + } + + /** + * Sends a request to the API to create a new inventory record. + */ + public LiveData> createInventory(InventoryDTO request) { + return executeCall(inventoryApi.createInventory(request)); + } + + public LiveData> updateInventory(Long id, InventoryDTO request) { + return executeCall(inventoryApi.updateInventory(id, request)); + } + + /** + * Sends a request to the API to delete a specific inventory record. + */ + public LiveData> deleteInventory(Long id) { + return executeCall(inventoryApi.deleteInventory(id)); + } + + public LiveData> bulkDeleteInventory(BulkDeleteRequest request) { + return executeCall(inventoryApi.bulkDeleteInventory(request)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/PetRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/PetRepository.java new file mode 100644 index 00000000..019b5884 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/PetRepository.java @@ -0,0 +1,81 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.PetApi; +import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.PetDTO; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import okhttp3.MultipartBody; + +@Singleton +public class PetRepository extends BaseRepository { + private final PetApi petApi; + + @Inject + public PetRepository(PetApi petApi) { + super("PetRepository"); + this.petApi = petApi; + } + + /** + * Retrieves a paginated list of pets from the API with optional filters. + */ + public LiveData>> getAllPets(int page, int size, String query, String status, String species, Long storeId, String sort) { + return executeCall(petApi.getAllPets(page, size, query, status, species, storeId, sort)); + } + + /** + * Retrieves a specific pet by its ID from the API. + */ + public LiveData> getPetById(Long id) { + return executeCall(petApi.getPetById(id)); + } + + /** + * Sends a request to the API to create a new pet record. + */ + public LiveData> createPet(PetDTO pet) { + return executeCall(petApi.createPet(pet)); + } + + /** + * Sends a request to the API to update an existing pet record. + */ + public LiveData> updatePet(Long id, PetDTO pet) { + return executeCall(petApi.updatePet(id, pet)); + } + + /** + * Sends a request to the API to delete a specific pet record. + */ + public LiveData> deletePet(Long id) { + return executeCall(petApi.deletePet(id)); + } + + /** + * Sends a request to the API to delete multiple pet records. + */ + public LiveData> bulkDeletePets(BulkDeleteRequest request) { + return executeCall(petApi.bulkDeletePets(request)); + } + + /** + * Uploads an image file for a specific pet via the API. + */ + public LiveData> uploadPetImage(Long id, MultipartBody.Part image) { + return executeCall(petApi.uploadPetImage(id, image)); + } + + /** + * Sends a request to the API to delete the image of a specific pet. + */ + public LiveData> deletePetImage(Long id) { + return executeCall(petApi.deletePetImage(id)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/ProductRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/ProductRepository.java new file mode 100644 index 00000000..a6d32336 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/ProductRepository.java @@ -0,0 +1,73 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.ProductApi; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.ProductDTO; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import okhttp3.MultipartBody; + +@Singleton +public class ProductRepository extends BaseRepository { + private final ProductApi productApi; + + @Inject + public ProductRepository(ProductApi productApi) { + super("ProductRepository"); + this.productApi = productApi; + } + + /** + * Retrieves a paginated list of products from the API, filtered by an optional query, category and sorted. + */ + public LiveData>> getAllProducts(String query, Long categoryId, int page, int size, String sort) { + return executeCall(productApi.getAllProducts(query, categoryId, page, size, sort)); + } + + /** + * Retrieves a specific product by its ID from the API. + */ + public LiveData> getProductById(Long id) { + return executeCall(productApi.getProductById(id)); + } + + /** + * Sends a request to the API to create a new product. + */ + public LiveData> createProduct(ProductDTO product) { + return executeCall(productApi.createProduct(product)); + } + + /** + * Sends a request to the API to update an existing product by ID. + */ + public LiveData> updateProduct(Long id, ProductDTO product) { + return executeCall(productApi.updateProduct(id, product)); + } + + /** + * Sends a request to the API to delete a specific product. + */ + public LiveData> deleteProduct(Long id) { + return executeCall(productApi.deleteProduct(id)); + } + + /** + * Uploads an image file for a specific product via the API. + */ + public LiveData> uploadProductImage(Long id, MultipartBody.Part image) { + return executeCall(productApi.uploadProductImage(id, image)); + } + + /** + * Sends a request to the API to delete the image of a specific product. + */ + public LiveData> deleteProductImage(Long id) { + return executeCall(productApi.deleteProductImage(id)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/ProductSupplierRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/ProductSupplierRepository.java new file mode 100644 index 00000000..9b2f8df3 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/ProductSupplierRepository.java @@ -0,0 +1,62 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.ProductSupplierApi; +import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.ProductSupplierDTO; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class ProductSupplierRepository extends BaseRepository { + private final ProductSupplierApi api; + + @Inject + public ProductSupplierRepository(ProductSupplierApi api) { + super("ProductSupplierRepository"); + this.api = api; + } + + /** + * Retrieves a paginated list of all product-supplier relationships from the API. + */ + public LiveData>> getAllProductSuppliers(int page, int size, String query, Long productId, Long supplierId, String sort) { + return executeCall(api.getAllProductSuppliers(page, size, query, productId, supplierId, sort)); + } + + /** + * Retrieves a single product-supplier relationship by product and supplier IDs. + */ + public LiveData> getProductSupplierById(Long productId, Long supplierId) { + return executeCall(api.getProductSupplierById(productId, supplierId)); + } + + /** + * Sends a request to the API to create a new product-supplier relationship. + */ + public LiveData> createProductSupplier(ProductSupplierDTO dto) { + return executeCall(api.createProductSupplier(dto)); + } + + /** + * Sends a request to the API to update an existing product-supplier relationship. + */ + public LiveData> updateProductSupplier(Long productId, Long supplierId, ProductSupplierDTO dto) { + return executeCall(api.updateProductSupplier(productId, supplierId, dto)); + } + + /** + * Sends a request to the API to delete a specific product-supplier relationship. + */ + public LiveData> deleteProductSupplier(Long productId, Long supplierId) { + return executeCall(api.deleteProductSupplier(productId, supplierId)); + } + + public LiveData> bulkDeleteProductSuppliers(BulkDeleteRequest request) { + return executeCall(api.bulkDeleteProductSuppliers(request)); + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/PurchaseOrderRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/PurchaseOrderRepository.java new file mode 100644 index 00000000..dd9bd637 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/PurchaseOrderRepository.java @@ -0,0 +1,36 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.PurchaseOrderApi; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.PurchaseOrderDTO; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class PurchaseOrderRepository extends BaseRepository { + private final PurchaseOrderApi purchaseOrderApi; + + @Inject + public PurchaseOrderRepository(PurchaseOrderApi purchaseOrderApi) { + super("PurchaseOrderRepository"); + this.purchaseOrderApi = purchaseOrderApi; + } + + /** + * Retrieves a paginated list of all purchase orders from the API. + */ + public LiveData>> getAllPurchaseOrders(int page, int size, String query, Long storeId, String sort) { + return executeCall(purchaseOrderApi.getAllPurchaseOrders(page, size, query, storeId, sort)); + } + + /** + * Retrieves a specific purchase order by its ID from the API. + */ + public LiveData> getPurchaseOrderById(Long id) { + return executeCall(purchaseOrderApi.getPurchaseOrderById(id)); + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/ServiceRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/ServiceRepository.java new file mode 100644 index 00000000..bd5f3ebc --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/ServiceRepository.java @@ -0,0 +1,65 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.ServiceApi; +import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.ServiceDTO; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class ServiceRepository extends BaseRepository { + private final ServiceApi serviceApi; + + @Inject + public ServiceRepository(ServiceApi serviceApi) { + super("ServiceRepository"); + this.serviceApi = serviceApi; + } + + /** + * Retrieves a paginated list of all services from the API. + */ + public LiveData>> getAllServices(int page, int size, String query, String sort) { + return executeCall(serviceApi.getAllServices(page, size, query, sort)); + } + + /** + * Retrieves a specific service by its ID from the API. + */ + public LiveData> getServiceById(Long id) { + return executeCall(serviceApi.getServiceById(id)); + } + + /** + * Sends a request to the API to create a new service. + */ + public LiveData> createService(ServiceDTO service) { + return executeCall(serviceApi.createService(service)); + } + + /** + * Sends a request to the API to update an existing service by ID. + */ + public LiveData> updateService(Long id, ServiceDTO service) { + return executeCall(serviceApi.updateService(id, service)); + } + + /** + * Sends a request to the API to delete a specific service. + */ + public LiveData> deleteService(Long id) { + return executeCall(serviceApi.deleteService(id)); + } + + /** + * Sends a request to the API to delete multiple services. + */ + public LiveData> bulkDeleteServices(BulkDeleteRequest request) { + return executeCall(serviceApi.bulkDeleteServices(request)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/StoreRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/StoreRepository.java new file mode 100644 index 00000000..44781a32 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/StoreRepository.java @@ -0,0 +1,29 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.StoreApi; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.StoreDTO; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class StoreRepository extends BaseRepository { + private final StoreApi storeApi; + + @Inject + public StoreRepository(StoreApi storeApi) { + super("StoreRepository"); + this.storeApi = storeApi; + } + + /** + * Retrieves a paginated list of all stores from the API. + */ + public LiveData>> getAllStores(int page, int size) { + return executeCall(storeApi.getAllStores(page, size)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/SupplierRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/SupplierRepository.java new file mode 100644 index 00000000..7aef86a2 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/SupplierRepository.java @@ -0,0 +1,65 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.SupplierApi; +import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.SupplierDTO; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class SupplierRepository extends BaseRepository { + private final SupplierApi supplierApi; + + @Inject + public SupplierRepository(SupplierApi supplierApi) { + super("SupplierRepository"); + this.supplierApi = supplierApi; + } + + /** + * Retrieves a paginated list of all suppliers from the API. + */ + public LiveData>> getAllSuppliers(int page, int size, String query, String sort) { + return executeCall(supplierApi.getAllSuppliers(page, size, query, sort)); + } + + /** + * Retrieves a specific supplier by its ID from the API. + */ + public LiveData> getSupplierById(Long id) { + return executeCall(supplierApi.getSupplierById(id)); + } + + /** + * Sends a request to the API to create a new supplier record. + */ + public LiveData> createSupplier(SupplierDTO supplier) { + return executeCall(supplierApi.createSupplier(supplier)); + } + + /** + * Sends a request to the API to update an existing supplier record by ID. + */ + public LiveData> updateSupplier(Long id, SupplierDTO supplier) { + return executeCall(supplierApi.updateSupplier(id, supplier)); + } + + /** + * Sends a request to the API to delete a specific supplier record. + */ + public LiveData> deleteSupplier(Long id) { + return executeCall(supplierApi.deleteSupplier(id)); + } + + /** + * Sends a request to the API to delete multiple supplier records. + */ + public LiveData> bulkDeleteSuppliers(BulkDeleteRequest request) { + return executeCall(supplierApi.bulkDeleteSuppliers(request)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/UserRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/UserRepository.java new file mode 100644 index 00000000..0ed9ced9 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/UserRepository.java @@ -0,0 +1,26 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.UserApi; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.UserDTO; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class UserRepository extends BaseRepository { + private final UserApi userApi; + + @Inject + public UserRepository(UserApi userApi) { + super("UserRepository"); + this.userApi = userApi; + } + + public LiveData>> getUsers(String role, int page, int size) { + return executeCall(userApi.getUsers(role, page, size)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/services/ChatNotificationService.java b/android/app/src/main/java/com/example/petstoremobile/services/ChatNotificationService.java index a1e16fa6..90113cac 100644 --- a/android/app/src/main/java/com/example/petstoremobile/services/ChatNotificationService.java +++ b/android/app/src/main/java/com/example/petstoremobile/services/ChatNotificationService.java @@ -8,24 +8,30 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.example.petstoremobile.api.ChatApi; import com.example.petstoremobile.api.CustomerApi; -import com.example.petstoremobile.api.RetrofitClient; import com.example.petstoremobile.api.auth.TokenManager; import com.example.petstoremobile.dtos.ConversationDTO; import com.example.petstoremobile.dtos.CustomerDTO; import com.example.petstoremobile.dtos.MessageDTO; import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.utils.NotificationHelper; +import com.example.petstoremobile.utils.RetrofitUtils; import com.example.petstoremobile.websocket.StompChatManager; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; + +import javax.inject.Inject; +import javax.inject.Named; + +import dagger.hilt.android.AndroidEntryPoint; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; // Service to receive notifications when a new conversation is created +@AndroidEntryPoint public class ChatNotificationService extends Service { private static final String TAG = "ChatNotificationService"; @@ -37,6 +43,11 @@ public class ChatNotificationService extends Service { private final Map customerIdToName = new HashMap<>(); private Long currentUserId; + @Inject CustomerApi customerApi; + @Inject ChatApi chatApi; + @Inject TokenManager tokenManager; + @Inject @Named("baseUrl") String baseUrl; + //When the service starts, connect to the websocket @Override public int onStartCommand(Intent intent, int flags, int startId) { @@ -48,69 +59,42 @@ public class ChatNotificationService extends Service { // helper function to connect to the websocket private void connectWebSocket() { //get the token and role from the shared preferences - TokenManager tm = TokenManager.getInstance(this); - String token = tm.getToken(); - String role = tm.getRole(); - currentUserId = tm.getUserId(); + String token = tokenManager.getToken(); + String role = tokenManager.getRole(); + currentUserId = tokenManager.getUserId(); if (token != null && stompChatManager == null) { - //load customers to have names associated with customer ids - CustomerApi customerApi = RetrofitClient.getCustomerApi(this); - customerApi.getAllCustomers(0, 1000).enqueue(new Callback>() { - @Override - public void onResponse(@NonNull Call> call, @NonNull Response> response) { - if (response.isSuccessful() && response.body() != null) { - for (CustomerDTO customer : response.body().getContent()) { - customerIdToName.put(customer.getCustomerId(), customer.getFullName()); - } - } - loadConversationsAndStartStomp(token, role); + customerApi.getAllCustomers(0, 1000).enqueue(RetrofitUtils.createSilentCallback(TAG, result -> { + for (CustomerDTO customer : result.getContent()) { + customerIdToName.put(customer.getCustomerId(), customer.getFullName()); } - - @Override - public void onFailure(@NonNull Call> call, @NonNull Throwable t) { - Log.e(TAG, "Failed to load customers", t); - loadConversationsAndStartStomp(token, role); - } - }); + loadConversationsAndStartStomp(token, role); + })); } } private void loadConversationsAndStartStomp(String token, String role) { // Fetch existing conversations - ChatApi chatApi = RetrofitClient.getChatApi(this); - chatApi.getAllConversations().enqueue(new Callback>() { - @Override - public void onResponse(@NonNull Call> call, @NonNull Response> response) { - if (response.isSuccessful() && response.body() != null) { - for (ConversationDTO conversation : response.body()) { - if (conversation.getId() != null) { - knownConversationIds.add(conversation.getId()); - conversationToCustomerId.put(conversation.getId(), conversation.getCustomerId()); - // subscribe to existing conversations to get message notifications - if (stompChatManager != null) { - stompChatManager.subscribeToConversation(conversation.getId()); - } - } + chatApi.getAllConversations().enqueue(RetrofitUtils.createSilentCallback(TAG, result -> { + for (ConversationDTO conversation : result) { + if (conversation.getId() != null) { + knownConversationIds.add(conversation.getId()); + conversationToCustomerId.put(conversation.getId(), conversation.getCustomerId()); + // subscribe to existing conversations to get message notifications + if (stompChatManager != null) { + stompChatManager.subscribeToConversation(conversation.getId()); } - Log.d(TAG, "Loaded " + knownConversationIds.size() + " existing conversations"); } - startStomp(token, role); } - - @Override - public void onFailure(@NonNull Call> call, @NonNull Throwable t) { - Log.e(TAG, "Failed to load existing conversations", t); - //tries to connect if loading fails - startStomp(token, role); - } - }); + Log.d(TAG, "Loaded " + knownConversationIds.size() + " existing conversations"); + startStomp(token, role); + })); } private void startStomp(String token, String role) { if (stompChatManager != null) return; - stompChatManager = new StompChatManager(token, role); + stompChatManager = new StompChatManager(token, role, baseUrl); // Listen for messages in existing conversations stompChatManager.setMessageListener(message -> { @@ -193,20 +177,9 @@ public class ChatNotificationService extends Service { // Helper function to fetch customer name for a conversation private void fetchCustomerName(Long customerId) { - CustomerApi customerApi = RetrofitClient.getCustomerApi(this); - customerApi.getCustomerById(customerId).enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - if (response.isSuccessful() && response.body() != null) { - customerIdToName.put(customerId, response.body().getFullName()); - } - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.e(TAG, "Failed to fetch customer name", t); - } - }); + customerApi.getCustomerById(customerId).enqueue(RetrofitUtils.createSilentCallback(TAG, result -> { + customerIdToName.put(customerId, result.getFullName()); + })); } //When the service is destroyed, disconnect from the websocket diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/BulkDeleteHandler.java b/android/app/src/main/java/com/example/petstoremobile/utils/BulkDeleteHandler.java new file mode 100644 index 00000000..fe67282b --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/BulkDeleteHandler.java @@ -0,0 +1,109 @@ +package com.example.petstoremobile.utils; + +import android.view.View; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.fragment.app.Fragment; +import androidx.lifecycle.LiveData; + +import java.util.List; + +/** + * A helper class to handle the UI and logic for bulk deletion across different fragments. + * Now supports String keys to accommodate both simple and composite keys. + */ +public class BulkDeleteHandler { + + /** + * Interface that adapters must implement to support bulk selection. + */ + public interface SelectableAdapter { + List getSelectedKeys(); + void clearSelection(); + } + + /** + * Functional interface for the API call execution. + */ + public interface BulkDeleteOperation { + LiveData> execute(List keys); + } + + private final Fragment fragment; + private final View layoutBar; + private final TextView tvCount; + private final SelectableAdapter adapter; + private final BulkDeleteOperation operation; + private final Runnable onSuccess; + private final String itemName; + + public BulkDeleteHandler(Fragment fragment, + View layoutBar, + TextView tvCount, + Button btnDelete, + SelectableAdapter adapter, + String itemName, + BulkDeleteOperation operation, + Runnable onSuccess) { + this.fragment = fragment; + this.layoutBar = layoutBar; + this.tvCount = tvCount; + this.adapter = adapter; + this.operation = operation; + this.onSuccess = onSuccess; + this.itemName = itemName; + + btnDelete.setOnClickListener(v -> confirmDelete()); + } + + /** + * Updates the UI when the selection count changes. + */ + public void onSelectionChanged(int selectedCount) { + if (selectedCount > 0) { + layoutBar.setVisibility(View.VISIBLE); + tvCount.setText(selectedCount + " selected"); + } else { + hideBar(); + } + } + + /** + * Hides the bulk delete bar and resets state. + */ + public void hideBar() { + if (layoutBar != null) { + layoutBar.setVisibility(View.GONE); + } + } + + /** + * Shows the confirmation dialog. + */ + private void confirmDelete() { + List keys = adapter.getSelectedKeys(); + if (keys.isEmpty()) return; + + DialogUtils.showBulkDeleteConfirmDialog(fragment.requireContext(), keys.size(), () -> performDelete(keys)); + } + + /** + * Executes the deletion via the provided operation. + */ + private void performDelete(List keys) { + operation.execute(keys).observe(fragment.getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status != Resource.Status.LOADING) { + if (resource.status == Resource.Status.SUCCESS) { + adapter.clearSelection(); + hideBar(); + onSuccess.run(); + Toast.makeText(fragment.getContext(), keys.size() + " " + itemName + "(s) deleted", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(fragment.getContext(), "Delete failed: " + resource.message, Toast.LENGTH_SHORT).show(); + } + } + }); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/DialogUtils.java b/android/app/src/main/java/com/example/petstoremobile/utils/DialogUtils.java new file mode 100644 index 00000000..bf304d1c --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/DialogUtils.java @@ -0,0 +1,59 @@ +package com.example.petstoremobile.utils; + +import android.content.Context; +import androidx.appcompat.app.AlertDialog; + +/** + * Utility class for creating and displaying common dialogs. + */ +public class DialogUtils { + + /** + * Interface for handling dialog button clicks. + */ + public interface DialogCallback { + void onConfirm(); + } + + /** + * Shows a confirmation dialog with "Yes" and "No" buttons. + */ + public static void showConfirmDialog(Context context, String title, String message, DialogCallback callback) { + new AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton("Yes", (dialog, which) -> callback.onConfirm()) + .setNegativeButton("No", null) + .show(); + } + + /** + * Shows a delete confirmation dialog. + */ + public static void showDeleteConfirmDialog(Context context, String itemName, DialogCallback callback) { + showConfirmDialog(context, "Delete " + itemName + "?", "Are you sure you want to delete this " + itemName.toLowerCase() + "? This action cannot be undone.", callback); + } + + /** + * Shows a confirmation dialog with specific "Delete" and "Cancel" buttons. + */ + public static void showBulkDeleteConfirmDialog(Context context, int count, DialogCallback callback) { + new AlertDialog.Builder(context) + .setTitle("Delete " + count + " item(s)?") + .setMessage("This cannot be undone.") + .setPositiveButton("Delete", (dialog, which) -> callback.onConfirm()) + .setNegativeButton("Cancel", null) + .show(); + } + + /** + * Shows a simple information or error dialog with an "OK" button. + */ + public static void showInfoDialog(Context context, String title, String message) { + new AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton("OK", null) + .show(); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/ErrorUtils.java b/android/app/src/main/java/com/example/petstoremobile/utils/ErrorUtils.java new file mode 100644 index 00000000..82a10cf7 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/ErrorUtils.java @@ -0,0 +1,71 @@ +package com.example.petstoremobile.utils; + +import android.content.Context; +import android.util.Log; +import android.widget.Toast; +import com.example.petstoremobile.dtos.ErrorResponse; +import com.google.gson.Gson; +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import retrofit2.Response; + +/** + * Utility class for handling API error responses. + */ +public class ErrorUtils { + private static final String TAG = "ErrorUtils"; + private static final Gson gson = new Gson(); + + /** + * Shows an error message to toast based on the response. + */ + public static void showErrorMessage(Context context, Response response, String defaultMessage) { + Toast.makeText(context, getErrorMessage(response, defaultMessage), Toast.LENGTH_LONG).show(); + } + + /** + * Extracts a user-friendly error message from the response body or status code. + */ + public static String getErrorMessage(Response response, String defaultMessage) { + if (response == null) return defaultMessage; + + try { + if (response.errorBody() != null) { + String errorJson = response.errorBody().string(); + ErrorResponse errorResponse = gson.fromJson(errorJson, ErrorResponse.class); + if (errorResponse != null && errorResponse.getMessage() != null) { + return errorResponse.getMessage(); + } + } + } catch (Exception e) { + Log.e(TAG, "Error parsing error body", e); + } + + // Handle specific status codes if no message was provided by the API + switch (response.code()) { + case 401: return "Unauthorized. Please login again."; + case 403: return "Access denied."; + case 404: return "Resource not found."; + case 500: return "Internal server error. Please try again later."; + case 503: return "Service unavailable. The server might be down."; + default: return defaultMessage + " (Code: " + response.code() + ")"; + } + } + + /** + * Converts a Throwable (from onFailure) into a user-friendly network error message. + */ + public static String getFailureMessage(Throwable t) { + if (t instanceof UnknownHostException || t instanceof ConnectException) { + return "No internet connection. Please check your settings."; + } else if (t instanceof SocketTimeoutException) { + return "The connection timed out. Please try again."; + } else if (t instanceof IOException) { + return "Network error occurred. Please try again."; + } else { + return "An unexpected error occurred: " + t.getLocalizedMessage(); + } + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/EventDecorator.java b/android/app/src/main/java/com/example/petstoremobile/utils/EventDecorator.java new file mode 100644 index 00000000..b58f38d4 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/EventDecorator.java @@ -0,0 +1,30 @@ +package com.example.petstoremobile.utils; + +import com.prolificinteractive.materialcalendarview.CalendarDay; +import com.prolificinteractive.materialcalendarview.DayViewDecorator; +import com.prolificinteractive.materialcalendarview.DayViewFacade; +import com.prolificinteractive.materialcalendarview.spans.DotSpan; + +import java.util.Collection; +import java.util.HashSet; + +public class EventDecorator implements DayViewDecorator { + + private final int color; + private final HashSet dates; + + public EventDecorator(int color, Collection dates) { + this.color = color; + this.dates = new HashSet<>(dates); + } + + @Override + public boolean shouldDecorate(CalendarDay day) { + return dates.contains(day); + } + + @Override + public void decorate(DayViewFacade view) { + view.addSpan(new DotSpan(8, color)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/FileUtils.java b/android/app/src/main/java/com/example/petstoremobile/utils/FileUtils.java new file mode 100644 index 00000000..dcdc1bd7 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/FileUtils.java @@ -0,0 +1,27 @@ +package com.example.petstoremobile.utils; + +import android.content.Context; +import android.net.Uri; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; + +public class FileUtils { + public static File getFileFromUri(Context context, Uri uri) { + try { + InputStream inputStream = context.getContentResolver().openInputStream(uri); + File tempFile = new File(context.getCacheDir(), "upload_image_" + System.currentTimeMillis() + ".jpg"); + FileOutputStream outputStream = new FileOutputStream(tempFile); + byte[] buffer = new byte[1024]; + int length; + while ((length = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, length); + } + outputStream.close(); + inputStream.close(); + return tempFile; + } catch (Exception e) { + return null; + } + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/GlideUtils.java b/android/app/src/main/java/com/example/petstoremobile/utils/GlideUtils.java new file mode 100644 index 00000000..d5810dc1 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/GlideUtils.java @@ -0,0 +1,122 @@ +package com.example.petstoremobile.utils; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.widget.ImageView; +import androidx.annotation.Nullable; +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.engine.GlideException; +import com.bumptech.glide.load.model.GlideUrl; +import com.bumptech.glide.load.model.LazyHeaders; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.target.Target; +import com.example.petstoremobile.R; + +/** + * Utility class for loading images using Glide with authentication tokens. + */ +public class GlideUtils { + + /** + * interface to check the status of the image load. + */ + public interface ImageLoadListener { + void onResourceReady(); + void onLoadFailed(); + } + + /** + * Loads an image from a URL into an ImageView with token. + */ + public static void loadImageWithToken(Context context, ImageView imageView, String url, String token, int placeholder) { + loadImageWithToken(context, imageView, url, token, placeholder, null); + } + + /** + * Loads an image from a URL into an ImageView with token and listener. + */ + public static void loadImageWithToken(Context context, ImageView imageView, String url, String token, int placeholder, ImageLoadListener listener) { + if (url == null) { + imageView.setImageResource(placeholder); + if (listener != null) listener.onLoadFailed(); + return; + } + + Object loadTarget = url; + if (token != null && url.startsWith("http")) { + loadTarget = new GlideUrl(url, new LazyHeaders.Builder() + .addHeader("Authorization", "Bearer " + token) + .build()); + } + + Glide.with(context) + .load(loadTarget) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .placeholder(placeholder) + .error(placeholder) + .listener(new RequestListener() { + @Override + public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) { + if (listener != null) listener.onLoadFailed(); + return false; + } + + @Override + public boolean onResourceReady(Drawable resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { + if (listener != null) listener.onResourceReady(); + return false; + } + }) + .into(imageView); + } + + /** + * Loads an image from a URL into an ImageView with token and applies circle cropping for image. + */ + public static void loadImageWithTokenCircle(Context context, ImageView imageView, String url, String token, int placeholder) { + loadImageWithTokenCircle(context, imageView, url, token, placeholder, null); + } + + /** + * Loads an image from a URL into an ImageView with token, circle cropping, and listener. + */ + public static void loadImageWithTokenCircle(Context context, ImageView imageView, String url, String token, int placeholder, ImageLoadListener listener) { + if (url == null) { + imageView.setImageResource(placeholder); + if (listener != null) listener.onLoadFailed(); + return; + } + + Object loadTarget = url; + if (token != null && url.startsWith("http")) { + loadTarget = new GlideUrl(url, new LazyHeaders.Builder() + .addHeader("Authorization", "Bearer " + token) + .build()); + } + + Glide.with(context) + .load(loadTarget) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .placeholder(placeholder) + .error(placeholder) + .listener(new RequestListener() { + @Override + public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) { + if (listener != null) listener.onLoadFailed(); + return false; + } + + @Override + public boolean onResourceReady(Drawable resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { + if (listener != null) listener.onResourceReady(); + return false; + } + }) + .into(imageView); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/ImagePickerHelper.java b/android/app/src/main/java/com/example/petstoremobile/utils/ImagePickerHelper.java new file mode 100644 index 00000000..4d1e9bf9 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/ImagePickerHelper.java @@ -0,0 +1,160 @@ +package com.example.petstoremobile.utils; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.provider.MediaStore; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; +import androidx.fragment.app.Fragment; +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Helper class to handle image picking from camera or gallery. + */ +public class ImagePickerHelper { + + /** + * Listener interface to handle the results of image picking. + */ + public interface ImagePickerListener { + /** + * Called when an image has been successfully selected or captured. + */ + void onImagePicked(Uri uri); + /** + * Called when the user chooses to remove the existing image. + */ + void onImageRemoved(); + } + + private final Fragment fragment; + private final ImagePickerListener listener; + private final ActivityResultLauncher galleryLauncher; + private final ActivityResultLauncher cameraLauncher; + private final ActivityResultLauncher permissionLauncher; + private Uri photoUri; + private final String tempFileName; + + /** + * Constructor for ImagePickerHelper. + * Registers activity launchers for gallery, camera, and permissions. + */ + public ImagePickerHelper(Fragment fragment, String tempFileName, ImagePickerListener listener) { + this.fragment = fragment; + this.tempFileName = tempFileName; + this.listener = listener; + + // Launcher to open gallery to select image + galleryLauncher = fragment.registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + Uri selectedImage = result.getData().getData(); + if (selectedImage != null) { + listener.onImagePicked(selectedImage); + } + } + } + ); + + // Launcher for camera to open and capture image + cameraLauncher = fragment.registerForActivityResult( + new ActivityResultContracts.TakePicture(), + success -> { + if (success && photoUri != null) { + listener.onImagePicked(photoUri); + } + } + ); + + // Launcher to request camera permission + permissionLauncher = fragment.registerForActivityResult( + new ActivityResultContracts.RequestPermission(), + granted -> { + if (granted) { + launchCamera(); + } else { + showPermissionDeniedDialog(); + } + } + ); + } + + /** + * Shows a dialog to choose between camera, gallery, and optionally remove photo. + */ + public void showImagePickerDialog(String title, boolean hasImage) { + List options = new ArrayList<>(); + options.add("Take Photo"); + options.add("Choose from Gallery"); + if (hasImage) { + options.add("Remove Photo"); + } + + new AlertDialog.Builder(fragment.requireContext()) + .setTitle(title) + .setItems(options.toArray(new String[0]), (dialog, which) -> { + String selected = options.get(which); + if (selected.equals("Take Photo")) { + checkCameraPermission(); + } else if (selected.equals("Choose from Gallery")) { + launchGallery(); + } else if (selected.equals("Remove Photo")) { + listener.onImageRemoved(); + } + }) + .show(); + } + + /** + * Checks if camera permission is granted and launches camera or requests permission. + */ + private void checkCameraPermission() { + if (ContextCompat.checkSelfPermission(fragment.requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + launchCamera(); + } else { + permissionLauncher.launch(Manifest.permission.CAMERA); + } + } + + /** + * Prepares a temporary file and launches the camera app. + */ + private void launchCamera() { + File photoFile = new File(fragment.requireContext().getCacheDir(), tempFileName); + photoUri = FileProvider.getUriForFile(fragment.requireContext(), fragment.requireContext().getPackageName() + ".fileprovider", photoFile); + cameraLauncher.launch(photoUri); + } + + /** + * Launches the gallery app to select an existing image. + */ + private void launchGallery() { + Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); + galleryLauncher.launch(intent); + } + + /** + * Shows a dialog explaining why camera permission is needed when denied. + */ + private void showPermissionDeniedDialog() { + new AlertDialog.Builder(fragment.requireContext()) + .setTitle("Permission Required") + .setMessage("Please grant camera permission to use this feature") + .setPositiveButton("Open Settings", (dialog, which) -> { + Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + intent.setData(Uri.fromParts("package", fragment.requireContext().getPackageName(), null)); + fragment.startActivity(intent); + }) + .setNegativeButton("Cancel", null) + .show(); + } +} 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 d173df14..a10389b6 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 @@ -1,6 +1,9 @@ package com.example.petstoremobile.utils; +import android.view.View; import android.widget.EditText; +import android.widget.Spinner; +import android.widget.TextView; public class InputValidator { @@ -94,4 +97,21 @@ public class InputValidator { } return true; } + + /** + * Checks if a selection has been made in a Spinner. + * Assumes position 0 is a placeholder like "None" or "Select". + */ + public static boolean isSpinnerSelected(Spinner spinner, String fieldName) { + if (spinner.getSelectedItemPosition() <= 0) { + View selectedView = spinner.getSelectedView(); + if (selectedView instanceof TextView) { + TextView tv = (TextView) selectedView; + tv.setError(fieldName + " is required"); + spinner.requestFocus(); + } + return false; + } + return true; + } } diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/Resource.java b/android/app/src/main/java/com/example/petstoremobile/utils/Resource.java new file mode 100644 index 00000000..4c766cc2 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/Resource.java @@ -0,0 +1,30 @@ +package com.example.petstoremobile.utils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class Resource { + public enum Status { SUCCESS, ERROR, LOADING } + + public final Status status; + public final T data; + public final String message; + + private Resource(Status status, @Nullable T data, @Nullable String message) { + this.status = status; + this.data = data; + this.message = message; + } + + public static Resource success(@Nullable T data) { + return new Resource<>(Status.SUCCESS, data, null); + } + + public static Resource error(String msg, @Nullable T data) { + return new Resource<>(Status.ERROR, data, msg); + } + + public static Resource loading(@Nullable T data) { + return new Resource<>(Status.LOADING, data, null); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/RetrofitUtils.java b/android/app/src/main/java/com/example/petstoremobile/utils/RetrofitUtils.java new file mode 100644 index 00000000..f86a6307 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/RetrofitUtils.java @@ -0,0 +1,105 @@ +package com.example.petstoremobile.utils; + +import android.content.Context; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.lifecycle.MutableLiveData; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +/** + * Utility class for common Retrofit operations and standardized callbacks. + */ +public class RetrofitUtils { + + /** + * Interface for handling successful API responses. + * @param The type of the response body. + */ + public interface SuccessCallback { + void onSuccess(T result); + } + + /** + * Enqueues a Retrofit call and updates the provided MutableLiveData with Resource states. + */ + public static void enqueue(@NonNull Call call, @NonNull MutableLiveData> data, String tag) { + data.setValue(Resource.loading(null)); + call.enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful()) { + data.setValue(Resource.success(response.body())); + } else { + String errorMsg = ErrorUtils.getErrorMessage(response, "API Error: " + response.code()); + Log.e(tag, errorMsg); + data.setValue(Resource.error(errorMsg, null)); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + String errorMsg = ErrorUtils.getFailureMessage(t); + Log.e(tag, "Network Error: " + t.getMessage(), t); + data.setValue(Resource.error(errorMsg, null)); + } + }); + } + + /** + * Creates a callback for Retrofit calls that handles errors and logging. + * @deprecated Use {@link #enqueue(Call, MutableLiveData, String)} for LiveData-based architecture. + */ + @Deprecated + public static Callback createCallback(Context context, String tag, String successMsg, SuccessCallback successCallback) { + return new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful()) { + if (successMsg != null) { + Toast.makeText(context, successMsg, Toast.LENGTH_SHORT).show(); + } + if (successCallback != null) { + successCallback.onSuccess(response.body()); + } + } else { + ErrorUtils.showErrorMessage(context, response, "Operation failed"); + Log.e(tag, "API Error: " + response.code()); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + String errorMsg = ErrorUtils.getFailureMessage(t); + Log.e(tag, "Network Error: " + t.getMessage(), t); + Toast.makeText(context, errorMsg, Toast.LENGTH_SHORT).show(); + } + }; + } + + /** + * Creates a callback that doesn't show toasts + */ + @Deprecated + public static Callback createSilentCallback(String tag, SuccessCallback successCallback) { + return new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful() && successCallback != null) { + successCallback.onSuccess(response.body()); + } else { + Log.e(tag, "API Error: " + response.code()); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + Log.e(tag, "Network Error: " + t.getMessage(), t); + } + }; + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/SelectionHelper.java b/android/app/src/main/java/com/example/petstoremobile/utils/SelectionHelper.java new file mode 100644 index 00000000..197cb557 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/SelectionHelper.java @@ -0,0 +1,68 @@ +package com.example.petstoremobile.utils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Helper class to manage selection state in Adapters for bulk operations. + * Uses String keys to support both simple Long IDs and composite keys (e.g., "id1-id2"). + */ +public class SelectionHelper { + + private final List selectedKeys = new ArrayList<>(); + private boolean selectionMode = false; + private final SelectionListener listener; + + public interface SelectionListener { + void onSelectionChanged(int count); + void onSelectionModeToggle(boolean selectionMode); + } + + public SelectionHelper(SelectionListener listener) { + this.listener = listener; + } + + public void toggleSelection(String key) { + if (key == null) return; + + if (selectedKeys.contains(key)) { + selectedKeys.remove(key); + } else { + selectedKeys.add(key); + } + + listener.onSelectionChanged(selectedKeys.size()); + + if (selectedKeys.isEmpty() && selectionMode) { + selectionMode = false; + listener.onSelectionModeToggle(false); + } + } + + public void startSelection(String key) { + if (key == null) return; + selectionMode = true; + selectedKeys.add(key); + listener.onSelectionChanged(selectedKeys.size()); + listener.onSelectionModeToggle(true); + } + + public boolean isSelected(String key) { + return selectedKeys.contains(key); + } + + public boolean isInSelectionMode() { + return selectionMode; + } + + public List getSelectedKeys() { + return new ArrayList<>(selectedKeys); + } + + public void clearSelection() { + selectedKeys.clear(); + selectionMode = false; + listener.onSelectionChanged(0); + listener.onSelectionModeToggle(false); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/SpinnerUtils.java b/android/app/src/main/java/com/example/petstoremobile/utils/SpinnerUtils.java new file mode 100644 index 00000000..b0aae8b8 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/SpinnerUtils.java @@ -0,0 +1,120 @@ +package com.example.petstoremobile.utils; + +import android.content.Context; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Spinner; + +import com.example.petstoremobile.adapters.BlackTextArrayAdapter; +import com.example.petstoremobile.adapters.WhiteTextArrayAdapter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +/** + * Utility class for Spinners. + */ +public class SpinnerUtils { + + /** + * Populates a spinner with a list of items and handles pre-selection. + */ + public static void populateSpinner(Context context, Spinner spinner, List data, + Function nameExtractor, String defaultText, + Long preselectedId, Function idExtractor) { + populateSpinnerWithAdapter(context, spinner, data, nameExtractor, defaultText, preselectedId, idExtractor, false); + } + + /** + * Populates a spinner with white text (for dark backgrounds). + */ + public static void populateWhiteSpinner(Context context, Spinner spinner, List data, + Function nameExtractor, String defaultText, + Long preselectedId, Function idExtractor) { + populateSpinnerWithAdapter(context, spinner, data, nameExtractor, defaultText, preselectedId, idExtractor, true); + } + + private static void populateSpinnerWithAdapter(Context context, Spinner spinner, List data, + Function nameExtractor, String defaultText, + Long preselectedId, Function idExtractor, + boolean useWhiteText) { + List names = new ArrayList<>(); + if (defaultText != null) { + names.add(defaultText); + } + + for (T item : data) { + names.add(nameExtractor.apply(item)); + } + + ArrayAdapter adapter; + if (useWhiteText) { + adapter = new WhiteTextArrayAdapter<>(context, android.R.layout.simple_spinner_item, names); + } else { + adapter = new BlackTextArrayAdapter<>(context, android.R.layout.simple_spinner_item, names); + } + + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(adapter); + + if (preselectedId != null && preselectedId != -1) { + int offset = (defaultText != null) ? 1 : 0; + for (int i = 0; i < data.size(); i++) { + Long currentId = idExtractor.apply(data.get(i)); + if (Objects.equals(currentId, preselectedId)) { + spinner.setSelection(i + offset); + break; + } + } + } + } + + /** + * Sets up a simple string spinner for filtering with a callback. + */ + public static void setupStringFilterSpinner(Context context, Spinner spinner, String[] items, Runnable onSelectionChanged) { + WhiteTextArrayAdapter adapter = new WhiteTextArrayAdapter<>(context, + android.R.layout.simple_spinner_item, items); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(adapter); + setupFilterSpinner(spinner, onSelectionChanged); + } + + /** + * Attaches an item selected listener to a spinner that triggers a callback. + */ + public static void setupFilterSpinner(Spinner spinner, Runnable onSelectionChanged) { + spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + onSelectionChanged.run(); + } + @Override public void onNothingSelected(AdapterView parent) {} + }); + } + + /** + * Sets the selection of a spinner based on a string value. + */ + public static void setSelectionByValue(Spinner spinner, String value) { + if (value == null || spinner.getAdapter() == null) return; + ArrayAdapter adapter = (ArrayAdapter) spinner.getAdapter(); + int pos = adapter.getPosition(value); + if (pos >= 0) { + spinner.setSelection(pos); + } + } + + /** + * Configures a simple string array spinner. + */ + public static void setupStringSpinner(Context context, Spinner spinner, String[] items) { + BlackTextArrayAdapter adapter = new BlackTextArrayAdapter<>(context, + android.R.layout.simple_spinner_item, items); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(adapter); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/UIUtils.java b/android/app/src/main/java/com/example/petstoremobile/utils/UIUtils.java new file mode 100644 index 00000000..22b8d4e0 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/UIUtils.java @@ -0,0 +1,18 @@ +package com.example.petstoremobile.utils; + +import android.telephony.PhoneNumberFormattingTextWatcher; +import android.text.InputFilter; +import android.widget.EditText; + +/** + * Utility class for shared UI component logic and formatting. + */ +public class UIUtils { + /** + * Formats an EditText for to phone format + */ + public static void formatPhoneInput(EditText editText) { + editText.addTextChangedListener(new PhoneNumberFormattingTextWatcher("CA")); + editText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(14)}); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/AdoptionViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AdoptionViewModel.java new file mode 100644 index 00000000..12eb9779 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AdoptionViewModel.java @@ -0,0 +1,68 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.AdoptionDTO; +import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.repositories.AdoptionRepository; +import com.example.petstoremobile.utils.Resource; + +import java.util.List; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; + +@HiltViewModel +public class AdoptionViewModel extends ViewModel { + private final AdoptionRepository repository; + + @Inject + public AdoptionViewModel(AdoptionRepository repository) { + this.repository = repository; + } + + /** + * Fetches a paginated list of all adoptions with filters. + */ + public LiveData>> getAllAdoptions(int page, int size, String query, String status, Long storeId, String date, Long employeeId) { + return repository.getAllAdoptions(page, size, query, status, storeId, date, employeeId); + } + + /** + * Retrieves a single adoption by its ID. + */ + public LiveData> getAdoptionById(Long id) { + return repository.getAdoptionById(id); + } + + /** + * Creates a new adoption record. + */ + public LiveData> createAdoption(AdoptionDTO adoption) { + return repository.createAdoption(adoption); + } + + /** + * Updates an existing adoption record by ID. + */ + public LiveData> updateAdoption(Long id, AdoptionDTO adoption) { + return repository.updateAdoption(id, adoption); + } + + /** + * Deletes an adoption record by ID. + */ + public LiveData> deleteAdoption(Long id) { + return repository.deleteAdoption(id); + } + + /** + * Deletes multiple adoption records. + */ + public LiveData> bulkDeleteAdoptions(List ids) { + return repository.bulkDeleteAdoptions(new BulkDeleteRequest(ids)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/AppointmentViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AppointmentViewModel.java new file mode 100644 index 00000000..69f24c95 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AppointmentViewModel.java @@ -0,0 +1,68 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.AppointmentDTO; +import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.repositories.AppointmentRepository; +import com.example.petstoremobile.utils.Resource; + +import java.util.List; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; + +@HiltViewModel +public class AppointmentViewModel extends ViewModel { + private final AppointmentRepository repository; + + @Inject + public AppointmentViewModel(AppointmentRepository repository) { + this.repository = repository; + } + + /** + * Fetches a paginated list of all appointments with optional filters. + */ + public LiveData>> getAllAppointments(int page, int size, String query, String status, Long storeId, String date, Long employeeId) { + return repository.getAllAppointments(page, size, query, status, storeId, date, employeeId); + } + + /** + * Retrieves a single appointment by its ID. + */ + public LiveData> getAppointmentById(Long id) { + return repository.getAppointmentById(id); + } + + /** + * Creates a new appointment. + */ + public LiveData> createAppointment(AppointmentDTO appointment) { + return repository.createAppointment(appointment); + } + + /** + * Updates an existing appointment record by ID. + */ + public LiveData> updateAppointment(Long id, AppointmentDTO appointment) { + return repository.updateAppointment(id, appointment); + } + + /** + * Deletes an appointment record by ID. + */ + public LiveData> deleteAppointment(Long id) { + return repository.deleteAppointment(id); + } + + /** + * Deletes multiple appointment records. + */ + public LiveData> bulkDeleteAppointments(List ids) { + return repository.bulkDeleteAppointments(new BulkDeleteRequest(ids)); + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/AuthViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AuthViewModel.java new file mode 100644 index 00000000..36e437bb --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AuthViewModel.java @@ -0,0 +1,69 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.AuthDTO; +import com.example.petstoremobile.dtos.AvatarUploadResponse; +import com.example.petstoremobile.dtos.UserDTO; +import com.example.petstoremobile.repositories.AuthRepository; +import com.example.petstoremobile.utils.Resource; + +import java.util.Map; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; +import okhttp3.MultipartBody; + +@HiltViewModel +public class AuthViewModel extends ViewModel { + private final AuthRepository repository; + + @Inject + public AuthViewModel(AuthRepository repository) { + this.repository = repository; + } + + /** + * Authenticates a user with username and password. + */ + public LiveData> login(String username, String password) { + return repository.login(new AuthDTO.LoginRequest(username, password)); + } + + /** + * Retrieves the profile information of the currently authenticated user. + */ + public LiveData> getMe() { + return repository.getMe(); + } + + /** + * Updates the profile information of the current user. + */ + public LiveData> updateMe(Map updates) { + return repository.updateMe(updates); + } + + /** + * Uploads a new avatar image for the current user. + */ + public LiveData> uploadAvatar(MultipartBody.Part avatar) { + return repository.uploadAvatar(avatar); + } + + /** + * Deletes the avatar image of the current user. + */ + public LiveData> deleteAvatar() { + return repository.deleteAvatar(); + } + + /** + * Logs out the current user by clearing stored credentials. + */ + public void logout() { + repository.logout(); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/ChatViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ChatViewModel.java new file mode 100644 index 00000000..2b516490 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ChatViewModel.java @@ -0,0 +1,58 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.ConversationDTO; +import com.example.petstoremobile.dtos.CustomerDTO; +import com.example.petstoremobile.dtos.MessageDTO; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.SendMessageRequest; +import com.example.petstoremobile.repositories.ChatRepository; +import com.example.petstoremobile.utils.Resource; + +import java.util.List; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; +/** + * ViewModel for managing chat-related UI state and data operations. + */ +@HiltViewModel +public class ChatViewModel extends ViewModel { + private final ChatRepository repository; + + @Inject + public ChatViewModel(ChatRepository repository) { + this.repository = repository; + } + + /** + * Retrieves all chat conversations for the current user. + */ + public LiveData>> getAllConversations() { + return repository.getAllConversations(); + } + + /** + * Retrieves the message history for a specific conversation. + */ + public LiveData>> getMessages(Long conversationId) { + return repository.getMessages(conversationId); + } + + /** + * Sends a plain text message to a conversation. + */ + public LiveData> sendMessage(Long conversationId, SendMessageRequest request) { + return repository.sendMessage(conversationId, request); + } + + /** + * Fetches a paginated list of customers. + */ + public LiveData>> getAllCustomers(int page, int size) { + return repository.getAllCustomers(page, size); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/CustomerViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/CustomerViewModel.java new file mode 100644 index 00000000..5ad7cc76 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/CustomerViewModel.java @@ -0,0 +1,37 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.CustomerDTO; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.repositories.CustomerRepository; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; + +@HiltViewModel +public class CustomerViewModel extends ViewModel { + private final CustomerRepository repository; + + @Inject + public CustomerViewModel(CustomerRepository repository) { + this.repository = repository; + } + + /** + * Fetches a paginated list of all customers. + */ + public LiveData>> getAllCustomers(int page, int size) { + return repository.getAllCustomers(page, size); + } + + /** + * Retrieves a single customer by their ID. + */ + public LiveData> getCustomerById(Long id) { + return repository.getCustomerById(id); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/InventoryViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/InventoryViewModel.java new file mode 100644 index 00000000..c7ccc070 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/InventoryViewModel.java @@ -0,0 +1,90 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.CategoryDTO; +import com.example.petstoremobile.dtos.InventoryDTO; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.StoreDTO; +import com.example.petstoremobile.repositories.CategoryRepository; +import com.example.petstoremobile.repositories.InventoryRepository; +import com.example.petstoremobile.repositories.StoreRepository; +import com.example.petstoremobile.utils.Resource; + +import java.util.List; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; + +@HiltViewModel +public class InventoryViewModel extends ViewModel { + private final InventoryRepository inventoryRepository; + private final CategoryRepository categoryRepository; + private final StoreRepository storeRepository; + + @Inject + public InventoryViewModel(InventoryRepository inventoryRepository, CategoryRepository categoryRepository, StoreRepository storeRepository) { + this.inventoryRepository = inventoryRepository; + this.categoryRepository = categoryRepository; + this.storeRepository = storeRepository; + } + + /** + * Retrieves a paginated list of inventory items, with optional filtering and sorting. + */ + public LiveData>> getAllInventory(String query, Long storeId, int page, int size, String sort) { + return inventoryRepository.getAllInventory(query, storeId, page, size, sort); + } + + /** + * Retrieves a single inventory item by its ID. + */ + public LiveData> getInventoryById(Long id) { + return inventoryRepository.getInventoryById(id); + } + + /** + * Creates a new inventory record. + */ + public LiveData> createInventory(InventoryDTO request) { + return inventoryRepository.createInventory(request); + } + + /** + * Updates an existing inventory record by ID. + */ + public LiveData> updateInventory(Long id, InventoryDTO request) { + return inventoryRepository.updateInventory(id, request); + } + + /** + * Deletes an inventory record by ID. + */ + public LiveData> deleteInventory(Long id) { + return inventoryRepository.deleteInventory(id); + } + + /** + * Deletes multiple inventory records in a single request. + */ + public LiveData> bulkDeleteInventory(List ids) { + return inventoryRepository.bulkDeleteInventory(new BulkDeleteRequest(ids)); + } + + /** + * Retrieves a paginated list of categories. + */ + public LiveData>> getAllCategories(int page, int size) { + return categoryRepository.getAllCategories(page, size); + } + + /** + * Retrieves a paginated list of stores. + */ + public LiveData>> getAllStores(int page, int size) { + return storeRepository.getAllStores(page, size); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/PetViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/PetViewModel.java new file mode 100644 index 00000000..c75926a7 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/PetViewModel.java @@ -0,0 +1,83 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.PetDTO; +import com.example.petstoremobile.repositories.PetRepository; +import com.example.petstoremobile.utils.Resource; + +import java.util.List; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; +import okhttp3.MultipartBody; + +@HiltViewModel +public class PetViewModel extends ViewModel { + private final PetRepository repository; + + @Inject + public PetViewModel(PetRepository repository) { + this.repository = repository; + } + + /** + * Fetches a paginated list of pets with filters. + */ + public LiveData>> getAllPets(int page, int size, String query, String status, String species, Long storeId, String sort) { + return repository.getAllPets(page, size, query, status, species, storeId, sort); + } + + /** + * Retrieves a single pet by its ID. + */ + public LiveData> getPetById(Long id) { + return repository.getPetById(id); + } + + /** + * Creates a new pet record. + */ + public LiveData> createPet(PetDTO pet) { + return repository.createPet(pet); + } + + /** + * Updates an existing pet record by ID. + */ + public LiveData> updatePet(Long id, PetDTO pet) { + return repository.updatePet(id, pet); + } + + /** + * Deletes a pet record by ID. + */ + public LiveData> deletePet(Long id) { + return repository.deletePet(id); + } + + /** + * Deletes multiple pet records. + */ + public LiveData> bulkDeletePets(List ids) { + return repository.bulkDeletePets(new BulkDeleteRequest(ids)); + } + + /** + * Uploads an image for a specific pet. + */ + public LiveData> uploadPetImage(Long id, MultipartBody.Part image) { + return repository.uploadPetImage(id, image); + } + + /** + * Deletes the image associated with a specific pet. + */ + public LiveData> deletePetImage(Long id) { + return repository.deletePetImage(id); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/ProductSupplierViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ProductSupplierViewModel.java new file mode 100644 index 00000000..f4302225 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ProductSupplierViewModel.java @@ -0,0 +1,58 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.ProductSupplierDTO; +import com.example.petstoremobile.repositories.ProductSupplierRepository; +import com.example.petstoremobile.utils.Resource; + +import java.util.List; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; + +@HiltViewModel +public class ProductSupplierViewModel extends ViewModel { + private final ProductSupplierRepository repository; + + @Inject + public ProductSupplierViewModel(ProductSupplierRepository repository) { + this.repository = repository; + } + + /** + * Fetches a paginated list of all product-supplier relationships. + */ + public LiveData>> getAllProductSuppliers(int page, int size, String query, Long productId, Long supplierId, String sort) { + return repository.getAllProductSuppliers(page, size, query, productId, supplierId, sort); + } + + /** + * Creates a new product-supplier relationship. + */ + public LiveData> createProductSupplier(ProductSupplierDTO dto) { + return repository.createProductSupplier(dto); + } + + /** + * Updates an existing product-supplier relationship. + */ + public LiveData> updateProductSupplier(Long productId, Long supplierId, ProductSupplierDTO dto) { + return repository.updateProductSupplier(productId, supplierId, dto); + } + + /** + * Deletes a product-supplier relationship by product and supplier IDs. + */ + public LiveData> deleteProductSupplier(Long productId, Long supplierId) { + return repository.deleteProductSupplier(productId, supplierId); + } + + public LiveData> bulkDeleteProductSuppliers(List ids) { + return repository.bulkDeleteProductSuppliers(new BulkDeleteRequest(ids)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/ProductViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ProductViewModel.java new file mode 100644 index 00000000..b44c08eb --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ProductViewModel.java @@ -0,0 +1,84 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.CategoryDTO; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.ProductDTO; +import com.example.petstoremobile.repositories.CategoryRepository; +import com.example.petstoremobile.repositories.ProductRepository; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; +import okhttp3.MultipartBody; + +@HiltViewModel +public class ProductViewModel extends ViewModel { + private final ProductRepository productRepository; + private final CategoryRepository categoryRepository; + + @Inject + public ProductViewModel(ProductRepository productRepository, CategoryRepository categoryRepository) { + this.productRepository = productRepository; + this.categoryRepository = categoryRepository; + } + + /** + * Retrieves a paginated list of products, optionally filtered by a query string, category and sorted. + */ + public LiveData>> getAllProducts(String query, Long categoryId, int page, int size, String sort) { + return productRepository.getAllProducts(query, categoryId, page, size, sort); + } + + /** + * Retrieves a single product by its ID. + */ + public LiveData> getProductById(Long id) { + return productRepository.getProductById(id); + } + + /** + * Creates a new product. + */ + public LiveData> createProduct(ProductDTO product) { + return productRepository.createProduct(product); + } + + /** + * Updates an existing product by ID. + */ + public LiveData> updateProduct(Long id, ProductDTO product) { + return productRepository.updateProduct(id, product); + } + + /** + * Deletes a product by its ID. + */ + public LiveData> deleteProduct(Long id) { + return productRepository.deleteProduct(id); + } + + /** + * Uploads an image for a specific product. + */ + public LiveData> uploadProductImage(Long id, MultipartBody.Part image) { + return productRepository.uploadProductImage(id, image); + } + + /** + * Deletes the image associated with a specific product. + */ + public LiveData> deleteProductImage(Long id) { + return productRepository.deleteProductImage(id); + } + + /** + * Retrieves a paginated list of all product categories. + */ + public LiveData>> getAllCategories(int page, int size) { + return categoryRepository.getAllCategories(page, size); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/PurchaseOrderViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/PurchaseOrderViewModel.java new file mode 100644 index 00000000..d9a24e5e --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/PurchaseOrderViewModel.java @@ -0,0 +1,37 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.PurchaseOrderDTO; +import com.example.petstoremobile.repositories.PurchaseOrderRepository; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; + +@HiltViewModel +public class PurchaseOrderViewModel extends ViewModel { + private final PurchaseOrderRepository repository; + + @Inject + public PurchaseOrderViewModel(PurchaseOrderRepository repository) { + this.repository = repository; + } + + /** + * Fetches a paginated list of all purchase orders. + */ + public LiveData>> getAllPurchaseOrders(int page, int size, String query, Long storeId, String sort) { + return repository.getAllPurchaseOrders(page, size, query, storeId, sort); + } + + /** + * Retrieves a single purchase order by its ID. + */ + public LiveData> getPurchaseOrderById(Long id) { + return repository.getPurchaseOrderById(id); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/ServiceViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ServiceViewModel.java new file mode 100644 index 00000000..ebd5c3b6 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ServiceViewModel.java @@ -0,0 +1,68 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.ServiceDTO; +import com.example.petstoremobile.repositories.ServiceRepository; +import com.example.petstoremobile.utils.Resource; + +import java.util.List; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; + +@HiltViewModel +public class ServiceViewModel extends ViewModel { + private final ServiceRepository repository; + + @Inject + public ServiceViewModel(ServiceRepository repository) { + this.repository = repository; + } + + /** + * Fetches a paginated list of all services. + */ + public LiveData>> getAllServices(int page, int size, String query, String sort) { + return repository.getAllServices(page, size, query, sort); + } + + /** + * Retrieves a single service by its ID. + */ + public LiveData> getServiceById(Long id) { + return repository.getServiceById(id); + } + + /** + * Creates a new service. + */ + public LiveData> createService(ServiceDTO service) { + return repository.createService(service); + } + + /** + * Updates an existing service by ID. + */ + public LiveData> updateService(Long id, ServiceDTO service) { + return repository.updateService(id, service); + } + + /** + * Deletes a service by ID. + */ + public LiveData> deleteService(Long id) { + return repository.deleteService(id); + } + + /** + * Deletes multiple services. + */ + public LiveData> bulkDeleteServices(List ids) { + return repository.bulkDeleteServices(new BulkDeleteRequest(ids)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/StoreViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/StoreViewModel.java new file mode 100644 index 00000000..83f4c3b3 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/StoreViewModel.java @@ -0,0 +1,30 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.StoreDTO; +import com.example.petstoremobile.repositories.StoreRepository; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; + +@HiltViewModel +public class StoreViewModel extends ViewModel { + private final StoreRepository repository; + + @Inject + public StoreViewModel(StoreRepository repository) { + this.repository = repository; + } + + /** + * Fetches a paginated list of all stores. + */ + public LiveData>> getAllStores(int page, int size) { + return repository.getAllStores(page, size); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/SupplierViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/SupplierViewModel.java new file mode 100644 index 00000000..1486a562 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/SupplierViewModel.java @@ -0,0 +1,68 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.BulkDeleteRequest; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.SupplierDTO; +import com.example.petstoremobile.repositories.SupplierRepository; +import com.example.petstoremobile.utils.Resource; + +import java.util.List; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; + +@HiltViewModel +public class SupplierViewModel extends ViewModel { + private final SupplierRepository repository; + + @Inject + public SupplierViewModel(SupplierRepository repository) { + this.repository = repository; + } + + /** + * Fetches a paginated list of all suppliers. + */ + public LiveData>> getAllSuppliers(int page, int size, String query, String sort) { + return repository.getAllSuppliers(page, size, query, sort); + } + + /** + * Retrieves a single supplier by its ID. + */ + public LiveData> getSupplierById(Long id) { + return repository.getSupplierById(id); + } + + /** + * Creates a new supplier record. + */ + public LiveData> createSupplier(SupplierDTO supplier) { + return repository.createSupplier(supplier); + } + + /** + * Updates an existing supplier record by ID. + */ + public LiveData> updateSupplier(Long id, SupplierDTO supplier) { + return repository.updateSupplier(id, supplier); + } + + /** + * Deletes a supplier record by ID. + */ + public LiveData> deleteSupplier(Long id) { + return repository.deleteSupplier(id); + } + + /** + * Deletes multiple supplier records. + */ + public LiveData> bulkDeleteSuppliers(List ids) { + return repository.bulkDeleteSuppliers(new BulkDeleteRequest(ids)); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/UserViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/UserViewModel.java new file mode 100644 index 00000000..d839f6c4 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/UserViewModel.java @@ -0,0 +1,27 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.dtos.UserDTO; +import com.example.petstoremobile.repositories.UserRepository; +import com.example.petstoremobile.utils.Resource; + +import javax.inject.Inject; + +import dagger.hilt.android.lifecycle.HiltViewModel; + +@HiltViewModel +public class UserViewModel extends ViewModel { + private final UserRepository userRepository; + + @Inject + public UserViewModel(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public LiveData>> getUsers(String role, int page, int size) { + return userRepository.getUsers(role, page, size); + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/websocket/StompChatManager.java b/android/app/src/main/java/com/example/petstoremobile/websocket/StompChatManager.java index af4264db..f0f5d7ec 100644 --- a/android/app/src/main/java/com/example/petstoremobile/websocket/StompChatManager.java +++ b/android/app/src/main/java/com/example/petstoremobile/websocket/StompChatManager.java @@ -3,7 +3,6 @@ package com.example.petstoremobile.websocket; import android.os.Handler; import android.os.Looper; import android.util.Log; -import com.example.petstoremobile.api.RetrofitClient; import com.example.petstoremobile.dtos.ConversationDTO; import com.example.petstoremobile.dtos.MessageDTO; import com.google.gson.Gson; @@ -54,14 +53,16 @@ public class StompChatManager { private ConnectionListener connectionListener; private final String authToken; private final String role; + private final String baseUrl; private boolean isConnected; private boolean isConnecting; private boolean manualDisconnect; private Long pendingConversationId; - public StompChatManager(String authToken, String role) { + public StompChatManager(String authToken, String role, String baseUrl) { this.authToken = authToken; this.role = role == null ? "" : role.trim().toUpperCase(Locale.ROOT); + this.baseUrl = baseUrl; } public void setMessageListener(MessageListener listener) { @@ -267,16 +268,16 @@ public class StompChatManager { // Make the URL for the websocket connection private String buildWebSocketUrl() { - String baseUrl = RetrofitClient.BASE_URL.endsWith("/") - ? RetrofitClient.BASE_URL.substring(0, RetrofitClient.BASE_URL.length() - 1) - : RetrofitClient.BASE_URL; - if (baseUrl.startsWith("https://")) { - return "wss://" + baseUrl.substring("https://".length()) + "/ws/chat"; + String cleanBaseUrl = baseUrl.endsWith("/") + ? baseUrl.substring(0, baseUrl.length() - 1) + : baseUrl; + if (cleanBaseUrl.startsWith("https://")) { + return "wss://" + cleanBaseUrl.substring("https://".length()) + "/ws/chat"; } - if (baseUrl.startsWith("http://")) { - return "ws://" + baseUrl.substring("http://".length()) + "/ws/chat"; + if (cleanBaseUrl.startsWith("http://")) { + return "ws://" + cleanBaseUrl.substring("http://".length()) + "/ws/chat"; } - return baseUrl + "/ws/chat"; + return cleanBaseUrl + "/ws/chat"; } // Helper to check if the current user is a customer @@ -292,4 +293,4 @@ public class StompChatManager { reconnectHandler.removeCallbacksAndMessages(null); reconnectHandler.postDelayed(this::connect, 1000); } -} +} \ No newline at end of file diff --git a/android/app/src/main/res/drawable/bg_search_bar.xml b/android/app/src/main/res/drawable/bg_search_bar.xml new file mode 100644 index 00000000..342e4649 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_search_bar.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/bg_spinner.xml b/android/app/src/main/res/drawable/bg_spinner.xml new file mode 100644 index 00000000..0fcab8e6 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_spinner.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/placeholder2.png b/android/app/src/main/res/drawable/placeholder2.png new file mode 100644 index 00000000..dec35ec5 Binary files /dev/null and b/android/app/src/main/res/drawable/placeholder2.png differ diff --git a/android/app/src/main/res/layout/activity_home.xml b/android/app/src/main/res/layout/activity_home.xml index c7b9f3ea..f5e822e2 100644 --- a/android/app/src/main/res/layout/activity_home.xml +++ b/android/app/src/main/res/layout/activity_home.xml @@ -5,13 +5,16 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" - android:background="@color/background_grey"> + android:background="@color/primary_dark"> - + android:layout_weight="1" + app:defaultNavHost="true" + app:navGraph="@navigation/nav_graph" /> diff --git a/android/app/src/main/res/layout/fragment_adoption.xml b/android/app/src/main/res/layout/fragment_adoption.xml index 492b9236..f222f6e7 100644 --- a/android/app/src/main/res/layout/fragment_adoption.xml +++ b/android/app/src/main/res/layout/fragment_adoption.xml @@ -12,6 +12,7 @@ android:orientation="vertical"> + android:textStyle="bold" + android:layout_marginStart="8dp"/> + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + - + - - - - + + + + + + + + + + diff --git a/desktop/src/main/resources/org/example/petshopdesktop/dialogviews/sale-detail-dialog-view.fxml b/desktop/src/main/resources/org/example/petshopdesktop/dialogviews/sale-detail-dialog-view.fxml new file mode 100644 index 00000000..a0964b27 --- /dev/null +++ b/desktop/src/main/resources/org/example/petshopdesktop/dialogviews/sale-detail-dialog-view.fxml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - + @@ -151,16 +151,16 @@ - + - - - - - - - - + + + + + + + + diff --git a/web/app/about/page.js b/web/app/about/page.js new file mode 100644 index 00000000..5ba61fe2 --- /dev/null +++ b/web/app/about/page.js @@ -0,0 +1,37 @@ +export default function AboutPage() { + return ( +
+
+

About Leon's Pet Store

+

Pet care, adoption support, grooming, and everyday essentials in one place.

+
+
+ +
+
+

What We Do

+

+ Leon's Pet Store connects families with adoptable pets, helpful services, and quality products for day-to-day pet care. +

+
+ +
+

Our Focus

+
    +
  • Support responsible pet adoption
  • +
  • Provide grooming and care services
  • +
  • Offer reliable pet supplies and essentials
  • +
  • Create a friendly experience for customers and staff
  • +
+
+ +
+

Visit the Store

+

+ Browse adoptable pets, schedule appointments, shop products, or contact the team for help finding the right fit for a pet and household. +

+
+
+
+ ); +} diff --git a/web/app/adopt/[id]/page.js b/web/app/adopt/[id]/page.js new file mode 100644 index 00000000..6c7b70e0 --- /dev/null +++ b/web/app/adopt/[id]/page.js @@ -0,0 +1,52 @@ +"use client"; + +import Link from "next/link"; +import { useState, useEffect } from "react"; +import { useParams } from "next/navigation"; +import PetProfile from "@/components/PetProfile"; + +const API_BASE = ""; + +export default function PetDetailPage() { + const { id } = useParams(); + const [pet, setPet] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id) return; + + fetch(`${API_BASE}/api/v1/pets/${id}`) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status} – ${res.statusText}`); + return res.json(); + }) + .then((data) => setPet(data)) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [id]); + + return ( +
+
+ ← Back to Pets + + {loading &&

Loading pet details...

} + {error &&

{error}

} + + {!loading && !error && pet && ( + + )} +
+
+ ); +} diff --git a/web/app/adopt/page.js b/web/app/adopt/page.js new file mode 100644 index 00000000..a6a2784a --- /dev/null +++ b/web/app/adopt/page.js @@ -0,0 +1,134 @@ +"use client"; + +import { useState, useEffect } from "react"; +import PetCard from "@/components/PetCard"; +import { fetchAllPages } from "@/lib/fetchAllPages"; + +const API_BASE = ""; + +export default function AdoptPage() { + const [pets, setPets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [health, setHealth] = useState(null); + const [search, setSearch] = useState(""); + const [query, setQuery] = useState(""); + + const PAGE_SIZE = 100; + + useEffect(() => { + fetch(`${API_BASE}/api/v1/health`) + .then((res) => (res.ok ? setHealth("online") : setHealth("error"))) + .catch(() => setHealth("offline")); + }, []); + + useEffect(() => { + setLoading(true); + setError(null); + + fetchAllPages((page) => { + const params = new URLSearchParams({ + page: String(page), + size: String(PAGE_SIZE), + sort: "id,asc", + status: "Available", + }); + if (query) { + params.set("q", query); + } + return `${API_BASE}/api/v1/pets?${params}`; + }) + .then((allPets) => { + setPets(allPets); + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [query]); + + function handleSearch(e) { + e.preventDefault(); + setLoading(true); + setError(null); + setQuery(search.trim()); + } + + return ( +
+
+

Find Your Perfect Companion

+

Give a loving pet their forever home

+
+
+ +
+
+
+ setSearch(e.target.value)} + /> + + {query && ( + + )} +
+ +
+
+ +
+ {loading &&

Loading pets...

} + + {error && ( +
+

Failed to load pets

+ {error} +

+ {health === "offline" + ? "The Spring Boot backend is not reachable. Make sure it is running in IntelliJ on port 8080." + : health === "error" + ? "The backend responded with an error. Check the IntelliJ Run console for stack traces." + : "The backend is reachable but the /pets endpoint failed. Check the IntelliJ Run console."} +

+
+ )} + + {!loading && !error && pets.length === 0 && ( +

No pets found.

+ )} + + {!loading && !error && pets.length > 0 && ( +
+ {pets.map((pet) => ( + + ))} +
+ )} + +
+
+ ); +} diff --git a/web/app/appointments/page.js b/web/app/appointments/page.js new file mode 100644 index 00000000..f8e4ec2d --- /dev/null +++ b/web/app/appointments/page.js @@ -0,0 +1,707 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useState, useEffect, useCallback, useRef } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useAuth } from "@/context/AuthContext"; + +const API_BASE = ""; + +const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", +]; + +function DatePicker({ value, minDate, onChange }) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const min = minDate ? new Date(minDate + "T00:00:00") : today; + + const parsed = value ? new Date(value + "T00:00:00") : null; + const [viewYear, setViewYear] = useState(parsed ? parsed.getFullYear() : min.getFullYear()); + const [viewMonth, setViewMonth] = useState(parsed ? parsed.getMonth() : min.getMonth()); + + function prevMonth() { + if (viewMonth === 0) { + setViewMonth(11); + setViewYear((y) => y - 1); + } + + else { + setViewMonth((m) => m - 1); + } + } + + function nextMonth() { + if (viewMonth === 11) { + setViewMonth(0); setViewYear((y) => y + 1); + } + + else { + setViewMonth((m) => m + 1); + } + } + + const firstDay = new Date(viewYear, viewMonth, 1).getDay(); + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); + + const minYear = min.getFullYear(); + const minMonth = min.getMonth(); + const isPrevDisabled = viewYear < minYear || (viewYear === minYear && viewMonth <= minMonth); + + function selectDay(day) { + const d = new Date(viewYear, viewMonth, day); + if (d < min) { + return; + } + const iso = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + + onChange(iso); + } + + function isSelected(day) { + if (!parsed) return false; + return parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth && parsed.getDate() === day; + } + + function isDisabled(day) { + return new Date(viewYear, viewMonth, day) < min; + } + + const cells = []; + for (let i = 0; i < firstDay; i++) { + cells.push({ key: `empty-${viewYear}-${viewMonth}-${String(i)}`, day: null }); + } + for (let d = 1; d <= daysInMonth; d++) { + cells.push({ key: `day-${viewYear}-${viewMonth}-${String(d)}`, day: d }); + } + + const s = { + widget: { + border: "1px solid #ddd", + borderRadius: "10px", + overflow: "hidden", + background: "white", + userSelect: "none", + fontFamily: "inherit", + }, + header: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + background: "orange", + padding: "0.55rem 0.75rem", + }, + monthLabel: { + fontSize: "0.95rem", + fontWeight: 700, + color: "white", + }, + nav: { + background: "none", + border: "none", + color: "white", + fontSize: "1.5rem", + lineHeight: 1, + cursor: "pointer", + padding: "0 0.4rem", + borderRadius: "4px", + }, + grid: { + display: "grid", + gridTemplateColumns: "repeat(7, 1fr)", + gap: "3px", + padding: "0.6rem", + }, + dayName: { + textAlign: "center", + fontSize: "0.7rem", + fontWeight: 700, + color: "#aaa", + padding: "0.25rem 0", + textTransform: "uppercase", + }, + dayBase: { + display: "flex", + alignItems: "center", + justifyContent: "center", + aspectRatio: "1 / 1", + border: "none", + borderRadius: "6px", + background: "none", + fontSize: "0.875rem", + cursor: "pointer", + color: "#333", + fontFamily: "inherit", + padding: 0, + width: "100%", + }, + daySelected: { + background: "orange", + color: "white", + fontWeight: 700, + }, + dayDisabled: { + color: "#ccc", + cursor: "default", + }, + selectedLabel: { + textAlign: "center", + fontSize: "0.82rem", + color: "#666", + padding: "0.35rem 0.5rem 0.5rem", + borderTop: "1px solid #f0f0f0", + }, + }; + + return ( +
+
+ + {MONTHS[viewMonth]} {viewYear} + +
+
+ {DAYS.map((d) => ( + {d} + ))} + {cells.map(({ key, day }) => + day === null ? ( + + ) : ( + + ) + )} +
+ {parsed && ( +
+ Selected: {MONTHS[parsed.getMonth()]} {parsed.getDate()}, {parsed.getFullYear()} +
+ )} +
+ ); +} + +function AppointmentsPage() { + const { user, token, loading: authLoading } = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + const preselectedPetId = searchParams.get("petId"); + const didPreselectRef = useRef(false); + + const [stores, setStores] = useState([]); + const [employees, setEmployees] = useState([]); + const [services, setServices] = useState([]); + const [allPets, setAllPets] = useState([]); + const [customerPets, setCustomerPets] = useState([]); + const [availableSlots, setAvailableSlots] = useState([]); + + const [storeId, setStoreId] = useState(""); + const [serviceId, setServiceId] = useState(""); + const [employeeId, setEmployeeId] = useState(""); + const [appointmentDate, setAppointmentDate] = useState(""); + const [appointmentTime, setAppointmentTime] = useState(""); + const [selectedPetIds, setSelectedPetIds] = useState([]); + + const [loadingSlots, setLoadingSlots] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [appointments, setAppointments] = useState([]); + const [loadingAppointments, setLoadingAppointments] = useState(false); + + const canBookAppointments = user?.role === "CUSTOMER"; + + useEffect(() => { + if (!authLoading && !user) { + const target = preselectedPetId ? `/appointments?petId=${encodeURIComponent(preselectedPetId)}` : "/appointments"; + router.push(`/login?next=${encodeURIComponent(target)}`); + } + + }, [authLoading, user, router, preselectedPetId]); + + useEffect(() => { + if (!token) { + return; + } + + fetch(`${API_BASE}/api/v1/dropdowns/stores`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((r) => r.json()) + .then(setStores) + .catch(() => {}); + + fetch(`${API_BASE}/api/v1/services?size=100`) + .then((r) => r.json()) + .then((data) => setServices(data.content ?? [])) + .catch(() => {}); + + fetch(`${API_BASE}/api/v1/pets?size=200&sort=id,asc&status=Available`) + .then((r) => r.json()) + .then((data) => setAllPets(data.content ?? [])) + .catch(() => {}); + + if (canBookAppointments) { + fetch(`${API_BASE}/api/v1/my-pets`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((r) => r.json()) + .then((data) => setCustomerPets(Array.isArray(data) ? data : [])) + .catch(() => {}); + } + }, [token, canBookAppointments]); + + useEffect(() => { + if (didPreselectRef.current) { + return; + } + if (!preselectedPetId || services.length === 0 || allPets.length === 0) { + return; + } + + const adoptionSvc = services.find((s) => + s.serviceName.toLowerCase().includes("adopt") + ); + + if (adoptionSvc) { + setServiceId(String(adoptionSvc.serviceId)); + } + setSelectedPetIds([Number(preselectedPetId)]); + didPreselectRef.current = true; + }, [preselectedPetId, services, allPets]); + + const loadAppointments = useCallback(() => { + + if (!token) { + return; + } + setLoadingAppointments(true); + fetch(`${API_BASE}/api/v1/appointments?size=50&sort=appointmentDate,desc`, { + headers: {Authorization: `Bearer ${token}`}, + }) + .then((r) => r.json()) + .then((data) => setAppointments(data.content ?? [])) + .catch(() => {}) + .finally(() => setLoadingAppointments(false)); + }, [token]); + + useEffect(() => { + loadAppointments(); + }, [loadAppointments]); + + useEffect(() => { + if (!token || !storeId) { + setEmployees([]); + setEmployeeId(""); + return; + } + + fetch(`${API_BASE}/api/v1/dropdowns/stores/${storeId}/employees`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((r) => r.json()) + .then((data) => setEmployees(Array.isArray(data) ? data : [])) + .catch(() => setEmployees([])); + }, [token, storeId]); + + useEffect(() => { + if (!employees.length) { + setEmployeeId(""); + return; + } + + const currentExists = employees.some((employee) => String(employee.id) === String(employeeId)); + if (!currentExists) { + setEmployeeId(String(employees[0].id)); + } + }, [employees, employeeId]); + + useEffect(() => { + if (!storeId || !serviceId || !appointmentDate) { + setAvailableSlots([]); + setAppointmentTime(""); + + return; + } + setLoadingSlots(true); + setAppointmentTime(""); + const params = new URLSearchParams({ storeId, serviceId, date: appointmentDate }); + fetch(`${API_BASE}/api/v1/appointments/availability?${params}`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to check availability"); + } + + return r.json(); + }) + .then(setAvailableSlots) + .catch(() => setAvailableSlots([])) + .finally(() => setLoadingSlots(false)); + }, [storeId, serviceId, appointmentDate]); + + const selectedService = services.find((s) => s.serviceId === Number(serviceId)); + const isAdoptionService = selectedService ? selectedService.serviceName.toLowerCase().includes("adopt") : false; + const isCustomerPetService = !!selectedService && !isAdoptionService; + + const adoptablePets = allPets.filter( + (p) => p.petStatus && p.petStatus.toLowerCase() === "available" + ); + + function handleServiceChange(newServiceId) { + setServiceId(newServiceId); + setSelectedPetIds([]); + } + + function togglePet(petId) { + if (isAdoptionService) { + setSelectedPetIds((prev) => + prev.includes(petId) ? [] : [petId] + ); + } + + else { + setSelectedPetIds((prev) => + prev.includes(petId) ? prev.filter((id) => id !== petId) : [...prev, petId] + ); + } + } + + function formatTime(timeStr) { + const [h, m] = timeStr.split(":"); + const hour = parseInt(h, 10); + const ampm = hour >= 12 ? "PM" : "AM"; + const display = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + + return `${display}:${m} ${ampm}`; + } + + function getMinDate() { + const d = new Date(); + d.setDate(d.getDate() + 1); + + return d.toISOString().split("T")[0]; + } + + const formValid = + storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0; + + async function handleSubmit(e) { + e.preventDefault(); + setError(null); + setSuccess(null); + + if (!canBookAppointments) { + setError("Only customer accounts can book appointments from the web app."); + + return; + } + + if (!user?.customerId) { + setError("Customer account not found. Please contact support."); + + return; + } + + if (selectedPetIds.length === 0) { + setError(isAdoptionService ? "Please select a pet to adopt." : "Please select at least one pet."); + + return; + } + + setSubmitting(true); + + try { + const body = { + customerId: user.customerId, + storeId: Number(storeId), + serviceId: Number(serviceId), + employeeId: employeeId ? Number(employeeId) : undefined, + appointmentDate, + appointmentTime: appointmentTime + ":00", + appointmentStatus: "Booked", + }; + + if (isCustomerPetService) { + body.customerPetIds = selectedPetIds; + } + + else { + body.petIds = selectedPetIds; + } + + const res = await fetch(`${API_BASE}/api/v1/appointments`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const data = await res.json().catch(() => null); + + throw new Error(data?.message || data?.error || `Request failed (${res.status})`); + } + + setSuccess("Appointment booked successfully!"); + setStoreId(""); + setServiceId(""); + setAppointmentDate(""); + setAppointmentTime(""); + setSelectedPetIds([]); + setAvailableSlots([]); + loadAppointments(); + } + + catch (err) { + setError(err.message); + } + + finally { + setSubmitting(false); + } + } + + if (authLoading) { + + return ( +
+

Loading...

+
+ ); + } + + if (!user) return null; + + const petsToShow = isAdoptionService ? adoptablePets : isCustomerPetService ? customerPets : []; + const petSectionLabel = isAdoptionService ? "Select a Pet to Adopt" : "Select Pet(s)"; + const noPetsMessage = isAdoptionService + ? "No pets are currently available for adoption." + : "No pets found. Please add your pets in your profile before booking."; + + return ( +
+
+

Schedule an Appointment

+

Book a service for your pet or schedule a pet adoption visit

+
+
+ +
+ {canBookAppointments ? ( +
+

New Appointment

+ + {error &&
{error}
} + {success &&
{success}
} + + + + + + {employees.length > 0 && ( + + )} + + {selectedService && ( +
+

{selectedService.serviceDesc}

+
+ )} + +
+ Date + +
+ + {storeId && serviceId && appointmentDate && ( +
+ Available Time Slots + {loadingSlots ? ( +

Checking availability...

+ ) : availableSlots.length === 0 ? ( +

No available slots for this date. Please try another date.

+ ) : ( +
+ {availableSlots.map((slot) => ( + + ))} +
+ )} +
+ )} + + {serviceId && ( +
+ {petSectionLabel} + {petsToShow.length === 0 ? ( +

{noPetsMessage}

+ ) : isAdoptionService ? ( +
+ {petsToShow.map((p) => ( + + ))} +
+ ) : ( +
+ {petsToShow.map((p) => ( + + ))} +
+ )} +
+ )} + + +
+ ) : null} + +
+

{canBookAppointments ? "Your Appointments" : "Appointments"}

+ {loadingAppointments ? ( +

Loading appointments...

+ ) : appointments.length === 0 ? ( +

No appointments yet.

+ ) : ( +
+ {appointments.map((a) => ( +
+
+ {a.serviceName} + + {a.appointmentStatus} + +
+
+ {a.storeName} + {a.appointmentDate} at {formatTime(a.appointmentTime)} +
+ {a.petNames && a.petNames.length > 0 && ( +
+ Pets: {a.petNames.join(", ")} +
+ )} + {a.customerPetNames && a.customerPetNames.length > 0 && ( +
+ Pets: {a.customerPetNames.join(", ")} +
+ )} +
+ ))} +
+ )} +
+
+
+ ); +} + +export default dynamic(() => Promise.resolve(AppointmentsPage), { + ssr: false, +}); diff --git a/web/app/contact/page.js b/web/app/contact/page.js new file mode 100644 index 00000000..85fba175 --- /dev/null +++ b/web/app/contact/page.js @@ -0,0 +1,73 @@ +const LOCATIONS = [ + { + name: "Downtown Branch", + address: "123 Main St", + phone: "(123) 456-7890", + email: "downtown@petshop.com", + }, + { + name: "North Branch", + address: "456 North Ave", + phone: "(987) 654-3210", + email: "north@petshop.com", + }, + { + name: "West Side Store", + address: "789 West Blvd", + phone: "(555) 123-4567", + email: "westside@petshop.com", + }, +]; + +const PERSONNEL = [ + { name: "John Doe", role: "Store Manager" }, + { name: "Sara Smith", role: "Staff" }, + { name: "Michael Johnson", role: "Grooming Team" }, +]; + +export default function ContactPage() { + return ( +
+
+

Contact Us

+

Reach the team, find a location, or connect with store personnel.

+
+
+ +
+
+

General Contact

+

Email: support@petshop.com

+

Phone: (000) 000-0000

+

Hours: Mon–Sat, 9:00 AM – 6:00 PM

+
+ +
+

Store Locations

+
+ {LOCATIONS.map((location) => ( +
+

{location.name}

+

{location.address}

+

{location.phone}

+

{location.email}

+
+ ))} +
+
+ +
+

Store Personnel

+
+ {PERSONNEL.map((person) => ( +
+

{person.name}

+

{person.role}

+
+ ))} +
+
+
+
+ ); +} diff --git a/web/app/globals.css b/web/app/globals.css index fd71a692..8571bfeb 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -232,7 +232,541 @@ body { border-radius: 2px; } +/* Adopt page */ + +.adopt-page { + min-height: 100vh; +} + +.adopt-hero { + text-align: center; + padding: 4rem 2rem 3rem; + background: linear-gradient(to bottom, #f9f9f9, #ffffff); +} + +.adopt-hero-title { + font-size: 3rem; + color: #333; + margin-bottom: 1rem; + font-weight: 700; + letter-spacing: -0.5px; +} + +.adopt-hero-subtitle { + font-size: 1.5rem; + color: #666; + margin-bottom: 2rem; + font-weight: 300; +} + +.adopt-controls { + max-width: 1200px; + margin: 0 auto 1.5rem; + padding: 0 2rem; +} + +.adopt-search-form { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.adopt-search-input { + flex: 1; + max-width: 400px; + padding: 0.6rem 1rem; + border: 2px solid #ddd; + border-radius: 6px; + font-size: 1rem; + font-family: Arial, sans-serif; + transition: border-color 0.2s ease; + outline: none; +} + +.adopt-search-input:focus { + border-color: orange; +} + +.adopt-search-btn { + padding: 0.6rem 1.4rem; + background: orange; + color: white; + border: none; + border-radius: 6px; + font-size: 1rem; + font-family: Arial, sans-serif; + cursor: pointer; + transition: background 0.2s ease; +} + +.adopt-search-btn:hover { + background: #e69500; +} + +.adopt-clear-btn { + padding: 0.6rem 1rem; + background: transparent; + color: #666; + border: 2px solid #ddd; + border-radius: 6px; + font-size: 1rem; + font-family: Arial, sans-serif; + cursor: pointer; + transition: all 0.2s ease; +} + +.adopt-clear-btn:hover { + border-color: #aaa; + color: #333; +} + +.adopt-grid-section { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem 4rem; +} + +.adopt-status-msg { + text-align: center; + color: #666; + font-size: 1.1rem; + padding: 3rem 0; +} + +.adopt-error { + color: #c0392b; +} + +.adopt-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1.75rem; +} + +.pet-card { + text-decoration: none; + color: inherit; + display: flex; + flex-direction: column; + border-radius: 16px; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + transition: transform 0.3s ease, box-shadow 0.3s ease; + background: #fff; +} + +.pet-card:hover { + transform: translateY(-5px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.13); +} + +.pet-card-image-wrapper { + background: #fff8ee; + display: flex; + align-items: center; + justify-content: center; + height: 160px; +} + +.pet-card-emoji { + font-size: 5rem; + line-height: 1; +} + +.pet-card-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.pet-card-body { + padding: 1rem 1.25rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.pet-card-name { + font-size: 1.2rem; + font-weight: 700; + color: #222; + margin: 0; +} + +.pet-card-species { + font-size: 0.95rem; + color: #666; + margin: 0; +} + +.pet-card-status { + display: inline-block; + margin-top: 0.4rem; + padding: 0.2rem 0.75rem; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 600; + text-transform: capitalize; + width: fit-content; +} + +.status-available { + background: #e6f9ee; + color: #1a7a3c; +} + +.status-adopted { + background: #ffe8e8; + color: #c0392b; +} + +.status-pending { + background: #fff4e0; + color: #b36b00; +} + +.status-other { + background: #f0f0f0; + color: #555; +} + +.adopt-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1.5rem; + margin-top: 3rem; +} + +.pagination-btn { + padding: 0.5rem 1.2rem; + background: orange; + color: white; + border: none; + border-radius: 6px; + font-size: 0.95rem; + font-family: Arial, sans-serif; + cursor: pointer; + transition: background 0.2s ease; +} + +.pagination-btn:hover:not(:disabled) { + background: #e69500; +} + +.pagination-btn:disabled { + background: #ddd; + color: #aaa; + cursor: default; +} + +.pagination-info { + font-size: 0.95rem; + color: #555; +} + +/* Pet details */ + +.pet-detail-page { + min-height: 100vh; + padding: 3rem 2rem 5rem; +} + +.pet-detail-container { + max-width: 860px; + margin: 0 auto; +} + +.pet-detail-back { + display: inline-block; + margin-bottom: 2rem; + color: orange; + text-decoration: none; + font-size: 1rem; + font-weight: 600; + transition: color 0.2s ease; +} + +.pet-detail-back:hover { + color: #e69500; +} + +.pet-detail-card { + display: flex; + gap: 3rem; + background: #fff; + border-radius: 20px; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.pet-detail-image-wrapper { + flex-shrink: 0; + width: 280px; + background: #fff8ee; + display: flex; + align-items: center; + justify-content: center; +} + +.pet-detail-emoji { + font-size: 8rem; + line-height: 1; +} + +.pet-detail-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.pet-detail-info { + flex: 1; + padding: 2.5rem 2.5rem 2.5rem 0; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.pet-detail-header { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.pet-detail-name { + font-size: 2.2rem; + font-weight: 700; + color: #222; + margin: 0; +} + +.pet-detail-fields { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid #eee; + border-radius: 10px; + overflow: hidden; +} + +.pet-detail-row { + display: flex; + align-items: center; + padding: 0.85rem 1.25rem; + border-bottom: 1px solid #eee; +} + +.pet-detail-row:last-child { + border-bottom: none; +} + +.pet-detail-label { + width: 140px; + font-size: 0.9rem; + font-weight: 600; + color: #888; + text-transform: uppercase; + letter-spacing: 0.04em; + flex-shrink: 0; +} + +.pet-detail-value { + font-size: 1rem; + color: #333; +} + +.pet-detail-price { + font-weight: 700; + color: #1a7a3c; + font-size: 1.1rem; +} + +.pet-detail-cta { + background: #fff8ee; + border-radius: 12px; + padding: 1.25rem 1.5rem; +} + +.pet-detail-cta-text { + font-size: 0.95rem; + color: #555; + margin: 0 0 1rem; +} + +.pet-detail-cta-btn { + display: inline-block; + padding: 0.65rem 1.5rem; + background: orange; + color: white; + text-decoration: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + transition: background 0.2s ease; +} + +.pet-detail-cta-btn:hover { + background: #e69500; +} + +/* Products Page */ + +.products-page { + min-height: 100vh; +} + +.info-page { + min-height: 100vh; + background: linear-gradient(to bottom, #f9f9f9, #ffffff); +} + +.info-hero { + text-align: center; + padding: 4rem 2rem 3rem; +} + +.info-title { + font-size: 3rem; + color: #333; + margin-bottom: 1rem; + font-weight: 700; +} + +.info-subtitle { + font-size: 1.25rem; + color: #666; + margin-bottom: 1.5rem; +} + +.info-content { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem 4rem; + display: grid; + gap: 1.5rem; +} + +.info-card { + background: white; + border-radius: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + padding: 1.5rem; +} + +.info-card h2 { + margin-top: 0; + margin-bottom: 1rem; + color: #222; +} + +.info-list { + margin: 0; + padding-left: 1.2rem; + display: grid; + gap: 0.5rem; +} + +.info-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.info-mini-card { + background: #fff8ee; + border-radius: 12px; + padding: 1rem; +} + +.info-mini-card h3 { + margin-top: 0; + margin-bottom: 0.5rem; +} + +.products-hero { + text-align: center; + padding: 4rem 2rem 3rem; + background: linear-gradient(to bottom, #f9f9f9, #ffffff); +} + +.products-hero-title { + font-size: 3rem; + color: #333; + margin-bottom: 1rem; + font-weight: 700; + letter-spacing: -0.5px; +} + +.products-hero-subtitle { + font-size: 1.5rem; + color: #666; + margin-bottom: 2rem; + font-weight: 300; +} + +.product-card-price { + display: inline-block; + margin-top: 0.4rem; + font-size: 1.05rem; + font-weight: 700; + color: #1a7a3c; +} + /* Responsive Design */ +@media (max-width: 1024px) { + .adopt-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 768px) { + .adopt-hero-title, + .products-hero-title { + font-size: 2rem; + } + + .adopt-hero-subtitle, + .products-hero-subtitle { + font-size: 1.2rem; + } + + .adopt-grid { + grid-template-columns: repeat(2, 1fr); + gap: 1.25rem; + } + + .pet-detail-card { + flex-direction: column; + gap: 0; + } + + .pet-detail-image-wrapper { + width: 100%; + height: 200px; + } + + .pet-detail-info { + padding: 1.75rem; + } +} + +@media (max-width: 480px) { + .adopt-grid { + grid-template-columns: 1fr; + } + + .adopt-hero-title, + .products-hero-title { + font-size: 1.6rem; + } + + .pet-detail-name { + font-size: 1.7rem; + } +} + @media (max-width: 1024px) { .slideshow-container { height: 400px; @@ -287,4 +821,987 @@ body { .centered-title-section { padding: 2rem 1rem; } -} \ No newline at end of file +} +/* Adopt diagnostics */ + +.adopt-controls-row { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; +} + +.backend-status { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.backend-status--online { + background: #1a7a3c; +} + +.backend-status--offline { + background: #c0392b; +} + +.backend-status--error { + background: #e67e00; +} + +.backend-status--checking { + background: #bbb; +} + +.adopt-error-box { + max-width: 640px; + margin: 3rem auto; + padding: 1.5rem 2rem; + background: #fff8f8; + border: 2px solid #f5c6c6; + border-radius: 12px; +} + +.adopt-error-title { + font-size: 1.1rem; + font-weight: 700; + color: #c0392b; + margin: 0 0 0.6rem; +} + +.adopt-error-detail { + display: block; + font-family: monospace; + font-size: 0.9rem; + background: #fff0f0; + border: 1px solid #f5c6c6; + border-radius: 6px; + padding: 0.5rem 0.75rem; + margin-bottom: 0.9rem; + word-break: break-all; +} + +.adopt-error-hint { + font-size: 0.9rem; + color: #555; + margin: 0; + line-height: 1.5; +} + +/* Auth/nav */ + +.navbar { + justify-content: space-between; +} + +.nav-auth { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + padding-left: 1.5rem; + flex-shrink: 0; +} + +.nav-greeting { + font-weight: 600; + white-space: nowrap; +} + +.nav-register-btn { + background: white; + color: orange !important; + font-weight: 600; + border-radius: 20px; + padding: 0.4rem 1rem !important; +} + +.nav-register-btn:hover { + background: #fff3e0 !important; +} + +.nav-logout-btn { + background: rgba(255, 255, 255, 0.2); + color: white; + border: 1px solid rgba(255, 255, 255, 0.6); + border-radius: 20px; + padding: 0.35rem 1rem; + font-size: 0.95rem; + cursor: pointer; + transition: background 0.2s ease; + white-space: nowrap; +} + +.nav-logout-btn:hover { + background: rgba(255, 255, 255, 0.35); +} + +/* Login/Register */ + +.auth-page { + min-height: calc(100vh - 70px); + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 1rem; + background: #fafafa; +} + +.auth-card { + background: white; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); + padding: 2.5rem; + width: 100%; + max-width: 440px; +} + +.auth-title { + font-size: 1.75rem; + font-weight: 700; + color: #222; + margin: 0 0 1.5rem; + text-align: center; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.auth-label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.9rem; + font-weight: 600; + color: #444; +} + +.auth-input { + padding: 0.6rem 0.85rem; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 1rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + outline: none; +} + +.auth-input:focus { + border-color: orange; + box-shadow: 0 0 0 3px rgba(255, 165, 0, 0.2); +} + +.auth-submit-btn { + margin-top: 0.5rem; + padding: 0.75rem; + background: orange; + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: background 0.2s ease, transform 0.1s ease; +} + +.auth-submit-btn:hover:not(:disabled) { + background: #e69500; +} + +.auth-submit-btn:active:not(:disabled) { + transform: scale(0.98); +} + +.auth-submit-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.auth-error { + background: #fff0f0; + border: 1px solid #f5c6c6; + color: #c0392b; + border-radius: 8px; + padding: 0.65rem 1rem; + font-size: 0.9rem; + margin-bottom: 0.5rem; +} + +.auth-switch { + text-align: center; + font-size: 0.9rem; + color: #666; + margin-top: 1.25rem; +} + +.auth-switch-link { + color: orange; + font-weight: 600; + text-decoration: none; +} + +.auth-switch-link:hover { + text-decoration: underline; +} + +/* User Profile Page */ + +.profile-card { + background: white; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); + padding: 2.5rem; + width: 100%; + max-width: 480px; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; +} + +.profile-avatar-circle { + width: 80px; + height: 80px; + border-radius: 50%; + background: orange; + color: white; + font-size: 2rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.25rem; + overflow: hidden; +} + +.profile-avatar-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.profile-name { + font-size: 1.5rem; + font-weight: 700; + color: #222; + margin: 0; +} + +.profile-role-badge { + display: inline-block; + background: #fff3e0; + color: #e67e00; + border: 1px solid #ffd180; + border-radius: 20px; + padding: 0.2rem 0.85rem; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.profile-fields { + width: 100%; + margin: 0.75rem 0 0; + border-top: 1px solid #eee; + padding-top: 1rem; +} + +.profile-update-form { + width: 100%; + display: grid; + gap: 0.9rem; +} + +.profile-update-title { + margin: 0.25rem 0 0; + font-size: 1.1rem; + color: #222; +} + +.profile-avatar-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; +} + +.profile-avatar-upload-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.65rem 1rem; + border-radius: 8px; + background: #fff3e0; + color: #a65c00; + font-weight: 600; + cursor: pointer; +} + +.profile-field-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 0.55rem 0; + border-bottom: 1px solid #f0f0f0; + gap: 1rem; +} + +.profile-field-label { + font-size: 0.85rem; + font-weight: 600; + color: #888; + flex-shrink: 0; +} + +.profile-field-value { + font-size: 0.95rem; + color: #222; + text-align: right; + word-break: break-word; +} + +.profile-logout-btn { + width: 100%; + margin-top: 1rem; +} + +.profile-loading { + color: #888; + font-size: 1rem; +} + +/* Appointments Page */ + +.appt-page { + min-height: 100vh; +} + +.appt-hero { + text-align: center; + padding: 3rem 2rem 2rem; + background: linear-gradient(135deg, #fff8f0 0%, #fff3e0 100%); +} + +.appt-hero-title { + font-size: 2.2rem; + font-weight: 800; + color: #222; + margin: 0 0 0.5rem; +} + +.appt-hero-subtitle { + font-size: 1.1rem; + color: #666; + margin: 0 0 1rem; +} + +.appt-content { + max-width: 900px; + margin: 0 auto; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 2.5rem; +} + +.appt-form { + background: white; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.appt-form-title { + font-size: 1.35rem; + font-weight: 700; + color: #222; + margin: 0; +} + +.appt-label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.9rem; + font-weight: 600; + color: #444; +} + +.appt-select, +.appt-input { + padding: 0.6rem 0.85rem; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 1rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + outline: none; + background: white; +} + +.appt-select:focus, +.appt-input:focus { + border-color: orange; + box-shadow: 0 0 0 3px rgba(255, 165, 0, 0.2); +} + +.appt-service-info { + background: #fff8f0; + border: 1px solid #ffd180; + border-radius: 8px; + padding: 0.75rem 1rem; + font-size: 0.9rem; + color: #555; +} + +.appt-service-info p { + margin: 0; +} + +.appt-slots-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.appt-slot-btn { + padding: 0.45rem 0.9rem; + border: 1px solid #ddd; + border-radius: 20px; + background: white; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.appt-slot-btn:hover { + border-color: orange; + background: #fff8f0; +} + +.appt-slot-btn--selected { + background: orange; + color: white; + border-color: orange; +} + +.appt-slot-btn--selected:hover { + background: #e69500; +} + +.appt-slots-loading, +.appt-no-slots { + font-size: 0.9rem; + color: #888; + font-weight: 400; + margin: 0.25rem 0 0; +} + +.appt-pets-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.appt-pet-chip { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.85rem; + border: 1px solid #ddd; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + user-select: none; +} + +.appt-pet-chip:hover { + border-color: orange; + background: #fff8f0; +} + +.appt-pet-chip--selected { + background: #fff3e0; + border-color: orange; + color: #c47600; +} + +.appt-pet-chip-species { + font-weight: 400; + color: #888; +} + +.appt-pet-chip--selected .appt-pet-chip-species { + color: #c47600; +} + +.appt-pet-checkbox { + accent-color: orange; +} + +.appt-link { + color: orange; + font-weight: 600; + text-decoration: none; +} + +.appt-link:hover { + text-decoration: underline; +} + +.appt-submit-btn { + margin-top: 0.5rem; + padding: 0.75rem; + background: orange; + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: background 0.2s ease, transform 0.1s ease; +} + +.appt-submit-btn:hover:not(:disabled) { + background: #e69500; +} + +.appt-submit-btn:active:not(:disabled) { + transform: scale(0.98); +} + +.appt-submit-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.appt-error { + background: #fff0f0; + border: 1px solid #f5c6c6; + color: #c0392b; + border-radius: 8px; + padding: 0.65rem 1rem; + font-size: 0.9rem; +} + +.appt-success { + background: #f0fff4; + border: 1px solid #b2dfdb; + color: #1a7a3c; + border-radius: 8px; + padding: 0.65rem 1rem; + font-size: 0.9rem; +} + +.appt-loading, +.appt-empty { + text-align: center; + color: #888; + font-size: 0.95rem; + padding: 1rem 0; +} + +.appt-history { + background: white; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); + padding: 2rem; +} + +.appt-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 1rem; +} + +.appt-card { + border: 1px solid #eee; + border-radius: 10px; + padding: 1rem 1.25rem; + transition: box-shadow 0.2s ease; +} + +.appt-card:hover { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); +} + +.appt-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.4rem; +} + +.appt-card-service { + font-weight: 700; + font-size: 1rem; + color: #222; +} + +.appt-card-status { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + padding: 0.2rem 0.7rem; + border-radius: 20px; + letter-spacing: 0.03em; +} + +.appt-card-status--booked { + background: #e3f2fd; + color: #1565c0; +} + +.appt-card-status--completed { + background: #e8f5e9; + color: #2e7d32; +} + +.appt-card-status--cancelled { + background: #fce4ec; + color: #c62828; +} + +.appt-card-details { + display: flex; + justify-content: space-between; + font-size: 0.88rem; + color: #666; +} + +.appt-card-pets { + font-size: 0.85rem; + color: #888; + margin-top: 0.35rem; +} + +/* Adoption Pet Selection */ + +.appt-adopt-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; + margin-top: 0.25rem; +} + +.appt-adopt-card { + display: flex; + align-items: center; + gap: 0.75rem; + border: 2px solid #eee; + border-radius: 12px; + padding: 0.65rem 0.85rem; + cursor: pointer; + transition: all 0.2s ease; + user-select: none; +} + +.appt-adopt-card:hover { + border-color: orange; + background: #fffaf5; +} + +.appt-adopt-card--selected { + border-color: orange; + background: #fff3e0; +} + +.appt-adopt-radio { + display: none; +} + +.appt-adopt-img { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.appt-adopt-img-placeholder { + width: 48px; + height: 48px; + border-radius: 50%; + background: #f5f5f5; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.3rem; + flex-shrink: 0; +} + +.appt-adopt-info { + display: flex; + flex-direction: column; + gap: 0.05rem; + min-width: 0; +} + +.appt-adopt-name { + font-weight: 700; + font-size: 0.9rem; + color: #222; +} + +.appt-adopt-detail { + font-size: 0.78rem; + color: #888; +} + +@media (max-width: 640px) { + .appt-content { + padding: 1rem; + } + + .appt-form, + .appt-history { + padding: 1.25rem; + } + + .appt-hero-title { + font-size: 1.6rem; + } + + .appt-card-details { + flex-direction: column; + gap: 0.15rem; + } +} + +/* Profile Page Layout (with pets section) */ + +.profile-page-layout { + min-height: calc(100vh - 70px); + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem 1rem; + background: #fafafa; + gap: 2rem; +} + +/* Profile Pets Section */ + +.profile-pets-section { + background: white; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); + padding: 2rem; + width: 100%; + max-width: 640px; +} + +.profile-pets-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.profile-pets-title { + font-size: 1.35rem; + font-weight: 700; + color: #222; + margin: 0; +} + +.profile-pets-add-btn { + background: orange; + color: white; + border: none; + border-radius: 20px; + padding: 0.4rem 1rem; + font-size: 0.85rem; + font-weight: 700; + cursor: pointer; + transition: background 0.2s ease; +} + +.profile-pets-add-btn:hover { + background: #e69500; +} + +.profile-pets-empty { + text-align: center; + color: #888; + font-size: 0.95rem; + padding: 1.5rem 0; +} + +.profile-pets-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.profile-pet-card { + display: flex; + align-items: center; + gap: 1rem; + border: 1px solid #eee; + border-radius: 12px; + padding: 0.75rem 1rem; + transition: box-shadow 0.2s ease; +} + +.profile-pet-card:hover { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); +} + +.profile-pet-card-img-area { + position: relative; + width: 56px; + height: 56px; + flex-shrink: 0; +} + +.profile-pet-card-img { + width: 56px; + height: 56px; + border-radius: 50%; + object-fit: cover; +} + +.profile-pet-card-placeholder { + width: 56px; + height: 56px; + border-radius: 50%; + background: #f5f5f5; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; +} + +.profile-pet-upload-label { + position: absolute; + bottom: -2px; + right: -2px; + width: 22px; + height: 22px; + background: white; + border: 1px solid #ddd; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + cursor: pointer; + transition: border-color 0.2s ease; +} + +.profile-pet-upload-label:hover { + border-color: orange; +} + +.profile-pet-upload-input { + display: none; +} + +.profile-pet-card-info { + display: flex; + flex-direction: column; + gap: 0.1rem; + flex: 1; + min-width: 0; +} + +.profile-pet-card-name { + font-weight: 700; + font-size: 0.95rem; + color: #222; +} + +.profile-pet-card-detail { + font-size: 0.82rem; + color: #888; +} + +.profile-pet-card-actions { + display: flex; + gap: 0.4rem; + flex-shrink: 0; +} + +.profile-pet-edit-btn, +.profile-pet-delete-btn { + padding: 0.3rem 0.7rem; + border-radius: 6px; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + border: 1px solid #ddd; + background: white; + transition: all 0.2s ease; +} + +.profile-pet-edit-btn:hover { + border-color: orange; + color: orange; +} + +.profile-pet-delete-btn:hover { + border-color: #c0392b; + color: #c0392b; +} + +/* Pet Add/Edit Form */ + +.profile-pet-form { + background: #fafafa; + border: 1px solid #eee; + border-radius: 12px; + padding: 1.25rem; + margin-bottom: 1rem; + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.profile-pet-form-title { + font-size: 1rem; + font-weight: 700; + color: #222; + margin: 0; +} + +.profile-pet-form-actions { + display: flex; + gap: 0.75rem; +} + +.profile-pet-form-actions .appt-submit-btn { + flex: 1; + margin-top: 0; +} + +.profile-pet-cancel-btn { + flex: 1; + padding: 0.75rem; + background: white; + color: #666; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.profile-pet-cancel-btn:hover { + border-color: #999; + color: #333; +} diff --git a/web/app/layout.js b/web/app/layout.js index 86efbb81..0a50c8db 100644 --- a/web/app/layout.js +++ b/web/app/layout.js @@ -1,18 +1,21 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import DisplayNav from "@/components/Navigation"; +import ClientProviders from "@/components/ClientProviders"; export const metadata = { title: "Leon's Pet Store", description: "Generated by create next app", }; -export default function RootLayout({ children }) { +export default function RootLayout({children}) { return ( - - {children} + + + {children} + ); diff --git a/web/app/login/page.js b/web/app/login/page.js new file mode 100644 index 00000000..2e7d1fe1 --- /dev/null +++ b/web/app/login/page.js @@ -0,0 +1,92 @@ +"use client"; + +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useAuth } from "@/context/AuthContext"; + +function resolveNextPath(candidate) { + if (!candidate || !candidate.startsWith("/")) { + return "/"; + } + if (candidate.startsWith("//") || candidate.startsWith("/login") || candidate.startsWith("/register")) { + return "/"; + } + return candidate; +} + +function LoginPage() { + const {login} = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + await login(username, password); + router.push(resolveNextPath(searchParams.get("next"))); + } + + catch (err) { + setError(err.message); + } + + finally { + setLoading(false); + } + } + + return ( +
+
+

Log In

+ + {error &&

{error}

} + +
+ + + + + +
+ +

+ Don't have an account?{" "} + Register here +

+
+
+ ); +} + +export default dynamic(() => Promise.resolve(LoginPage), { + ssr: false, +}); diff --git a/web/app/page.js b/web/app/page.js index bf0875e6..91f71946 100644 --- a/web/app/page.js +++ b/web/app/page.js @@ -4,40 +4,38 @@ import Image from "next/image"; import Link from "next/link"; import { useState, useEffect } from "react"; -export default function Home() { - // Slideshow images array - const slideshowImages = [ - { src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets" }, - { src: "/images/home/slideshow/pet2.jpg", alt: "Pet supplies" }, - { src: "/images/home/slideshow/pet3.jpg", alt: "Pet grooming" }, - { src: "/images/home/slideshow/pet4.jpg", alt: "Pet food" }, - ]; +const slideshowImages = [ + {id: "slide-1", src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets"}, + {id: "slide-2", src: "/images/home/slideshow/pet2.jpg", alt: "Pet supplies"}, + {id: "slide-3", src: "/images/home/slideshow/pet3.jpg", alt: "Pet grooming"}, + {id: "slide-4", src: "/images/home/slideshow/pet4.jpg", alt: "Pet food"}, +]; +const navImages = [ + {id: "nav-adopt", src: "/images/home/navimages/adopt.jpg", alt: "Adopt a Pet", link: "/adopt", title: "Adopt a Pet"}, + {id: "nav-products", src: "/images/home/navimages/store.jpg", alt: "Online Store", link: "/products", title: "Online Store"}, + {id: "nav-appointments", src: "/images/home/navimages/appointments.jpg", alt: "Appointments", link: "/appointments", title: "Appointments"}, + {id: "nav-about", src: "/images/home/navimages/about.jpg", alt: "About Us", link: "/about", title: "About Us"}, +]; + +export default function Home() { const [currentSlide, setCurrentSlide] = useState(0); - // Auto-advance slideshow + //Auto-advance slideshow useEffect(() => { //Change slide every 7.5 seconds const timer = setInterval(() => {setCurrentSlide((prev) => (prev + 1) % slideshowImages.length);}, 7500); return () => clearInterval(timer); - }, [slideshowImages.length]); - - // Four images that link to other pages - const navImages = [ - { src: "/images/home/navimages/adopt.jpg", alt: "Adopt a Pet", link: "/adopt", title: "Adopt a Pet" }, - { src: "/images/home/navimages/store.jpg", alt: "Online Store", link: "/store", title: "Online Store" }, - { src: "/images/home/navimages/appointments.jpg", alt: "Appointments", link: "/appointments", title: "Appointments" }, - { src: "/images/home/navimages/about.jpg", alt: "About Us", link: "/about", title: "About Us" }, - ]; + }, []); return (
- {/* Slideshow Section */} + {/* Slideshow */}
{slideshowImages.map((image, index) => (
- {/* Centered Title Section */} + {/* Title Section */}

Welcome to Leon's Pet Store

Your One-Stop Shop for All Things Pets

- {/* Four Image Links Section */} + {/* Image Hyperlinks */}
{navImages.map((item, index) => ( - +
); -} \ No newline at end of file +} diff --git a/web/app/products/[id]/page.js b/web/app/products/[id]/page.js new file mode 100644 index 00000000..47a3059b --- /dev/null +++ b/web/app/products/[id]/page.js @@ -0,0 +1,54 @@ +"use client"; + +import Link from "next/link"; +import { useState, useEffect } from "react"; +import { useParams } from "next/navigation"; +import ProductProfile from "@/components/ProductProfile"; + +const API_BASE = ""; + +export default function ProductDetailPage() { + const { id } = useParams(); + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id) { + return; + } + + fetch(`${API_BASE}/api/v1/products/${id}`) + .then((res) => { + if (!res.ok) { + throw new Error(`HTTP ${res.status} – ${res.statusText}`); + } + + return res.json(); + }) + .then((data) => setProduct(data)) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [id]); + + return ( +
+
+ ← Back to Products + + {loading &&

Loading product details...

} + {error &&

{error}

} + + {!loading && !error && product && ( + + )} +
+
+ ); +} diff --git a/web/app/products/page.js b/web/app/products/page.js new file mode 100644 index 00000000..19cf8670 --- /dev/null +++ b/web/app/products/page.js @@ -0,0 +1,111 @@ +"use client"; + +import { useState, useEffect } from "react"; +import ProductCard from "@/components/ProductCard"; +import { fetchAllPages } from "@/lib/fetchAllPages"; + +const API_BASE = ""; + +export default function ProductsPage() { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [query, setQuery] = useState(""); + + const PAGE_SIZE = 100; + + useEffect(() => { + setLoading(true); + setError(null); + + fetchAllPages((page) => { + const params = new URLSearchParams({ + page: String(page), + size: String(PAGE_SIZE), + sort: "prodId,asc", + }); + if (query) { + params.set("q", query); + } + return `${API_BASE}/api/v1/products?${params}`; + }) + .then((allProducts) => { + setProducts(allProducts); + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [query]); + + function handleSearch(e) { + e.preventDefault(); + setLoading(true); + setError(null); + setQuery(search.trim()); + } + + return ( +
+
+

Shop Our Products

+

Everything your pet needs, all in one place

+
+
+ +
+
+
+ setSearch(e.target.value)} + /> + + {query && ( + + )} +
+
+
+ +
+ {loading &&

Loading products...

} + + {error && ( +
+

Failed to load products

+ {error} +
+ )} + + {!loading && !error && products.length === 0 && ( +

No products found.

+ )} + + {!loading && !error && products.length > 0 && ( +
+ {products.map((product) => ( + + ))} +
+ )} + +
+
+ ); +} diff --git a/web/app/profile/page.js b/web/app/profile/page.js new file mode 100644 index 00000000..ff79521e --- /dev/null +++ b/web/app/profile/page.js @@ -0,0 +1,528 @@ +"use client"; + +import { useEffect, useState, useCallback, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/context/AuthContext"; + +const API_BASE = ""; + +export default function ProfilePage() { + const {user, token, loading, logout, refreshUser} = useAuth(); + const router = useRouter(); + const petImageObjectUrlsRef = useRef([]); + + const [pets, setPets] = useState([]); + const [loadingPets, setLoadingPets] = useState(false); + const [showForm, setShowForm] = useState(false); + const [editingPet, setEditingPet] = useState(null); + const [petName, setPetName] = useState(""); + const [species, setSpecies] = useState(""); + const [breed, setBreed] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [petError, setPetError] = useState(null); + const [profileForm, setProfileForm] = useState({ fullName: "", email: "", phone: "" }); + const [profileSubmitting, setProfileSubmitting] = useState(false); + const [profileError, setProfileError] = useState(null); + const [profileSuccess, setProfileSuccess] = useState(null); + const [avatarSubmitting, setAvatarSubmitting] = useState(false); + + const clearPetImageObjectUrls = useCallback(() => { + for (const objectUrl of petImageObjectUrlsRef.current) { + URL.revokeObjectURL(objectUrl); + } + petImageObjectUrlsRef.current = []; + }, []); + + useEffect(() => { + if (!loading && !user) { + router.replace(`/login?next=${encodeURIComponent("/profile")}`); + } + + }, [user, loading, router]); + + useEffect(() => { + setProfileForm({ + fullName: user?.fullName || "", + email: user?.email || "", + phone: user?.phone || "", + }); + }, [user]); + + const loadPets = useCallback(async () => { + if (!token) return; + setLoadingPets(true); + + try { + const response = await fetch(`${API_BASE}/api/v1/my-pets`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`Request failed (${response.status})`); + } + + const petData = await response.json(); + clearPetImageObjectUrls(); + + const petsWithResolvedImages = await Promise.all( + (Array.isArray(petData) ? petData : []).map(async (pet) => { + if (!pet.imageUrl) { + return pet; + } + + try { + const imageResponse = await fetch(`${API_BASE}${pet.imageUrl}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!imageResponse.ok) { + return { ...pet, imageUrl: null }; + } + + const blob = await imageResponse.blob(); + const objectUrl = URL.createObjectURL(blob); + petImageObjectUrlsRef.current.push(objectUrl); + + return { ...pet, imageUrl: objectUrl }; + } catch { + return { ...pet, imageUrl: null }; + } + }) + ); + + setPets(petsWithResolvedImages); + } + + catch { + } + + finally { + setLoadingPets(false); + } + }, [token, clearPetImageObjectUrls]); + + useEffect(() => { + return () => { + clearPetImageObjectUrls(); + }; + }, [clearPetImageObjectUrls]); + + useEffect(() => { + if (user?.role === "CUSTOMER") { + loadPets(); + } + }, [user, loadPets]); + + function handleLogout() { + logout(); + router.push("/"); + } + + async function handleProfileSubmit(e) { + e.preventDefault(); + setProfileSubmitting(true); + setProfileError(null); + setProfileSuccess(null); + + try { + const res = await fetch(`${API_BASE}/api/v1/auth/me`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(profileForm), + }); + + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.message || `Request failed (${res.status})`); + } + + await refreshUser(); + setProfileSuccess("Profile updated successfully."); + } + + catch (err) { + setProfileError(err.message); + } + + finally { + setProfileSubmitting(false); + } + } + + async function handleAvatarUpload(file) { + if (!file) { + return; + } + + const formData = new FormData(); + formData.append("avatar", file); + setAvatarSubmitting(true); + setProfileError(null); + setProfileSuccess(null); + + try { + const res = await fetch(`${API_BASE}/api/v1/auth/me/avatar`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.message || "Failed to upload avatar"); + } + + await refreshUser(); + setProfileSuccess(data?.message || "Avatar updated successfully."); + } + + catch (err) { + setProfileError(err.message); + } + + finally { + setAvatarSubmitting(false); + } + } + + async function handleAvatarDelete() { + setAvatarSubmitting(true); + setProfileError(null); + setProfileSuccess(null); + + try { + const res = await fetch(`${API_BASE}/api/v1/auth/me/avatar`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.message || "Failed to delete avatar"); + } + + await refreshUser(); + setProfileSuccess(data?.message || "Avatar removed successfully."); + } + + catch (err) { + setProfileError(err.message); + } + + finally { + setAvatarSubmitting(false); + } + } + + function openAddForm() { + setEditingPet(null); + setPetName(""); + setSpecies(""); + setBreed(""); + setPetError(null); + setShowForm(true); + } + + function openEditForm(pet) { + setEditingPet(pet); + setPetName(pet.petName); + setSpecies(pet.species); + setBreed(pet.breed || ""); + setPetError(null); + setShowForm(true); + } + + function closeForm() { + setShowForm(false); + setEditingPet(null); + setPetError(null); + } + + async function handlePetSubmit(e) { + e.preventDefault(); + setPetError(null); + setSubmitting(true); + + const url = editingPet + ? `${API_BASE}/api/v1/my-pets/${editingPet.customerPetId}` + : `${API_BASE}/api/v1/my-pets`; + + try { + const res = await fetch(url, { + method: editingPet ? "PUT" : "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ petName, species, breed: breed || null }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(data?.message || `Request failed (${res.status})`); + } + + closeForm(); + loadPets(); + } + + catch (err) { + setPetError(err.message); + } + + finally { + setSubmitting(false); + } + } + + async function handleDeletePet(id) { + if (!confirm("Remove this pet profile?")) { + return; + } + + try { + await fetch(`${API_BASE}/api/v1/my-pets/${id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + loadPets(); + } + + catch { + } + } + + async function handleImageUpload(petId, file) { + const formData = new FormData(); + formData.append("image", file); + + try { + const res = await fetch(`${API_BASE}/api/v1/my-pets/${petId}/image`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + + if (!res.ok) { + const data = await res.json().catch(() => null); + alert(data?.message || "Failed to upload image"); + return; + } + + loadPets(); + } + + catch { + alert("Failed to upload image"); + } + } + + if (loading || !user) { + return

Loading…

; + } + + const fields = [ + {label: "Full Name", value: user.fullName}, + {label: "Username", value: user.username}, + {label: "Email", value: user.email}, + {label: "Phone", value: user.phone || "—"}, + {label: "Role", value: user.role}, + ...(user.storeName ? [{ label: "Store", value: user.storeName }] : []), + ]; + + return ( +
+
+
+ {user.avatarUrl ? ( + {user.fullName + ) : ( + (user.fullName || user.username).charAt(0).toUpperCase() + )} +
+ +

{user.fullName || user.username}

+ {user.role} + +
+ {fields.map(({ label, value }) => ( +
+
{label}
+
{value}
+
+ ))} +
+ +
+

Update Profile

+ {profileError &&
{profileError}
} + {profileSuccess &&
{profileSuccess}
} + + + +
+ + {user.avatarUrl && ( + + )} +
+ +
+ + +
+ + {user.role === "CUSTOMER" && ( +
+
+

My Pets

+ +
+ + {showForm && ( +
+

+ {editingPet ? "Edit Pet" : "Add a New Pet"} +

+ {petError &&
{petError}
} + + + +
+ + +
+
+ )} + + {loadingPets ? ( +

Loading pets...

+ ) : pets.length === 0 && !showForm ? ( +

No pet profiles yet. Add your first pet above!

+ ) : ( +
+ {pets.map((pet) => ( +
+
+ {pet.imageUrl ? ( + {pet.petName} + ) : ( +
🐾
+ )} + +
+
+ {pet.petName} + {pet.species} + {pet.breed && {pet.breed}} +
+
+ + +
+
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/web/app/register/page.js b/web/app/register/page.js new file mode 100644 index 00000000..17f49672 --- /dev/null +++ b/web/app/register/page.js @@ -0,0 +1,168 @@ +"use client"; + +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useAuth } from "@/context/AuthContext"; + +function resolveNextPath(candidate) { + if (!candidate || !candidate.startsWith("/")) { + return "/"; + } + if (candidate.startsWith("//") || candidate.startsWith("/login") || candidate.startsWith("/register")) { + return "/"; + } + return candidate; +} + +function RegisterPage() { + const {register} = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [form, setForm] = useState({ + fullName: "", + username: "", + email: "", + phone: "", + password: "", + confirmPassword: "",}); + + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + function handleChange(e) { + setForm((prev) => ({ ...prev, [e.target.name]: e.target.value })); + } + + async function handleSubmit(e) { + e.preventDefault(); + setError(""); + + if (form.password !== form.confirmPassword) { + setError("Passwords do not match."); + return; + } + + setLoading(true); + try { + await register({fullName: form.fullName, + username: form.username, + email: form.email, + phone: form.phone, + password: form.password, + }); + router.push(resolveNextPath(searchParams.get("next"))); + } + + catch (err) { + setError(err.message); + } + + finally { + setLoading(false); + } + } + + return ( +
+
+

Create Account

+ + {error &&

{error}

} + +
+ + + + + + + + + + + + + +
+ +

+ Already have an account?{" "} + Log in here +

+
+
+ ); +} + +export default dynamic(() => Promise.resolve(RegisterPage), { + ssr: false, +}); diff --git a/web/components/ClientProviders.js b/web/components/ClientProviders.js new file mode 100644 index 00000000..64e8157a --- /dev/null +++ b/web/components/ClientProviders.js @@ -0,0 +1,7 @@ +"use client"; + +import { AuthProvider } from "@/context/AuthContext"; + +export default function ClientProviders({children}) { + return {children}; +} diff --git a/web/components/Navigation.js b/web/components/Navigation.js index 4780df6c..3259c1d1 100644 --- a/web/components/Navigation.js +++ b/web/components/Navigation.js @@ -1,26 +1,54 @@ -import Link from "next/link"; +"use client"; + import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/context/AuthContext"; export default function DisplayNav() { + const {user, logout, loading} = useAuth(); + const router = useRouter(); + + function handleLogout() { + logout(); + router.push("/"); + } + return ( + + +
+ Home + Adopt a Pet + Online Store + Schedule an Appointment + Contact Us + About Us +
+ +
+ {loading ? null : user ? ( + <> + + Hello, {user.fullName || user.username} + + + + ) : ( + <> + Log In + Register + + )} +
+ ); -} \ No newline at end of file +} diff --git a/web/components/PetCard.js b/web/components/PetCard.js new file mode 100644 index 00000000..4c6410b9 --- /dev/null +++ b/web/components/PetCard.js @@ -0,0 +1,27 @@ +import Link from "next/link"; +import { getStatusClass } from "@/components/petUtils"; + +export default function PetCard({petId, petName, petSpecies, petStatus, imageUrl}) { + return ( + +
+ {petName} { + e.currentTarget.onerror = null; + e.currentTarget.src = "/images/pet-placeholder.png"; + }} + /> +
+
+

{petName}

+

{petSpecies}

+ + {petStatus} + +
+ + ); +} diff --git a/web/components/PetProfile.js b/web/components/PetProfile.js new file mode 100644 index 00000000..8b7fd6f0 --- /dev/null +++ b/web/components/PetProfile.js @@ -0,0 +1,64 @@ +import Link from "next/link"; +import { getStatusClass } from "@/components/petUtils"; + +export default function PetProfile({ petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl }) { + return ( +
+
+ {petName} { + e.currentTarget.onerror = null; + e.currentTarget.src = "/images/pet-placeholder.png"; + }} + /> +
+ +
+
+

{petName}

+ + {petStatus} + +
+ +
+
+ Species + {petSpecies ?? "—"} +
+
+ Breed + {petBreed ?? "—"} +
+
+ Age + + {petAge != null ? `${petAge} ${petAge === 1 ? "year" : "years"}` : "—"} + +
+
+ Adoption Fee + + {petPrice != null ? `$${parseFloat(petPrice).toFixed(2)}` : "—"} + +
+
+ + {/* Status */} + {petStatus?.toLowerCase() === "available" && ( +
+

+ Interested in adopting {petName}? Visit us in store or schedule an appointment. +

+ + Schedule an Appointment + +
+ )} +
+
+ ); +} diff --git a/web/components/ProductCard.js b/web/components/ProductCard.js new file mode 100644 index 00000000..0b4c76ed --- /dev/null +++ b/web/components/ProductCard.js @@ -0,0 +1,26 @@ +import Link from "next/link"; + +export default function ProductCard({ prodId, prodName, categoryName, prodPrice, imageUrl }) { + return ( + +
+ {prodName} { + e.currentTarget.onerror = null; + e.currentTarget.src = "/images/pet-placeholder.png"; + }} + /> +
+
+

{prodName}

+

{categoryName}

+ {prodPrice != null && ( + ${parseFloat(prodPrice).toFixed(2)} + )} +
+ + ); +} diff --git a/web/components/ProductProfile.js b/web/components/ProductProfile.js new file mode 100644 index 00000000..fda41420 --- /dev/null +++ b/web/components/ProductProfile.js @@ -0,0 +1,42 @@ +import Link from "next/link"; + +export default function ProductProfile({ prodName, categoryName, prodDesc, prodPrice, imageUrl }) { + return ( +
+
+ {prodName} { + e.currentTarget.onerror = null; + e.currentTarget.src = "/images/pet-placeholder.png"; + }} + /> +
+ +
+
+

{prodName}

+
+ +
+
+ Category + {categoryName ?? "—"} +
+
+ Price + + {prodPrice != null ? `$${parseFloat(prodPrice).toFixed(2)}` : "—"} + +
+
+ Description + {prodDesc ?? "—"} +
+
+
+
+ ); +} diff --git a/web/components/petUtils.js b/web/components/petUtils.js new file mode 100644 index 00000000..4a07b30e --- /dev/null +++ b/web/components/petUtils.js @@ -0,0 +1,54 @@ + +//Temporary, until image support is added +export const SPECIES_EMOJI = { + dog: "🐶", + cat: "🐱", + bird: "🐦", + rabbit: "🐰", + hamster: "🐹", + fish: "🐟", + turtle: "🐢", + snake: "🐍", + lizard: "🦎", + guinea: "🐹", +}; + +export function getSpeciesEmoji(species) { + if (!species) { + + return "🐾"; + } + + const key = species.toLowerCase(); + + for (const [k, v] of Object.entries(SPECIES_EMOJI)) { + if (key.includes(k)) { + + return v; + } + } + + return "🐾"; +} + +export function getStatusClass(status) { + if (!status) { + return ""; + } + + const s = status.toLowerCase(); + + if (s === "available") { + return "status-available"; + } + + if (s === "adopted") { + return "status-adopted"; + } + + if (s === "pending") { + return "status-pending"; + } + + return "status-other"; +} diff --git a/web/context/AuthContext.js b/web/context/AuthContext.js new file mode 100644 index 00000000..e861f624 --- /dev/null +++ b/web/context/AuthContext.js @@ -0,0 +1,123 @@ +"use client"; + +import { createContext, useContext, useState, useEffect, useCallback } from "react"; + +const AuthContext = createContext(null); + +const TOKEN_KEY = "auth_token"; + +async function fetchCurrentUser(token) { + const res = await fetch("/api/v1/auth/me", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return null; + return res.json(); +} + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [loading, setLoading] = useState(true); + + const refreshUser = useCallback(async (providedToken) => { + const activeToken = providedToken ?? token; + if (!activeToken) { + setUser(null); + return null; + } + + const userInfo = await fetchCurrentUser(activeToken); + if (!userInfo) { + localStorage.removeItem(TOKEN_KEY); + setToken(null); + setUser(null); + return null; + } + + if (!token) { + setToken(activeToken); + } + setUser(userInfo); + return userInfo; + }, [token]); + + useEffect(() => { + const stored = localStorage.getItem(TOKEN_KEY); + if (!stored) { + setLoading(false); + + return; + } + refreshUser(stored) + .catch(() => { + localStorage.removeItem(TOKEN_KEY); + setToken(null); + setUser(null); + }) + .finally(() => setLoading(false)); + }, [refreshUser]); + + const login = useCallback(async (username, password) => { + const res = await fetch("/api/v1/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.message || "Login failed"); + } + + const jwt = data.token; + localStorage.setItem(TOKEN_KEY, jwt); + setToken(jwt); + + const userInfo = await refreshUser(jwt); + + return userInfo; + }, [refreshUser]); + + const register = useCallback(async ({ username, password, email, fullName, phone }) => { + const res = await fetch("/api/v1/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password, email, fullName, phone }), + }); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.message || "Registration failed"); + } + + const jwt = data.token; + + localStorage.setItem(TOKEN_KEY, jwt); + setToken(jwt); + + const userInfo = await refreshUser(jwt); + + return userInfo; + }, [refreshUser]); + + const logout = useCallback(() => { + localStorage.removeItem(TOKEN_KEY); + setToken(null); + setUser(null);}, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + + return ctx; +} diff --git a/web/lib/fetchAllPages.js b/web/lib/fetchAllPages.js new file mode 100644 index 00000000..624d172c --- /dev/null +++ b/web/lib/fetchAllPages.js @@ -0,0 +1,19 @@ +export async function fetchAllPages(urlBuilder) { + const items = []; + let page = 0; + let totalPages = 1; + + while (page < totalPages) { + const res = await fetch(urlBuilder(page)); + if (!res.ok) { + throw new Error(`HTTP ${res.status} – ${res.statusText}`); + } + + const data = await res.json(); + items.push(...(data.content ?? [])); + totalPages = Math.max(data.totalPages ?? 1, 1); + page += 1; + } + + return items; +} diff --git a/web/next.config.mjs b/web/next.config.mjs index 690d2d0d..b725bf69 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -1,7 +1,14 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - /* config options here */ reactCompiler: true, + async rewrites() { + return [ + { + source: "/api/:path*", + destination: "http://localhost:8080/api/:path*", + }, + ]; + }, }; export default nextConfig; diff --git a/web/package-lock.json b/web/package-lock.json index 2ccc06fb..d85b7abd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,7 @@ "name": "threaded-pets", "version": "0.1.0", "dependencies": { - "next": "16.1.6", + "next": "^16.2.2", "react": "19.2.3", "react-dom": "19.2.3" }, @@ -1032,9 +1032,9 @@ } }, "node_modules/@next/env": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz", + "integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1048,9 +1048,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", - "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz", + "integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==", "cpu": [ "arm64" ], @@ -1064,9 +1064,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz", + "integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==", "cpu": [ "x64" ], @@ -1080,9 +1080,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz", + "integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==", "cpu": [ "arm64" ], @@ -1096,9 +1096,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz", + "integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==", "cpu": [ "arm64" ], @@ -1112,9 +1112,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz", + "integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==", "cpu": [ "x64" ], @@ -1128,9 +1128,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz", + "integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==", "cpu": [ "x64" ], @@ -1144,9 +1144,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz", + "integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==", "cpu": [ "arm64" ], @@ -1160,9 +1160,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz", + "integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==", "cpu": [ "x64" ], @@ -1741,9 +1741,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2422,9 +2422,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -3568,9 +3568,9 @@ } }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -4954,14 +4954,14 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", - "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz", + "integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==", "license": "MIT", "dependencies": { - "@next/env": "16.1.6", + "@next/env": "16.2.2", "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -4973,15 +4973,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.6", - "@next/swc-darwin-x64": "16.1.6", - "@next/swc-linux-arm64-gnu": "16.1.6", - "@next/swc-linux-arm64-musl": "16.1.6", - "@next/swc-linux-x64-gnu": "16.1.6", - "@next/swc-linux-x64-musl": "16.1.6", - "@next/swc-win32-arm64-msvc": "16.1.6", - "@next/swc-win32-x64-msvc": "16.1.6", - "sharp": "^0.34.4" + "@next/swc-darwin-arm64": "16.2.2", + "@next/swc-darwin-x64": "16.2.2", + "@next/swc-linux-arm64-gnu": "16.2.2", + "@next/swc-linux-arm64-musl": "16.2.2", + "@next/swc-linux-x64-gnu": "16.2.2", + "@next/swc-linux-x64-musl": "16.2.2", + "@next/swc-win32-arm64-msvc": "16.2.2", + "@next/swc-win32-x64-msvc": "16.2.2", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -5298,9 +5298,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -6099,9 +6099,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { diff --git a/web/package.json b/web/package.json index 20ad5734..5cb43e9f 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,7 @@ "lint": "eslint" }, "dependencies": { - "next": "16.1.6", + "next": "^16.2.2", "react": "19.2.3", "react-dom": "19.2.3" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 00000000..dea2c8f7 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,4004 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + next: + specifier: 16.1.6 + version: 16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: + specifier: 19.2.3 + version: 19.2.3 + react-dom: + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@tailwindcss/postcss': + specifier: ^4 + version: 4.2.2 + babel-plugin-react-compiler: + specifier: 1.0.0 + version: 1.0.0 + eslint: + specifier: ^9 + version: 9.39.4(jiti@2.6.1) + eslint-config-next: + specifier: 16.1.6 + version: 16.1.6(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + tailwindcss: + specifier: ^4 + version: 4.2.2 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.9.1': + resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@next/env@16.1.6': + resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} + + '@next/eslint-plugin-next@16.1.6': + resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==} + + '@next/swc-darwin-arm64@16.1.6': + resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.1.6': + resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.1.6': + resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.1.6': + resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.1.6': + resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.1.6': + resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.1.6': + resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.1.6': + resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.2.2': + resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@typescript-eslint/eslint-plugin@8.57.2': + resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.57.2': + resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.57.2': + resolution: {integrity: sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.57.2': + resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.57.2': + resolution: {integrity: sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.57.2': + resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.57.2': + resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.57.2': + resolution: {integrity: sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.57.2': + resolution: {integrity: sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.57.2': + resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.11.1: + resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.12: + resolution: {integrity: sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001782: + resolution: {integrity: sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.328: + resolution: {integrity: sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.1: + resolution: {integrity: sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@16.1.6: + resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==} + peerDependencies: + eslint: '>=9.0.0' + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next@16.1.6: + resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.57.2: + resolution: {integrity: sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emnapi/core@1.9.1': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + dependencies: + eslint: 9.39.4(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@next/env@16.1.6': {} + + '@next/eslint-plugin-next@16.1.6': + dependencies: + fast-glob: 3.3.1 + + '@next/swc-darwin-arm64@16.1.6': + optional: true + + '@next/swc-darwin-x64@16.1.6': + optional: true + + '@next/swc-linux-arm64-gnu@16.1.6': + optional: true + + '@next/swc-linux-arm64-musl@16.1.6': + optional: true + + '@next/swc-linux-x64-gnu@16.1.6': + optional: true + + '@next/swc-linux-x64-musl@16.1.6': + optional: true + + '@next/swc-win32-arm64-msvc@16.1.6': + optional: true + + '@next/swc-win32-x64-msvc@16.1.6': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@rtsao/scc@1.1.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/postcss@4.2.2': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + postcss: 8.5.8 + tailwindcss: 4.2.2 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.2 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.2 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.57.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.57.2': + dependencies: + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 + + '@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.57.2': {} + + '@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.57.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.57.2': + dependencies: + '@typescript-eslint/types': 8.57.2 + eslint-visitor-keys: 5.0.1 + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.11.1: {} + + axobject-query@4.1.0: {} + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.0 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.12: {} + + brace-expansion@1.1.13: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.12 + caniuse-lite: 1.0.30001782 + electron-to-chromium: 1.5.328 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001782: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + client-only@0.0.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + detect-libc@2.1.2: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.328: {} + + emoji-regex@9.2.2: {} + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + es-abstract@1.24.1: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + safe-array-concat: 1.1.3 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-next@16.1.6(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 16.1.6 + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) + globals: 16.4.0 + typescript-eslint: 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + get-tsconfig: 4.13.7 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.1 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.4(jiti@2.6.1) + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + eslint: 9.39.4(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.1 + eslint: 9.39.4(jiti@2.6.1) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.4.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.7.4 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.13 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@next/env': 16.1.6 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.12 + caniuse-lite: 1.0.30001782 + postcss: 8.4.31 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.3) + optionalDependencies: + '@next/swc-darwin-arm64': 16.1.6 + '@next/swc-darwin-x64': 16.1.6 + '@next/swc-linux-arm64-gnu': 16.1.6 + '@next/swc-linux-arm64-musl': 16.1.6 + '@next/swc-linux-x64-gnu': 16.1.6 + '@next/swc-linux-x64-musl': 16.1.6 + '@next/swc-win32-arm64-msvc': 16.1.6 + '@next/swc-win32-x64-msvc': 16.1.6 + babel-plugin-react-compiler: 1.0.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-releases@2.0.36: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + possible-typed-array-names@1.1.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react@19.2.3: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + stable-hash@0.0.5: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-bom@3.0.0: {} + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3): + dependencies: + client-only: 0.0.1 + react: 19.2.3 + optionalDependencies: + '@babel/core': 7.29.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} diff --git a/web/public/images/home/navimages/about.jpg b/web/public/images/home/navimages/about.jpg new file mode 100644 index 00000000..64537455 Binary files /dev/null and b/web/public/images/home/navimages/about.jpg differ diff --git a/web/public/images/pet-placeholder.png b/web/public/images/pet-placeholder.png new file mode 100644 index 00000000..207e9d29 Binary files /dev/null and b/web/public/images/pet-placeholder.png differ