diff --git a/android/.gitignore b/android/.gitignore index f7930e52..edc74b8b 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -1,4 +1,5 @@ *.iml +nohup.out .gradle /local.properties /.idea/* @@ -16,6 +17,8 @@ /app/src/androidTest/ /app/src/test/ .DS_Store +/.project +/.settings/ /build /captures .externalNativeBuild diff --git a/android/app/.gitignore b/android/app/.gitignore index 12b09d99..fdcdf404 100644 --- a/android/app/.gitignore +++ b/android/app/.gitignore @@ -1,3 +1,7 @@ /build +/nohup.out +/.classpath +/.project +/.settings/ /src/test/ /src/androidTest/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d1cc3c30..fa8c88a7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -56,6 +56,9 @@ dependencies { 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") + testImplementation(libs.junit) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 633ea6ba..cef8c8c2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> + + + + requestPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { + if (!isGranted) { + Log.w("HomeActivity", "Notification permission denied"); + } + }); @Override protected void onCreate(Bundle savedInstanceState) { @@ -34,17 +49,15 @@ public class HomeActivity extends AppCompatActivity { }); //get the bottom navbar from the layout - BottomNavigationView bottomNav = findViewById(R.id.bottom_navigation); - - // Load ListFragment by default only if this is a fresh start + bottomNav = findViewById(R.id.bottom_navigation); + + //load the list fragment by default if it's a fresh start if (savedInstanceState == null) { - loadFragment(new ListFragment()); - bottomNav.setSelectedItemId(R.id.nav_list); + handleIntent(getIntent()); } - //when an item in the bar is selected, load the corresponding fragment + //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; @@ -57,13 +70,59 @@ public class HomeActivity extends AppCompatActivity { } 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 + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + handleIntent(intent); } - //helper function to load a fragment + // Helper function to process intents for navigation. + // like clicking a notification or just launching the app from a fresh start + 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); + } + 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 + private void startNotificationService() { + Intent serviceIntent = new Intent(this, ChatNotificationService.class); + startService(serviceIntent); + } + + //Helper function to request for notification permission + private void requestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS); + } + } + } + + //Helper function to load a fragment private void loadFragment(Fragment fragment) { getSupportFragmentManager() .beginTransaction() .replace(R.id.fragment_container, fragment) .commit(); } -} \ No newline at end of file +} 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 6bc65f23..242000c8 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 @@ -19,6 +19,7 @@ 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 retrofit2.Call; import retrofit2.Callback; @@ -38,11 +39,17 @@ public class MainActivity extends AppCompatActivity { super.onCreate(savedInstanceState); // Check if user is already logged in - if (TokenManager.getInstance(this).isLoggedIn()) { - Intent intent = new Intent(this, HomeActivity.class); - startActivity(intent); - finish(); - return; + 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); + finish(); + return; + } } EdgeToEdge.enable(this); @@ -82,19 +89,28 @@ public class MainActivity extends AppCompatActivity { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful() && response.body() != null) { + String role = response.body().getRole(); + + // Check if the user is a CUSTOMER and deny login if so + 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; + } + //save login data in shared preferences TokenManager.getInstance(MainActivity.this).saveLoginData( response.body().getToken(), response.body().getUsername(), - response.body().getRole() + role ); //fetch user id from api then login to home activity - RetrofitClient.getAuthApi(MainActivity.this).getCurrentUser() - .enqueue(new Callback() { + RetrofitClient.getAuthApi(MainActivity.this).getMe() + .enqueue(new Callback() { @Override - public void onResponse(Call call, - Response response) { + public void onResponse(Call call, + Response response) { if (response.isSuccessful() && response.body() != null) { TokenManager.getInstance(MainActivity.this) .saveUserId(response.body().getId()); @@ -106,7 +122,7 @@ public class MainActivity extends AppCompatActivity { } @Override - public void onFailure(Call call, + public void onFailure(Call call, Throwable t) { Log.e("MainActivity", "Failed to fetch userId", t); @@ -129,4 +145,4 @@ public class MainActivity extends AppCompatActivity { }); }); } -} \ 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 35fccfbc..e2eb3090 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 @@ -3,17 +3,23 @@ package com.example.petstoremobile.api; import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.dtos.PetDTO; +import okhttp3.MultipartBody; import retrofit2.Call; import retrofit2.http.Body; import retrofit2.http.DELETE; import retrofit2.http.GET; +import retrofit2.http.Multipart; import retrofit2.http.POST; import retrofit2.http.PUT; +import retrofit2.http.Part; import retrofit2.http.Path; import retrofit2.http.Query; //api calls to CRUD pets public interface PetApi { + // endpoint for downloading the pet's image file + String PET_IMAGE_PATH = "api/v1/pets/%d/image"; + // Get all pets @GET("api/v1/pets") Call> getAllPets( @@ -37,4 +43,9 @@ public interface PetApi { @DELETE("api/v1/pets/{id}") Call deletePet(@Path("id") Long id); + // Upload pet image + @Multipart + @POST("api/v1/pets/{id}/image") + Call uploadPetImage(@Path("id") Long id, @Part MultipartBody.Part image); + } 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 250dabb6..4cee968b 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 @@ -1,6 +1,7 @@ package com.example.petstoremobile.api; import android.content.Context; +import android.os.Build; import com.example.petstoremobile.api.auth.AuthApi; import com.example.petstoremobile.api.auth.AuthInterceptor; @@ -12,9 +13,23 @@ import retrofit2.converter.gson.GsonConverterFactory; //Retrofit client Used for API calls public class RetrofitClient { - //base URL - public static final String BASE_URL = "http://10.0.2.2:8080"; //for emulator testing -// public static final String BASE_URL = "http://10.0.0.200:8080/"; //for hardware testing + public static final String BASE_URL = getBaseUrl(); + + // Helper function to determine BASE_URL based on whether we are testing on an emulator or a real device + private static String getBaseUrl() { + if (Build.FINGERPRINT.contains("generic") + || Build.FINGERPRINT.contains("unknown") + || Build.MODEL.contains("google_sdk") + || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for x86") + || Build.MANUFACTURER.contains("Genymotion") + || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) + || "google_sdk".equals(Build.PRODUCT)) { + return "http://10.0.2.2:8080/"; //emulator testing + } else { + return "http://10.0.0.200:8080/"; //Hardware testing + } + } private static Retrofit retrofit = null; @@ -95,7 +110,6 @@ public class RetrofitClient { return getClient(context).create(MessageApi.class); } - public static StoreApi getStoreApi(Context context) { return getClient(context).create(StoreApi.class); } diff --git a/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java b/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java index cf82623f..75605083 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java @@ -1,17 +1,40 @@ package com.example.petstoremobile.api.auth; import com.example.petstoremobile.dtos.AuthDTO; +import com.example.petstoremobile.dtos.UserDTO; +import java.util.Map; + +import okhttp3.MultipartBody; import retrofit2.Call; import retrofit2.http.Body; import retrofit2.http.GET; +import retrofit2.http.Multipart; import retrofit2.http.POST; +import retrofit2.http.PUT; +import retrofit2.http.Part; //Api for logging in and getting current user public interface AuthApi { + + // endpoint for downloading the current user's avatar file + String AVATAR_FILE_PATH = "api/v1/auth/me/avatar/file"; + + //login endpoint @POST("api/v1/auth/login") Call login(@Body AuthDTO.LoginRequest loginRequest); + //get current user endpoint @GET("api/v1/auth/me") - Call getCurrentUser(); + Call getMe(); + + //update current user endpoint + @PUT("api/v1/auth/me") + Call updateMe(@Body Map updates); + + //upload avatar endpoint + @Multipart + @POST("api/v1/auth/me/avatar") + Call uploadAvatar(@Part MultipartBody.Part avatar); + } diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/ErrorResponse.java b/android/app/src/main/java/com/example/petstoremobile/dtos/ErrorResponse.java new file mode 100644 index 00000000..ddb23eaa --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/ErrorResponse.java @@ -0,0 +1,15 @@ +package com.example.petstoremobile.dtos; + +//Used to get messages of any errors from the backend + +public class ErrorResponse { + private String message; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/UserDTO.java b/android/app/src/main/java/com/example/petstoremobile/dtos/UserDTO.java new file mode 100644 index 00000000..2ab9902e --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/UserDTO.java @@ -0,0 +1,50 @@ +package com.example.petstoremobile.dtos; + +public class UserDTO { + private Long id; + private String username; + private String email; + private String fullName; + private String phone; + private String avatarUrl; + private String role; + private Long storeId; + private String storeName; + + // Getters + public Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public String getEmail() { + return email; + } + + public String getFullName() { + return fullName; + } + + public String getPhone() { + return phone; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public String getRole() { + return role; + } + + public Long getStoreId() { + return storeId; + } + + public String getStoreName() { + return 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 b73fdc6b..0e405c63 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 @@ -25,6 +25,7 @@ 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.websocket.StompChatManager; import java.util.*; import java.util.stream.Collectors; @@ -40,6 +41,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis private RecyclerView rvChatList, rvMessages; private EditText etMessage; private Button btnSend; + private TextView tvChatTitle; // Adapters private ChatAdapter chatAdapter; @@ -75,6 +77,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis rvMessages = view.findViewById(R.id.rvMessages); etMessage = view.findViewById(R.id.etMessage); btnSend = view.findViewById(R.id.btnSend); + tvChatTitle = view.findViewById(R.id.tvChatTitle); ImageButton hamburger = view.findViewById(R.id.btnHamburger); hamburger.setOnClickListener(v -> drawerLayout.openDrawer(GravityCompat.START)); @@ -121,6 +124,10 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis Log.e(TAG, "No token found"); } + if (getArguments() != null && getArguments().containsKey("conversation_id")) { + activeConversationId = getArguments().getLong("conversation_id"); + } + loadCustomers(); } @@ -165,7 +172,21 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis .collect(Collectors.toList()); chatList.addAll(loaded); chatAdapter.notifyDataSetChanged(); - if (activeConversationId == null) { + + 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); @@ -186,6 +207,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis public void onChatClick(Chat chat) { activeConversationId = Long.parseLong(chat.getChatId()); setConversationActive(true); + tvChatTitle.setText(chat.getCustomerName()); drawerLayout.closeDrawer(GravityCompat.START); if (stompChatManager != null) { @@ -305,6 +327,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis if (activeConversationId != null && activeConversationId.equals(dto.getId())) { setConversationActive(true); + tvChatTitle.setText(name); } } @@ -386,6 +409,8 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis etMessage.setEnabled(active); if (!active) { activeConversationId = null; + ChatNotificationService.activeConversationIdInUi = null; + if (tvChatTitle != null) tvChatTitle.setText("Customer Chat"); if (stompChatManager != null) { stompChatManager.clearConversationSubscription(); } @@ -395,6 +420,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis etMessage.setHint("Select a chat to start messaging"); } else { etMessage.setHint("Type a message..."); + ChatNotificationService.activeConversationIdInUi = activeConversationId; } } @@ -402,6 +428,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis @Override public void onDestroyView() { super.onDestroyView(); + 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 dee6ca80..b63b42b1 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 @@ -14,6 +14,7 @@ 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; @@ -56,6 +57,13 @@ public class ListFragment extends Fragment { drawerPurchaseOrderView=view.findViewById(R.id.drawerPurchaseOrderView); + // Check user role and restrict access for STAFF + String role = TokenManager.getInstance(requireContext()).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); @@ -170,4 +178,4 @@ public class ListFragment extends Fragment { .addToBackStack(null) .commit(); } -} \ No newline at end of file +} 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 2bb92e4c..c7253c70 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 @@ -16,6 +16,7 @@ import androidx.fragment.app.Fragment; import android.provider.MediaStore; import android.text.InputType; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -23,20 +24,43 @@ 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.dtos.UserDTO; +import com.example.petstoremobile.services.ChatNotificationService; +import com.example.petstoremobile.utils.InputValidator; +import com.google.gson.Gson; import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.RequestBody; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; public class ProfileFragment extends Fragment { //initialize the view/controls private ImageView imgProfile; private TextView tvProfileName, tvProfileEmail, tvProfilePhone, tvProfileRole; - private Button btnChangePhoto, btnEditEmail, btnEditPhone, btnLogout; private Uri photoUri; + private UserDTO currentUser; //Initialize the launchers for camera and gallery private ActivityResultLauncher galleryLauncher; @@ -58,8 +82,7 @@ public class ProfileFragment extends Fragment { && result.getData() != null) { //get the selected image and set the image to the profile Uri selectedImage = result.getData().getData(); - imgProfile.setImageURI(selectedImage); - //TODO: SAVE CHANGED PHOTO TO DATABASE + uploadAvatar(selectedImage); } } ); @@ -71,10 +94,7 @@ public class ProfileFragment extends Fragment { success -> { //if a photo is taken set the image profile to it otherwise do nothing if (success) { - //Clear the old image and set the new one - imgProfile.setImageURI(null); - imgProfile.setImageURI(photoUri); - //TODO: SAVE CHANGED PHOTO TO DATABASE + uploadAvatar(photoUri); } } ); @@ -107,7 +127,6 @@ public class ProfileFragment extends Fragment { ); } - //TODO: MAKE PROFILE VIEW DISPLAY PROFILE DATA FROM DATABASE @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -119,10 +138,13 @@ public class ProfileFragment extends Fragment { tvProfileEmail = view.findViewById(R.id.tvProfileEmail); tvProfilePhone = view.findViewById(R.id.tvProfilePhone); tvProfileRole = view.findViewById(R.id.tvProfileRole); - btnChangePhoto = view.findViewById(R.id.btnChangePhoto); - btnEditEmail = view.findViewById(R.id.btnEditEmail); - btnEditPhone = view.findViewById(R.id.btnEditPhone); - btnLogout = view.findViewById(R.id.btnLogout); + 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); + + //Load Profile Data from backend + loadProfileData(); //Set up listeners for the buttons //Change photo button @@ -150,7 +172,6 @@ public class ProfileFragment extends Fragment { } }) .show(); - //TODO: UPDATE PHOTO IN DATABASE }); //Edit email button @@ -170,19 +191,10 @@ public class ProfileFragment extends Fragment { .setTitle("Edit Email") .setView(input) .setPositiveButton("Save", (dialog, which) -> { - String newEmail = input.getText().toString(); - //if the new value is a valid email then set the email to the new value - if (android.util.Patterns.EMAIL_ADDRESS.matcher(newEmail).matches()) { - tvProfileEmail.setText(newEmail); - //TODO: UPDATE THE EMAIL IN DATABASE - } - else { - //tell the user to email is invalid - new AlertDialog.Builder(requireContext()) - .setTitle("Error") - .setMessage("Email is invalid") - .setPositiveButton("OK", null) - .show(); + if (InputValidator.isValidEmail(input)) { + updateProfileField("email", input.getText().toString()); + } else { + Toast.makeText(requireContext(), "Email is invalid", Toast.LENGTH_SHORT).show(); } }) .setNegativeButton("Cancel", null) @@ -210,19 +222,10 @@ public class ProfileFragment extends Fragment { .setTitle("Edit Phone Number") .setView(input) .setPositiveButton("Save", (dialog, which) -> { - String newPhone = input.getText().toString(); - //if the new value is format: (XXX) XXX-XXXX then set the phone to the new value - if (newPhone.matches("\\(\\d{3}\\) \\d{3}-\\d{4}")) { //TODO MAKE VALIDATION CLASS INSTEAD FOR THIS - tvProfilePhone.setText(newPhone); - //TODO: UPDATE PHONE IN DATABASE - } - else { - //tell the user to email cannot be empty - new AlertDialog.Builder(requireContext()) - .setTitle("Error") - .setMessage("Phone number is invalid. Format: (XXX) XXX-XXXX") - .setPositiveButton("OK", null) - .show(); + if (InputValidator.isValidPhone(input)) { + updateProfileField("phone", input.getText().toString()); + } else { + Toast.makeText(requireContext(), "Phone number is invalid", Toast.LENGTH_SHORT).show(); } }) .setNegativeButton("Cancel", null) @@ -231,6 +234,10 @@ public class ProfileFragment extends Fragment { //Logout button btnLogout.setOnClickListener(v -> { + // Stop notification service before logging out so notifications stop + Intent serviceIntent = new Intent(requireContext(), ChatNotificationService.class); + requireContext().stopService(serviceIntent); + TokenManager.getInstance(requireContext()).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); @@ -252,4 +259,152 @@ public class ProfileFragment extends Fragment { //launch the camera to capture the photo and save the photo to photoUri cameraLauncher.launch(photoUri); } -} \ No newline at end of file + + //Helper function to call the backend to get profile data and load it to the view + private void loadProfileData() { + AuthApi authApi = RetrofitClient.getAuthApi(requireContext()); + + 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 + 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 = RetrofitClient.BASE_URL + AuthApi.AVATAR_FILE_PATH; + String token = TokenManager.getInstance(requireContext()).getToken(); + + if (token != null) { + // Create GlideUrl with token to fetch the image + GlideUrl glideUrl = new GlideUrl(avatarUrl, new LazyHeaders.Builder() + .addHeader("Authorization", "Bearer " + token) + .build()); + + // Load image using Glide + Glide.with(ProfileFragment.this) + .load(glideUrl) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .placeholder(R.drawable.placeholder) + .error(R.drawable.placeholder) + .into(imgProfile); + } else { + // load placeholder image if token is null + Glide.with(ProfileFragment.this) + .load(R.drawable.placeholder) + .into(imgProfile); + } + } + 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(); + } + }); + } + + //Helper function to call the backend to upload a profile image + private void uploadAvatar(Uri uri) { + try { + File file = getFileFromUri(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("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(); + } + }); + } catch (Exception e) { + Log.e("UPLOAD_AVATAR", "Error: " + e.getMessage()); + } + } + + // 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 + 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(); + } + }); + } +} 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 6448b5f3..91257673 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 @@ -13,8 +13,11 @@ import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; import android.widget.EditText; import android.widget.ImageButton; +import android.widget.Spinner; import android.widget.Toast; import com.example.petstoremobile.R; @@ -43,6 +46,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen private PetApi api; private SwipeRefreshLayout swipeRefreshLayout; private EditText etSearch; + private Spinner spinnerStatus; //load pet view @Override @@ -57,6 +61,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen setupRecyclerView(view); setupSearch(view); + setupStatusFilter(view); setupSwipeRefresh(view); loadPetData(); @@ -82,24 +87,48 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen etSearch.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} @Override public void onTextChanged(CharSequence s, int start, int before, int count) { - filterPets(s.toString()); + filterPets(); } @Override public void afterTextChanged(Editable s) {} }); } - private void filterPets(String query) { + //Setup the status filter spinner + private void setupStatusFilter(View view) { + spinnerStatus = view.findViewById(R.id.spinnerStatus); + String[] statuses = {"All Statuses", "Available", "Adopted"}; + ArrayAdapter adapter = new ArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_item, statuses); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinnerStatus.setAdapter(adapter); + + spinnerStatus.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + filterPets(); + } + + @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(); - if (query.isEmpty()) { - filteredList.addAll(petList); - } else { - String lower = query.toLowerCase(); - for (PetDTO p : petList) { - if (p.getPetName().toLowerCase().contains(lower) - || p.getPetSpecies().toLowerCase().contains(lower) - || p.getPetBreed().toLowerCase().contains(lower)) { - filteredList.add(p); - } + for (PetDTO p : petList) { + boolean matchesSearch = query.isEmpty() || + p.getPetName().toLowerCase().contains(query) || + p.getPetSpecies().toLowerCase().contains(query) || + p.getPetBreed().toLowerCase().contains(query); + + boolean matchesStatus = selectedStatus.equals("All Statuses") || + p.getPetStatus().equalsIgnoreCase(selectedStatus); + + if (matchesSearch && matchesStatus) { + filteredList.add(p); } } adapter.notifyDataSetChanged(); @@ -173,7 +202,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen if (response.isSuccessful() && response.body() != null) { petList.clear(); petList.addAll(response.body().getContent()); - filterPets(etSearch.getText().toString()); + filterPets(); } else { Log.e("onResponse: ", response.message()); 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 6906bc73..70c19833 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 @@ -25,6 +25,8 @@ import com.example.petstoremobile.api.RetrofitClient; import com.example.petstoremobile.dtos.PetDTO; import com.example.petstoremobile.fragments.ListFragment; import com.example.petstoremobile.fragments.listfragments.PetFragment; +import com.example.petstoremobile.utils.ActivityLogger; +import com.example.petstoremobile.utils.InputValidator; import retrofit2.Call; import retrofit2.Callback; @@ -65,26 +67,27 @@ public class PetDetailFragment extends Fragment { //Method to Update or Add a pet 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; + //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(); - String ageStr = etPetAge.getText().toString().trim(); + int age = Integer.parseInt(etPetAge.getText().toString().trim()); String priceStr = etPetPrice.getText().toString().trim(); String status = spinnerPetStatus.getSelectedItem().toString(); - //check if all the fields are filled - if (name.isEmpty() || species.isEmpty() || breed.isEmpty() || ageStr.isEmpty() || priceStr.isEmpty()) { - Toast.makeText(getContext(), "Please fill in all fields", Toast.LENGTH_SHORT).show(); - return; - } - //create a pet object to send to the API PetDTO petDTO = new PetDTO(); petDTO.setPetName(name); petDTO.setPetSpecies(species); petDTO.setPetBreed(breed); - petDTO.setPetAge(Integer.parseInt(ageStr)); + petDTO.setPetAge(age); petDTO.setPetPrice(priceStr); petDTO.setPetStatus(status); @@ -98,6 +101,7 @@ public class PetDetailFragment extends Fragment { @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 { @@ -107,6 +111,7 @@ public class PetDetailFragment extends Fragment { @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(); } @@ -117,6 +122,7 @@ public class PetDetailFragment extends Fragment { @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 { @@ -126,6 +132,7 @@ public class PetDetailFragment extends Fragment { @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(); } @@ -146,6 +153,7 @@ public class PetDetailFragment extends Fragment { @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 { @@ -155,6 +163,7 @@ public class PetDetailFragment extends Fragment { @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(); } 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 2defbb69..c7b0a4c5 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 @@ -20,6 +20,8 @@ import com.example.petstoremobile.api.ServiceApi; 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.InputValidator; import retrofit2.Call; import retrofit2.Callback; @@ -58,24 +60,24 @@ public class ServiceDetailFragment extends Fragment { //Method to Update or Add a service 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; + //get all the values from the fields String name = etServiceName.getText().toString().trim(); String desc = etServiceDesc.getText().toString().trim(); - String durationStr = etServiceDuration.getText().toString().trim(); - String priceStr = etServicePrice.getText().toString().trim(); - - //check if all the fields are filled (desc is optional) - if (name.isEmpty() || durationStr.isEmpty() || priceStr.isEmpty()) { - Toast.makeText(getContext(), "Please fill in all fields", Toast.LENGTH_SHORT).show(); - return; - } + int duration = Integer.parseInt(etServiceDuration.getText().toString().trim()); + double price = Double.parseDouble(etServicePrice.getText().toString().trim()); //create a service object to send to the API ServiceDTO serviceDTO = new ServiceDTO(); serviceDTO.setServiceName(name); serviceDTO.setServiceDesc(desc); - serviceDTO.setServiceDuration(Integer.parseInt(durationStr)); - serviceDTO.setServicePrice(Double.parseDouble(priceStr)); + serviceDTO.setServiceDuration(duration); + serviceDTO.setServicePrice(price); ServiceApi serviceApi = RetrofitClient.getServiceApi(requireContext()); @@ -87,6 +89,7 @@ public class ServiceDetailFragment extends Fragment { @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 { @@ -96,6 +99,7 @@ public class ServiceDetailFragment extends Fragment { @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(); } @@ -106,6 +110,7 @@ public class ServiceDetailFragment extends Fragment { @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 { @@ -115,6 +120,7 @@ public class ServiceDetailFragment extends Fragment { @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(); } @@ -134,6 +140,7 @@ public class ServiceDetailFragment extends Fragment { @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 { @@ -143,6 +150,7 @@ public class ServiceDetailFragment extends Fragment { @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(); } 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 8537d6c2..df5c5520 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 @@ -20,6 +20,8 @@ import com.example.petstoremobile.api.SupplierApi; 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.InputValidator; import retrofit2.Call; import retrofit2.Callback; @@ -58,6 +60,13 @@ public class SupplierDetailFragment extends Fragment { //Method to Update or Add a supplier 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; + //get all the values from the fields String company = etSupCompany.getText().toString().trim(); String firstName = etSupContactFirstName.getText().toString().trim(); @@ -65,12 +74,6 @@ public class SupplierDetailFragment extends Fragment { String email = etSupEmail.getText().toString().trim(); String phone = etSupPhone.getText().toString().trim(); - //check if all the fields are filled - if (company.isEmpty() || firstName.isEmpty() || lastName.isEmpty() || email.isEmpty() || phone.isEmpty()) { - Toast.makeText(getContext(), "Please fill in all fields", Toast.LENGTH_SHORT).show(); - return; - } - //create a supplier object to send to the API SupplierDTO supplierDTO = new SupplierDTO(); supplierDTO.setSupCompany(company); @@ -89,6 +92,7 @@ public class SupplierDetailFragment extends Fragment { @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 { @@ -98,6 +102,7 @@ public class SupplierDetailFragment extends Fragment { @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(); } @@ -108,6 +113,7 @@ public class SupplierDetailFragment extends Fragment { @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 { @@ -117,6 +123,7 @@ public class SupplierDetailFragment extends Fragment { @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(); } @@ -136,6 +143,7 @@ public class SupplierDetailFragment extends Fragment { @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 { @@ -145,6 +153,7 @@ public class SupplierDetailFragment extends Fragment { @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(); } @@ -197,6 +206,11 @@ public class SupplierDetailFragment extends Fragment { 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); 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 280789e8..454d263b 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 @@ -16,26 +16,42 @@ import androidx.core.content.FileProvider; import androidx.fragment.app.Fragment; 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 java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; import java.util.Locale; +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.RequestBody; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + 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; // launchers for camera and gallery private ActivityResultLauncher galleryLauncher; @@ -53,8 +69,7 @@ public class PetProfileFragment extends Fragment { result -> { if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { Uri selectedImage = result.getData().getData(); - imgPet.setImageURI(selectedImage); - // TODO: SAVE CHANGED PHOTO TO DATABASE + uploadPetImage(selectedImage); } } ); @@ -64,9 +79,7 @@ public class PetProfileFragment extends Fragment { new ActivityResultContracts.TakePicture(), success -> { if (success) { - imgPet.setImageURI(null); - imgPet.setImageURI(photoUri); - // TODO: SAVE CHANGED PHOTO TO DATABASE + uploadPetImage(photoUri); } } ); @@ -112,11 +125,15 @@ public class PetProfileFragment extends Fragment { // 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); } //set button click listeners @@ -169,6 +186,74 @@ public class PetProfileFragment extends Fragment { return view; } + // Helper function to load pet image from backend + private void loadPetImage(int petId) { + String imageUrl = RetrofitClient.BASE_URL + String.format(Locale.US, PetApi.PET_IMAGE_PATH, petId); + + Glide.with(this) + .load(imageUrl) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .placeholder(R.drawable.placeholder) + .error(R.drawable.placeholder) + .into(imgPet); + } + + // Helper function to upload pet image to backend + private void uploadPetImage(Uri uri) { + try { + File file = getFileFromUri(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); + } else { + Toast.makeText(requireContext(), "Failed to upload pet photo", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + Log.e("UPLOAD_PET_IMAGE", "Failure: " + t.getMessage()); + Toast.makeText(requireContext(), "Network error", Toast.LENGTH_SHORT).show(); + } + }); + } catch (Exception e) { + Log.e("UPLOAD_PET_IMAGE", "Error: " + e.getMessage()); + } + } + + // Helper function to create a temporary File object from a Uri for uploading + private File getFileFromUri(Uri uri) { + try { + InputStream inputStream = requireContext().getContentResolver().openInputStream(uri); + File tempFile = new File(requireContext().getCacheDir(), "upload_pet_image.jpg"); + FileOutputStream outputStream = new FileOutputStream(tempFile); + byte[] buffer = new byte[1024]; + int length; + while ((length = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, length); + } + 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); 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 new file mode 100644 index 00000000..a1e16fa6 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/services/ChatNotificationService.java @@ -0,0 +1,226 @@ +package com.example.petstoremobile.services; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; +import android.util.Log; +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.websocket.StompChatManager; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +// Service to receive notifications when a new conversation is created +public class ChatNotificationService extends Service { + private static final String TAG = "ChatNotificationService"; + + public static Long activeConversationIdInUi = null; + + private StompChatManager stompChatManager; + private final Set knownConversationIds = new HashSet<>(); + private final Map conversationToCustomerId = new HashMap<>(); + private final Map customerIdToName = new HashMap<>(); + private Long currentUserId; + + //When the service starts, connect to the websocket + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log.d(TAG, "Service started"); + connectWebSocket(); + return START_STICKY; + } + + // 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(); + + 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); + } + + @Override + public void onFailure(@NonNull Call> call, @NonNull Throwable t) { + Log.e(TAG, "Failed to load customers", t); + 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()); + } + } + } + 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); + } + }); + } + + private void startStomp(String token, String role) { + if (stompChatManager != null) return; + + stompChatManager = new StompChatManager(token, role); + + // Listen for messages in existing conversations + stompChatManager.setMessageListener(message -> { + if (message != null && !message.getSenderId().equals(currentUserId)) { + // Check if this conversation is already active in the view + //if it is then don't make a notification for this chat + if (activeConversationIdInUi != null && activeConversationIdInUi.equals(message.getConversationId())) { + Log.d(TAG, "Disable notification for active conversation: " + message.getConversationId()); + return; + } + + String title = "New Message"; + Long customerId = conversationToCustomerId.get(message.getConversationId()); + if (customerId != null && customerIdToName.containsKey(customerId)) { + //append the customer name to the title of the notification + title = "New message from " + customerIdToName.get(customerId); + } + + NotificationHelper.showNotification( + getApplicationContext(), + title, + message.getContent(), + message.getConversationId() + ); + } + }); + + //When a conversation gets created, show a notification + stompChatManager.setConversationListener(conversation -> { + //check if the conversation exists + if (conversation != null && conversation.getId() != null) { + //check if the conversation is new + if (!knownConversationIds.contains(conversation.getId())) { + //add the conversation to the set of known conversations + knownConversationIds.add(conversation.getId()); + conversationToCustomerId.put(conversation.getId(), conversation.getCustomerId()); + + // Subscribe to the new conversation's messages + stompChatManager.subscribeToConversation(conversation.getId()); + + String title = "New Support Request"; + if (customerIdToName.containsKey(conversation.getCustomerId())) { + //append the customer name to the title of the notification + title = "New Support Request from " + customerIdToName.get(conversation.getCustomerId()); + } else { + // Try to fetch customer name for the new request + fetchCustomerName(conversation.getCustomerId()); + } + + //Display a notification + NotificationHelper.showNotification( + getApplicationContext(), + title, + "A customer is requesting assistance", + conversation.getId() + ); + } + } + }); + + // Subscribe to existing conversations if they were already loaded + for (Long id : knownConversationIds) { + stompChatManager.subscribeToConversation(id); + } + + stompChatManager.setConnectionListener(new StompChatManager.ConnectionListener() { + @Override + public void onSocketOpened() { + Log.d(TAG, "WebSocket connected in service"); + } + + @Override + public void onSocketClosed() { Log.d(TAG, "WebSocket closed in service"); } + + @Override + public void onSocketError() { Log.e(TAG, "WebSocket error in service"); } + }); + stompChatManager.connect(); + } + + // 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); + } + }); + } + + //When the service is destroyed, disconnect from the websocket + @Override + public void onDestroy() { + if (stompChatManager != null) { + stompChatManager.disconnect(); + } + super.onDestroy(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } +} 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 8182912b..d173df14 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 @@ -61,10 +61,11 @@ public class InputValidator { return true; } - // Checks if the phone number is valid (digits, spaces, dashes, brackets allowed) + // Checks if the phone number is valid public static boolean isValidPhone(EditText field) { String phone = field.getText().toString().trim(); - if (phone.isEmpty() || !phone.matches("[0-9\\-\\s\\(\\)\\+]+")) { + // Android built in phone validation pattern + if (phone.isEmpty() || !android.util.Patterns.PHONE.matcher(phone).matches()) { field.setError("Enter a valid phone number"); field.requestFocus(); return false; @@ -94,4 +95,3 @@ public class InputValidator { return true; } } - diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/NotificationHelper.java b/android/app/src/main/java/com/example/petstoremobile/utils/NotificationHelper.java new file mode 100644 index 00000000..cf89b42b --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/NotificationHelper.java @@ -0,0 +1,56 @@ +package com.example.petstoremobile.utils; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import androidx.core.app.NotificationCompat; +import com.example.petstoremobile.R; +import com.example.petstoremobile.activities.HomeActivity; + +// Helper class to show notifications when called +public class NotificationHelper { + private static final String CHANNEL_ID = "chat_notifications"; + private static final String CHANNEL_NAME = "Chat Notifications"; + private static final String CHANNEL_DESC = "Notifications for new conversations"; + private static final int NOTIFICATION_ID = 1; + + // a function to show a notification + public static void showNotification(Context context, String title, String message, Long conversationId) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + //check if the device is running on Oreo or higher so we can set up a notification channel + // for these devices + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Create a notification channel + NotificationChannel channel = new NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH); + channel.setDescription(CHANNEL_DESC); + notificationManager.createNotificationChannel(channel); + } + + //make the notification navigate the chat if it is clicked + Intent intent = new Intent(context, HomeActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.putExtra("navigate_to", "chat"); + if (conversationId != null) { + intent.putExtra("conversation_id", conversationId); + } + + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + //build the notification for display + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setAutoCancel(true) + .setContentIntent(pendingIntent); + + notificationManager.notify(NOTIFICATION_ID, builder.build()); + } +} diff --git a/android/app/src/main/res/layout/fragment_chat.xml b/android/app/src/main/res/layout/fragment_chat.xml index e56e283f..875bccfd 100644 --- a/android/app/src/main/res/layout/fragment_chat.xml +++ b/android/app/src/main/res/layout/fragment_chat.xml @@ -28,12 +28,15 @@ android:contentDescription="Open menu"/> + android:textStyle="bold" + android:paddingStart="8dp" + android:paddingEnd="8dp"/> diff --git a/android/app/src/main/res/layout/fragment_pet.xml b/android/app/src/main/res/layout/fragment_pet.xml index 2ad2d22d..1f625bac 100644 --- a/android/app/src/main/res/layout/fragment_pet.xml +++ b/android/app/src/main/res/layout/fragment_pet.xml @@ -38,18 +38,34 @@ - + android:orientation="horizontal" + android:padding="8dp" + android:gravity="center_vertical"> + + + + + @@ -129,7 +129,7 @@ android:id="@+id/tvProfilePhone" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="(123) 123-1234" + android:text="No phone loaded" android:textColor="@color/text_dark" android:textSize="16sp" /> @@ -174,7 +174,7 @@ android:id="@+id/tvProfileRole" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="Manager" + android:text="No role loaded" android:textSize="16sp" android:textColor="@color/accent_coral"/> diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 6f3b755b..65291b96 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - - - + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 6f3b755b..65291b96 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,6 @@ - - - + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp index c209e78e..e999e53a 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..e00f6353 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index b2dfe3d1..d808a1c1 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 4f0f1d64..66fe6992 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..200b401a Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 62b611da..0601d8e4 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 948a3070..1c7bc3d6 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..c506b115 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 1b9a6956..b58b7c23 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 28d4b77f..5014af81 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..ac6ea480 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 9287f508..55cec160 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index aa7d6427..4c08661e 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..fe6d8c0d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 9126ae37..b444a7ab 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..62a2dd1e --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FDE0E0 + \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore index 4ade3c30..9aece802 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,4 +1,5 @@ target/ +nohup.out !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ diff --git a/backend/petshop-api.postman_collection.json b/backend/petshop-api.postman_collection.json index e8be58ef..7db7d61f 100644 --- a/backend/petshop-api.postman_collection.json +++ b/backend/petshop-api.postman_collection.json @@ -90,6 +90,10 @@ "key": "avatarFile", "value": "postman/avatar.png" }, + { + "key": "avatarUrl", + "value": "" + }, { "key": "bulkPetId", "value": "" @@ -117,6 +121,10 @@ { "key": "bulkInventoryId", "value": "" + }, + { + "key": "adoptedPetId", + "value": "4" } ], "item": [ @@ -212,6 +220,7 @@ " pm.response.to.have.status(200);", "});", "var jsonData = pm.response.json();", + "if (jsonData.id !== undefined) pm.collectionVariables.set('userId', jsonData.id);", "if (jsonData.token) pm.collectionVariables.set('customerToken', jsonData.token);" ] } @@ -307,7 +316,9 @@ "exec": [ "pm.test('Status code is 200', function () {", " pm.response.to.have.status(200);", - "});" + "});", + "var jsonData = pm.response.json();", + "if (jsonData.id !== undefined) pm.collectionVariables.set('userId', jsonData.id);" ] } } @@ -381,7 +392,8 @@ " pm.response.to.have.status(200);", "});", "var jsonData = pm.response.json();", - "pm.expect(jsonData.avatarUrl).to.be.a('string');" + "pm.expect(jsonData.avatarUrl).to.be.a('string');", + "pm.collectionVariables.set('avatarUrl', jsonData.avatarUrl);" ] } } @@ -414,7 +426,68 @@ " pm.response.to.have.status(200);", "});", "var jsonData = pm.response.json();", - "pm.expect(jsonData.avatarUrl).to.be.a('string');" + "pm.expect(jsonData.avatarUrl).to.be.a('string');", + "pm.collectionVariables.set('avatarUrl', jsonData.avatarUrl);" + ] + } + } + ] + }, + { + "name": "Get My Avatar File", + "request": { + "method": "GET", + "url": "{{baseUrl}}{{avatarUrl}}", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{customerToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test('Avatar response is an image', function () {", + " pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');", + "});" + ] + } + } + ] + }, + { + "name": "Get User Avatar File As Staff", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/users/{{userId}}/avatar/file", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test('Avatar response is an image', function () {", + " pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');", + "});" ] } } @@ -662,6 +735,95 @@ } ] }, + { + "name": "Upload Pet Image", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/v1/pets/{{petId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "image", + "type": "file", + "src": "{{avatarFile}}" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "var jsonData = pm.response.json();", + "pm.expect(jsonData.imageUrl).to.be.a('string');" + ] + } + } + ] + }, + { + "name": "Get Pet Image Public", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/pets/{{petId}}/image" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test('Pet image response is an image', function () {", + " pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');", + "});" + ] + } + } + ] + }, + { + "name": "Delete Pet Image", + "request": { + "method": "DELETE", + "url": "{{baseUrl}}/api/v1/pets/{{petId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ] + }, { "name": "Delete Pet", "request": { @@ -769,6 +931,120 @@ } } ] + }, + { + "name": "Upload Adopted Pet Image", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "image", + "type": "file", + "src": "{{avatarFile}}" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ] + }, + { + "name": "Get Adopted Pet Image Public", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 403', function () {", + " pm.response.to.have.status(403);", + "});" + ] + } + } + ] + }, + { + "name": "Get Adopted Pet Image As Staff", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test('Pet image response is an image', function () {", + " pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');", + "});" + ] + } + } + ] + }, + { + "name": "Delete Adopted Pet Image", + "request": { + "method": "DELETE", + "url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ] } ] }, @@ -1015,6 +1291,95 @@ } } ] + }, + { + "name": "Upload Product Image", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/v1/products/{{productId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "image", + "type": "file", + "src": "{{avatarFile}}" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "var jsonData = pm.response.json();", + "pm.expect(jsonData.imageUrl).to.be.a('string');" + ] + } + } + ] + }, + { + "name": "Get Product Image", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/products/{{productId}}/image" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test('Product image response is an image', function () {", + " pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');", + "});" + ] + } + } + ] + }, + { + "name": "Delete Product Image", + "request": { + "method": "DELETE", + "url": "{{baseUrl}}/api/v1/products/{{productId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ] } ] }, @@ -4468,4 +4833,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java b/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java index 3fb28d10..000ebe86 100644 --- a/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java +++ b/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java @@ -10,6 +10,9 @@ import java.util.Arrays; public class FlywayContextInitializer implements ApplicationContextInitializer { + private static final int MAX_RETRIES = 15; + private static final long RETRY_DELAY_MILLIS = 1000L; + @Override public void initialize(ConfigurableApplicationContext applicationContext) { ConfigurableEnvironment environment = applicationContext.getEnvironment(); @@ -29,12 +32,33 @@ public class FlywayContextInitializer implements ApplicationContextInitializer !location.isEmpty()) .toArray(String[]::new); - Flyway.configure() - .dataSource(url, username, password) - .locations(locations) - .baselineOnMigrate(environment.getProperty("spring.flyway.baseline-on-migrate", Boolean.class, false)) - .baselineVersion(MigrationVersion.fromVersion(environment.getProperty("spring.flyway.baseline-version", "1"))) - .load() - .migrate(); + RuntimeException lastFailure = null; + for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + Flyway.configure() + .dataSource(url, username, password) + .locations(locations) + .baselineOnMigrate(environment.getProperty("spring.flyway.baseline-on-migrate", Boolean.class, false)) + .baselineVersion(MigrationVersion.fromVersion(environment.getProperty("spring.flyway.baseline-version", "1"))) + .load() + .migrate(); + return; + } catch (RuntimeException ex) { + lastFailure = ex; + if (attempt == MAX_RETRIES) { + throw ex; + } + try { + Thread.sleep(RETRY_DELAY_MILLIS); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for database startup", interruptedException); + } + } + } + + if (lastFailure != null) { + throw lastFailure; + } } } diff --git a/backend/src/main/java/com/petshop/backend/controller/AuthController.java b/backend/src/main/java/com/petshop/backend/controller/AuthController.java index 2bd2b47d..b426aadc 100644 --- a/backend/src/main/java/com/petshop/backend/controller/AuthController.java +++ b/backend/src/main/java/com/petshop/backend/controller/AuthController.java @@ -13,10 +13,13 @@ import com.petshop.backend.repository.EmployeeRepository; import com.petshop.backend.repository.EmployeeStoreRepository; import com.petshop.backend.repository.UserRepository; import com.petshop.backend.security.JwtUtil; +import com.petshop.backend.service.AvatarStorageService; import com.petshop.backend.service.UserBusinessLinkageService; import com.petshop.backend.util.AuthenticationHelper; import jakarta.validation.Valid; +import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; @@ -28,15 +31,9 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.io.File; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; import java.util.HashMap; import java.util.Map; -import java.util.UUID; @RestController @RequestMapping("/api/v1/auth") @@ -49,8 +46,9 @@ public class AuthController { private final UserBusinessLinkageService userBusinessLinkageService; private final EmployeeRepository employeeRepository; private final EmployeeStoreRepository employeeStoreRepository; + private final AvatarStorageService avatarStorageService; - public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService, EmployeeRepository employeeRepository, EmployeeStoreRepository employeeStoreRepository) { + public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService, EmployeeRepository employeeRepository, EmployeeStoreRepository employeeStoreRepository, AvatarStorageService avatarStorageService) { this.authenticationManager = authenticationManager; this.userRepository = userRepository; this.jwtUtil = jwtUtil; @@ -58,6 +56,7 @@ public class AuthController { this.userBusinessLinkageService = userBusinessLinkageService; this.employeeRepository = employeeRepository; this.employeeStoreRepository = employeeStoreRepository; + this.avatarStorageService = avatarStorageService; } @PostMapping("/register") @@ -155,7 +154,7 @@ public class AuthController { user.getEmail(), user.getFullName(), user.getPhone(), - user.getAvatarUrl(), + avatarStorageService.toOwnerAvatarUrl(user), user.getRole().name(), employeeStore != null ? employeeStore.getStore().getStoreId() : null, employeeStore != null ? employeeStore.getStore().getStoreName() : null @@ -224,7 +223,7 @@ public class AuthController { updatedUser.getEmail(), updatedUser.getFullName(), updatedUser.getPhone(), - updatedUser.getAvatarUrl(), + avatarStorageService.toOwnerAvatarUrl(updatedUser), updatedUser.getRole().name(), employeeStore != null ? employeeStore.getStore().getStoreId() : null, employeeStore != null ? employeeStore.getStore().getStoreName() : null @@ -273,26 +272,12 @@ public class AuthController { } try { - String uploadDir = "uploads/avatars"; - File directory = new File(uploadDir); - if (!directory.exists()) { - directory.mkdirs(); - } - - String originalFilename = file.getOriginalFilename(); - String extension = originalFilename != null && originalFilename.contains(".") - ? originalFilename.substring(originalFilename.lastIndexOf(".")) - : ".jpg"; - String filename = UUID.randomUUID().toString() + extension; - Path filePath = Paths.get(uploadDir, filename); - - Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); - - String avatarUrl = "/uploads/avatars/" + filename; - user.setAvatarUrl(avatarUrl); + avatarStorageService.deleteAvatar(user); + String avatarPath = avatarStorageService.storeAvatar(file); + user.setAvatarUrl(avatarPath); userRepository.save(user); - return ResponseEntity.ok(new AvatarUploadResponse(avatarUrl, "Avatar uploaded successfully")); + return ResponseEntity.ok(new AvatarUploadResponse(avatarStorageService.toOwnerAvatarUrl(user), "Avatar uploaded successfully")); } catch (IOException e) { Map error = new HashMap<>(); @@ -305,25 +290,41 @@ public class AuthController { public ResponseEntity getAvatar() { User user = getAuthenticatedUser(); - if (user.getAvatarUrl() == null || user.getAvatarUrl().isEmpty()) { + if (!avatarStorageService.hasAvatar(user)) { Map error = new HashMap<>(); error.put("message", "No avatar uploaded"); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); } Map response = new HashMap<>(); - response.put("avatarUrl", user.getAvatarUrl()); + response.put("avatarUrl", avatarStorageService.toOwnerAvatarUrl(user)); return ResponseEntity.ok(response); } + @GetMapping("/me/avatar/file") + public ResponseEntity getAvatarFile() { + User user = getAuthenticatedUser(); + + if (!avatarStorageService.hasAvatar(user)) { + return ResponseEntity.notFound().build(); + } + + try { + Resource resource = avatarStorageService.loadAvatarResource(user); + MediaType mediaType = avatarStorageService.resolveMediaType(user); + return ResponseEntity.ok().contentType(mediaType).body(resource); + } catch (IllegalArgumentException ex) { + return ResponseEntity.notFound().build(); + } + } + @DeleteMapping("/me/avatar") public ResponseEntity deleteAvatar() { User user = getAuthenticatedUser(); - if (user.getAvatarUrl() != null && !user.getAvatarUrl().isEmpty()) { + if (avatarStorageService.hasAvatar(user)) { try { - Path filePath = Paths.get("." + user.getAvatarUrl()); - Files.deleteIfExists(filePath); + avatarStorageService.deleteAvatar(user); } catch (IOException e) { } user.setAvatarUrl(null); diff --git a/backend/src/main/java/com/petshop/backend/controller/PetImageController.java b/backend/src/main/java/com/petshop/backend/controller/PetImageController.java new file mode 100644 index 00000000..bd9717ca --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/controller/PetImageController.java @@ -0,0 +1,94 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.dto.pet.PetResponse; +import com.petshop.backend.entity.User; +import com.petshop.backend.security.AppPrincipal; +import com.petshop.backend.service.PetService; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/pets") +public class PetImageController { + + private final PetService petService; + + public PetImageController(PetService petService) { + this.petService = petService; + } + + @PostMapping("/{id}/image") + @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") + public ResponseEntity uploadPetImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) { + try { + PetResponse response = petService.uploadPetImage(id, image); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException ex) { + return badRequest(ex.getMessage()); + } catch (IOException ex) { + return badRequest("Failed to upload pet image: " + ex.getMessage()); + } + } + + @GetMapping("/{id}/image") + public ResponseEntity getPetImage(@PathVariable Long id) { + try { + PetService.ImagePayload payload = petService.loadPetImage(id, currentUserId(), currentUserRole()); + return ResponseEntity.ok().contentType(payload.mediaType()).body(payload.resource()); + } catch (PetService.ForbiddenImageAccessException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + + @DeleteMapping("/{id}/image") + @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") + public ResponseEntity deletePetImage(@PathVariable Long id) { + return ResponseEntity.ok(petService.deletePetImage(id)); + } + + private ResponseEntity> badRequest(String message) { + Map error = new HashMap<>(); + error.put("message", message); + return ResponseEntity.badRequest().body(error); + } + + private Long currentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof AppPrincipal appPrincipal) { + return appPrincipal.getUserId(); + } + return null; + } + + private User.Role currentUserRole() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof AppPrincipal appPrincipal) { + return appPrincipal.getRole(); + } + return null; + } +} diff --git a/backend/src/main/java/com/petshop/backend/controller/ProductImageController.java b/backend/src/main/java/com/petshop/backend/controller/ProductImageController.java new file mode 100644 index 00000000..015847ad --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/controller/ProductImageController.java @@ -0,0 +1,61 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.dto.product.ProductResponse; +import com.petshop.backend.service.ProductService; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/products") +public class ProductImageController { + + private final ProductService productService; + + public ProductImageController(ProductService productService) { + this.productService = productService; + } + + @PostMapping("/{id}/image") + @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") + public ResponseEntity uploadProductImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) { + try { + ProductResponse response = productService.uploadProductImage(id, image); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException ex) { + return badRequest(ex.getMessage()); + } catch (IOException ex) { + return badRequest("Failed to upload product image: " + ex.getMessage()); + } + } + + @GetMapping("/{id}/image") + public ResponseEntity getProductImage(@PathVariable Long id) { + ProductService.ImagePayload payload = productService.loadProductImage(id); + return ResponseEntity.ok().contentType(payload.mediaType()).body(payload.resource()); + } + + @DeleteMapping("/{id}/image") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity deleteProductImage(@PathVariable Long id) { + return ResponseEntity.ok(productService.deleteProductImage(id)); + } + + private ResponseEntity> badRequest(String message) { + Map error = new HashMap<>(); + error.put("message", message); + return ResponseEntity.badRequest().body(error); + } +} diff --git a/backend/src/main/java/com/petshop/backend/controller/UserAvatarController.java b/backend/src/main/java/com/petshop/backend/controller/UserAvatarController.java new file mode 100644 index 00000000..bb5c9342 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/controller/UserAvatarController.java @@ -0,0 +1,43 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.entity.User; +import com.petshop.backend.repository.UserRepository; +import com.petshop.backend.service.AvatarStorageService; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/users") +public class UserAvatarController { + + private final UserRepository userRepository; + private final AvatarStorageService avatarStorageService; + + public UserAvatarController(UserRepository userRepository, AvatarStorageService avatarStorageService) { + this.userRepository = userRepository; + this.avatarStorageService = avatarStorageService; + } + + @GetMapping("/{userId}/avatar/file") + @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") + public ResponseEntity getUserAvatarFile(@PathVariable Long userId) { + User user = userRepository.findById(userId).orElse(null); + if (user == null || !avatarStorageService.hasAvatar(user)) { + return ResponseEntity.notFound().build(); + } + + try { + Resource resource = avatarStorageService.loadAvatarResource(user); + return ResponseEntity.ok() + .contentType(avatarStorageService.resolveMediaType(user)) + .body(resource); + } catch (IllegalArgumentException ex) { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/pet/PetResponse.java b/backend/src/main/java/com/petshop/backend/dto/pet/PetResponse.java index f691361a..e3213653 100644 --- a/backend/src/main/java/com/petshop/backend/dto/pet/PetResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/pet/PetResponse.java @@ -12,13 +12,14 @@ public class PetResponse { private Integer petAge; private String petStatus; private BigDecimal petPrice; + private String imageUrl; private LocalDateTime createdAt; private LocalDateTime updatedAt; public PetResponse() { } - public PetResponse(Long petId, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, LocalDateTime createdAt, LocalDateTime updatedAt) { + public PetResponse(Long petId, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) { this.petId = petId; this.petName = petName; this.petSpecies = petSpecies; @@ -26,6 +27,7 @@ public class PetResponse { this.petAge = petAge; this.petStatus = petStatus; this.petPrice = petPrice; + this.imageUrl = imageUrl; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -86,6 +88,14 @@ public class PetResponse { this.petPrice = petPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } @@ -107,12 +117,12 @@ public class PetResponse { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; PetResponse that = (PetResponse) o; - return Objects.equals(petId, that.petId) && Objects.equals(petName, that.petName) && Objects.equals(petSpecies, that.petSpecies) && Objects.equals(petBreed, that.petBreed) && Objects.equals(petAge, that.petAge) && Objects.equals(petStatus, that.petStatus) && Objects.equals(petPrice, that.petPrice) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); + return Objects.equals(petId, that.petId) && Objects.equals(petName, that.petName) && Objects.equals(petSpecies, that.petSpecies) && Objects.equals(petBreed, that.petBreed) && Objects.equals(petAge, that.petAge) && Objects.equals(petStatus, that.petStatus) && Objects.equals(petPrice, that.petPrice) && Objects.equals(imageUrl, that.imageUrl) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); } @Override public int hashCode() { - return Objects.hash(petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, createdAt, updatedAt); + return Objects.hash(petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl, createdAt, updatedAt); } @Override @@ -125,6 +135,7 @@ public class PetResponse { ", petAge=" + petAge + ", petStatus='" + petStatus + '\'' + ", petPrice=" + petPrice + + ", imageUrl='" + imageUrl + '\'' + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + '}'; diff --git a/backend/src/main/java/com/petshop/backend/dto/product/ProductResponse.java b/backend/src/main/java/com/petshop/backend/dto/product/ProductResponse.java index 96baa5ce..c08abf9a 100644 --- a/backend/src/main/java/com/petshop/backend/dto/product/ProductResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/product/ProductResponse.java @@ -11,19 +11,21 @@ public class ProductResponse { private String categoryName; private String prodDesc; private BigDecimal prodPrice; + private String imageUrl; private LocalDateTime createdAt; private LocalDateTime updatedAt; public ProductResponse() { } - public ProductResponse(Long prodId, String prodName, Long categoryId, String categoryName, String prodDesc, BigDecimal prodPrice, LocalDateTime createdAt, LocalDateTime updatedAt) { + public ProductResponse(Long prodId, String prodName, Long categoryId, String categoryName, String prodDesc, BigDecimal prodPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) { this.prodId = prodId; this.prodName = prodName; this.categoryId = categoryId; this.categoryName = categoryName; this.prodDesc = prodDesc; this.prodPrice = prodPrice; + this.imageUrl = imageUrl; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -76,6 +78,14 @@ public class ProductResponse { this.prodPrice = prodPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } @@ -97,12 +107,12 @@ public class ProductResponse { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ProductResponse that = (ProductResponse) o; - return Objects.equals(prodId, that.prodId) && Objects.equals(prodName, that.prodName) && Objects.equals(categoryId, that.categoryId) && Objects.equals(categoryName, that.categoryName) && Objects.equals(prodDesc, that.prodDesc) && Objects.equals(prodPrice, that.prodPrice) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); + return Objects.equals(prodId, that.prodId) && Objects.equals(prodName, that.prodName) && Objects.equals(categoryId, that.categoryId) && Objects.equals(categoryName, that.categoryName) && Objects.equals(prodDesc, that.prodDesc) && Objects.equals(prodPrice, that.prodPrice) && Objects.equals(imageUrl, that.imageUrl) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); } @Override public int hashCode() { - return Objects.hash(prodId, prodName, categoryId, categoryName, prodDesc, prodPrice, createdAt, updatedAt); + return Objects.hash(prodId, prodName, categoryId, categoryName, prodDesc, prodPrice, imageUrl, createdAt, updatedAt); } @Override @@ -114,6 +124,7 @@ public class ProductResponse { ", categoryName='" + categoryName + '\'' + ", prodDesc='" + prodDesc + '\'' + ", prodPrice=" + prodPrice + + ", imageUrl='" + imageUrl + '\'' + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + '}'; diff --git a/backend/src/main/java/com/petshop/backend/entity/Pet.java b/backend/src/main/java/com/petshop/backend/entity/Pet.java index e827f612..8f6a6020 100644 --- a/backend/src/main/java/com/petshop/backend/entity/Pet.java +++ b/backend/src/main/java/com/petshop/backend/entity/Pet.java @@ -35,6 +35,9 @@ public class Pet { @Column(nullable = false, precision = 10, scale = 2) private BigDecimal petPrice; + @Column(length = 255) + private String imageUrl; + @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @@ -46,7 +49,7 @@ public class Pet { public Pet() { } - public Pet(Long id, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, LocalDateTime createdAt, LocalDateTime updatedAt) { + public Pet(Long id, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) { this.id = id; this.petName = petName; this.petSpecies = petSpecies; @@ -54,6 +57,7 @@ public class Pet { this.petAge = petAge; this.petStatus = petStatus; this.petPrice = petPrice; + this.imageUrl = imageUrl; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -114,6 +118,14 @@ public class Pet { this.petPrice = petPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } @@ -153,6 +165,7 @@ public class Pet { ", petAge=" + petAge + ", petStatus='" + petStatus + '\'' + ", petPrice=" + petPrice + + ", imageUrl='" + imageUrl + '\'' + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + '}'; diff --git a/backend/src/main/java/com/petshop/backend/entity/Product.java b/backend/src/main/java/com/petshop/backend/entity/Product.java index 9eb9c2d6..84c17c1a 100644 --- a/backend/src/main/java/com/petshop/backend/entity/Product.java +++ b/backend/src/main/java/com/petshop/backend/entity/Product.java @@ -29,6 +29,9 @@ public class Product { @Column(nullable = false, precision = 10, scale = 2) private BigDecimal prodPrice; + @Column(length = 255) + private String imageUrl; + @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @@ -40,12 +43,13 @@ public class Product { public Product() { } - public Product(Long prodId, String prodName, Category category, String prodDesc, BigDecimal prodPrice, LocalDateTime createdAt, LocalDateTime updatedAt) { + public Product(Long prodId, String prodName, Category category, String prodDesc, BigDecimal prodPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) { this.prodId = prodId; this.prodName = prodName; this.category = category; this.prodDesc = prodDesc; this.prodPrice = prodPrice; + this.imageUrl = imageUrl; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -90,6 +94,14 @@ public class Product { this.prodPrice = prodPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } @@ -127,6 +139,7 @@ public class Product { ", category=" + category + ", prodDesc='" + prodDesc + '\'' + ", prodPrice=" + prodPrice + + ", imageUrl='" + imageUrl + '\'' + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + '}'; diff --git a/backend/src/main/java/com/petshop/backend/repository/AdoptionRepository.java b/backend/src/main/java/com/petshop/backend/repository/AdoptionRepository.java index d009b17a..2af9c52f 100644 --- a/backend/src/main/java/com/petshop/backend/repository/AdoptionRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/AdoptionRepository.java @@ -8,6 +8,8 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface AdoptionRepository extends JpaRepository { @@ -24,4 +26,6 @@ public interface AdoptionRepository extends JpaRepository { "LOWER(a.customer.lastName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + "LOWER(a.pet.petName) LIKE LOWER(CONCAT('%', :q, '%')))") Page searchAdoptionsByCustomer(@Param("customerId") Long customerId, @Param("q") String query, Pageable pageable); + + Optional findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(Long petId, String adoptionStatus); } diff --git a/backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java b/backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java new file mode 100644 index 00000000..952ca600 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java @@ -0,0 +1,105 @@ +package com.petshop.backend.service; + +import com.petshop.backend.entity.User; +import org.springframework.core.io.PathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.UUID; + +@Service +public class AvatarStorageService { + + private static final String STORED_PREFIX = "/uploads/avatars/"; + private static final String OWNER_ENDPOINT = "/api/v1/auth/me/avatar/file"; + + private final Path avatarDirectory = Paths.get("uploads", "avatars").toAbsolutePath().normalize(); + + public String storeAvatar(MultipartFile file) throws IOException { + Files.createDirectories(avatarDirectory); + + String originalFilename = file.getOriginalFilename(); + String extension = resolveExtension(originalFilename); + String filename = UUID.randomUUID() + extension; + Path filePath = avatarDirectory.resolve(filename).normalize(); + + Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); + return STORED_PREFIX + filename; + } + + public Resource loadAvatarResource(User user) { + Path filePath = resolveStoredAvatarPath(user.getAvatarUrl()); + if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) { + throw new IllegalArgumentException("Avatar file was not found"); + } + return new PathResource(filePath); + } + + public void deleteAvatar(User user) throws IOException { + if (user.getAvatarUrl() == null || user.getAvatarUrl().isBlank()) { + return; + } + Files.deleteIfExists(resolveStoredAvatarPath(user.getAvatarUrl())); + } + + public String toOwnerAvatarUrl(User user) { + return hasAvatar(user) ? OWNER_ENDPOINT : null; + } + + public String toStoredAvatarUrl(String avatarFilenamePath) { + return avatarFilenamePath; + } + + public boolean hasAvatar(User user) { + return user.getAvatarUrl() != null && !user.getAvatarUrl().isBlank(); + } + + public MediaType resolveMediaType(User user) { + try { + return MediaTypeFactory.getMediaType(loadAvatarResource(user)).orElse(MediaType.APPLICATION_OCTET_STREAM); + } catch (IllegalArgumentException ex) { + return MediaType.APPLICATION_OCTET_STREAM; + } + } + + private Path resolveStoredAvatarPath(String storedAvatarUrl) { + if (storedAvatarUrl == null || storedAvatarUrl.isBlank() || !storedAvatarUrl.startsWith(STORED_PREFIX)) { + throw new IllegalArgumentException("Avatar file was not found"); + } + + String filename = storedAvatarUrl.substring(STORED_PREFIX.length()); + if (filename.isBlank() || filename.contains("/") || filename.contains("\\") || filename.contains("..")) { + throw new IllegalArgumentException("Avatar file was not found"); + } + + Path resolved = avatarDirectory.resolve(filename).normalize(); + if (!resolved.startsWith(avatarDirectory)) { + throw new IllegalArgumentException("Avatar file was not found"); + } + return resolved; + } + + private String resolveExtension(String originalFilename) { + if (originalFilename == null) { + return ".jpg"; + } + int extensionIndex = originalFilename.lastIndexOf('.'); + if (extensionIndex < 0 || extensionIndex == originalFilename.length() - 1) { + return ".jpg"; + } + String extension = originalFilename.substring(extensionIndex).toLowerCase(Locale.ROOT); + return switch (extension) { + case ".jpg", ".jpeg", ".png", ".gif" -> extension; + default -> ".jpg"; + }; + } +} diff --git a/backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java b/backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java new file mode 100644 index 00000000..34a92ff0 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java @@ -0,0 +1,97 @@ +package com.petshop.backend.service; + +import org.springframework.core.io.PathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.UUID; + +@Service +public class CatalogImageStorageService { + + private static final String PET_PREFIX = "/uploads/pets/"; + private static final String PRODUCT_PREFIX = "/uploads/products/"; + + public String storePetImage(MultipartFile file) throws IOException { + return storeImage(file, Paths.get("uploads", "pets").toAbsolutePath().normalize(), PET_PREFIX); + } + + public String storeProductImage(MultipartFile file) throws IOException { + return storeImage(file, Paths.get("uploads", "products").toAbsolutePath().normalize(), PRODUCT_PREFIX); + } + + public Resource loadPetImage(String storedPath) { + return new PathResource(resolveStoredPath(storedPath, Paths.get("uploads", "pets").toAbsolutePath().normalize(), PET_PREFIX)); + } + + public Resource loadProductImage(String storedPath) { + return new PathResource(resolveStoredPath(storedPath, Paths.get("uploads", "products").toAbsolutePath().normalize(), PRODUCT_PREFIX)); + } + + public MediaType resolveMediaType(Resource resource) { + return MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM); + } + + public void deletePetImage(String storedPath) throws IOException { + deleteImage(storedPath, Paths.get("uploads", "pets").toAbsolutePath().normalize(), PET_PREFIX); + } + + public void deleteProductImage(String storedPath) throws IOException { + deleteImage(storedPath, Paths.get("uploads", "products").toAbsolutePath().normalize(), PRODUCT_PREFIX); + } + + private String storeImage(MultipartFile file, Path directory, String prefix) throws IOException { + Files.createDirectories(directory); + String extension = resolveExtension(file.getOriginalFilename()); + String filename = UUID.randomUUID() + extension; + Path filePath = directory.resolve(filename).normalize(); + Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); + return prefix + filename; + } + + private void deleteImage(String storedPath, Path directory, String prefix) throws IOException { + if (storedPath == null || storedPath.isBlank()) { + return; + } + Files.deleteIfExists(resolveStoredPath(storedPath, directory, prefix)); + } + + private Path resolveStoredPath(String storedPath, Path directory, String prefix) { + if (storedPath == null || storedPath.isBlank() || !storedPath.startsWith(prefix)) { + throw new IllegalArgumentException("Image file was not found"); + } + String filename = storedPath.substring(prefix.length()); + if (filename.isBlank() || filename.contains("/") || filename.contains("\\") || filename.contains("..")) { + throw new IllegalArgumentException("Image file was not found"); + } + Path resolved = directory.resolve(filename).normalize(); + if (!resolved.startsWith(directory)) { + throw new IllegalArgumentException("Image file was not found"); + } + return resolved; + } + + private String resolveExtension(String originalFilename) { + if (originalFilename == null) { + return ".jpg"; + } + int extensionIndex = originalFilename.lastIndexOf('.'); + if (extensionIndex < 0 || extensionIndex == originalFilename.length() - 1) { + return ".jpg"; + } + String extension = originalFilename.substring(extensionIndex).toLowerCase(Locale.ROOT); + return switch (extension) { + case ".jpg", ".jpeg", ".png", ".gif" -> extension; + default -> ".jpg"; + }; + } +} 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 b59d589b..5c35dfd1 100644 --- a/backend/src/main/java/com/petshop/backend/service/PetService.java +++ b/backend/src/main/java/com/petshop/backend/service/PetService.java @@ -3,21 +3,34 @@ package com.petshop.backend.service; import com.petshop.backend.dto.common.BulkDeleteRequest; import com.petshop.backend.dto.pet.PetRequest; import com.petshop.backend.dto.pet.PetResponse; +import com.petshop.backend.entity.Adoption; import com.petshop.backend.entity.Pet; +import com.petshop.backend.entity.User; import com.petshop.backend.exception.ResourceNotFoundException; +import com.petshop.backend.repository.AdoptionRepository; import com.petshop.backend.repository.PetRepository; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Locale; @Service public class PetService { private final PetRepository petRepository; + private final AdoptionRepository adoptionRepository; + private final CatalogImageStorageService catalogImageStorageService; - public PetService(PetRepository petRepository) { + public PetService(PetRepository petRepository, AdoptionRepository adoptionRepository, CatalogImageStorageService catalogImageStorageService) { this.petRepository = petRepository; + this.adoptionRepository = adoptionRepository; + this.catalogImageStorageService = catalogImageStorageService; } public Page getAllPets(String query, Pageable pageable) { @@ -68,17 +81,107 @@ public class PetService { @Transactional public void deletePet(Long id) { - if (!petRepository.existsById(id)) { - throw new ResourceNotFoundException("Pet not found with id: " + id); - } - petRepository.deleteById(id); + Pet pet = petRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id)); + deleteStoredImageIfPresent(pet.getImageUrl()); + petRepository.delete(pet); } @Transactional public void bulkDeletePets(BulkDeleteRequest request) { + petRepository.findAllById(request.getIds()).forEach(pet -> deleteStoredImageIfPresent(pet.getImageUrl())); petRepository.deleteAllById(request.getIds()); } + @Transactional + public PetResponse uploadPetImage(Long id, MultipartFile file) throws IOException { + validateImageFile(file); + Pet pet = findPet(id); + deleteStoredImageIfPresent(pet.getImageUrl()); + pet.setImageUrl(catalogImageStorageService.storePetImage(file)); + return mapToResponse(petRepository.save(pet)); + } + + @Transactional + public PetResponse deletePetImage(Long id) { + Pet pet = findPet(id); + deleteStoredImageIfPresent(pet.getImageUrl()); + pet.setImageUrl(null); + return mapToResponse(petRepository.save(pet)); + } + + public ImagePayload loadPetImage(Long id, Long requesterUserId, User.Role requesterRole) { + Pet pet = findPet(id); + if (pet.getImageUrl() == null || pet.getImageUrl().isBlank()) { + throw new ResourceNotFoundException("Pet image not found for id: " + id); + } + if (!canViewPetImage(pet, requesterUserId, requesterRole)) { + throw new ForbiddenImageAccessException(); + } + Resource resource = catalogImageStorageService.loadPetImage(pet.getImageUrl()); + MediaType mediaType = catalogImageStorageService.resolveMediaType(resource); + return new ImagePayload(resource, mediaType); + } + + public boolean isPubliclyVisible(Pet pet) { + return "available".equalsIgnoreCase(normalizeStatus(pet.getPetStatus())); + } + + private boolean canViewPetImage(Pet pet, Long requesterUserId, User.Role requesterRole) { + if (isPubliclyVisible(pet)) { + return true; + } + if (requesterRole == User.Role.STAFF || requesterRole == User.Role.ADMIN) { + return true; + } + if (requesterUserId == null) { + return false; + } + if (!"adopted".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()))) { + return false; + } + return adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(pet.getPetId(), "Completed") + .map(Adoption::getCustomer) + .map(customer -> requesterUserId.equals(customer.getUserId())) + .orElse(false); + } + + private Pet findPet(Long id) { + return petRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id)); + } + + private void validateImageFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("Please select an image to upload"); + } + if (file.getSize() > 5 * 1024 * 1024) { + throw new IllegalArgumentException("Image file size must be less than 5MB"); + } + String contentType = file.getContentType(); + if (contentType == null) { + throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed"); + } + String normalized = contentType.toLowerCase(Locale.ROOT); + if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/gif")) { + throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed"); + } + } + + private void deleteStoredImageIfPresent(String storedImagePath) { + if (storedImagePath == null || storedImagePath.isBlank()) { + return; + } + try { + catalogImageStorageService.deletePetImage(storedImagePath); + } catch (IOException ignored) { + } + } + + private String normalizeStatus(String status) { + return status == null ? "" : status.trim(); + } + private PetResponse mapToResponse(Pet pet) { return new PetResponse( pet.getPetId(), @@ -88,8 +191,15 @@ public class PetService { pet.getPetAge(), pet.getPetStatus(), pet.getPetPrice(), + pet.getImageUrl() != null && !pet.getImageUrl().isBlank() ? "/api/v1/pets/" + pet.getPetId() + "/image" : null, pet.getCreatedAt(), pet.getUpdatedAt() ); } + + public record ImagePayload(Resource resource, MediaType mediaType) { + } + + public static class ForbiddenImageAccessException extends RuntimeException { + } } 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 b907e38f..0473a8eb 100644 --- a/backend/src/main/java/com/petshop/backend/service/ProductService.java +++ b/backend/src/main/java/com/petshop/backend/service/ProductService.java @@ -8,20 +8,28 @@ import com.petshop.backend.entity.Product; import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.repository.CategoryRepository; import com.petshop.backend.repository.ProductRepository; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Locale; @Service public class ProductService { private final ProductRepository productRepository; private final CategoryRepository categoryRepository; + private final CatalogImageStorageService catalogImageStorageService; - public ProductService(ProductRepository productRepository, CategoryRepository categoryRepository) { + public ProductService(ProductRepository productRepository, CategoryRepository categoryRepository, CatalogImageStorageService catalogImageStorageService) { this.productRepository = productRepository; this.categoryRepository = categoryRepository; + this.catalogImageStorageService = catalogImageStorageService; } public Page getAllProducts(String query, Pageable pageable) { @@ -74,17 +82,76 @@ public class ProductService { @Transactional public void deleteProduct(Long id) { - if (!productRepository.existsById(id)) { - throw new ResourceNotFoundException("Product not found with id: " + id); - } - productRepository.deleteById(id); + Product product = findProduct(id); + deleteStoredImageIfPresent(product.getImageUrl()); + productRepository.delete(product); } @Transactional public void bulkDeleteProducts(BulkDeleteRequest request) { + productRepository.findAllById(request.getIds()).forEach(product -> deleteStoredImageIfPresent(product.getImageUrl())); productRepository.deleteAllById(request.getIds()); } + @Transactional + public ProductResponse uploadProductImage(Long id, MultipartFile file) throws IOException { + validateImageFile(file); + Product product = findProduct(id); + deleteStoredImageIfPresent(product.getImageUrl()); + product.setImageUrl(catalogImageStorageService.storeProductImage(file)); + return mapToResponse(productRepository.save(product)); + } + + @Transactional + public ProductResponse deleteProductImage(Long id) { + Product product = findProduct(id); + deleteStoredImageIfPresent(product.getImageUrl()); + product.setImageUrl(null); + return mapToResponse(productRepository.save(product)); + } + + public ImagePayload loadProductImage(Long id) { + Product product = findProduct(id); + if (product.getImageUrl() == null || product.getImageUrl().isBlank()) { + throw new ResourceNotFoundException("Product image not found with id: " + id); + } + Resource resource = catalogImageStorageService.loadProductImage(product.getImageUrl()); + MediaType mediaType = catalogImageStorageService.resolveMediaType(resource); + return new ImagePayload(resource, mediaType); + } + + private Product findProduct(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id)); + } + + private void validateImageFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("Please select an image to upload"); + } + if (file.getSize() > 5 * 1024 * 1024) { + throw new IllegalArgumentException("Image file size must be less than 5MB"); + } + String contentType = file.getContentType(); + if (contentType == null) { + throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed"); + } + String normalized = contentType.toLowerCase(Locale.ROOT); + if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/gif")) { + throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed"); + } + } + + private void deleteStoredImageIfPresent(String storedImagePath) { + if (storedImagePath == null || storedImagePath.isBlank()) { + return; + } + try { + catalogImageStorageService.deleteProductImage(storedImagePath); + } catch (IOException ignored) { + } + } + private ProductResponse mapToResponse(Product product) { return new ProductResponse( product.getProdId(), @@ -93,8 +160,12 @@ public class ProductService { product.getCategory().getCategoryName(), product.getProdDesc(), product.getProdPrice(), + product.getImageUrl() != null && !product.getImageUrl().isBlank() ? "/api/v1/products/" + product.getProdId() + "/image" : null, product.getCreatedAt(), product.getUpdatedAt() ); } + + public record ImagePayload(Resource resource, MediaType mediaType) { + } } diff --git a/backend/src/main/resources/db/migration/V8__pet_product_image_urls.sql b/backend/src/main/resources/db/migration/V8__pet_product_image_urls.sql new file mode 100644 index 00000000..a4c98248 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__pet_product_image_urls.sql @@ -0,0 +1,5 @@ +ALTER TABLE pet + ADD COLUMN imageUrl VARCHAR(255) NULL; + +ALTER TABLE product + ADD COLUMN imageUrl VARCHAR(255) NULL; diff --git a/desktop/.gitignore b/desktop/.gitignore index c5df67b2..2dc38ee8 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -1,4 +1,5 @@ target/ +nohup.out !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ diff --git a/desktop/pom.xml b/desktop/pom.xml index 9842bdfa..fe513dc6 100644 --- a/desktop/pom.xml +++ b/desktop/pom.xml @@ -76,6 +76,14 @@ 25 + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + --add-opens org.example.petshopdesktop/org.example.petshopdesktop=ALL-UNNAMED + + org.openjfx javafx-maven-plugin diff --git a/desktop/src/main/java/module-info.java b/desktop/src/main/java/module-info.java index 910be91c..d6cefd63 100644 --- a/desktop/src/main/java/module-info.java +++ b/desktop/src/main/java/module-info.java @@ -2,6 +2,7 @@ module org.example.petshopdesktop { requires javafx.controls; requires javafx.fxml; requires javafx.web; + requires java.desktop; requires java.sql; requires java.net.http; requires com.fasterxml.jackson.databind; diff --git a/desktop/src/main/java/org/example/petshopdesktop/DTOs/ProductDTO.java b/desktop/src/main/java/org/example/petshopdesktop/DTOs/ProductDTO.java index 3ea081df..3270d6ca 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/DTOs/ProductDTO.java +++ b/desktop/src/main/java/org/example/petshopdesktop/DTOs/ProductDTO.java @@ -15,15 +15,17 @@ public class ProductDTO { private SimpleIntegerProperty categoryId; //used for edit and delete private SimpleStringProperty categoryName; private SimpleStringProperty prodDesc; + private SimpleStringProperty imageUrl; //constructor - public ProductDTO(int prodId, String prodName, double prodPrice, int categoryId, String categoryName, String prodDesc) { + public ProductDTO(int prodId, String prodName, double prodPrice, int categoryId, String categoryName, String prodDesc, String imageUrl) { this.prodId = new SimpleIntegerProperty(prodId); this.prodName = new SimpleStringProperty(prodName); this.prodPrice = new SimpleDoubleProperty(prodPrice); this.categoryId = new SimpleIntegerProperty(categoryId); this.categoryName = new SimpleStringProperty(categoryName); this.prodDesc = new SimpleStringProperty(prodDesc); + this.imageUrl = new SimpleStringProperty(imageUrl); } //getter and setters @@ -99,6 +101,18 @@ public class ProductDTO { this.categoryId.set(categoryId); } + public String getImageUrl() { + return imageUrl.get(); + } + + public SimpleStringProperty imageUrlProperty() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl.set(imageUrl); + } + /** * Converts DTO into product for editing and deleting * @return diff --git a/desktop/src/main/java/org/example/petshopdesktop/Validator.java b/desktop/src/main/java/org/example/petshopdesktop/Validator.java index 9c6f78a6..2940cde4 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/Validator.java +++ b/desktop/src/main/java/org/example/petshopdesktop/Validator.java @@ -13,6 +13,7 @@ public class Validator { if (value == null || value.isBlank()){ msg += name + " is required. \n"; } + return msg; } @@ -24,8 +25,13 @@ public class Validator { */ public static String isNonNegativeDouble(String value, String name){ String msg =""; + if (value == null) { + msg += name + " must be a number.\n"; + + return msg; + } double result; - try{ + try { result = Double.parseDouble(value); if (result < 0){ msg += name + " must be greater than or equal 0. \n"; @@ -34,6 +40,34 @@ public class Validator { catch (NumberFormatException e){ msg += name + " must be a number.\n"; } + + return msg; + } + + /** + * Checks if the input is a positive double + * @param value input of string + * @param name name of input + * @return error msg if input is not a number or not positive, otherwise empty + */ + public static String isPositiveDouble(String value, String name){ + String msg =""; + if (value == null) { + msg += name + " must be a number.\n"; + + return msg; + } + double result; + try { + result = Double.parseDouble(value); + if (result <= 0){ + msg += name + " must be greater than 0. \n"; + } + } + catch (NumberFormatException e){ + msg += name + " must be a number.\n"; + } + return msg; } @@ -47,8 +81,13 @@ public class Validator { */ public static String isDoubleInRange(String value, String name, double minValue, double maxValue){ String msg =""; + if (value == null) { + msg += name + " must be a number.\n"; + + return msg; + } double result; - try{ + try { result = Double.parseDouble(value); if (result < minValue || result > maxValue){ msg += name + " must be between " + minValue + " and " + maxValue + "\n"; @@ -57,6 +96,7 @@ public class Validator { catch (NumberFormatException e){ msg += name + " must be a number.\n"; } + return msg; } @@ -69,7 +109,7 @@ public class Validator { public static String isNonNegativeInteger(String value, String name){ String msg =""; int result; - try{ + try { result = Integer.parseInt(value); if (result < 0){ msg += name + " must be greater than or equal 0. \n"; @@ -78,6 +118,29 @@ public class Validator { catch (NumberFormatException e){ msg += name + " must be a whole number.\n"; } + + return msg; + } + + /** + * Checks if the input is a positive integer + * @param value input of string + * @param name name of input + * @return error msg if input is not a number or not positive, otherwise empty + */ + public static String isPositiveInteger(String value, String name){ + String msg =""; + int result; + try { + result = Integer.parseInt(value); + if (result <= 0){ + msg += name + " must be greater than 0. \n"; + } + } + catch (NumberFormatException e){ + msg += name + " must be a whole number.\n"; + } + return msg; } @@ -90,9 +153,10 @@ public class Validator { */ public static String isLessThanVarChars(String value, String name, int length){ String msg =""; - if (value.length() > length){ + if (value == null || value.length() > length){ msg += name + " must be less than " + length + " characters. \n"; } + return msg; } @@ -104,11 +168,17 @@ public class Validator { */ public static String isValidEmail(String value, String name){ String msg = ""; + if (value == null) { + msg += name + " is not in a valid format. \n"; + + return msg; + } String regex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"; if (!value.matches(regex)){ msg += name + " is not in a valid format. \n"; } + return msg; } @@ -120,11 +190,17 @@ public class Validator { */ public static String isValidPhoneNumber(String value, String name){ String msg = ""; + if (value == null) { + msg += name + " must be in format XXX-XXX-XXXX. \n"; + + return msg; + } String regex = "^\\d{3}-\\d{3}-\\d{4}$"; if (!value.matches(regex)){ msg += name + " must be in format XXX-XXX-XXXX. \n"; } + return msg; } } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/ApiClient.java b/desktop/src/main/java/org/example/petshopdesktop/api/ApiClient.java index c0fbd874..3914d226 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/ApiClient.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/ApiClient.java @@ -48,6 +48,31 @@ public class ApiClient { return handleResponse(response, responseClass); } + public byte[] getBytes(String path) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .GET() + .timeout(Duration.ofSeconds(30)); + + addAuthHeader(builder); + + HttpRequest request = builder.build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + int statusCode = response.statusCode(); + if (statusCode == 200 || statusCode == 201) { + return response.body(); + } else if (statusCode == 401) { + throw new RuntimeException("Authentication failed. Please log in again."); + } else if (statusCode == 403) { + throw new RuntimeException("Access restricted. You don't have permission to perform this action."); + } else if (statusCode == 404) { + throw new RuntimeException("File not found."); + } else { + throw new RuntimeException("Request failed with status " + statusCode); + } + } + public String getRawResponse(String path) throws Exception { HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create(baseUrl + path)) @@ -199,15 +224,21 @@ public class ApiClient { try { if (response.body() != null && !response.body().isEmpty()) { var errorNode = objectMapper.readTree(response.body()); - if (errorNode.has("message")) { - return errorNode.get("message").asText(); - } if (errorNode.has("errors")) { StringBuilder sb = new StringBuilder(); errorNode.get("errors").fields().forEachRemaining(entry -> { - sb.append(entry.getValue().asText()).append("\n"); + String errorText = entry.getValue().asText(); + if (errorText != null && !errorText.isBlank()) { + sb.append(errorText).append("\n"); + } }); - return sb.toString().trim(); + if (sb.length() > 0) { + String message = errorNode.has("message") ? errorNode.get("message").asText() : null; + return (message != null && !message.isBlank() ? message + "\n" : "") + sb.toString().trim(); + } + } + if (errorNode.has("message")) { + return errorNode.get("message").asText(); } } } catch (Exception e) { diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/pet/PetResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/pet/PetResponse.java index a7932253..b1155214 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/pet/PetResponse.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/pet/PetResponse.java @@ -11,6 +11,7 @@ public class PetResponse { private Integer petAge; private String petStatus; private BigDecimal petPrice; + private String imageUrl; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -73,6 +74,14 @@ public class PetResponse { this.petPrice = petPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/product/ProductResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/product/ProductResponse.java index c989fdf5..18f9b678 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/product/ProductResponse.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/product/ProductResponse.java @@ -8,6 +8,7 @@ public class ProductResponse { private String categoryName; private BigDecimal prodPrice; private String prodDesc; + private String imageUrl; public ProductResponse() { } @@ -51,4 +52,12 @@ public class ProductResponse { public void setProdDesc(String prodDesc) { this.prodDesc = prodDesc; } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/AuthApi.java b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/AuthApi.java index a273a738..0755ef9e 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/AuthApi.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/AuthApi.java @@ -26,6 +26,10 @@ public class AuthApi { return apiClient.postMultipart("/api/v1/auth/me/avatar", "avatar", filePath, AvatarUploadResponse.class); } + public byte[] getMyAvatarFile() throws Exception { + return apiClient.getBytes("/api/v1/auth/me/avatar/file"); + } + public void deleteAvatar() throws Exception { apiClient.delete("/api/v1/auth/me/avatar"); } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/PetApi.java b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/PetApi.java index b5fe23e9..f96372e8 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/PetApi.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/PetApi.java @@ -9,6 +9,7 @@ import org.example.petshopdesktop.api.dto.pet.PetResponse; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.util.List; public class PetApi { @@ -47,6 +48,18 @@ public class PetApi { return apiClient.put("/api/v1/pets/" + id, request, PetResponse.class); } + public PetResponse uploadPetImage(Long id, Path imagePath) throws Exception { + return apiClient.postMultipart("/api/v1/pets/" + id + "/image", "image", imagePath, PetResponse.class); + } + + public void deletePetImage(Long id) throws Exception { + apiClient.delete("/api/v1/pets/" + id + "/image"); + } + + public byte[] getPetImage(Long id) throws Exception { + return apiClient.getBytes("/api/v1/pets/" + id + "/image"); + } + public void deletePets(List ids) throws Exception { apiClient.deleteWithBody("/api/v1/pets", new BulkDeleteRequest(ids)); } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ProductApi.java b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ProductApi.java index 5bffd489..4b8c89f4 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ProductApi.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ProductApi.java @@ -9,6 +9,7 @@ import org.example.petshopdesktop.api.dto.product.ProductResponse; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.util.List; public class ProductApi { @@ -47,6 +48,18 @@ public class ProductApi { return apiClient.put("/api/v1/products/" + id, request, ProductResponse.class); } + public ProductResponse uploadProductImage(Long id, Path imagePath) throws Exception { + return apiClient.postMultipart("/api/v1/products/" + id + "/image", "image", imagePath, ProductResponse.class); + } + + public void deleteProductImage(Long id) throws Exception { + apiClient.delete("/api/v1/products/" + id + "/image"); + } + + public byte[] getProductImage(Long id) throws Exception { + return apiClient.getBytes("/api/v1/products/" + id + "/image"); + } + public void deleteProducts(List ids) throws Exception { apiClient.deleteWithBody("/api/v1/products", new BulkDeleteRequest(ids)); } diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java index b262be0b..d87e181d 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java @@ -16,17 +16,18 @@ import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.paint.ImagePattern; import javafx.scene.shape.Circle; -import javafx.stage.FileChooser; import javafx.stage.Stage; -import org.example.petshopdesktop.api.ApiConfig; import org.example.petshopdesktop.api.ChatRealtimeClient; import org.example.petshopdesktop.api.dto.auth.AvatarUploadResponse; import org.example.petshopdesktop.api.dto.auth.UserInfoResponse; import org.example.petshopdesktop.api.endpoints.AuthApi; import org.example.petshopdesktop.auth.UserSession; +import org.example.petshopdesktop.util.FilePickerSupport; import org.example.petshopdesktop.ui.SvgWebViewFactory; import org.example.petshopdesktop.util.ActivityLogger; +import java.io.ByteArrayInputStream; + public class MainLayoutController { private static final String NAV_BASE_STYLE = "-fx-background-color: transparent; " + @@ -205,12 +206,7 @@ public class MainLayoutController { @FXML void btnChangeAvatarClicked(ActionEvent event) { - FileChooser chooser = new FileChooser(); - chooser.setTitle("Choose Profile Picture"); - chooser.getExtensionFilters().addAll( - new FileChooser.ExtensionFilter("Image Files", "*.png", "*.jpg", "*.jpeg", "*.gif") - ); - java.io.File file = chooser.showOpenDialog(btnChangeAvatar.getScene().getWindow()); + java.io.File file = FilePickerSupport.pickImageFile(btnChangeAvatar.getScene().getWindow()); if (file == null) { return; } @@ -218,8 +214,7 @@ public class MainLayoutController { try { AvatarUploadResponse response = AuthApi.getInstance().uploadAvatar(file.toPath()); UserSession.getInstance().setAvatarUrl(response.getAvatarUrl()); - renderAvatar(UserSession.getInstance().getEmployeeName(), response.getAvatarUrl()); - btnRemoveAvatar.setDisable(response.getAvatarUrl() == null || response.getAvatarUrl().isBlank()); + refreshProfileHeader(); } catch (Exception e) { ActivityLogger.getInstance().logException("MainLayoutController.btnChangeAvatarClicked", e, "Uploading avatar"); showAvatarError(e.getMessage() != null ? e.getMessage() : "Could not upload profile picture."); @@ -263,7 +258,7 @@ public class MainLayoutController { @FXML public void initialize() { logoContainer.getChildren().setAll(SvgWebViewFactory.build("/org/example/petshopdesktop/images/leons-pet-store-badge-light.svg", 94)); - renderAvatar(UserSession.getInstance().getEmployeeName(), UserSession.getInstance().getAvatarUrl()); + renderAvatar(UserSession.getInstance().getEmployeeName(), null); btnRemoveAvatar.setDisable(UserSession.getInstance().getAvatarUrl() == null || UserSession.getInstance().getAvatarUrl().isBlank()); refreshProfileHeader(); applyRBAC(); @@ -285,20 +280,35 @@ public class MainLayoutController { String displayName = userInfo.getFullName() == null || userInfo.getFullName().isBlank() ? UserSession.getInstance().getUsername() : userInfo.getFullName(); + Image avatarImage = loadAvatarImage(userInfo.getAvatarUrl()); Platform.runLater(() -> { UserSession.getInstance().setEmployeeName(displayName); UserSession.getInstance().setAvatarUrl(userInfo.getAvatarUrl()); lblUsername.setText(displayName); - renderAvatar(displayName, userInfo.getAvatarUrl()); + renderAvatar(displayName, avatarImage); btnRemoveAvatar.setDisable(userInfo.getAvatarUrl() == null || userInfo.getAvatarUrl().isBlank()); }); } catch (Exception e) { - Platform.runLater(() -> renderAvatar(UserSession.getInstance().getEmployeeName(), UserSession.getInstance().getAvatarUrl())); + Platform.runLater(() -> renderAvatar(UserSession.getInstance().getEmployeeName(), null)); } }).start(); } - private void renderAvatar(String displayName, String avatarUrl) { + private Image loadAvatarImage(String avatarUrl) { + if (avatarUrl == null || avatarUrl.isBlank()) { + return null; + } + + try { + byte[] imageBytes = AuthApi.getInstance().getMyAvatarFile(); + Image image = new Image(new ByteArrayInputStream(imageBytes), 52, 52, true, true); + return image.isError() ? null : image; + } catch (Exception e) { + return null; + } + } + + private void renderAvatar(String displayName, Image avatarImage) { Circle border = new Circle(29); border.setFill(Color.web("#dbe4ee")); @@ -306,21 +316,9 @@ public class MainLayoutController { Label initials = new Label(initials(displayName)); initials.setStyle("-fx-text-fill: white; -fx-font-weight: bold; -fx-font-size: 16px;"); - if (avatarUrl != null && !avatarUrl.isBlank()) { - try { - String resolvedUrl = avatarUrl.startsWith("http") ? avatarUrl : ApiConfig.getInstance().getBaseUrl() + avatarUrl; - Image image = new Image(resolvedUrl, 52, 52, true, true, true); - if (!image.isError()) { - circle.setFill(new ImagePattern(image)); - initials.setVisible(false); - } else { - circle.setFill(Color.web("#4ECDC4")); - initials.setVisible(true); - } - } catch (Exception e) { - circle.setFill(Color.web("#4ECDC4")); - initials.setVisible(true); - } + if (avatarImage != null) { + circle.setFill(new ImagePattern(avatarImage)); + initials.setVisible(false); } else { circle.setFill(Color.web("#4ECDC4")); initials.setVisible(true); diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/PetController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/PetController.java index 55ccb3e1..88928cb5 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/PetController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/PetController.java @@ -6,9 +6,12 @@ import javafx.collections.ObservableList; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; +import javafx.geometry.Pos; import javafx.scene.Scene; import javafx.scene.control.*; import javafx.scene.control.cell.PropertyValueFactory; +import javafx.scene.image.ImageView; +import javafx.scene.layout.StackPane; import javafx.stage.Modality; import javafx.stage.Stage; import org.example.petshopdesktop.api.dto.pet.PetResponse; @@ -16,6 +19,7 @@ import org.example.petshopdesktop.api.endpoints.PetApi; import org.example.petshopdesktop.controllers.dialogcontrollers.PetDialogController; import org.example.petshopdesktop.models.Pet; import org.example.petshopdesktop.util.ActivityLogger; +import org.example.petshopdesktop.util.DesktopImageSupport; import java.io.IOException; import java.util.List; @@ -42,6 +46,9 @@ public class PetController { @FXML private TableColumn colPetId; + @FXML + private TableColumn colPetImage; + @FXML private TableColumn colPetName; @@ -134,12 +141,14 @@ public class PetController { tvPets.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE); colPetId.setCellValueFactory(new PropertyValueFactory("petId")); + colPetImage.setCellValueFactory(new PropertyValueFactory("imageUrl")); colPetName.setCellValueFactory(new PropertyValueFactory("petName")); colPetSpecies.setCellValueFactory(new PropertyValueFactory("petSpecies")); colPetBreed.setCellValueFactory(new PropertyValueFactory("petBreed")); colPetAge.setCellValueFactory(new PropertyValueFactory("petAge")); colPetStatus.setCellValueFactory(new PropertyValueFactory("petStatus")); colPetPrice.setCellValueFactory(new PropertyValueFactory("petPrice")); + configureImageColumn(colPetImage); displayPets(); @@ -262,8 +271,30 @@ public class PetController { response.getPetBreed(), response.getPetAge() != null ? response.getPetAge() : 0, response.getPetStatus(), - response.getPetPrice().doubleValue() + response.getPetPrice().doubleValue(), + response.getImageUrl() ); } + private void configureImageColumn(TableColumn column) { + column.setCellFactory(col -> new TableCell<>() { + private final ImageView imageView = new ImageView(); + private final StackPane container = new StackPane(imageView); + { + container.setAlignment(Pos.CENTER); + } + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null || item.isBlank()) { + setGraphic(null); + return; + } + DesktopImageSupport.loadImageInto(imageView, item, 48, 48); + setGraphic(container); + } + }); + } + } diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/ProductController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/ProductController.java index e6168911..053e0105 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/ProductController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/ProductController.java @@ -6,9 +6,12 @@ import javafx.collections.ObservableList; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; +import javafx.geometry.Pos; import javafx.scene.Scene; import javafx.scene.control.*; import javafx.scene.control.cell.PropertyValueFactory; +import javafx.scene.image.ImageView; +import javafx.scene.layout.StackPane; import javafx.stage.Modality; import javafx.stage.Stage; import org.example.petshopdesktop.DTOs.ProductDTO; @@ -16,6 +19,7 @@ import org.example.petshopdesktop.api.dto.product.ProductResponse; import org.example.petshopdesktop.api.endpoints.ProductApi; import org.example.petshopdesktop.controllers.dialogcontrollers.ProductDialogController; import org.example.petshopdesktop.util.ActivityLogger; +import org.example.petshopdesktop.util.DesktopImageSupport; import java.io.IOException; import java.util.ArrayList; @@ -46,6 +50,9 @@ public class ProductController { @FXML private TableColumn colProductId; + @FXML + private TableColumn colProductImage; + @FXML private TableColumn colProductName; @@ -74,10 +81,12 @@ public class ProductController { tvProducts.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE); //set up table columns colProductId.setCellValueFactory(new PropertyValueFactory("prodId")); + colProductImage.setCellValueFactory(new PropertyValueFactory("imageUrl")); colProductName.setCellValueFactory(new PropertyValueFactory("prodName")); colProductPrice.setCellValueFactory(new PropertyValueFactory("prodPrice")); colProductCategory.setCellValueFactory(new PropertyValueFactory("categoryName")); colProductDesc.setCellValueFactory(new PropertyValueFactory("prodDesc")); + configureImageColumn(colProductImage); displayProduct(); @@ -292,8 +301,30 @@ public class ProductController { response.getProdPrice().doubleValue(), 0, response.getCategoryName(), - response.getProdDesc() + response.getProdDesc(), + response.getImageUrl() ); } + private void configureImageColumn(TableColumn column) { + column.setCellFactory(col -> new TableCell<>() { + private final ImageView imageView = new ImageView(); + private final StackPane container = new StackPane(imageView); + { + container.setAlignment(Pos.CENTER); + } + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null || item.isBlank()) { + setGraphic(null); + return; + } + DesktopImageSupport.loadImageInto(imageView, item, 48, 48); + setGraphic(container); + } + }); + } + } diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/PetDialogController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/PetDialogController.java index cd7f2690..78ad836a 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/PetDialogController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/PetDialogController.java @@ -6,6 +6,7 @@ import javafx.event.EventHandler; import javafx.fxml.FXML; import javafx.scene.Node; import javafx.scene.control.*; +import javafx.scene.image.ImageView; import javafx.scene.input.MouseEvent; import javafx.stage.Stage; import org.example.petshopdesktop.Validator; @@ -14,8 +15,12 @@ import org.example.petshopdesktop.api.dto.pet.PetResponse; import org.example.petshopdesktop.api.endpoints.PetApi; import org.example.petshopdesktop.models.Pet; import org.example.petshopdesktop.util.ActivityLogger; +import org.example.petshopdesktop.util.DesktopImageSupport; +import org.example.petshopdesktop.util.FilePickerSupport; +import java.io.File; import java.math.BigDecimal; + public class PetDialogController { @FXML @@ -24,6 +29,12 @@ public class PetDialogController { @FXML private Button btnSave; + @FXML + private Button btnChangeImage; + + @FXML + private Button btnRemoveImage; + @FXML private ComboBox cbPetStatus; @@ -33,6 +44,12 @@ public class PetDialogController { @FXML private Label lblPetId; + @FXML + private Label lblImageStatus; + + @FXML + private ImageView imgPetPreview; + @FXML private TextField txtPetAge; @@ -49,6 +66,9 @@ public class PetDialogController { private TextField txtPetSpecies; private String mode = null; + private File selectedImageFile; + private String currentImageUrl; + private boolean removeImageRequested; private ObservableList statusList = FXCollections.observableArrayList( "Available", "Adopted" @@ -73,6 +93,10 @@ public class PetDialogController { closeStage(mouseEvent); } }); + + btnChangeImage.setOnMouseClicked(mouseEvent -> handleChangeImage()); + btnRemoveImage.setOnMouseClicked(mouseEvent -> handleRemoveImage()); + refreshImagePreview(); } private void buttonSaveClicked(MouseEvent mouseEvent) { @@ -97,13 +121,14 @@ public class PetDialogController { //Check validation (format) errorMsg += Validator.isNonNegativeDouble(txtPetPrice.getText(), "Price"); - errorMsg += Validator.isNonNegativeInteger(txtPetAge.getText(), "Age"); + errorMsg += Validator.isPositiveInteger(txtPetAge.getText(), "Age"); if(errorMsg.isEmpty()){ PetRequest request = buildPetRequest(); try { if(mode.equals("Add")) { - PetApi.getInstance().createPet(request); + PetResponse response = PetApi.getInstance().createPet(request); + applyImageChanges(response.getPetId()); } else { String[] parts = lblPetId.getText().split(": "); if (parts.length < 2) { @@ -111,6 +136,7 @@ public class PetDialogController { } Long petId = Long.parseLong(parts[1]); PetApi.getInstance().updatePet(petId, request); + applyImageChanges(petId); } //tell the user operation was successful @@ -175,6 +201,10 @@ public class PetDialogController { txtPetBreed.setText(pet.getPetBreed()); txtPetAge.setText(pet.getPetAge() + ""); txtPetPrice.setText(pet.getPetPrice() + ""); + currentImageUrl = pet.getImageUrl(); + selectedImageFile = null; + removeImageRequested = false; + refreshImagePreview(); //get the right combobox selection for (String status : cbPetStatus.getItems()) { @@ -192,10 +222,76 @@ public class PetDialogController { lblMode.setText(mode + " Pet"); if(mode.equals("Add")) { lblPetId.setVisible(false); + currentImageUrl = null; + selectedImageFile = null; + removeImageRequested = false; + refreshImagePreview(); } else if(mode.equals("Edit")) { lblPetId.setVisible(true); + refreshImagePreview(); } } + private void handleChangeImage() { + File file = FilePickerSupport.pickImageFile(btnSave.getScene().getWindow()); + if (file == null) { + return; + } + selectedImageFile = file; + removeImageRequested = false; + lblImageStatus.setText("Selected: " + file.getName()); + DesktopImageSupport.loadImageInto(imgPetPreview, file.toURI().toString(), 120, 120); + btnRemoveImage.setDisable(false); + } + + private void handleRemoveImage() { + selectedImageFile = null; + removeImageRequested = true; + currentImageUrl = null; + refreshImagePreview(); + } + + private void applyImageChanges(Long petId) throws Exception { + String previousImageUrl = currentImageUrl; + if (removeImageRequested) { + try { + PetApi.getInstance().deletePetImage(petId); + } catch (Exception ignored) { + } + } + if (selectedImageFile != null) { + PetApi.getInstance().uploadPetImage(petId, selectedImageFile.toPath()); + currentImageUrl = "/api/v1/pets/" + petId + "/image"; + } else if (removeImageRequested) { + currentImageUrl = null; + } + DesktopImageSupport.evict(previousImageUrl); + DesktopImageSupport.evict(currentImageUrl); + selectedImageFile = null; + removeImageRequested = false; + refreshImagePreview(); + } + + private void refreshImagePreview() { + if (imgPetPreview == null || lblImageStatus == null || btnRemoveImage == null) { + return; + } + imgPetPreview.setImage(null); + if (selectedImageFile != null) { + lblImageStatus.setText("Selected: " + selectedImageFile.getName()); + DesktopImageSupport.loadImageInto(imgPetPreview, selectedImageFile.toURI().toString(), 120, 120); + btnRemoveImage.setDisable(false); + return; + } + if (currentImageUrl != null && !currentImageUrl.isBlank()) { + lblImageStatus.setText("Current image loaded"); + DesktopImageSupport.loadImageInto(imgPetPreview, currentImageUrl, 120, 120); + btnRemoveImage.setDisable(false); + return; + } + lblImageStatus.setText("No image selected"); + btnRemoveImage.setDisable(true); + } + } diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/ProductDialogController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/ProductDialogController.java index 25fe1da8..7b354a9d 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/ProductDialogController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/ProductDialogController.java @@ -6,16 +6,21 @@ import javafx.event.EventHandler; import javafx.fxml.FXML; import javafx.scene.Node; import javafx.scene.control.*; +import javafx.scene.image.ImageView; import javafx.scene.input.MouseEvent; import javafx.stage.Stage; import org.example.petshopdesktop.DTOs.ProductDTO; import org.example.petshopdesktop.Validator; import org.example.petshopdesktop.api.dto.common.DropdownOption; import org.example.petshopdesktop.api.dto.product.ProductRequest; +import org.example.petshopdesktop.api.dto.product.ProductResponse; import org.example.petshopdesktop.api.endpoints.DropdownApi; import org.example.petshopdesktop.api.endpoints.ProductApi; import org.example.petshopdesktop.util.ActivityLogger; +import org.example.petshopdesktop.util.DesktopImageSupport; +import org.example.petshopdesktop.util.FilePickerSupport; +import java.io.File; import java.math.BigDecimal; import java.util.List; @@ -27,6 +32,12 @@ public class ProductDialogController { @FXML private Button btnSave; + @FXML + private Button btnChangeImage; + + @FXML + private Button btnRemoveImage; + @FXML private ComboBox cbProdCategory; @@ -36,6 +47,12 @@ public class ProductDialogController { @FXML private Label lblProdId; + @FXML + private Label lblImageStatus; + + @FXML + private ImageView imgProductPreview; + @FXML private TextField txtProdDesc; @@ -46,6 +63,9 @@ public class ProductDialogController { private TextField txtProdPrice; private String mode = null; + private File selectedImageFile; + private String currentImageUrl; + private boolean removeImageRequested; /** * Add event listeners to buttons when dialog loads @@ -82,6 +102,10 @@ public class ProductDialogController { System.out.println("Error loading categories: " + e.getMessage()); } + btnChangeImage.setOnMouseClicked(mouseEvent -> handleChangeImage()); + btnRemoveImage.setOnMouseClicked(mouseEvent -> handleRemoveImage()); + refreshImagePreview(); + } /** @@ -106,7 +130,7 @@ public class ProductDialogController { errorMsg += Validator.isLessThanVarChars(txtProdPrice.getText(), "Product Price", 12); //Check Validation (format) - errorMsg += Validator.isNonNegativeDouble(txtProdPrice.getText(), "Product Price"); + errorMsg += Validator.isPositiveDouble(txtProdPrice.getText(), "Product Price"); if (errorMsg.isEmpty()) { try { @@ -123,7 +147,8 @@ public class ProductDialogController { request.setProdDesc(txtProdDesc.getText()); if (mode.equals("Add")) { - ProductApi.getInstance().createProduct(request); + ProductResponse response = ProductApi.getInstance().createProduct(request); + applyImageChanges(response.getProdId()); } else { String[] parts = lblProdId.getText().split(": "); if (parts.length < 2) { @@ -131,6 +156,7 @@ public class ProductDialogController { } Long productId = Long.parseLong(parts[1]); ProductApi.getInstance().updateProduct(productId, request); + applyImageChanges(productId); } Alert alert = new Alert(Alert.AlertType.INFORMATION); @@ -167,6 +193,10 @@ public class ProductDialogController { txtProdName.setText(product.getProdName()); txtProdDesc.setText(product.getProdDesc()); txtProdPrice.setText(product.getProdPrice() + ""); + currentImageUrl = product.getImageUrl(); + selectedImageFile = null; + removeImageRequested = false; + refreshImagePreview(); for (DropdownOption category : cbProdCategory.getItems()) { if(category.getLabel().equals(product.getCategoryName())){ @@ -197,10 +227,76 @@ public class ProductDialogController { lblMode.setText(mode + " Product"); if(mode.equals("Add")) { lblProdId.setVisible(false); + currentImageUrl = null; + selectedImageFile = null; + removeImageRequested = false; + refreshImagePreview(); } else if(mode.equals("Edit")) { lblProdId.setVisible(true); + refreshImagePreview(); } } + private void handleChangeImage() { + File file = FilePickerSupport.pickImageFile(btnSave.getScene().getWindow()); + if (file == null) { + return; + } + selectedImageFile = file; + removeImageRequested = false; + lblImageStatus.setText("Selected: " + file.getName()); + DesktopImageSupport.loadImageInto(imgProductPreview, file.toURI().toString(), 120, 120); + btnRemoveImage.setDisable(false); + } + + private void handleRemoveImage() { + selectedImageFile = null; + removeImageRequested = true; + currentImageUrl = null; + refreshImagePreview(); + } + + private void applyImageChanges(Long productId) throws Exception { + String previousImageUrl = currentImageUrl; + if (removeImageRequested) { + try { + ProductApi.getInstance().deleteProductImage(productId); + } catch (Exception ignored) { + } + } + if (selectedImageFile != null) { + ProductApi.getInstance().uploadProductImage(productId, selectedImageFile.toPath()); + currentImageUrl = "/api/v1/products/" + productId + "/image"; + } else if (removeImageRequested) { + currentImageUrl = null; + } + DesktopImageSupport.evict(previousImageUrl); + DesktopImageSupport.evict(currentImageUrl); + selectedImageFile = null; + removeImageRequested = false; + refreshImagePreview(); + } + + private void refreshImagePreview() { + if (imgProductPreview == null || lblImageStatus == null || btnRemoveImage == null) { + return; + } + imgProductPreview.setImage(null); + if (selectedImageFile != null) { + lblImageStatus.setText("Selected: " + selectedImageFile.getName()); + DesktopImageSupport.loadImageInto(imgProductPreview, selectedImageFile.toURI().toString(), 120, 120); + btnRemoveImage.setDisable(false); + return; + } + if (currentImageUrl != null && !currentImageUrl.isBlank()) { + lblImageStatus.setText("Current image loaded"); + DesktopImageSupport.loadImageInto(imgProductPreview, currentImageUrl, 120, 120); + btnRemoveImage.setDisable(false); + return; + } + lblImageStatus.setText("No image selected"); + btnRemoveImage.setDisable(true); + } + } diff --git a/desktop/src/main/java/org/example/petshopdesktop/models/Pet.java b/desktop/src/main/java/org/example/petshopdesktop/models/Pet.java index e1f2e3fb..fc1723c5 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/models/Pet.java +++ b/desktop/src/main/java/org/example/petshopdesktop/models/Pet.java @@ -12,8 +12,9 @@ public class Pet { private SimpleIntegerProperty petAge; private SimpleStringProperty petStatus; private SimpleDoubleProperty petPrice; + private SimpleStringProperty imageUrl; - public Pet(int petId, String petName, String petSpecies, String petBreed, int petAge, String petStatus, double petPrice) { + public Pet(int petId, String petName, String petSpecies, String petBreed, int petAge, String petStatus, double petPrice, String imageUrl) { this.petId = new SimpleIntegerProperty(petId); this.petName = new SimpleStringProperty(petName); this.petSpecies = new SimpleStringProperty(petSpecies); @@ -21,6 +22,7 @@ public class Pet { this.petAge = new SimpleIntegerProperty(petAge); this.petStatus = new SimpleStringProperty(petStatus); this.petPrice = new SimpleDoubleProperty(petPrice); + this.imageUrl = new SimpleStringProperty(imageUrl); } public int getPetId() { @@ -106,4 +108,16 @@ public class Pet { public SimpleDoubleProperty petPriceProperty() { return petPrice; } + + public String getImageUrl() { + return imageUrl.get(); + } + + public void setImageUrl(String imageUrl) { + this.imageUrl.set(imageUrl); + } + + public SimpleStringProperty imageUrlProperty() { + return imageUrl; + } } diff --git a/desktop/src/main/java/org/example/petshopdesktop/util/DesktopImageSupport.java b/desktop/src/main/java/org/example/petshopdesktop/util/DesktopImageSupport.java new file mode 100644 index 00000000..59cbcb98 --- /dev/null +++ b/desktop/src/main/java/org/example/petshopdesktop/util/DesktopImageSupport.java @@ -0,0 +1,62 @@ +package org.example.petshopdesktop.util; + +import javafx.application.Platform; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import org.example.petshopdesktop.api.ApiClient; + +import java.io.ByteArrayInputStream; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public final class DesktopImageSupport { + + private static final Map IMAGE_CACHE = new ConcurrentHashMap<>(); + + private DesktopImageSupport() { + } + + public static void loadImageInto(ImageView imageView, String imageUrl, double width, double height) { + imageView.setFitWidth(width); + imageView.setFitHeight(height); + imageView.setPreserveRatio(true); + imageView.setSmooth(true); + imageView.setImage(null); + + if (imageUrl == null || imageUrl.isBlank()) { + return; + } + + if (imageUrl.startsWith("file:")) { + Image image = new Image(imageUrl, 0, 0, true, true); + if (!image.isError()) { + imageView.setImage(image); + } + return; + } + + Image cached = IMAGE_CACHE.get(imageUrl); + if (cached != null) { + imageView.setImage(cached); + return; + } + + new Thread(() -> { + try { + byte[] bytes = ApiClient.getInstance().getBytes(imageUrl); + Image image = new Image(new ByteArrayInputStream(bytes)); + if (!image.isError()) { + IMAGE_CACHE.put(imageUrl, image); + Platform.runLater(() -> imageView.setImage(image)); + } + } catch (Exception ignored) { + } + }, "desktop-image-loader").start(); + } + + public static void evict(String imageUrl) { + if (imageUrl != null && !imageUrl.isBlank()) { + IMAGE_CACHE.remove(imageUrl); + } + } +} diff --git a/desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java b/desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java new file mode 100644 index 00000000..313c78e9 --- /dev/null +++ b/desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java @@ -0,0 +1,77 @@ +package org.example.petshopdesktop.util; + +import javafx.stage.FileChooser; +import javafx.stage.Window; + +import javax.swing.JFileChooser; +import javax.swing.UIManager; +import javax.swing.filechooser.FileNameExtensionFilter; +import java.awt.Component; +import java.awt.GraphicsEnvironment; +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.util.concurrent.atomic.AtomicReference; + +public final class FilePickerSupport { + + private FilePickerSupport() { + } + + public static File pickImageFile(Window ownerWindow) { + if (shouldUseAwtPicker()) { + return pickImageFileWithSwing(); + } + return pickImageFileWithJavaFx(ownerWindow); + } + + private static boolean shouldUseAwtPicker() { + if (GraphicsEnvironment.isHeadless()) { + return false; + } + String sessionType = System.getenv("XDG_SESSION_TYPE"); + String waylandDisplay = System.getenv("WAYLAND_DISPLAY"); + return "wayland".equalsIgnoreCase(sessionType) || (waylandDisplay != null && !waylandDisplay.isBlank()); + } + + private static File pickImageFileWithJavaFx(Window ownerWindow) { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Choose Profile Picture"); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Image Files", "*.png", "*.jpg", "*.jpeg", "*.gif")); + return chooser.showOpenDialog(ownerWindow); + } + + private static File pickImageFileWithSwing() { + AtomicReference selectedFile = new AtomicReference<>(); + Runnable dialogTask = () -> { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception ignored) { + } + + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Choose Profile Picture"); + chooser.setAcceptAllFileFilterUsed(false); + chooser.setFileFilter(new FileNameExtensionFilter("Image Files", "png", "jpg", "jpeg", "gif")); + + int result = chooser.showOpenDialog((Component) null); + if (result == JFileChooser.APPROVE_OPTION) { + selectedFile.set(chooser.getSelectedFile()); + } + }; + + try { + if (java.awt.EventQueue.isDispatchThread()) { + dialogTask.run(); + } else { + java.awt.EventQueue.invokeAndWait(dialogTask); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return null; + } catch (InvocationTargetException ex) { + throw new IllegalStateException("Failed to open Swing file picker", ex.getCause()); + } + + return selectedFile.get(); + } +} diff --git a/desktop/src/main/resources/org/example/petshopdesktop/dialogviews/pet-dialog-view.fxml b/desktop/src/main/resources/org/example/petshopdesktop/dialogviews/pet-dialog-view.fxml index d4c97ddb..2f5bd110 100644 --- a/desktop/src/main/resources/org/example/petshopdesktop/dialogviews/pet-dialog-view.fxml +++ b/desktop/src/main/resources/org/example/petshopdesktop/dialogviews/pet-dialog-view.fxml @@ -5,15 +5,15 @@ + - - + @@ -62,18 +62,13 @@ - + - + - - - - - @@ -163,6 +158,22 @@ + + + + + +