diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a6d27404..caaf8e17 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -2,6 +2,8 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) + alias(libs.plugins.hilt) + alias(libs.plugins.navigation.safeargs) } val localProperties = Properties().apply { @@ -37,6 +39,7 @@ android { buildFeatures { buildConfig = true + viewBinding = true } buildTypes { @@ -55,35 +58,45 @@ 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.bumptech.glide:glide:4.16.0") - annotationProcessor("com.github.bumptech.glide:compiler:4.16.0") - implementation("com.github.prolificinteractive:material-calendarview:2.0.1") + // Testing testImplementation(libs.junit) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) 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 aea1f427..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,80 +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(findViewById(R.id.main), (v, insets) -> { + 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 the bottom navbar from the layout - bottomNav = findViewById(R.id.bottom_navigation); - + // Initialize Navigation Component + NavHostFragment navHostFragment = (NavHostFragment) getSupportFragmentManager() + .findFragmentById(R.id.nav_host_fragment); + if (navHostFragment != null) { + navController = navHostFragment.getNavController(); + NavigationUI.setupWithNavController(binding.bottomNavigation, navController); + } + //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 { - loadFragment(new ListFragment()); - bottomNav.setSelectedItemId(R.id.nav_list); } } - // 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() { 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) { @@ -117,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 94a51ac3..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,164 +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(findViewById(R.id.main), (v, insets) -> { + 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/AppointmentAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/AppointmentAdapter.java index 9960b5b6..fb260541 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 @@ -59,14 +59,14 @@ public class AppointmentAdapter extends RecyclerView.Adapter messages; private Long currentUserId; + private String token; public MessageAdapter(List messages, Long currentUserId) { this.messages = messages; @@ -28,6 +34,10 @@ public class MessageAdapter extends RecyclerView.Adapter { private List petList; private OnPetClickListener petClickListener; + private String baseUrl; + private String token; // Interface for pet click on recycler view public interface OnPetClickListener { @@ -33,6 +33,14 @@ public class PetAdapter extends RecyclerView.Adapter { this.petClickListener = petClickListener; } + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public void setToken(String token) { + this.token = token; + } + // Get the controls of each row in recycler view public static class PetViewHolder extends RecyclerView.ViewHolder { TextView tvPetName, tvPetSpeciesBreed, tvPetAge, tvPetPrice, tvPetStatus; @@ -66,11 +74,11 @@ public class PetAdapter extends RecyclerView.Adapter { holder.tvPetSpeciesBreed.setText(pet.getPetSpecies() + " - " + pet.getPetBreed()); holder.tvPetAge.setText("Age: " + pet.getPetAge() + " yr(s)"); - try { - double price = Double.parseDouble(pet.getPetPrice()); + Double price = pet.getPetPrice(); + if (price != null) { holder.tvPetPrice.setText("$" + String.format("%.2f", price)); - } catch (Exception e) { - holder.tvPetPrice.setText("$" + pet.getPetPrice()); + } else { + holder.tvPetPrice.setText("$0.00"); } holder.tvPetStatus.setText(pet.getPetStatus()); @@ -82,16 +90,13 @@ public class PetAdapter extends RecyclerView.Adapter { holder.tvPetStatus.setBackgroundColor(Color.parseColor("#F44336")); } - // Load pet image using Glide with circle crop - String imageUrl = RetrofitClient.BASE_URL + String.format(PetApi.PET_IMAGE_PATH, pet.getPetId()); - Glide.with(holder.itemView.getContext()) - .load(imageUrl) - .circleCrop() - .diskCacheStrategy(DiskCacheStrategy.NONE) - .skipMemoryCache(true) - .placeholder(R.drawable.placeholder) - .error(R.drawable.placeholder) - .into(holder.ivPetProfile); + // Load pet image using Glide + if (baseUrl != null) { + String imageUrl = baseUrl + String.format(PetApi.PET_IMAGE_PATH, pet.getPetId()); + GlideUtils.loadImageWithTokenCircle(holder.itemView.getContext(), holder.ivPetProfile, imageUrl, token, R.drawable.placeholder); + } else { + holder.ivPetProfile.setImageResource(R.drawable.placeholder); + } //when a row is clicked, open the detail view holder.itemView.setOnClickListener(v -> petClickListener.onPetClick(position)); 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 a44ec993..ad1cf678 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 @@ -6,18 +6,18 @@ 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.example.petstoremobile.R; import com.example.petstoremobile.api.ProductApi; -import com.example.petstoremobile.api.RetrofitClient; 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); @@ -28,6 +28,14 @@ public class ProductAdapter extends RecyclerView.Adapter listener.onProductClick(position)); } 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/AppointmentApi.java b/android/app/src/main/java/com/example/petstoremobile/api/AppointmentApi.java index 5d7044cf..d811b2e0 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/AppointmentApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/AppointmentApi.java @@ -17,7 +17,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); 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..6c747e6e 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 @@ -16,12 +16,14 @@ import retrofit2.http.Query; public interface InventoryApi { - // GET /api/v1/inventory?q=...&page=...&size=... + // GET /api/v1/inventory?q=...&page=...&size=...&category=...&storeId=...&sort=... @GET("api/v1/inventory") Call> getAllInventory( - @Query("q") String query, @Query("page") int page, @Query("size") int size, + @Query("q") String query, + @Query("category") String category, + @Query("storeId") Long storeId, @Query("sort") String sort); // GET /api/v1/inventory/{id} diff --git a/android/app/src/main/java/com/example/petstoremobile/api/MessageApi.java b/android/app/src/main/java/com/example/petstoremobile/api/MessageApi.java index 13df781f..29ff4ae0 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/MessageApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/MessageApi.java @@ -3,10 +3,14 @@ package com.example.petstoremobile.api; import com.example.petstoremobile.dtos.MessageDTO; import com.example.petstoremobile.dtos.SendMessageRequest; import java.util.List; +import okhttp3.MultipartBody; +import okhttp3.RequestBody; import retrofit2.Call; import retrofit2.http.Body; import retrofit2.http.GET; +import retrofit2.http.Multipart; import retrofit2.http.POST; +import retrofit2.http.Part; import retrofit2.http.Path; //api calls to get and send messages @@ -17,4 +21,12 @@ public interface MessageApi { @POST("api/v1/chat/conversations/{id}/messages") Call sendMessage(@Path("id") Long conversationId, @Body SendMessageRequest request); + + @Multipart + @POST("api/v1/chat/conversations/{id}/messages/attachment") + Call sendMessageWithAttachment( + @Path("id") Long conversationId, + @Part("content") RequestBody content, + @Part MultipartBody.Part file + ); } \ No newline at end of file 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 ff7b79a7..7db2c1e3 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 @@ -20,11 +20,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 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 dc02fd6c..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 @@ -12,8 +12,10 @@ public interface ProductApi { @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); 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..67a0e7f2 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 @@ -10,7 +10,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); 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..e5a5a06d 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("query") 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 b08af532..971e7306 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 @@ -7,6 +7,7 @@ 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; @@ -15,7 +16,7 @@ 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(); @@ -50,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) 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..b659a95f 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 @@ -18,7 +18,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 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..9f870a51 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 @@ -18,7 +18,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 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..0311840d --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/di/NetworkModule.java @@ -0,0 +1,176 @@ +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); + } +} \ No newline at end of file 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 01f8ef5d..8505753e 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,8 +1,5 @@ package com.example.petstoremobile.dtos; -import java.math.BigDecimal; -import java.util.List; - public class AppointmentDTO { private Long appointmentId; @@ -17,20 +14,20 @@ public class AppointmentDTO { 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; public AppointmentDTO(Long customerId, Long storeId, Long serviceId, String appointmentDate, String appointmentTime, - String appointmentStatus, List petIds) { - this(customerId, storeId, serviceId, null, appointmentDate, appointmentTime, appointmentStatus, 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, List petIds) { + String appointmentStatus, Long petId) { this.customerId = customerId; this.storeId = storeId; this.serviceId = serviceId; @@ -38,7 +35,7 @@ public class AppointmentDTO { this.appointmentDate = appointmentDate; this.appointmentTime = appointmentTime; this.appointmentStatus = appointmentStatus; - this.petIds = petIds; + this.petId = petId; } public Long getAppointmentId() { @@ -89,12 +86,12 @@ public class AppointmentDTO { return appointmentStatus; } - public List getPetNames() { - return petNames; + public String getPetName() { + return petName; } - public List getPetIds() { - return petIds; + public Long getPetId() { + return petId; } public String getCreatedAt() { @@ -105,16 +102,8 @@ public class AppointmentDTO { return updatedAt; } - 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(); + return petId; } public String getServiceType() { 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/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/fragments/ChatFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/ChatFragment.java index f52d5ea6..5ed6ac6e 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 @@ -8,55 +8,50 @@ import android.os.Bundle; import android.provider.OpenableColumns; import android.util.Log; import android.view.*; -import android.widget.*; +import android.view.inputmethod.EditorInfo; 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.FileUtils; +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.io.File; +import java.util.*; + +import javax.inject.Inject; +import javax.inject.Named; + +import dagger.hilt.android.AndroidEntryPoint; +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.RequestBody; + +@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 ImageButton btnAttach; - private TextView tvChatTitle; - - // Preview views - private View layoutAttachmentPreview; - private ImageView ivPreview; - private TextView tvPreviewName; - private ImageButton btnRemoveAttachment; + private FragmentChatBinding binding; + private ChatViewModel viewModel; // Adapters private ChatAdapter chatAdapter; @@ -68,10 +63,8 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis 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; @@ -79,10 +72,13 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis 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 -> { @@ -96,33 +92,28 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis ); } + /** + * 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); - btnAttach = view.findViewById(R.id.btnAttach); - 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; + }); - layoutAttachmentPreview = view.findViewById(R.id.layoutAttachmentPreview); - ivPreview = view.findViewById(R.id.ivPreview); - tvPreviewName = view.findViewById(R.id.tvPreviewName); - btnRemoveAttachment = view.findViewById(R.id.btnRemoveAttachment); - - ImageButton hamburger = view.findViewById(R.id.btnHamburger); - hamburger.setOnClickListener(v -> drawerLayout.openDrawer(GravityCompat.START)); //When the send button is clicked check if there is an attachment and send using the correct helper function - btnSend.setOnClickListener(v -> { + binding.btnSend.setOnClickListener(v -> { if (pendingAttachmentUri != null) { sendWithAttachment(pendingAttachmentUri); } else { @@ -131,43 +122,47 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis }); //When the attachment button is clicked open the file picker - btnAttach.setOnClickListener(v -> selectAttachment()); - btnRemoveAttachment.setOnClickListener(v -> removeAttachment()); + binding.btnAttach.setOnClickListener(v -> selectAttachment()); + binding.btnRemoveAttachment.setOnClickListener(v -> removeAttachment()); setupRecyclerViews(); loadInitialData(); - return view; + return binding.getRoot(); } - // Helper function to setup recycler views for chat and messages + /** + * 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); @@ -178,89 +173,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); @@ -269,94 +249,87 @@ 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(); + } + }); } - //Helper function to open file picker when the attachment button is clicked + /** + * 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); } - //Helper function to show the attachment preview + /** + * Displays a preview of the selected attachment in the UI. + */ private void showAttachmentPreview(Uri uri) { pendingAttachmentUri = uri; - layoutAttachmentPreview.setVisibility(View.VISIBLE); + binding.layoutAttachmentPreview.setVisibility(View.VISIBLE); String mimeType = requireContext().getContentResolver().getType(uri); String fileName = getFileName(uri); - tvPreviewName.setText(fileName); + binding.tvPreviewName.setText(fileName); // If the file is an image, display a thumbnail of the image as well if (mimeType != null && mimeType.startsWith("image/")) { - ivPreview.setVisibility(View.VISIBLE); - Glide.with(this).load(uri).into(ivPreview); + binding.ivPreview.setVisibility(View.VISIBLE); + Glide.with(this).load(uri).into(binding.ivPreview); } else { - ivPreview.setVisibility(View.GONE); + binding.ivPreview.setVisibility(View.GONE); } } - //Helper function to remove the attachment + /** + * Clears the current attachment selection and hides the preview UI. + */ private void removeAttachment() { pendingAttachmentUri = null; - layoutAttachmentPreview.setVisibility(View.GONE); + binding.layoutAttachmentPreview.setVisibility(View.GONE); } - //Helper function to get the file name from the uri to display in attachment preview + /** + * Show the display name of the file from its Uri. + */ private String getFileName(Uri uri) { String result = null; if (uri.getScheme().equals("content")) { @@ -379,15 +352,40 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis return result; } - //Helper function to send the message with attachment + /** + * 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(); - //TODO: send the message with attachment when backend is done - Log.d(TAG, "Send with attachment happening"); + try { + File file = FileUtils.getFileFromUri(requireContext(), uri); + if (file == null) return; + + String mimeType = requireContext().getContentResolver().getType(uri); + RequestBody requestFile = RequestBody.create(file, MediaType.parse(mimeType != null ? mimeType : "application/octet-stream")); + MultipartBody.Part filePart = MultipartBody.Part.createFormData("file", file.getName(), requestFile); + RequestBody contentPart = RequestBody.create(text, MediaType.parse("text/plain")); + + viewModel.sendMessageWithAttachment(activeConversationId, contentPart, filePart).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(); + } + }); + } catch (Exception e) { + Log.e(TAG, "Error sending message with attachment", e); + } } - // When a message is received updates the chat preview + /** + * 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 @@ -401,81 +399,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()); @@ -490,63 +509,74 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis 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); - btnAttach.setEnabled(active); + binding.btnSend.setEnabled(active); + binding.etMessage.setEnabled(active); + binding.btnAttach.setEnabled(active); if (!active) { activeConversationId = null; ChatNotificationService.activeConversationIdInUi = null; removeAttachment(); - if (tvChatTitle != null) tvChatTitle.setText("Customer Chat"); + 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 b63b42b1..f2621091 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,92 +2,67 @@ 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.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.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 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); - drawerPurchaseOrderView=view.findViewById(R.id.drawerPurchaseOrderView); - + 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); } //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 @@ -98,84 +73,53 @@ 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)); - //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(); - }); - - 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 b29c038d..c80a82ae 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,199 +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.ArrayList; import java.util.HashMap; -import java.util.List; 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 -> { - List options = new ArrayList<>(); - options.add("Take Photo"); - options.add("Choose from Gallery"); - if (hasImage) { - options.add("Remove Photo"); - } - - //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(options.toArray(new String[0]), (dialog, which) -> { - String selected = options.get(which); - if (selected.equals("Take Photo")) { - // 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 if (selected.equals("Choose from Gallery")) { - // Choose Gallery - Intent intent = new Intent(Intent.ACTION_PICK, - MediaStore.Images.Media.EXTERNAL_CONTENT_URI); - galleryLauncher.launch(intent); - } else if (selected.equals("Remove Photo")) { - deleteAvatar(); - } - }) - .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); @@ -216,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 @@ -246,107 +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) - .listener(new com.bumptech.glide.request.RequestListener() { - @Override - public boolean onLoadFailed(@androidx.annotation.Nullable com.bumptech.glide.load.engine.GlideException e, Object model, com.bumptech.glide.request.target.Target target, boolean isFirstResource) { - hasImage = false; - return false; - } - - @Override - public boolean onResourceReady(android.graphics.drawable.Drawable resource, Object model, com.bumptech.glide.request.target.Target target, com.bumptech.glide.load.DataSource dataSource, boolean isFirstResource) { - hasImage = true; - return false; - } - }) - .into(imgProfile); - } else { - // load placeholder image if token is null - hasImage = false; - 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 @@ -354,24 +227,15 @@ 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) { + currentUser = resource.data; + Toast.makeText(getContext(), "Avatar updated successfully", Toast.LENGTH_SHORT).show(); + // Reload image after successful upload + loadProfileData(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Upload failed: " + resource.message, Toast.LENGTH_SHORT).show(); } }); } catch (Exception e) { @@ -379,80 +243,39 @@ public class ProfileFragment extends Fragment { } } + /** + * Sends a request to the API to delete the current user's avatar image. + */ private void deleteAvatar() { - AuthApi authApi = RetrofitClient.getAuthApi(requireContext()); - authApi.deleteAvatar().enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - Toast.makeText(requireContext(), "Avatar removed successfully", Toast.LENGTH_SHORT).show(); - hasImage = false; - imgProfile.setImageResource(R.drawable.placeholder); - } else { - Toast.makeText(requireContext(), "Failed to remove avatar", Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - Log.e("DELETE_AVATAR", "Failure: " + t.getMessage()); - Toast.makeText(requireContext(), "Network error", Toast.LENGTH_SHORT).show(); + 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(); } }); } - // 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); - } - 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/AdoptionFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AdoptionFragment.java index 34e7d602..fafd0d93 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AdoptionFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AdoptionFragment.java @@ -1,141 +1,252 @@ package com.example.petstoremobile.fragments.listfragments; +import android.graphics.Color; 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 com.example.petstoremobile.R; import com.example.petstoremobile.adapters.AdoptionAdapter; -import com.example.petstoremobile.api.AdoptionApi; -import com.example.petstoremobile.api.RetrofitClient; +import com.example.petstoremobile.databinding.FragmentAdoptionBinding; import com.example.petstoremobile.dtos.AdoptionDTO; -import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.fragments.ListFragment; -import com.example.petstoremobile.fragments.listfragments.detailfragments.AdoptionDetailFragment; -import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.example.petstoremobile.viewmodels.AdoptionViewModel; +import com.example.petstoremobile.utils.EventDecorator; +import com.prolificinteractive.materialcalendarview.CalendarDay; +import com.prolificinteractive.materialcalendarview.CalendarMode; +import com.prolificinteractive.materialcalendarview.MaterialCalendarView; +import java.text.ParseException; +import java.text.SimpleDateFormat; import java.util.*; -import retrofit2.*; +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint public class AdoptionFragment extends Fragment implements AdoptionAdapter.OnAdoptionClickListener { + private FragmentAdoptionBinding binding; private List adoptionList = new ArrayList<>(); private List filteredList = new ArrayList<>(); private AdoptionAdapter adapter; - private AdoptionApi api; - private SwipeRefreshLayout swipeRefresh; - private EditText etSearch; - private ImageButton hamburger; + private AdoptionViewModel viewModel; + private CalendarDay selectedCalendarDay; + private boolean isMonthMode = false; + private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()); + /** + * Initializes the fragment and its ViewModel. + */ @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(AdoptionViewModel.class); + } + + /** + * Sets up the fragment's UI components, including RecyclerView, Search, SwipeRefresh, and Calendar. + */ + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_adoption, container, false); + binding = FragmentAdoptionBinding.inflate(inflater, container, false); - api = RetrofitClient.getAdoptionApi(requireContext()); - hamburger = view.findViewById(R.id.btnHamburgerAdoption); - - setupRecyclerView(view); - setupSearch(view); - setupSwipeRefresh(view); + setupRecyclerView(); + setupSearch(); + setupSwipeRefresh(); + setupCalendar(); loadAdoptions(); - FloatingActionButton fab = view.findViewById(R.id.fabAddAdoption); - fab.setOnClickListener(v -> openDetail(-1)); + binding.fabAddAdoption.setOnClickListener(v -> openDetail(-1)); - hamburger.setOnClickListener(v -> { - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.openDrawer(); + binding.btnHamburgerAdoption.setOnClickListener(v -> { + Fragment parent = getParentFragment(); + if (parent != null) { + Fragment grandParent = parent.getParentFragment(); + if (grandParent instanceof ListFragment) { + ((ListFragment) grandParent).openDrawer(); + } + } }); - return view; + binding.btnToggleCalendarModeAdoption.setOnClickListener(v -> toggleCalendarMode()); + + return binding.getRoot(); } - private void setupRecyclerView(View view) { - RecyclerView rv = view.findViewById(R.id.recyclerViewAdoptions); + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + /** + * Toggles the calendar display between week and month modes. + */ + private void toggleCalendarMode() { + isMonthMode = !isMonthMode; + binding.calendarViewAdoption.state().edit() + .setCalendarDisplayMode(isMonthMode ? CalendarMode.MONTHS : CalendarMode.WEEKS) + .commit(); + } + + /** + * Sets up the date selection listener for the calendar. + */ + private void setupCalendar() { + binding.calendarViewAdoption.setOnDateChangedListener((widget, date, selected) -> { + if (selected) { + if (date.equals(selectedCalendarDay)) { + selectedCalendarDay = null; + binding.calendarViewAdoption.clearSelection(); + } else { + selectedCalendarDay = date; + } + } else { + selectedCalendarDay = null; + } + filter(binding.etSearchAdoption.getText().toString()); + }); + } + + /** + * Updates the calendar decorators to highlight days with adoptions. + */ + private void updateCalendarDecorators() { + HashSet datesWithAdoptions = new HashSet<>(); + for (AdoptionDTO adoption : adoptionList) { + try { + if (adoption.getAdoptionDate() != null) { + Date date = dateFormat.parse(adoption.getAdoptionDate()); + if (date != null) { + Calendar cal = Calendar.getInstance(); + cal.setTime(date); + datesWithAdoptions.add(CalendarDay.from(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH))); + } + } + } catch (ParseException e) { + Log.e("AdoptionFragment", "Error parsing date: " + adoption.getAdoptionDate()); + } + } + binding.calendarViewAdoption.removeDecorators(); + binding.calendarViewAdoption.addDecorator(new EventDecorator(Color.RED, datesWithAdoptions)); + } + + /** + * Initializes the RecyclerView for displaying adoptions. + */ + private void setupRecyclerView() { adapter = new AdoptionAdapter(filteredList, this); - rv.setLayoutManager(new LinearLayoutManager(getContext())); - rv.setAdapter(adapter); + binding.recyclerViewAdoptions.setLayoutManager(new LinearLayoutManager(getContext())); + binding.recyclerViewAdoptions.setAdapter(adapter); } - private void setupSearch(View view) { - etSearch = view.findViewById(R.id.etSearchAdoption); - etSearch.addTextChangedListener(new TextWatcher() { + /** + * Sets up the search bar for filtering + */ + private void setupSearch() { + binding.etSearchAdoption.addTextChangedListener(new android.text.TextWatcher() { public void beforeTextChanged(CharSequence s, int a, int b, int c) {} - public void afterTextChanged(Editable s) {} + public void afterTextChanged(android.text.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.swipeRefreshAdoption); - swipeRefresh.setOnRefreshListener(this::loadAdoptions); + /** + * Sets up the SwipeRefreshLayout to reload adoption data. + */ + private void setupSwipeRefresh() { + binding.swipeRefreshAdoption.setOnRefreshListener(this::loadAdoptions); } + /** + * Filters the adoption list based on search query and selected calendar date. + */ private void filter(String query) { filteredList.clear(); - if (query.isEmpty()) { - filteredList.addAll(adoptionList); - } else { - String lower = query.toLowerCase(); - for (AdoptionDTO a : adoptionList) { - if ((a.getCustomerName() != null && a.getCustomerName().toLowerCase().contains(lower)) - || (a.getPetName() != null && a.getPetName().toLowerCase().contains(lower)) - || (a.getAdoptionStatus() != null && a.getAdoptionStatus().toLowerCase().contains(lower))) { - filteredList.add(a); - } + String lowerQuery = query.toLowerCase(); + + String selectedDateString = null; + if (selectedCalendarDay != null) { + selectedDateString = String.format(Locale.getDefault(), "%04d-%02d-%02d", + selectedCalendarDay.getYear(), selectedCalendarDay.getMonth(), selectedCalendarDay.getDay()); + } + + for (AdoptionDTO a : adoptionList) { + boolean matchesSearch = query.isEmpty() || + (a.getCustomerName() != null && a.getCustomerName().toLowerCase().contains(lowerQuery)) || + (a.getPetName() != null && a.getPetName().toLowerCase().contains(lowerQuery)) || + (a.getAdoptionStatus() != null && a.getAdoptionStatus().toLowerCase().contains(lowerQuery)); + + boolean matchesDate = (selectedDateString == null) || + (a.getAdoptionDate() != null && a.getAdoptionDate().startsWith(selectedDateString)); + + if (matchesSearch && matchesDate) { + filteredList.add(a); } } adapter.notifyDataSetChanged(); } + /** + * Fetches the adoption list from the server through the ViewModel. + */ private void loadAdoptions() { - if (swipeRefresh != null) swipeRefresh.setRefreshing(true); - api.getAllAdoptions(0, 100).enqueue(new Callback>() { - public void onResponse(Call> c, - Response> r) { - if (swipeRefresh != null) swipeRefresh.setRefreshing(false); - if (r.isSuccessful() && r.body() != null) { - adoptionList.clear(); - adoptionList.addAll(r.body().getContent()); - filter(etSearch != null ? etSearch.getText().toString() : ""); - } else { - Toast.makeText(getContext(), "Failed to load adoptions", Toast.LENGTH_SHORT).show(); - Log.e("AdoptionFragment", "Error: " + r.message()); - } - } - public void onFailure(Call> c, Throwable t) { - if (swipeRefresh != null) swipeRefresh.setRefreshing(false); - Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); - Log.e("AdoptionFragment", t.getMessage()); + //Load all adoptions from the backend using viewModel + viewModel.getAllAdoptions(0, 500).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.swipeRefreshAdoption.setRefreshing(true); + break; + case SUCCESS: + // Hide loading indicator and display data + binding.swipeRefreshAdoption.setRefreshing(false); + if (resource.data != null) { + adoptionList.clear(); + adoptionList.addAll(resource.data.getContent()); + updateCalendarDecorators(); + filter(binding.etSearchAdoption != null ? binding.etSearchAdoption.getText().toString() : ""); + } + break; + case ERROR: + // Hide loading indicator and toast error message + binding.swipeRefreshAdoption.setRefreshing(false); + Toast.makeText(getContext(), "Failed to load adoptions: " + resource.message, Toast.LENGTH_SHORT).show(); + Log.e("AdoptionFragment", "Error loading adoptions: " + resource.message); + break; } }); } + /** + * Navigates to the adoption detail screen for a specific adoption or to create a new one. + */ private void openDetail(int position) { - AdoptionDetailFragment detail = new AdoptionDetailFragment(); Bundle args = new Bundle(); if (position != -1) { AdoptionDTO a = filteredList.get(position); args.putLong("adoptionId", a.getAdoptionId()); - args.putLong("petId", a.getPetId() != null ? a.getPetId() : -1); - args.putLong("customerId", a.getCustomerId() != null ? a.getCustomerId() : -1); - args.putString("adoptionDate", a.getAdoptionDate()); - args.putString("adoptionStatus", a.getAdoptionStatus()); } - detail.setArguments(args); - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.loadFragment(detail); + NavHostFragment.findNavController(this).navigate(R.id.nav_adoption_detail, args); } + /** + * Handles item click in the adoption list. + */ @Override public void onAdoptionClick(int position) { openDetail(position); } -} \ No newline at end of file +} 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 49da714c..3724850b 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 @@ -4,38 +4,36 @@ 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.AdapterView; 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.adapters.WhiteTextArrayAdapter; +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.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.utils.SpinnerUtils; +import com.example.petstoremobile.viewmodels.AppointmentViewModel; import com.example.petstoremobile.utils.EventDecorator; -import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.example.petstoremobile.viewmodels.AuthViewModel; +import com.example.petstoremobile.viewmodels.StoreViewModel; import com.prolificinteractive.materialcalendarview.CalendarDay; import com.prolificinteractive.materialcalendarview.CalendarMode; -import com.prolificinteractive.materialcalendarview.MaterialCalendarView; -import com.prolificinteractive.materialcalendarview.OnDateSelectedListener; import java.text.ParseException; import java.text.SimpleDateFormat; @@ -46,89 +44,160 @@ 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 ImageButton btnToggleCalendarMode; - private MaterialCalendarView calendarView; + private AppointmentViewModel appointmentViewModel; + private StoreViewModel storeViewModel; + private AuthViewModel authViewModel; + 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, - Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_appointment, container, false); - - api = RetrofitClient.getAppointmentApi(requireContext()); - hamburger = view.findViewById(R.id.btnHamburger); - calendarView = view.findViewById(R.id.calendarView); - btnToggleCalendarMode = view.findViewById(R.id.btnToggleCalendarMode); - - setupRecyclerView(view); - setupSearch(view); - setupSwipeRefresh(view); - setupCalendar(); - loadAppointmentData(); - loadPets(); - loadServices(); - - - FloatingActionButton fabAdd = view.findViewById(R.id.fabAddAppointment); - fabAdd.setOnClickListener(v -> openAppointmentDetails(-1)); - - hamburger.setOnClickListener(v -> { - ListFragment listFragment = (ListFragment) getParentFragment(); - if (listFragment != null) - listFragment.openDrawer(); - }); - - btnToggleCalendarMode.setOnClickListener(v -> toggleCalendarMode()); - - return view; + 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); } - // Toggle Calendar Mode from week to month and other way around + /** + * 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) { + binding = FragmentAppointmentBinding.inflate(inflater, container, false); + + setupRecyclerView(); + setupSearch(); + setupStatusFilter(); + setupStoreFilter(); + setupSwipeRefresh(); + setupCalendar(); + setupFilterToggle(); + setupMyAppointmentFilter(); + + binding.fabAddAppointment.setOnClickListener(v -> openAppointmentDetails(-1)); + + 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(); + } + + @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; - calendarView.state().edit() + binding.calendarView.state().edit() .setCalendarDisplayMode(isMonthMode ? CalendarMode.MONTHS : CalendarMode.WEEKS) .commit(); } - private void setupCalendar() { - calendarView.setOnDateChangedListener(new OnDateSelectedListener() { - @Override - public void onDateSelected(@NonNull MaterialCalendarView widget, @NonNull CalendarDay date, boolean selected) { - if (selected) { - if (date.equals(selectedCalendarDay)) { - selectedCalendarDay = null; - calendarView.clearSelection(); - } else { - selectedCalendarDay = date; - } - } else { - selectedCalendarDay = null; - } - filterAppointments(etSearch.getText().toString()); + /** + * 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(); } }); } - //Set indicators for dates with appointments on the calendar + /** + * 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) { @@ -146,31 +215,105 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. } } //update the indicators to the calendar - calendarView.removeDecorators(); - calendarView.addDecorator(new EventDecorator(Color.RED, datesWithAppointments)); + binding.calendarView.removeDecorators(); + binding.calendarView.addDecorator(new EventDecorator(Color.RED, datesWithAppointments)); } - 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) { + /** + * 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"}; + WhiteTextArrayAdapter adapter = new WhiteTextArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_item, statuses); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + binding.spinnerStatus.setAdapter(adapter); + + binding.spinnerStatus.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - filterAppointments(s.toString()); + public void onItemSelected(AdapterView parent, View view, int position, long id) { + loadAppointmentData(); } + @Override public void onNothingSelected(AdapterView parent) {} + }); + } + /** + * Configures the store filter spinner. + */ + private void setupStoreFilter() { + binding.spinnerStore.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override - public void afterTextChanged(Editable s) { + public void onItemSelected(AdapterView parent, View view, int position, long id) { + loadAppointmentData(); + } + @Override public void onNothingSelected(AdapterView parent) {} + }); + } + + /** + * Fetches store data to populate the store filter. + */ + private void loadStoreData() { + storeViewModel.getAllStores(0, 100).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + storeList = resource.data.getContent(); + SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerStore, storeList, + StoreDTO::getStoreName, "All Stores", -1L, StoreDTO::getStoreId); } }); } - private void filterAppointments(String query) { - filteredList.clear(); - String lowerQuery = query.toLowerCase(); + /** + * 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) { + Bundle args = new Bundle(); + if (position != -1) { + AppointmentDTO a = appointmentList.get(position); + args.putLong("appointmentId", a.getAppointmentId()); + } + 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); + } + + /** + * Fetches appointment data from the server with all active filters. + */ + private void loadAppointmentData() { + String query = binding.etSearchAppointment.getText().toString().trim(); + String status = binding.spinnerStatus.getSelectedItem() != null ? binding.spinnerStatus.getSelectedItem().toString() : "All Statuses"; + + Long storeId = null; + if (binding.spinnerStore.getSelectedItemPosition() > 0 && !storeList.isEmpty()) { + storeId = storeList.get(binding.spinnerStore.getSelectedItemPosition() - 1).getStoreId(); + } String selectedDateString = null; if (selectedCalendarDay != null) { @@ -178,157 +321,49 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. selectedCalendarDay.getYear(), selectedCalendarDay.getMonth(), selectedCalendarDay.getDay()); } - for (AppointmentDTO a : appointmentList) { - boolean matchesSearch = query.isEmpty() || - (a.getCustomerName() != null && a.getCustomerName().toLowerCase().contains(lowerQuery)) || - (a.getServiceType() != null && a.getServiceType().toLowerCase().contains(lowerQuery)) || - (a.getPetName() != null && a.getPetName().toLowerCase().contains(lowerQuery)); - - boolean matchesDate = (selectedDateString == null) || - (a.getAppointmentDate() != null && a.getAppointmentDate().equals(selectedDateString)); - - if (matchesSearch && matchesDate) { - filteredList.add(a); - } - } - adapter.notifyDataSetChanged(); - } - - private void setupSwipeRefresh(View view) { - swipeRefreshLayout = view.findViewById(R.id.swipeRefreshAppointment); - swipeRefreshLayout.setOnRefreshListener(this::loadAppointmentData); - } - - private void openAppointmentDetails(int position) { - AppointmentDetailFragment detailFragment = new AppointmentDetailFragment(); - Bundle args = new Bundle(); - - if (position != -1) { - AppointmentDTO a = filteredList.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()); + Long employeeId = null; + if (binding.btnMyAppointments.isChecked()) { + employeeId = currentUserId; } - detailFragment.setArguments(args); - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.loadFragment(detailFragment); - } - public void onAppointmentSaved(int position, AppointmentDTO appointment) { - loadAppointmentData(); - } + if (status.equals("All Statuses")) status = null; + else status = status.toUpperCase(); - public void onAppointmentDeleted(int position) { - loadAppointmentData(); - } + appointmentViewModel.getAllAppointments(0, 500, query, status, storeId, selectedDateString, employeeId).observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; - @Override - public void onAppointmentClick(int position) { - openAppointmentDetails(position); - } - - private void loadAppointmentData() { - if (swipeRefreshLayout != null) - swipeRefreshLayout.setRefreshing(true); - api.getAllAppointments(0, 500).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()); - updateCalendarDecorators(); - 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()); + // 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; } }); } - - - // 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()); - - } - }); + /** + * Initializes the RecyclerView for displaying appointments. + */ + private void setupRecyclerView() { + adapter = new AppointmentAdapter(appointmentList, this); + binding.recyclerViewAppointments.setLayoutManager(new LinearLayoutManager(getContext())); + binding.recyclerViewAppointments.setAdapter(adapter); } - - // 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(); - - } - return ""; - } - - private String getServiceName(Long id) { - for (ServiceDTO s : serviceList) { - if (s.getServiceId().equals(id))return s.getServiceName(); - } - return ""; - } - - 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); - } -} +} \ No newline at end of file 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 37e533ee..1fe9d188 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,8 +1,6 @@ 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; @@ -10,205 +8,168 @@ 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.BlackTextArrayAdapter; 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.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; // 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(); + 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(); + } + } + }); + + binding.btnBulkDelete.setOnClickListener(v -> confirmBulkDelete()); + + return binding.getRoot(); + } @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()); - } - - BlackTextArrayAdapter spinnerAdapter = new BlackTextArrayAdapter<>( - 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) {} + }); + } + /** + * Configures the store filter spinner. + */ + private void setupStoreFilter() { + binding.spinnerStore.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override - public void onNothingSelected(AdapterView parent) { + public void onItemSelected(AdapterView parent, View view, int position, long id) { + loadInventory(true); + } + @Override public void onNothingSelected(AdapterView parent) {} + }); + } + + /** + * 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(); @@ -221,75 +182,70 @@ 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; + + //Load all inventory items from the backend using viewModel + viewModel.getAllInventory(query, null, storeId, currentPage, PAGE_SIZE, "product.prodName").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 + 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++; + } + 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; + } + }); } - // Bulk delete + /** + * Displays a confirmation dialog before performing a bulk deletion of selected items. + */ private void confirmBulkDelete() { List ids = adapter.getSelectedIds(); if (ids.isEmpty()) @@ -303,62 +259,57 @@ public class InventoryFragment extends Fragment implements InventoryAdapter.OnIn .show(); } + /** + * Executes the bulk deletion of inventory items through the ViewModel. + */ 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(); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); - } - }); + viewModel.bulkDeleteInventory(ids).observe(getViewLifecycleOwner(), resource -> { + if (resource != null && resource.status != Resource.Status.LOADING) { + if (resource.status == Resource.Status.SUCCESS) { + adapter.clearSelection(); + hideBulkDeleteBar(); + loadInventory(true); + Toast.makeText(getContext(), ids.size() + " item(s) deleted", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(getContext(), "Delete failed: " + resource.message, Toast.LENGTH_SHORT).show(); + } + } + }); } + /** + * Hides the bulk deletion UI bar. + */ private void hideBulkDeleteBar() { - if (btnBulkDelete != null) - btnBulkDelete.setVisibility(View.GONE); - if (tvSelectionCount != null) - tvSelectionCount.setVisibility(View.GONE); + if (binding != null) { + binding.btnBulkDelete.setVisibility(View.GONE); + binding.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()) { @@ -366,14 +317,17 @@ 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"); + binding.btnBulkDelete.setVisibility(View.VISIBLE); + binding.tvSelectionCount.setVisibility(View.VISIBLE); + binding.tvSelectionCount.setText(selectedCount + " selected"); } else { hideBulkDeleteBar(); } } -} \ 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 4c8effcf..f6b46b0e 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; @@ -14,224 +16,268 @@ 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.BlackTextArrayAdapter; 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.adapters.WhiteTextArrayAdapter; +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.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; - //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(); - hamburger = view.findViewById(R.id.btnHamburger); + binding.fabAddPet.setOnClickListener(v -> openPetDetails()); - setupRecyclerView(view); - setupSearch(view); - setupStatusFilter(view); - setupSwipeRefresh(view); - - - //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(); } + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + /** + * Reloads data every time the fragment becomes visible. + */ @Override public void onResume() { super.onResume(); loadPetData(); + loadStoreData(); } - private void setupSearch(View view) { - etSearch = view.findViewById(R.id.etSearchPet); - etSearch.addTextChangedListener(new TextWatcher() { + /** + * 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"}; - BlackTextArrayAdapter adapter = new BlackTextArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_item, statuses); + /** + * Configures the status filter spinner. + */ + private void setupStatusFilter() { + String[] statuses = {"All Statuses", "Available", "Adopted", "Owned"}; + WhiteTextArrayAdapter adapter = new WhiteTextArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_item, statuses); adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - spinnerStatus.setAdapter(adapter); + binding.spinnerStatus.setAdapter(adapter); - spinnerStatus.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + binding.spinnerStatus.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView parent, View view, int position, long id) { - filterPets(); + loadPetData(); } + @Override public void onNothingSelected(AdapterView parent) {} + }); + } + /** + * Configures the species filter spinner with species. + */ + private void setupSpeciesFilter() { + String[] species = {"All Species", "Dog", "Cat", "Bird", "Rabbit", "Fish", "Hamster"}; + WhiteTextArrayAdapter adapter = new WhiteTextArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_item, species); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + binding.spinnerSpecies.setAdapter(adapter); + + binding.spinnerSpecies.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @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); + public void onItemSelected(AdapterView parent, View view, int position, long id) { + loadPetData(); } - } - adapter.notifyDataSetChanged(); - } - - private void setupSwipeRefresh(View view) { - swipeRefreshLayout = view.findViewById(R.id.swipeRefreshPet); - swipeRefreshLayout.setOnRefreshListener(() -> { - loadPetData(); + @Override public void onNothingSelected(AdapterView parent) {} }); } - //Open pet profile + /** + * Configures the store filter spinner. + */ + private void setupStoreFilter() { + binding.spinnerStore.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + loadPetData(); + } + @Override public void onNothingSelected(AdapterView parent) {} + }); + } + + /** + * Fetches store data to populate the store filter. + */ + private void loadStoreData() { + storeViewModel.getAllStores(0, 100).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + storeList = resource.data.getContent(); + SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerStore, storeList, + StoreDTO::getStoreName, "All Stores", -1L, StoreDTO::getStoreId); + } + }); + } + + /** + * Sets up the SwipeRefreshLayout. + */ + private void setupSwipeRefresh() { + binding.swipeRefreshPet.setOnRefreshListener(this::loadPetData); + } + + /** + * 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 + /** + * Fetches pet data from the server with all active filters. + */ private void loadPetData() { - if (swipeRefreshLayout != null) { - swipeRefreshLayout.setRefreshing(true); + 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(); } - 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()); - } - } + if (status.equals("All Statuses")) status = null; + if (species.equals("All Species")) species = null; - @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()); + 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 e8b29611..0eea959f 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,135 +1,233 @@ 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.AdapterView; +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 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) { + binding = FragmentProductBinding.inflate(inflater, container, false); + + setupRecyclerView(); + setupSearch(); + setupCategoryFilter(); + setupSwipeRefresh(); + setupFilterToggle(); + + binding.fabAddProduct.setOnClickListener(v -> openProductDetails(-1)); + + binding.btnHamburgerProduct.setOnClickListener(v -> { + Fragment parent = getParentFragment(); + if (parent != null) { + Fragment grandParent = parent.getParentFragment(); + if (grandParent instanceof ListFragment) { + ((ListFragment) grandParent).openDrawer(); + } + } + }); + + return binding.getRoot(); + } @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_product, container, false); - - setupRecyclerView(view); - setupSearch(view); - setupSwipeRefresh(view); - - loadProducts(); - - FloatingActionButton fab = view.findViewById(R.id.fabAddProduct); - fab.setOnClickListener(v -> openDetail(-1)); - - ImageButton hamburger = view.findViewById(R.id.btnHamburgerProduct); - hamburger.setOnClickListener(v -> { - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.openDrawer(); - }); - - return view; + public void onDestroyView() { + super.onDestroyView(); + binding = null; } - 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); + /** + * Reloads data every time the fragment becomes visible. + */ + @Override + public void onResume() { + super.onResume(); + loadProductData(); + loadCategoryData(); } - 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(); + /** + * 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); } }); } - private void setupSwipeRefresh(View view) { - swipeRefresh = view.findViewById(R.id.swipeRefreshProduct); - swipeRefresh.setOnRefreshListener(this::loadProducts); - } - - private void filter() { - String query = etSearch.getText().toString().toLowerCase(); - - filteredList.clear(); - for (ProductDTO p : productList) { - boolean matchesSearch = query.isEmpty() || - (p.getProdName() != null && p.getProdName().toLowerCase().contains(query)) || - (p.getCategoryName() != null && p.getCategoryName().toLowerCase().contains(query)) || - (p.getProdDesc() != null && p.getProdDesc().toLowerCase().contains(query)); - - if (matchesSearch) { - filteredList.add(p); + /** + * 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(); } - } - adapter.notifyDataSetChanged(); + @Override public void afterTextChanged(Editable s) {} + }); } - 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(); - } 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()); - } - }); + /** + * Configures the category filter spinner. + */ + private void setupCategoryFilter() { + binding.spinnerCategory.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + loadProductData(); + } + @Override public void onNothingSelected(AdapterView parent) {} + }); } - private void openDetail(int position) { - ProductDetailFragment detail = new ProductDetailFragment(); + /** + * 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 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); + ProductDTO product = productList.get(position); + args.putLong("productId", product.getProdId()); } - detail.setArguments(args); - ListFragment lf = (ListFragment) getParentFragment(); - if (lf != null) lf.loadFragment(detail); + NavHostFragment.findNavController(this).navigate(R.id.nav_product_detail, args); } @Override - public void onProductClick(int position) { openDetail(position); } -} \ No newline at end of file + 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..1c0a9c19 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,268 @@ 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.AdapterView; +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.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; + /** + * 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(); - 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(); - } + }); - 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); + 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(); + 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() { + binding.spinnerProduct.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + loadData(); + } + @Override public void onNothingSelected(AdapterView parent) {} + }); + } + + /** + * Configures the supplier filter spinner. + */ + private void setupSupplierFilter() { + binding.spinnerSupplier.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + loadData(); + } + @Override public void onNothingSelected(AdapterView parent) {} + }); + } + + /** + * 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 +} 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..452f8d69 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,231 @@ 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.AdapterView; +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() { + binding.spinnerStore.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + loadData(); + } + @Override public void onNothingSelected(AdapterView parent) {} + }); + } + + /** + * Fetches store data to populate the store filter. + */ + private void loadStoreData() { + storeViewModel.getAllStores(0, 100).observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + storeList = resource.data.getContent(); + SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerStore, storeList, + StoreDTO::getStoreName, "All Stores", -1L, StoreDTO::getStoreId); + } + }); + } + + /** + * 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 c813aa5a..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,63 +1,71 @@ package com.example.petstoremobile.fragments.listfragments; import android.os.Bundle; +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 android.widget.EditText; -import android.widget.ImageButton; import com.example.petstoremobile.R; import com.example.petstoremobile.adapters.SaleAdapter; +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.RefundDetailFragment; 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 FragmentSaleBinding binding; private List saleList = new ArrayList<>(); private List filteredList = new ArrayList<>(); private SaleAdapter adapter; - private SwipeRefreshLayout swipeRefreshLayout; - private EditText etSearch; - private ImageButton btnHamburger; + + @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); - btnHamburger = view.findViewById(R.id.btnHamburger); - - setupRecyclerView(view); + setupRecyclerView(); loadSaleData(); - setupSearch(view); - setupSwipeRefresh(view); + setupSearch(); + setupSwipeRefresh(); // Make the hamburger button open the drawer from listFragment - if (btnHamburger != null) { - btnHamburger.setOnClickListener(v -> { - ListFragment listFragment = (ListFragment) getParentFragment(); - 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.etSearchSale); - etSearch.addTextChangedListener(new TextWatcher() { + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + private void setupSearch() { + binding.etSearchSale.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } @@ -91,11 +99,10 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis adapter.notifyDataSetChanged(); } - private void setupSwipeRefresh(View view) { - swipeRefreshLayout = view.findViewById(R.id.swipeRefreshSale); - swipeRefreshLayout.setOnRefreshListener(() -> { + private void setupSwipeRefresh() { + binding.swipeRefreshSale.setOnRefreshListener(() -> { loadSaleData(); - swipeRefreshLayout.setRefreshing(false); + binding.swipeRefreshSale.setRefreshing(false); }); } @@ -103,19 +110,14 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis @Override public void onSaleClick(int position) { Sale sale = filteredList.get(position); - RefundDetailFragment refundFragment = new RefundDetailFragment(); 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()); - refundFragment.setArguments(args); - refundFragment.setSaleFragment(this); - - ListFragment listFragment = (ListFragment) getParentFragment(); - if (listFragment != null) - listFragment.loadFragment(refundFragment); + + NavHostFragment.findNavController(this).navigate(R.id.nav_refund_detail, args); } public void reloadSales() { @@ -135,10 +137,9 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis adapter.notifyDataSetChanged(); } - private void setupRecyclerView(View view) { - RecyclerView recyclerView = view.findViewById(R.id.recyclerViewSales); + private void setupRecyclerView() { adapter = new SaleAdapter(filteredList, this); - recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - recyclerView.setAdapter(adapter); + binding.recyclerViewSales.setLayoutManager(new LinearLayoutManager(getContext())); + binding.recyclerViewSales.setAdapter(adapter); } -} \ No newline at end of file +} 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..bda282ce 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 @@ -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,177 +15,180 @@ 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.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.viewmodels.ServiceViewModel; 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 ServiceFragment extends Fragment implements ServiceAdapter.OnServiceClickListener { + private FragmentServiceBinding binding; private List serviceList = new ArrayList<>(); - private List filteredList = new ArrayList<>(); private ServiceAdapter adapter; - private ImageButton hamburger; - private ServiceApi api; - private SwipeRefreshLayout swipeRefreshLayout; - private EditText etSearch; + private ServiceViewModel viewModel; - //load service view + /** + * Initializes the fragment and its associated ServiceViewModel. + */ @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(view); - setupSearch(view); - setupSwipeRefresh(view); + setupRecyclerView(); + setupSearch(); + setupSwipeRefresh(); + setupFilterToggle(); loadServiceData(); //Add button to opens the add dialog - FloatingActionButton fabAddService = view.findViewById(R.id.fabAddService); - fabAddService.setOnClickListener(v -> openServiceDetails(-1)); + binding.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() { + @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.etSearchService.setText(""); + } + }); + } + + /** + * Configures 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()); + loadServiceData(); } @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); - } - } - } - adapter.notifyDataSetChanged(); + /** + * Sets up the SwipeRefreshLayout to allow manual reloading of service data. + */ + private void setupSwipeRefresh() { + binding.swipeRefreshService.setOnRefreshListener(this::loadServiceData); } - private void setupSwipeRefresh(View view) { - swipeRefreshLayout = view.findViewById(R.id.swipeRefreshService); - swipeRefreshLayout.setOnRefreshListener(() -> { - loadServiceData(); - }); - } - - //Open the service detail view depending on the mode + /** + * Navigates to the service detail screen for editing an existing service or adding a new one. + */ 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 editing a service, add the service id 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()); + ServiceDTO service = serviceList.get(position); + args.putLong("serviceId", service.getServiceId()); } - //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); - } + NavHostFragment.findNavController(this).navigate(R.id.nav_service_detail, args); } - // Called by ServiceAdapter when a row is clicked to open the details view + /** + * Handles item click in the service list. + */ @Override public void onServiceClick(int position) { openServiceDetails(position); } - // Helper function to get a list of all services from the backend + /** + * Fetches all service data from the server through the ViewModel and updates the UI. + */ private void loadServiceData() { - if (swipeRefreshLayout != null) { - swipeRefreshLayout.setRefreshing(true); - } - 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()); + String query = binding.etSearchService != null ? binding.etSearchService.getText().toString().trim() : ""; + if (query.isEmpty()) query = null; - } else { - Log.e("onResponse: ", response.message()); - } - } + //Load services from the backend with query and default sort + viewModel.getAllServices(0, 100, query, "serviceName").observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; - @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()); + // Check the status to see if the resource is loaded and display the data + switch (resource.status) { + case LOADING: + // Show loading indicator + binding.swipeRefreshService.setRefreshing(true); + break; + case SUCCESS: + // Hide loading indicator and display data + binding.swipeRefreshService.setRefreshing(false); + if (resource.data != null) { + serviceList.clear(); + serviceList.addAll(resource.data.getContent()); + adapter.notifyDataSetChanged(); + } + break; + case ERROR: + // Hide loading indicator and toast error message + binding.swipeRefreshService.setRefreshing(false); + if (getContext() != null) { + Toast.makeText(getContext(), "Failed to load services: " + resource.message, Toast.LENGTH_SHORT).show(); + } + Log.e("ServiceFragment", "Error loading services: " + resource.message); + break; } }); } - //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); + /** + * Initializes the RecyclerView with a layout manager and adapter for services. + */ + private void setupRecyclerView() { + adapter = new ServiceAdapter(serviceList, this); + binding.recyclerViewServices.setLayoutManager(new LinearLayoutManager(getContext())); + binding.recyclerViewServices.setAdapter(adapter); } -} \ 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..3d2e038d 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,181 @@ 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.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; - //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(); 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() { + @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 + /** + * Fetches all supplier data from the server through the ViewModel and updates the UI. + */ private void loadSupplierData() { - if (swipeRefreshLayout != null) { - swipeRefreshLayout.setRefreshing(true); - } - 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()); + String query = binding.etSearchSupplier != null ? binding.etSearchSupplier.getText().toString().trim() : ""; + if (query.isEmpty()) query = null; - } else { - Log.e("onResponse: ", response.message()); - } - } + //Load suppliers from the backend with query and default sort + viewModel.getAllSuppliers(0, 100, query, "supCompany").observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; - @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()); + // 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 47733da2..d38622f5 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,26 +2,34 @@ 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.adapters.BlackTextArrayAdapter; -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 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, spinnerStatus; - private Button btnSave, btnDelete, btnBack; + private FragmentAdoptionDetailBinding binding; private long adoptionId = -1; private boolean isEditing = false; @@ -33,44 +41,59 @@ public class AdoptionDetailFragment extends Fragment { private final String[] STATUSES = {"Pending", "Approved", "Rejected"}; + private AdoptionViewModel adoptionViewModel; + private PetViewModel petViewModel; + private CustomerViewModel customerViewModel; + + @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); + } + @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); - 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 BlackTextArrayAdapter<>(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), @@ -78,117 +101,116 @@ public class AdoptionDetailFragment extends Fragment { }); } - private void loadData() { + /** + * Fetches required data for spinners from the backend. + */ + private void loadSpinnersData() { loadPets(); loadCustomers(); } + /** + * 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 BlackTextArrayAdapter<>(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 BlackTextArrayAdapter<>(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); + } + + /** + * 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); - - 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; + binding.etAdoptionDate.setText(a.getAdoptionDate()); + SpinnerUtils.setSelectionByValue(binding.spinnerAdoptionStatus, a.getAdoptionStatus()); + + refreshPetSpinner(); + refreshCustomerSpinner(); + } 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; } - 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()]; + CustomerDTO customer = customerList.get(binding.spinnerAdoptionCustomer.getSelectedItemPosition() - 1); + PetDTO pet = petList.get(binding.spinnerAdoptionPet.getSelectedItemPosition() - 1); + String status = STATUSES[binding.spinnerAdoptionStatus.getSelectedItemPosition()]; AdoptionDTO dto = new AdoptionDTO( pet.getPetId(), @@ -197,62 +219,46 @@ public class AdoptionDetailFragment extends Fragment { status ); - 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 eaabc061..cf9bb837 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,23 +6,33 @@ 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.adapters.BlackTextArrayAdapter; -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 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; @@ -35,61 +45,73 @@ public class AppointmentDetailFragment extends Fragment { private List serviceList = new ArrayList<>(); private List customerList = new ArrayList<>(); private List storeList = new ArrayList<>(); - private List allAppointments = 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; + + @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); + } @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 BlackTextArrayAdapter<>(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 BlackTextArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, hours)); - spinnerMinute.setAdapter(new BlackTextArrayAdapter<>(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)); @@ -98,218 +120,204 @@ 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(); } + /** + * 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 BlackTextArrayAdapter<>(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 BlackTextArrayAdapter<>(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 BlackTextArrayAdapter<>(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 BlackTextArrayAdapter<>(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); } + /** + * 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; + + 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(); + } 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); 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(":"); @@ -323,7 +331,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; @@ -341,104 +349,76 @@ public class AppointmentDetailFragment extends Fragment { 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 f98351e3..1a32df6e 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 @@ -9,43 +9,40 @@ 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.adapters.BlackTextArrayAdapter; -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.utils.InputValidator; +import com.example.petstoremobile.utils.Resource; +import com.example.petstoremobile.viewmodels.InventoryViewModel; +import com.example.petstoremobile.viewmodels.ProductViewModel; 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 boolean isEditing = false; private long inventoryId = -1; @@ -61,62 +58,72 @@ public class InventoryDetailFragment extends Fragment { private final List productSuggestions = new ArrayList<>(); private ArrayAdapter dropdownAdapter; - public void setInventoryFragment(InventoryFragment fragment) { - this.inventoryFragment = fragment; + /** + * 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); } + /** + * Inflates the layout. + */ @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_inventory_detail, container, false); + binding = FragmentInventoryDetailBinding.inflate(inflater, container, false); + return binding.getRoot(); + } - inventoryApi = RetrofitClient.getInventoryApi(requireContext()); - productApi = RetrofitClient.getProductApi(requireContext()); + /** + * Sets up UI components after the view is created. + */ + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); - initViews(view); setupProductSearch(); handleArguments(); - btnBack.setOnClickListener(v -> navigateBack()); - btnSave.setOnClickListener(v -> saveInventory()); - btnDelete.setOnClickListener(v -> confirmDelete()); - - return view; - } - - 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); + binding.btnInventoryBack.setOnClickListener(v -> navigateBack()); + binding.btnSaveInventory.setOnClickListener(v -> saveInventory()); + binding.btnDeleteInventory.setOnClickListener(v -> confirmDelete()); // Setup dropdown adapter dropdownAdapter = new BlackTextArrayAdapter<>(requireContext(), android.R.layout.simple_dropdown_item_1line, new ArrayList<>()); - etProductSearch.setAdapter(dropdownAdapter); - etProductSearch.setThreshold(1); // start showing after 1 character + binding.etProductSearch.setAdapter(dropdownAdapter); + binding.etProductSearch.setThreshold(1); // start showing after 1 character } - // Product search dropdown + @Override + public void onDestroyView() { + super.onDestroyView(); + if (searchRunnable != null) { + searchHandler.removeCallbacks(searchRunnable); + } + binding = null; + } + + /** + * Sets up the product search dropdown. + */ private void setupProductSearch() { - etProductSearch.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int i, int i1, int i2) { + binding.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 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); + binding.tvProductInfo.setVisibility(View.GONE); if (searchRunnable != null) searchHandler.removeCallbacks(searchRunnable); @@ -130,163 +137,142 @@ public class InventoryDetailFragment extends Fragment { }); // When user picks an item from the dropdown - etProductSearch.setOnItemClickListener((parent, view, position, id) -> { + binding.etProductSearch.setOnItemClickListener((parent, view, position, id) -> { if (position < productSuggestions.size()) { selectedProduct = productSuggestions.get(position); // Show product details below the search box - tvProductInfo.setText( + binding.tvProductInfo.setText( "ID: " + selectedProduct.getProdId() + " • " + selectedProduct.getCategoryName()); - tvProductInfo.setVisibility(View.VISIBLE); + binding.tvProductInfo.setVisibility(View.VISIBLE); } }); } + /** + * Searches for products matching the query from the backend. + */ 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()); + if (getView() == null) return; + productViewModel.getAllProducts(query, null, 0, 20, "prodName").observe(getViewLifecycleOwner(), resource -> { + if (resource.status == Resource.Status.SUCCESS && resource.data != null) { + productSuggestions.clear(); + productSuggestions.addAll(resource.data.getContent()); - // 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(); + // Build display strings: "Product Name (ID: X)" + List names = new ArrayList<>(); + for (ProductDTO p : productSuggestions) { + names.add(p.getProdName() + " (ID: " + p.getProdId() + ")"); } - } - @Override - public void onFailure(Call> call, Throwable t) { - Toast.makeText(getContext(), "Failed to load products", Toast.LENGTH_SHORT).show(); + dropdownAdapter.clear(); + dropdownAdapter.addAll(names); + dropdownAdapter.notifyDataSetChanged(); + binding.etProductSearch.showDropDown(); } }); } - // Arguments (edit mode) - + /** + * 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.tvProductInfo.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.etProductSearch.setText(inv.getProductName()); + binding.etQuantity.setText(String.valueOf(inv.getQuantity())); + + if (inv.getProdId() != null) { + binding.tvProductInfo.setText( + "ID: " + inv.getProdId() + + " • " + inv.getCategoryName()); + binding.tvProductInfo.setVisibility(View.VISIBLE); + + selectedProduct = new ProductDTO(); + selectedProduct.setProdId(inv.getProdId()); + selectedProduct.setProdName(inv.getProductName()); + selectedProduct.setCategoryName(inv.getCategoryName()); + } + } 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(); + binding.etProductSearch.setError("Please select a product from the list"); + binding.etProductSearch.requestFocus(); 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; - } - - if (quantity < 0) { - etQuantity.setError("Quantity must be 0 or more"); - etQuantity.requestFocus(); - return; - } + int quantity = Integer.parseInt(binding.etQuantity.getText().toString().trim()); InventoryRequest request = new InventoryRequest(selectedProduct.getProdId(), 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?") @@ -296,45 +282,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 c37558a7..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,84 +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.adapters.BlackTextArrayAdapter; -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(); @@ -89,157 +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(); + + selectedStoreId = p.getStoreId(); + updateStoreSpinnerSelection(); + } else if (resource.status == Resource.Status.ERROR) { + Toast.makeText(getContext(), "Failed to load pet: " + resource.message, Toast.LENGTH_SHORT).show(); + } + }); } - //helper function to set up the spinner menu for pet status + /** + * 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() { - BlackTextArrayAdapter adapter = new BlackTextArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, - new String[]{"Available", "Adopted"}); - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - spinnerPetStatus.setAdapter(adapter); + 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 40bdc91b..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,396 +1,321 @@ package com.example.petstoremobile.fragments.listfragments.detailfragments; -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 android.provider.MediaStore; -import android.util.Log; import android.view.*; import android.widget.*; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.core.content.ContextCompat; -import androidx.core.content.FileProvider; +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.bumptech.glide.load.engine.DiskCacheStrategy; import com.example.petstoremobile.R; -import com.example.petstoremobile.adapters.BlackTextArrayAdapter; 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.io.FileOutputStream; -import java.io.InputStream; import java.math.BigDecimal; import java.util.*; + +import javax.inject.Inject; + +import javax.inject.Named; + +import dagger.hilt.android.AndroidEntryPoint; import okhttp3.MediaType; import okhttp3.MultipartBody; import okhttp3.RequestBody; -import retrofit2.*; +/** + * 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 ImageView ivProductImage; + 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; - private ActivityResultLauncher galleryLauncher; - private ActivityResultLauncher cameraLauncher; - private ActivityResultLauncher permissionLauncher; + @Inject @Named("baseUrl") String baseUrl; + @Inject TokenManager tokenManager; + /** + * Initializes activity launchers and the ImagePickerHelper. + */ @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - galleryLauncher = registerForActivityResult( - new ActivityResultContracts.StartActivityForResult(), - result -> { - if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { - Uri selectedImage = result.getData().getData(); - if (isEditing) { - uploadProductImage(selectedImage); - } else { - ivProductImage.setImageURI(selectedImage); - photoUri = selectedImage; - hasImage = true; - } - } - } - ); - cameraLauncher = registerForActivityResult( - new ActivityResultContracts.TakePicture(), - success -> { - if (success) { - if (isEditing) { - uploadProductImage(photoUri); - } else { - ivProductImage.setImageURI(photoUri); - hasImage = true; - } - } - } - ); - permissionLauncher = registerForActivityResult( - new ActivityResultContracts.RequestPermission(), - granted -> { - if (granted) launchCamera(); - else Toast.makeText(getContext(), "Camera permission denied", Toast.LENGTH_SHORT).show(); - } - ); + 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()); - ivProductImage.setOnClickListener(v -> showImagePickerDialog()); - 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); - ivProductImage = v.findViewById(R.id.ivProductImage); - } - - // Helper function to show the image picker dialog - private void showImagePickerDialog() { - List options = new ArrayList<>(); - options.add("Take Photo"); - options.add("Choose from Gallery"); - if (hasImage) { - options.add("Remove Photo"); - } - - new AlertDialog.Builder(requireContext()) - .setTitle("Select Product Image") - .setItems(options.toArray(new String[0]), (dialog, which) -> { - String selectedOption = options.get(which); - if (selectedOption.equals("Take Photo")) { - if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) - == PackageManager.PERMISSION_GRANTED) { - launchCamera(); - } else { - permissionLauncher.launch(Manifest.permission.CAMERA); - } - } else if (selectedOption.equals("Choose from Gallery")) { - Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); - galleryLauncher.launch(intent); - } else if (selectedOption.equals("Remove Photo")) { - removePhoto(); - } - }) - .show(); - } - - // Helper function to remove the photo - private void removePhoto() { - if (isEditing) { - RetrofitClient.getProductApi(requireContext()).deleteProductImage(prodId) - .enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - Toast.makeText(getContext(), "Photo removed", Toast.LENGTH_SHORT).show(); - ivProductImage.setImageResource(R.drawable.placeholder2); - hasImage = false; - } else { - Toast.makeText(getContext(), "Failed to remove photo", Toast.LENGTH_SHORT).show(); - } - } - @Override - public void onFailure(Call call, Throwable t) { - Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); - } - }); - } else { - photoUri = null; - hasImage = false; - ivProductImage.setImageResource(R.drawable.placeholder2); - } - } - - // Helper function to launch the camera - private void launchCamera() { - File photoFile = new File(requireContext().getCacheDir(), "product_photo.jpg"); - photoUri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".fileprovider", photoFile); - cameraLauncher.launch(photoUri); + @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 BlackTextArrayAdapter<>(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); - hasImage = true; - - 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; } } - //load the product image from the backend - private void loadProductImage() { - String imageUrl = RetrofitClient.BASE_URL + String.format(Locale.US, ProductApi.PRODUCT_IMAGE_PATH, prodId); - Glide.with(this) - .load(imageUrl) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .skipMemoryCache(true) - .placeholder(R.drawable.placeholder2) - .error(R.drawable.placeholder2) - .into(ivProductImage); - } - - // Function to upload the product image by calling the backend - private void uploadProductImage(Uri uri) { - try { - File file = getFileFromUri(uri); - if (file == null) return; - - RequestBody requestFile = RequestBody.create(file, MediaType.parse(requireContext().getContentResolver().getType(uri))); - MultipartBody.Part body = MultipartBody.Part.createFormData("image", file.getName(), requestFile); - - RetrofitClient.getProductApi(requireContext()).uploadProductImage(prodId, body) - .enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - Toast.makeText(getContext(), "Image uploaded", Toast.LENGTH_SHORT).show(); - hasImage = true; - loadProductImage(); - } else { - Toast.makeText(getContext(), "Upload failed", Toast.LENGTH_SHORT).show(); - } - } - @Override - public void onFailure(Call call, Throwable t) { - Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); - } - }); - } catch (Exception e) { - Log.e("ProductDetail", "Error uploading image", e); - } - } - - // Helper function to get the File from the Uri - private File getFileFromUri(Uri uri) { - try { - InputStream inputStream = requireContext().getContentResolver().openInputStream(uri); - File tempFile = new File(requireContext().getCacheDir(), "upload_product_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); + /** + * 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(); } - outputStream.close(); - inputStream.close(); - return tempFile; - } catch (Exception e) { - return null; + }); + } + + /** + * 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(); } } - private void saveProduct() { - String name = etProductName.getText().toString().trim(); - String desc = etProductDesc.getText().toString().trim(); - String priceStr = etProductPrice.getText().toString().trim(); - - if (name.isEmpty()) { - etProductName.setError("Enter product name"); return; + /** + * 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; } - if (spinnerCategory.getSelectedItemPosition() == 0) { + + 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); - ProductApi api = RetrofitClient.getProductApi(requireContext()); if (isEditing) { - api.updateProduct(prodId, dto).enqueue(simpleCallback("Updated")); - } else { - api.createProduct(dto).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful() && response.body() != null) { - long newId = response.body().getProdId(); - if (photoUri != null) { - prodId = newId; - uploadProductImage(photoUri); - } - Toast.makeText(getContext(), "Saved", Toast.LENGTH_SHORT).show(); - navigateBack(); + 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 saving", Toast.LENGTH_SHORT).show(); + Toast.makeText(getContext(), "Error: " + resource.message, Toast.LENGTH_SHORT).show(); } } - @Override - public void onFailure(Call call, Throwable t) { - Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); + }); + } else { + 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 { - Toast.makeText(getContext(), "Error " + r.code(), Toast.LENGTH_SHORT).show(); - } - } - public void onFailure(Call c, Throwable t) { - 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 2bd47432..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,27 +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.adapters.BlackTextArrayAdapter; -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; @@ -32,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 BlackTextArrayAdapter<>(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 BlackTextArrayAdapter<>(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..4a30e4cd 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,99 @@ 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.tvPODetailDate.setText(po.getOrderDate()); + + String status = po.getStatus() != null ? po.getStatus() : ""; + binding.tvPODetailStatus.setText(status); + switch (status) { + case "Completed": + binding.tvPODetailStatus.setTextColor(Color.parseColor("#4CAF50")); 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/RefundDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/RefundDetailFragment.java index 41b8f3b2..ce532696 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/RefundDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/RefundDetailFragment.java @@ -1,54 +1,66 @@ package com.example.petstoremobile.fragments.listfragments.detailfragments; import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.navigation.fragment.NavHostFragment; + 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.Spinner; -import android.widget.TextView; import android.widget.Toast; -import com.example.petstoremobile.R; -import com.example.petstoremobile.adapters.BlackTextArrayAdapter; -import com.example.petstoremobile.fragments.ListFragment; + +import com.example.petstoremobile.api.SaleApi; +import com.example.petstoremobile.databinding.FragmentRefundDetailBinding; import com.example.petstoremobile.fragments.listfragments.SaleFragment; import com.example.petstoremobile.utils.ActivityLogger; import com.example.petstoremobile.utils.InputValidator; +import com.example.petstoremobile.utils.SpinnerUtils; +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint public class RefundDetailFragment extends Fragment { - private EditText etRefundSaleId, etRefundReason; - private TextView tvSaleInfo; - private Spinner spinnerRefundPayment; - private Button btnLoadSale, btnProcessRefund, btnBack; + private FragmentRefundDetailBinding binding; private int saleId; private SaleFragment saleFragment; + @Inject SaleApi saleApi; + public void setSaleFragment(SaleFragment fragment) { this.saleFragment = fragment; } @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_refund_detail, container, false); + binding = FragmentRefundDetailBinding.inflate(inflater, container, false); + return binding.getRoot(); + } - initViews(view); + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); setupSpinner(); handleArguments(); - btnBack.setOnClickListener(v -> goBack()); - btnLoadSale.setOnClickListener(v -> loadSaleDetails()); - btnProcessRefund.setOnClickListener(v -> processRefund()); + binding.btnRefundBack.setOnClickListener(v -> goBack()); + binding.btnLoadSale.setOnClickListener(v -> loadSaleDetails()); + binding.btnProcessRefund.setOnClickListener(v -> processRefund()); + } - return view; + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; } private void loadSaleDetails() { - String idText = etRefundSaleId.getText().toString().trim(); + String idText = binding.etRefundSaleId.getText().toString().trim(); if (idText.isEmpty()) { Toast.makeText(getContext(), "Enter a Sale ID", Toast.LENGTH_SHORT).show(); return; @@ -58,22 +70,21 @@ public class RefundDetailFragment extends Fragment { int id = Integer.parseInt(idText); // TODO: Replace with actual API call - GET v1/sales/{id} // For now show placeholder info - tvSaleInfo.setText("Sale ID: " + id + " loaded. Enter reason and payment method to process refund."); - tvSaleInfo.setTextColor(getResources().getColor(android.R.color.holo_green_dark)); + binding.tvSaleInfo.setText("Sale ID: " + id + " loaded. Enter reason and payment method to process refund."); + binding.tvSaleInfo.setTextColor(getResources().getColor(android.R.color.holo_green_dark)); } catch (NumberFormatException e) { Toast.makeText(getContext(), "Invalid Sale ID", Toast.LENGTH_SHORT).show(); } } private void processRefund() { - if (!InputValidator.isNotEmpty(etRefundSaleId, "Sale ID")) + if (!InputValidator.isNotEmpty(binding.etRefundSaleId, "Sale ID")) return; - if (!InputValidator.isNotEmpty(etRefundReason, "Refund Reason")) + if (!InputValidator.isNotEmpty(binding.etRefundReason, "Refund Reason")) return; - String idText = etRefundSaleId.getText().toString().trim(); - String reason = etRefundReason.getText().toString().trim(); - String payment = spinnerRefundPayment.getSelectedItem().toString(); + String idText = binding.etRefundSaleId.getText().toString().trim(); + String reason = binding.etRefundReason.getText().toString().trim(); try { int id = Integer.parseInt(idText); @@ -91,46 +102,25 @@ public class RefundDetailFragment extends Fragment { private void handleArguments() { if (getArguments() != null && getArguments().containsKey("saleId")) { saleId = getArguments().getInt("saleId"); - etRefundSaleId.setText(String.valueOf(saleId)); + binding.etRefundSaleId.setText(String.valueOf(saleId)); String info = "Sale Date: " + getArguments().getString("saleDate") + " | Employee: " + getArguments().getString("employeeName") + " | Total: $" + String.format("%.2f", getArguments().getDouble("total")) + " | Payment: " + getArguments().getString("paymentMethod"); - tvSaleInfo.setText(info); - tvSaleInfo.setTextColor(getResources().getColor(android.R.color.holo_green_dark)); + binding.tvSaleInfo.setText(info); + binding.tvSaleInfo.setTextColor(getResources().getColor(android.R.color.holo_green_dark)); // Pre-select payment method - String payment = getArguments().getString("paymentMethod"); - ArrayAdapter adapter = (ArrayAdapter) spinnerRefundPayment.getAdapter(); - if (adapter != null && payment != null) { - int pos = adapter.getPosition(payment); - if (pos >= 0) - spinnerRefundPayment.setSelection(pos); - } + SpinnerUtils.setSelectionByValue(binding.spinnerRefundPayment, getArguments().getString("paymentMethod")); } } private void goBack() { - ListFragment listFragment = (ListFragment) getParentFragment(); - if (listFragment != null) - listFragment.getChildFragmentManager().popBackStack(); - } - - private void initViews(View view) { - etRefundSaleId = view.findViewById(R.id.etRefundSaleId); - etRefundReason = view.findViewById(R.id.etRefundReason); - tvSaleInfo = view.findViewById(R.id.tvSaleInfo); - spinnerRefundPayment = view.findViewById(R.id.spinnerRefundPayment); - btnLoadSale = view.findViewById(R.id.btnLoadSale); - btnProcessRefund = view.findViewById(R.id.btnProcessRefund); - btnBack = view.findViewById(R.id.btnRefundBack); + NavHostFragment.findNavController(this).popBackStack(); } private void setupSpinner() { - BlackTextArrayAdapter adapter = new BlackTextArrayAdapter<>(requireContext(), - android.R.layout.simple_spinner_item, + SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerRefundPayment, new String[] { "Cash", "Card", "Debit" }); - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - spinnerRefundPayment.setAdapter(adapter); } -} \ No newline at end of file +} 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 c4c47d31..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,310 +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.ArrayList; -import java.util.List; 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 -> { - List options = new ArrayList<>(); - options.add("Take Photo"); - options.add("Choose from Gallery"); - if (hasImage) { - options.add("Remove Photo"); - } - - new AlertDialog.Builder(requireContext()) - .setTitle("Change Pet Photo") - .setItems(options.toArray(new String[0]), (dialog, which) -> { - String selected = options.get(which); - if (selected.equals("Take Photo")) { - // 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 if (selected.equals("Choose from Gallery")) { - Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); - galleryLauncher.launch(intent); - } else if (selected.equals("Remove Photo")) { - deletePetImage(); - } - }) - .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) - .listener(new com.bumptech.glide.request.RequestListener() { - @Override - public boolean onLoadFailed(@androidx.annotation.Nullable com.bumptech.glide.load.engine.GlideException e, Object model, com.bumptech.glide.request.target.Target target, boolean isFirstResource) { - hasImage = false; - return false; - } + GlideUtils.loadImageWithToken(requireContext(), binding.imgPet, imageUrl, token, R.drawable.placeholder, new GlideUtils.ImageLoadListener() { + @Override + public void onResourceReady() { + hasImage = true; + } - @Override - public boolean onResourceReady(android.graphics.drawable.Drawable resource, Object model, com.bumptech.glide.request.target.Target target, com.bumptech.glide.load.DataSource dataSource, boolean isFirstResource) { - hasImage = true; - return false; - } - }) - .into(imgPet); + @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()); } } + /** + * Sends a request to the ViewModel to remove the current pet photo. + */ private void deletePetImage() { - PetApi petApi = RetrofitClient.getPetApi(requireContext()); - petApi.deletePetImage((long) petId).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - Toast.makeText(requireContext(), "Pet photo removed", Toast.LENGTH_SHORT).show(); + 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; - imgPet.setImageResource(R.drawable.placeholder); + binding.imgPet.setImageResource(R.drawable.placeholder); } else { - Toast.makeText(requireContext(), "Failed to remove pet photo", Toast.LENGTH_SHORT).show(); + Toast.makeText(getContext(), "Delete failed: " + resource.message, Toast.LENGTH_SHORT).show(); } } - - @Override - public void onFailure(Call call, Throwable t) { - Log.e("DELETE_PET_IMAGE", "Failure: " + t.getMessage()); - Toast.makeText(requireContext(), "Network error", Toast.LENGTH_SHORT).show(); - } }); } - - // 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); - } - 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/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..0e73b706 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/AdoptionRepository.java @@ -0,0 +1,57 @@ +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.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) { + return executeCall(adoptionApi.getAllAdoptions(page, size)); + } + + /** + * 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)); + } +} 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..1c85e91a --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/AppointmentRepository.java @@ -0,0 +1,57 @@ +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.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)); + } +} \ 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..2ec410f9 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/AuthRepository.java @@ -0,0 +1,107 @@ +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.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..8301797d --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/ChatRepository.java @@ -0,0 +1,74 @@ +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; + +import okhttp3.MultipartBody; +import okhttp3.RequestBody; + +/** + * 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)); + } + + /** + * Sends a message with a file attachment to a conversation. + */ + public LiveData> sendMessageWithAttachment(Long conversationId, RequestBody content, MultipartBody.Part file) { + return executeCall(messageApi.sendMessageWithAttachment(conversationId, content, file)); + } + + /** + * 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..05719c38 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/InventoryRepository.java @@ -0,0 +1,60 @@ +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.InventoryRequest; +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, String category, Long storeId, int page, int size, String sort) { + return executeCall(inventoryApi.getAllInventory(page, size, query, category, 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(InventoryRequest request) { + return executeCall(inventoryApi.createInventory(request)); + } + + public LiveData> updateInventory(Long id, InventoryRequest 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..88ac2295 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/PetRepository.java @@ -0,0 +1,73 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.PetApi; +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)); + } + + /** + * 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..e5c135a5 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/ProductSupplierRepository.java @@ -0,0 +1,57 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.ProductSupplierApi; +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)); + } +} \ 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..b55f9a33 --- /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 api; + + @Inject + public PurchaseOrderRepository(PurchaseOrderApi api) { + super("PurchaseOrderRepo"); + this.api = api; + } + + /** + * 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(api.getAllPurchaseOrders(page, size, query, storeId, sort)); + } + + /** + * Retrieves a specific purchase order by its ID from the API. + */ + public LiveData> getPurchaseOrderById(Long id) { + return executeCall(api.getPurchaseOrderById(id)); + } +} 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..7787c36a --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/ServiceRepository.java @@ -0,0 +1,57 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.ServiceApi; +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)); + } +} 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..e4eb4c0c --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/SupplierRepository.java @@ -0,0 +1,57 @@ +package com.example.petstoremobile.repositories; + +import androidx.lifecycle.LiveData; + +import com.example.petstoremobile.api.SupplierApi; +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)); + } +} 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/DialogUtils.java b/android/app/src/main/java/com/example/petstoremobile/utils/DialogUtils.java new file mode 100644 index 00000000..55436846 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/DialogUtils.java @@ -0,0 +1,47 @@ +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 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/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/SpinnerUtils.java b/android/app/src/main/java/com/example/petstoremobile/utils/SpinnerUtils.java new file mode 100644 index 00000000..0041a0b2 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/SpinnerUtils.java @@ -0,0 +1,94 @@ +package com.example.petstoremobile.utils; + +import android.content.Context; +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 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..039cef30 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AdoptionViewModel.java @@ -0,0 +1,58 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.AdoptionDTO; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.repositories.AdoptionRepository; +import com.example.petstoremobile.utils.Resource; + +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. + */ + public LiveData>> getAllAdoptions(int page, int size) { + return repository.getAllAdoptions(page, size); + } + + /** + * 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); + } +} 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..913d7ab2 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AppointmentViewModel.java @@ -0,0 +1,58 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.AppointmentDTO; +import com.example.petstoremobile.dtos.PageResponse; +import com.example.petstoremobile.repositories.AppointmentRepository; +import com.example.petstoremobile.utils.Resource; + +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); + } +} \ 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..061ee687 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AuthViewModel.java @@ -0,0 +1,68 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import com.example.petstoremobile.dtos.AuthDTO; +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..51435f82 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ChatViewModel.java @@ -0,0 +1,68 @@ +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; +import okhttp3.MultipartBody; +import okhttp3.RequestBody; + +/** + * 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); + } + + /** + * Sends a message with a file attachment to a conversation. + */ + public LiveData> sendMessageWithAttachment(Long conversationId, RequestBody content, MultipartBody.Part file) { + return repository.sendMessageWithAttachment(conversationId, content, file); + } + + /** + * 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..02a5f1bb --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/InventoryViewModel.java @@ -0,0 +1,91 @@ +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.InventoryRequest; +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, String category, Long storeId, int page, int size, String sort) { + return inventoryRepository.getAllInventory(query, category, 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(InventoryRequest request) { + return inventoryRepository.createInventory(request); + } + + /** + * Updates an existing inventory record by ID. + */ + public LiveData> updateInventory(Long id, InventoryRequest 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..b0af57c8 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/PetViewModel.java @@ -0,0 +1,73 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +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 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); + } + + /** + * 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..95613929 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ProductSupplierViewModel.java @@ -0,0 +1,51 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +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 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); + } +} 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..142ac85b --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ServiceViewModel.java @@ -0,0 +1,58 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +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 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); + } +} 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..a89426de --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/SupplierViewModel.java @@ -0,0 +1,58 @@ +package com.example.petstoremobile.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +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 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); + } +} 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/layout/activity_home.xml b/android/app/src/main/res/layout/activity_home.xml index 9c61c329..f5e822e2 100644 --- a/android/app/src/main/res/layout/activity_home.xml +++ b/android/app/src/main/res/layout/activity_home.xml @@ -7,12 +7,14 @@ android:orientation="vertical" android:background="@color/primary_dark"> - + 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..5bc95c38 100644 --- a/android/app/src/main/res/layout/fragment_adoption.xml +++ b/android/app/src/main/res/layout/fragment_adoption.xml @@ -28,15 +28,35 @@ android:contentDescription="Open menu"/> + + + + + android:textStyle="bold" + android:layout_marginStart="8dp"/> + + + + + + + + + + + + + + + + + + + + + + + + + + - - diff --git a/android/app/src/main/res/layout/fragment_inventory.xml b/android/app/src/main/res/layout/fragment_inventory.xml index 4b075953..8197078e 100644 --- a/android/app/src/main/res/layout/fragment_inventory.xml +++ b/android/app/src/main/res/layout/fragment_inventory.xml @@ -30,42 +30,78 @@ android:contentDescription="Open menu"/> + android:textStyle="bold" + android:layout_marginStart="8dp"/> + + + android:orientation="vertical" + android:paddingStart="12dp" + android:paddingEnd="12dp" + android:paddingTop="10dp" + android:paddingBottom="10dp" + android:visibility="gone" + android:background="@color/primary_dark" + android:elevation="4dp"> - + + + + + + + + android:id="@+id/spinnerStore" + android:layout_width="match_parent" + android:layout_height="44dp" + android:layout_marginTop="8dp" + android:background="@drawable/bg_spinner" + android:paddingStart="12dp" + android:paddingEnd="8dp"/> + diff --git a/android/app/src/main/res/layout/fragment_list.xml b/android/app/src/main/res/layout/fragment_list.xml index 7859f5fd..a55933b9 100644 --- a/android/app/src/main/res/layout/fragment_list.xml +++ b/android/app/src/main/res/layout/fragment_list.xml @@ -1,6 +1,7 @@ @@ -10,10 +11,13 @@ android:layout_height="match_parent" android:background="@color/background_grey"> - + android:layout_height="match_parent" + app:defaultNavHost="true" + app:navGraph="@navigation/list_nav_graph" /> - - - - - - - - - - - - - - - - - - + android:layout_height="48dp" + android:orientation="horizontal" + android:gravity="center_vertical" + android:paddingStart="16dp" + android:paddingEnd="16dp" + android:background="?attr/selectableItemBackground"> + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - + android:layout_height="wrap_content" + android:text="Sale" + android:textColor="@color/white" + android:textSize="15sp"/> + + + + + + - + - - - - \ No newline at end of file + diff --git a/android/app/src/main/res/layout/fragment_pet.xml b/android/app/src/main/res/layout/fragment_pet.xml index 1f625bac..9273c0f0 100644 --- a/android/app/src/main/res/layout/fragment_pet.xml +++ b/android/app/src/main/res/layout/fragment_pet.xml @@ -29,42 +29,107 @@ android:contentDescription="Open menu"/> + android:textStyle="bold" + android:layout_marginStart="8dp"/> + + + android:orientation="vertical" + android:paddingStart="12dp" + android:paddingEnd="12dp" + android:paddingTop="10dp" + android:paddingBottom="10dp" + android:visibility="gone" + android:background="@color/primary_dark" + android:elevation="4dp"> - + + + + + + + + + + + + + + + + + + android:id="@+id/spinnerStore" + android:layout_width="match_parent" + android:layout_height="44dp" + android:layout_marginTop="8dp" + android:background="@drawable/bg_spinner" + android:paddingStart="12dp" + android:paddingEnd="8dp"/> + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_pet_profile.xml b/android/app/src/main/res/layout/fragment_pet_profile.xml index b750bd29..f5af4445 100644 --- a/android/app/src/main/res/layout/fragment_pet_profile.xml +++ b/android/app/src/main/res/layout/fragment_pet_profile.xml @@ -221,6 +221,35 @@ + + + + + + + + @@ -241,4 +270,4 @@ - \ No newline at end of file + diff --git a/android/app/src/main/res/layout/fragment_product.xml b/android/app/src/main/res/layout/fragment_product.xml index 21c9d9ec..8dba3f51 100644 --- a/android/app/src/main/res/layout/fragment_product.xml +++ b/android/app/src/main/res/layout/fragment_product.xml @@ -12,6 +12,7 @@ android:orientation="vertical"> + android:textStyle="bold" + android:layout_marginStart="8dp"/> + + - + android:orientation="vertical" + android:paddingStart="12dp" + android:paddingEnd="12dp" + android:paddingTop="10dp" + android:paddingBottom="10dp" + android:visibility="gone" + android:background="@color/primary_dark" + android:elevation="4dp"> + + + + + + + + + + + + + android:textStyle="bold" + android:layout_marginStart="8dp"/> + + - + android:orientation="vertical" + android:paddingStart="12dp" + android:paddingEnd="12dp" + android:paddingTop="10dp" + android:paddingBottom="10dp" + android:visibility="gone" + android:background="@color/primary_dark" + android:elevation="4dp"> + + + + + + + + + + + + + + + + + + + + @@ -27,27 +28,78 @@ android:contentDescription="Open menu"/> + android:textStyle="bold" + android:layout_marginStart="8dp"/> + + - + android:orientation="vertical" + android:paddingStart="12dp" + android:paddingEnd="12dp" + android:paddingTop="10dp" + android:paddingBottom="10dp" + android:visibility="gone" + android:background="@color/primary_dark" + android:elevation="4dp"> + + + + + + + + + + + + + android:textStyle="bold" + android:layout_marginStart="8dp"/> + + - + android:orientation="vertical" + android:paddingStart="12dp" + android:paddingEnd="12dp" + android:paddingTop="10dp" + android:paddingBottom="10dp" + android:visibility="gone" + android:background="@color/primary_dark" + android:elevation="4dp"> + + + + + + + + + + + android:textStyle="bold" + android:layout_marginStart="8dp"/> + + - + android:orientation="vertical" + android:paddingStart="12dp" + android:paddingEnd="12dp" + android:paddingTop="10dp" + android:paddingBottom="10dp" + android:visibility="gone" + android:background="@color/primary_dark" + android:elevation="4dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/navigation/nav_graph.xml b/android/app/src/main/res/navigation/nav_graph.xml new file mode 100644 index 00000000..d104e4d5 --- /dev/null +++ b/android/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,26 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 37562787..3898c951 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,4 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.navigation.safeargs) apply false } \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 18b8ebb8..f1b9518b 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -5,9 +5,16 @@ junitVersion = "1.3.0" espressoCore = "3.7.0" appcompat = "1.7.1" material = "1.13.0" -activity = "1.12.4" +activity = "1.13.0" constraintlayout = "2.2.1" swiperefreshlayout = "1.2.0" +hilt = "2.51.1" +navigation = "2.8.8" +retrofit = "2.11.0" +okhttp = "4.12.0" +glide = "4.16.0" +viewpager2 = "1.1.0" +camera = "1.4.1" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -18,7 +25,29 @@ material = { group = "com.google.android.material", name = "material", version.r activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment", version.ref = "navigation" } +navigation-ui = { group = "androidx.navigation", name = "navigation-ui", version.ref = "navigation" } + +# Networking +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } + +# UI Components +viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "viewpager2" } +glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } +glide-compiler = { group = "com.github.bumptech.glide", name = "compiler", version.ref = "glide" } + +# CameraX +camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camera" } +camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camera" } +camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camera" } +camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camera" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } - +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +navigation-safeargs = { id = "androidx.navigation.safeargs", version.ref = "navigation" } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 7e694390..5ef50837 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,7 @@ #Sun Mar 01 14:36:37 MST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/backend/src/main/java/com/petshop/backend/controller/AppointmentController.java b/backend/src/main/java/com/petshop/backend/controller/AppointmentController.java index cf04f041..5e64afd9 100644 --- a/backend/src/main/java/com/petshop/backend/controller/AppointmentController.java +++ b/backend/src/main/java/com/petshop/backend/controller/AppointmentController.java @@ -36,20 +36,29 @@ public class AppointmentController { @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')") public ResponseEntity> getAllAppointments( @RequestParam(required = false) String q, + @RequestParam(required = false) Long storeId, + @RequestParam(required = false) String status, + @RequestParam(required = false) String date, + @RequestParam(required = false) Long customerId, + @RequestParam(required = false) Long employeeId, Pageable pageable) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String role = authentication.getAuthorities().stream() .findFirst() .map(authority -> authority.getAuthority().replace("ROLE_", "")) .orElse(null); - Long customerId = null; + Long effectiveCustomerId = customerId; if (role != null && role.equals("CUSTOMER")) { User user = AuthenticationHelper.getAuthenticatedUser(userRepository); - customerId = user.getId(); + effectiveCustomerId = user.getId(); } - return ResponseEntity.ok(appointmentService.getAllAppointments(q, pageable, customerId)); + LocalDate appointmentDate = (date != null && !date.isBlank()) ? LocalDate.parse(date) : null; + + return ResponseEntity.ok(appointmentService.getAllAppointments( + q, effectiveCustomerId, employeeId, storeId, status, appointmentDate, pageable)); } @GetMapping("/{id}") diff --git a/backend/src/main/java/com/petshop/backend/controller/InventoryController.java b/backend/src/main/java/com/petshop/backend/controller/InventoryController.java index 35ccbff9..f7aea48f 100644 --- a/backend/src/main/java/com/petshop/backend/controller/InventoryController.java +++ b/backend/src/main/java/com/petshop/backend/controller/InventoryController.java @@ -26,8 +26,9 @@ public class InventoryController { @GetMapping public ResponseEntity> getAllInventory( @RequestParam(required = false) String q, + @RequestParam(required = false) Long storeId, Pageable pageable) { - return ResponseEntity.ok(inventoryService.getAllInventory(q, pageable)); + return ResponseEntity.ok(inventoryService.getAllInventory(q, storeId, pageable)); } @GetMapping("/{id}") diff --git a/backend/src/main/java/com/petshop/backend/controller/ProductSupplierController.java b/backend/src/main/java/com/petshop/backend/controller/ProductSupplierController.java index e6d78e28..f521cb76 100644 --- a/backend/src/main/java/com/petshop/backend/controller/ProductSupplierController.java +++ b/backend/src/main/java/com/petshop/backend/controller/ProductSupplierController.java @@ -26,8 +26,10 @@ public class ProductSupplierController { @GetMapping public ResponseEntity> getAllProductSuppliers( @RequestParam(required = false) String q, + @RequestParam(required = false) Long productId, + @RequestParam(required = false) Long supplierId, Pageable pageable) { - return ResponseEntity.ok(productSupplierService.getAllProductSuppliers(q, pageable)); + return ResponseEntity.ok(productSupplierService.getAllProductSuppliers(q, productId, supplierId, pageable)); } @GetMapping("/{productId}/{supplierId}") diff --git a/backend/src/main/java/com/petshop/backend/controller/PurchaseOrderController.java b/backend/src/main/java/com/petshop/backend/controller/PurchaseOrderController.java index 369f6995..c73d9fcc 100644 --- a/backend/src/main/java/com/petshop/backend/controller/PurchaseOrderController.java +++ b/backend/src/main/java/com/petshop/backend/controller/PurchaseOrderController.java @@ -22,8 +22,9 @@ public class PurchaseOrderController { @GetMapping public ResponseEntity> getAllPurchaseOrders( @RequestParam(required = false) String q, + @RequestParam(required = false) Long storeId, Pageable pageable) { - return ResponseEntity.ok(purchaseOrderService.getAllPurchaseOrders(q, pageable)); + return ResponseEntity.ok(purchaseOrderService.getAllPurchaseOrders(q, storeId, pageable)); } @GetMapping("/{id}") diff --git a/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java b/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java index 00edebf9..dc7d40ef 100644 --- a/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java @@ -22,20 +22,25 @@ public interface AppointmentRepository extends JpaRepository List findByStoreAndDate(@Param("storeId") Long storeId, @Param("date") LocalDate date); @Query("SELECT a FROM Appointment a LEFT JOIN a.pet p WHERE " + + "(:q IS NULL OR (" + "LOWER(a.customer.firstName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + "LOWER(a.customer.lastName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + "LOWER(a.service.serviceName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + - "LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%'))") - Page searchAppointments(@Param("q") String query, Pageable pageable); - - Page findByCustomerId(Long customerId, Pageable pageable); - - @Query("SELECT a FROM Appointment a LEFT JOIN a.pet p WHERE a.customer.id = :customerId AND (" + - "LOWER(a.customer.firstName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + - "LOWER(a.customer.lastName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + - "LOWER(a.service.serviceName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + - "LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')))") - Page searchAppointmentsByCustomer(@Param("customerId") Long customerId, @Param("q") String query, Pageable pageable); + "LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%'))" + + ")) AND " + + "(:customerId IS NULL OR a.customer.id = :customerId) AND " + + "(:employeeId IS NULL OR a.employee.id = :employeeId) AND " + + "(:storeId IS NULL OR a.store.storeId = :storeId) AND " + + "(:status IS NULL OR LOWER(a.appointmentStatus) = LOWER(:status)) AND " + + "(:date IS NULL OR a.appointmentDate = :date)") + Page searchAppointments( + @Param("q") String query, + @Param("customerId") Long customerId, + @Param("employeeId") Long employeeId, + @Param("storeId") Long storeId, + @Param("status") String status, + @Param("date") LocalDate date, + Pageable pageable); @Query("SELECT a FROM Appointment a JOIN FETCH a.service WHERE a.employee.id = :employeeId AND a.appointmentDate = :date AND LOWER(a.appointmentStatus) NOT IN ('cancelled', 'missed')") List findByEmployeeIdAndAppointmentDate(@Param("employeeId") Long employeeId, @Param("date") LocalDate date); diff --git a/backend/src/main/java/com/petshop/backend/repository/InventoryRepository.java b/backend/src/main/java/com/petshop/backend/repository/InventoryRepository.java index b448b497..7dd535eb 100644 --- a/backend/src/main/java/com/petshop/backend/repository/InventoryRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/InventoryRepository.java @@ -20,8 +20,10 @@ public interface InventoryRepository extends JpaRepository { Optional findByProductIdAndStoreId(@Param("productId") Long productId, @Param("storeId") Long storeId); @Query("SELECT i FROM Inventory i LEFT JOIN i.store s WHERE " + + "(:q IS NULL OR (" + "LOWER(i.product.prodName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + "LOWER(i.product.category.categoryName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + - "LOWER(s.storeName) LIKE LOWER(CONCAT('%', :q, '%'))") - Page searchInventory(@Param("q") String query, Pageable pageable); + "LOWER(s.storeName) LIKE LOWER(CONCAT('%', :q, '%')))) AND " + + "(:storeId IS NULL OR i.store.storeId = :storeId)") + Page searchInventory(@Param("q") String query, @Param("storeId") Long storeId, Pageable pageable); } diff --git a/backend/src/main/java/com/petshop/backend/repository/ProductSupplierRepository.java b/backend/src/main/java/com/petshop/backend/repository/ProductSupplierRepository.java index 46e87945..15d17703 100644 --- a/backend/src/main/java/com/petshop/backend/repository/ProductSupplierRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/ProductSupplierRepository.java @@ -12,7 +12,13 @@ import org.springframework.stereotype.Repository; public interface ProductSupplierRepository extends JpaRepository { @Query("SELECT ps FROM ProductSupplier ps WHERE " + - "LOWER(ps.product.prodName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + - "LOWER(ps.supplier.supCompany) LIKE LOWER(CONCAT('%', :q, '%'))") - Page searchProductSuppliers(@Param("q") String query, Pageable pageable); + "(:q IS NULL OR (LOWER(ps.product.prodName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + + "LOWER(ps.supplier.supCompany) LIKE LOWER(CONCAT('%', :q, '%')))) AND " + + "(:productId IS NULL OR ps.product.prodId = :productId) AND " + + "(:supplierId IS NULL OR ps.supplier.supId = :supplierId)") + Page searchProductSuppliers( + @Param("q") String query, + @Param("productId") Long productId, + @Param("supplierId") Long supplierId, + Pageable pageable); } diff --git a/backend/src/main/java/com/petshop/backend/repository/PurchaseOrderRepository.java b/backend/src/main/java/com/petshop/backend/repository/PurchaseOrderRepository.java index d3b445c4..5ebf9457 100644 --- a/backend/src/main/java/com/petshop/backend/repository/PurchaseOrderRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/PurchaseOrderRepository.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Repository; public interface PurchaseOrderRepository extends JpaRepository { @Query("SELECT po FROM PurchaseOrder po WHERE " + - "LOWER(po.supplier.supCompany) LIKE LOWER(CONCAT('%', :q, '%'))") - Page searchPurchaseOrders(@Param("q") String query, Pageable pageable); + "(:q IS NULL OR LOWER(po.supplier.supCompany) LIKE LOWER(CONCAT('%', :q, '%'))) AND " + + "(:storeId IS NULL OR po.store.storeId = :storeId)") + Page searchPurchaseOrders(@Param("q") String query, @Param("storeId") Long storeId, Pageable pageable); } diff --git a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java index d7a65149..d037b057 100644 --- a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java +++ b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java @@ -45,22 +45,27 @@ public class AppointmentService { } @Transactional(readOnly = true) - public Page getAllAppointments(String query, Pageable pageable, Long customerId) { - Page appointments; + public Page getAllAppointments( + String query, + Long customerId, + Long employeeId, + Long storeId, + String status, + LocalDate date, + Pageable pageable) { - if (customerId != null) { - if (query != null && !query.trim().isEmpty()) { - appointments = appointmentRepository.searchAppointmentsByCustomer(customerId, query, pageable); - } else { - appointments = appointmentRepository.findByCustomerId(customerId, pageable); - } - } else { - if (query != null && !query.trim().isEmpty()) { - appointments = appointmentRepository.searchAppointments(query, pageable); - } else { - appointments = appointmentRepository.findAll(pageable); - } - } + String normalizedQuery = normalizeFilter(query); + String normalizedStatus = normalizeFilter(status); + + Page appointments = appointmentRepository.searchAppointments( + normalizedQuery, + customerId, + employeeId, + storeId, + normalizedStatus, + date, + pageable + ); return appointments.map(this::mapToResponse); } @@ -204,6 +209,14 @@ public class AppointmentService { return availableSlots; } + private String normalizeFilter(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + private void validateAppointmentRequest(AppointmentRequest request) { if ("Booked".equalsIgnoreCase(request.getAppointmentStatus())) { LocalDateTime appointmentDateTime = LocalDateTime.of(request.getAppointmentDate(), request.getAppointmentTime()); diff --git a/backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java b/backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java index 952ca600..dff64508 100644 --- a/backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java +++ b/backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java @@ -48,7 +48,10 @@ public class AvatarStorageService { if (user.getAvatarUrl() == null || user.getAvatarUrl().isBlank()) { return; } - Files.deleteIfExists(resolveStoredAvatarPath(user.getAvatarUrl())); + try { + Files.deleteIfExists(resolveStoredAvatarPath(user.getAvatarUrl())); + } catch (IllegalArgumentException ignored) { + } } public String toOwnerAvatarUrl(User user) { diff --git a/backend/src/main/java/com/petshop/backend/service/InventoryService.java b/backend/src/main/java/com/petshop/backend/service/InventoryService.java index 499e8dd5..884458f9 100644 --- a/backend/src/main/java/com/petshop/backend/service/InventoryService.java +++ b/backend/src/main/java/com/petshop/backend/service/InventoryService.java @@ -28,13 +28,9 @@ public class InventoryService { this.storeRepository = storeRepository; } - public Page getAllInventory(String query, Pageable pageable) { - Page inventory; - if (query != null && !query.trim().isEmpty()) { - inventory = inventoryRepository.searchInventory(query, pageable); - } else { - inventory = inventoryRepository.findAll(pageable); - } + public Page getAllInventory(String query, Long storeId, Pageable pageable) { + String normalizedQuery = normalizeFilter(query); + Page inventory = inventoryRepository.searchInventory(normalizedQuery, storeId, pageable); return inventory.map(this::mapToResponse); } @@ -97,6 +93,14 @@ public class InventoryService { inventoryRepository.deleteAllById(request.getIds()); } + private String normalizeFilter(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + private InventoryResponse mapToResponse(Inventory inventory) { StoreLocation store = inventory.getStore(); return new InventoryResponse( diff --git a/backend/src/main/java/com/petshop/backend/service/PetService.java b/backend/src/main/java/com/petshop/backend/service/PetService.java index ef6ab4c5..09dd402b 100644 --- a/backend/src/main/java/com/petshop/backend/service/PetService.java +++ b/backend/src/main/java/com/petshop/backend/service/PetService.java @@ -231,7 +231,7 @@ public class PetService { } try { catalogImageStorageService.deletePetImage(storedImagePath); - } catch (IOException ignored) { + } catch (IOException | IllegalArgumentException ignored) { } } diff --git a/backend/src/main/java/com/petshop/backend/service/ProductService.java b/backend/src/main/java/com/petshop/backend/service/ProductService.java index 8d4b1da2..d18890d5 100644 --- a/backend/src/main/java/com/petshop/backend/service/ProductService.java +++ b/backend/src/main/java/com/petshop/backend/service/ProductService.java @@ -143,7 +143,7 @@ public class ProductService { } try { catalogImageStorageService.deleteProductImage(storedImagePath); - } catch (IOException ignored) { + } catch (IOException | IllegalArgumentException ignored) { } } diff --git a/backend/src/main/java/com/petshop/backend/service/ProductSupplierService.java b/backend/src/main/java/com/petshop/backend/service/ProductSupplierService.java index 7e3677a9..c9be85f1 100644 --- a/backend/src/main/java/com/petshop/backend/service/ProductSupplierService.java +++ b/backend/src/main/java/com/petshop/backend/service/ProductSupplierService.java @@ -11,10 +11,15 @@ import com.petshop.backend.repository.ProductRepository; import com.petshop.backend.repository.ProductSupplierRepository; import com.petshop.backend.repository.SupplierRepository; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.List; + @Service public class ProductSupplierService { @@ -28,13 +33,10 @@ public class ProductSupplierService { this.supplierRepository = supplierRepository; } - public Page getAllProductSuppliers(String query, Pageable pageable) { - Page productSuppliers; - if (query != null && !query.trim().isEmpty()) { - productSuppliers = productSupplierRepository.searchProductSuppliers(query, pageable); - } else { - productSuppliers = productSupplierRepository.findAll(pageable); - } + public Page getAllProductSuppliers(String query, Long productId, Long supplierId, Pageable pageable) { + String normalizedQuery = normalizeFilter(query); + Pageable mappedPageable = mapSortProperties(pageable); + Page productSuppliers = productSupplierRepository.searchProductSuppliers(normalizedQuery, productId, supplierId, mappedPageable); return productSuppliers.map(this::mapToResponse); } @@ -95,6 +97,33 @@ public class ProductSupplierService { }); } + private String normalizeFilter(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private Pageable mapSortProperties(Pageable pageable) { + if (pageable.getSort().isUnsorted()) { + return pageable; + } + + List orders = new ArrayList<>(); + for (Sort.Order order : pageable.getSort()) { + String property = order.getProperty(); + if ("productName".equalsIgnoreCase(property)) { + orders.add(new Sort.Order(order.getDirection(), "product.prodName")); + } else if ("supplierName".equalsIgnoreCase(property)) { + orders.add(new Sort.Order(order.getDirection(), "supplier.supCompany")); + } else { + orders.add(order); + } + } + return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(orders)); + } + private ProductSupplierResponse mapToResponse(ProductSupplier productSupplier) { return new ProductSupplierResponse( productSupplier.getProduct().getProdId(), diff --git a/backend/src/main/java/com/petshop/backend/service/PurchaseOrderService.java b/backend/src/main/java/com/petshop/backend/service/PurchaseOrderService.java index e9f7e4a5..7c42cee9 100644 --- a/backend/src/main/java/com/petshop/backend/service/PurchaseOrderService.java +++ b/backend/src/main/java/com/petshop/backend/service/PurchaseOrderService.java @@ -18,13 +18,9 @@ public class PurchaseOrderService { this.purchaseOrderRepository = purchaseOrderRepository; } - public Page getAllPurchaseOrders(String query, Pageable pageable) { - Page purchaseOrders; - if (query != null && !query.trim().isEmpty()) { - purchaseOrders = purchaseOrderRepository.searchPurchaseOrders(query, pageable); - } else { - purchaseOrders = purchaseOrderRepository.findAll(pageable); - } + public Page getAllPurchaseOrders(String query, Long storeId, Pageable pageable) { + String normalizedQuery = normalizeFilter(query); + Page purchaseOrders = purchaseOrderRepository.searchPurchaseOrders(normalizedQuery, storeId, pageable); return purchaseOrders.map(this::mapToResponse); } @@ -34,6 +30,14 @@ public class PurchaseOrderService { return mapToResponse(purchaseOrder); } + private String normalizeFilter(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + private PurchaseOrderResponse mapToResponse(PurchaseOrder purchaseOrder) { StoreLocation store = purchaseOrder.getStore(); return new PurchaseOrderResponse(