Merge main into nomorebreaking

This commit is contained in:
2026-03-29 17:07:35 -06:00
113 changed files with 10089 additions and 279 deletions

3
android/.gitignore vendored
View File

@@ -1,4 +1,5 @@
*.iml *.iml
nohup.out
.gradle .gradle
/local.properties /local.properties
/.idea/* /.idea/*
@@ -16,6 +17,8 @@
/app/src/androidTest/ /app/src/androidTest/
/app/src/test/ /app/src/test/
.DS_Store .DS_Store
/.project
/.settings/
/build /build
/captures /captures
.externalNativeBuild .externalNativeBuild

View File

@@ -1,3 +1,7 @@
/build /build
/nohup.out
/.classpath
/.project
/.settings/
/src/test/ /src/test/
/src/androidTest/ /src/androidTest/

View File

@@ -56,6 +56,9 @@ dependencies {
implementation("io.reactivex.rxjava2:rxjava:2.2.21") implementation("io.reactivex.rxjava2:rxjava:2.2.21")
implementation("io.reactivex.rxjava2:rxandroid:2.1.1") 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) testImplementation(libs.junit)
androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.espresso.core)

View File

@@ -8,6 +8,7 @@
android:name="android.permission.READ_EXTERNAL_STORAGE" android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature <uses-feature
android:name="android.hardware.camera" android:name="android.hardware.camera"
@@ -24,6 +25,11 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.PetStoreMobile"> android:theme="@style/Theme.PetStoreMobile">
<service
android:name=".services.ChatNotificationService"
android:exported="false" />
<activity <activity
android:name=".activities.HomeActivity" android:name=".activities.HomeActivity"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -7,7 +7,5 @@ public class PetStoreApplication extends Application {
@Override @Override
public void onCreate() { public void onCreate() {
super.onCreate(); super.onCreate();
// Clear login data on app so when the application closes, the user is logged out and have to re-login
TokenManager.getInstance(this).clearLoginData();
} }
} }

View File

@@ -1,25 +1,40 @@
package com.example.petstoremobile.activities; package com.example.petstoremobile.activities;
import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log;
import androidx.activity.EdgeToEdge; import androidx.activity.EdgeToEdge;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets; import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat; import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import com.example.petstoremobile.R; import com.example.petstoremobile.R;
import com.example.petstoremobile.fragments.ChatFragment; import com.example.petstoremobile.fragments.ChatFragment;
import com.example.petstoremobile.fragments.ListFragment; import com.example.petstoremobile.fragments.ListFragment;
import com.example.petstoremobile.fragments.ProfileFragment; import com.example.petstoremobile.fragments.ProfileFragment;
import com.example.petstoremobile.services.ChatNotificationService;
import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.bottomnavigation.BottomNavigationView;
public class HomeActivity extends AppCompatActivity { public class HomeActivity extends AppCompatActivity {
private BottomNavigationView bottomNav;
// Launcher to ask for notification permission
private final ActivityResultLauncher<String> requestPermissionLauncher =
registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
if (!isGranted) {
Log.w("HomeActivity", "Notification permission denied");
}
});
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
@@ -34,17 +49,15 @@ public class HomeActivity extends AppCompatActivity {
}); });
//get the bottom navbar from the layout //get the bottom navbar from the layout
BottomNavigationView bottomNav = findViewById(R.id.bottom_navigation); bottomNav = findViewById(R.id.bottom_navigation);
// Load ListFragment by default only if this is a fresh start //load the list fragment by default if it's a fresh start
if (savedInstanceState == null) { if (savedInstanceState == null) {
loadFragment(new ListFragment()); handleIntent(getIntent());
bottomNav.setSelectedItemId(R.id.nav_list);
} }
//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 -> { bottomNav.setOnItemSelectedListener(item -> {
if (item.getItemId() == R.id.nav_list) { if (item.getItemId() == R.id.nav_list) {
loadFragment(new ListFragment()); loadFragment(new ListFragment());
return true; return true;
@@ -57,13 +70,59 @@ public class HomeActivity extends AppCompatActivity {
} }
return false; 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) { private void loadFragment(Fragment fragment) {
getSupportFragmentManager() getSupportFragmentManager()
.beginTransaction() .beginTransaction()
.replace(R.id.fragment_container, fragment) .replace(R.id.fragment_container, fragment)
.commit(); .commit();
} }
} }

View File

@@ -19,6 +19,7 @@ import com.example.petstoremobile.api.auth.AuthApi;
import com.example.petstoremobile.api.auth.TokenManager; import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.api.RetrofitClient; import com.example.petstoremobile.api.RetrofitClient;
import com.example.petstoremobile.dtos.AuthDTO; import com.example.petstoremobile.dtos.AuthDTO;
import com.example.petstoremobile.dtos.UserDTO;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.Callback; import retrofit2.Callback;
@@ -38,11 +39,17 @@ public class MainActivity extends AppCompatActivity {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
// Check if user is already logged in // Check if user is already logged in
if (TokenManager.getInstance(this).isLoggedIn()) { TokenManager tokenManager = TokenManager.getInstance(this);
Intent intent = new Intent(this, HomeActivity.class); if (tokenManager.isLoggedIn()) {
startActivity(intent); if ("CUSTOMER".equalsIgnoreCase(tokenManager.getRole())) {
finish(); // If a customer somehow remained logged in, clear them out
return; tokenManager.clearLoginData();
} else {
Intent intent = new Intent(this, HomeActivity.class);
startActivity(intent);
finish();
return;
}
} }
EdgeToEdge.enable(this); EdgeToEdge.enable(this);
@@ -82,19 +89,28 @@ public class MainActivity extends AppCompatActivity {
@Override @Override
public void onResponse(Call<AuthDTO.LoginResponse> call, Response<AuthDTO.LoginResponse> response) { public void onResponse(Call<AuthDTO.LoginResponse> call, Response<AuthDTO.LoginResponse> response) {
if (response.isSuccessful() && response.body() != null) { 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 //save login data in shared preferences
TokenManager.getInstance(MainActivity.this).saveLoginData( TokenManager.getInstance(MainActivity.this).saveLoginData(
response.body().getToken(), response.body().getToken(),
response.body().getUsername(), response.body().getUsername(),
response.body().getRole() role
); );
//fetch user id from api then login to home activity //fetch user id from api then login to home activity
RetrofitClient.getAuthApi(MainActivity.this).getCurrentUser() RetrofitClient.getAuthApi(MainActivity.this).getMe()
.enqueue(new Callback<AuthDTO.UserResponse>() { .enqueue(new Callback<UserDTO>() {
@Override @Override
public void onResponse(Call<AuthDTO.UserResponse> call, public void onResponse(Call<UserDTO> call,
Response<AuthDTO.UserResponse> response) { Response<UserDTO> response) {
if (response.isSuccessful() && response.body() != null) { if (response.isSuccessful() && response.body() != null) {
TokenManager.getInstance(MainActivity.this) TokenManager.getInstance(MainActivity.this)
.saveUserId(response.body().getId()); .saveUserId(response.body().getId());
@@ -106,7 +122,7 @@ public class MainActivity extends AppCompatActivity {
} }
@Override @Override
public void onFailure(Call<AuthDTO.UserResponse> call, public void onFailure(Call<UserDTO> call,
Throwable t) { Throwable t) {
Log.e("MainActivity", "Failed to fetch userId", t); Log.e("MainActivity", "Failed to fetch userId", t);
@@ -129,4 +145,4 @@ public class MainActivity extends AppCompatActivity {
}); });
}); });
} }
} }

View File

@@ -3,17 +3,23 @@ package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.PetDTO; import com.example.petstoremobile.dtos.PetDTO;
import okhttp3.MultipartBody;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.http.Body; import retrofit2.http.Body;
import retrofit2.http.DELETE; import retrofit2.http.DELETE;
import retrofit2.http.GET; import retrofit2.http.GET;
import retrofit2.http.Multipart;
import retrofit2.http.POST; import retrofit2.http.POST;
import retrofit2.http.PUT; import retrofit2.http.PUT;
import retrofit2.http.Part;
import retrofit2.http.Path; import retrofit2.http.Path;
import retrofit2.http.Query; import retrofit2.http.Query;
//api calls to CRUD pets //api calls to CRUD pets
public interface PetApi { public interface PetApi {
// endpoint for downloading the pet's image file
String PET_IMAGE_PATH = "api/v1/pets/%d/image";
// Get all pets // Get all pets
@GET("api/v1/pets") @GET("api/v1/pets")
Call<PageResponse<PetDTO>> getAllPets( Call<PageResponse<PetDTO>> getAllPets(
@@ -37,4 +43,9 @@ public interface PetApi {
@DELETE("api/v1/pets/{id}") @DELETE("api/v1/pets/{id}")
Call<Void> deletePet(@Path("id") Long id); Call<Void> deletePet(@Path("id") Long id);
// Upload pet image
@Multipart
@POST("api/v1/pets/{id}/image")
Call<Void> uploadPetImage(@Path("id") Long id, @Part MultipartBody.Part image);
} }

View File

@@ -1,6 +1,7 @@
package com.example.petstoremobile.api; package com.example.petstoremobile.api;
import android.content.Context; import android.content.Context;
import android.os.Build;
import com.example.petstoremobile.api.auth.AuthApi; import com.example.petstoremobile.api.auth.AuthApi;
import com.example.petstoremobile.api.auth.AuthInterceptor; import com.example.petstoremobile.api.auth.AuthInterceptor;
@@ -12,9 +13,23 @@ import retrofit2.converter.gson.GsonConverterFactory;
//Retrofit client Used for API calls //Retrofit client Used for API calls
public class RetrofitClient { public class RetrofitClient {
//base URL public static final String BASE_URL = getBaseUrl();
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 // 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; private static Retrofit retrofit = null;
@@ -95,7 +110,6 @@ public class RetrofitClient {
return getClient(context).create(MessageApi.class); return getClient(context).create(MessageApi.class);
} }
public static StoreApi getStoreApi(Context context) { public static StoreApi getStoreApi(Context context) {
return getClient(context).create(StoreApi.class); return getClient(context).create(StoreApi.class);
} }

View File

@@ -1,17 +1,40 @@
package com.example.petstoremobile.api.auth; package com.example.petstoremobile.api.auth;
import com.example.petstoremobile.dtos.AuthDTO; import com.example.petstoremobile.dtos.AuthDTO;
import com.example.petstoremobile.dtos.UserDTO;
import java.util.Map;
import okhttp3.MultipartBody;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.http.Body; import retrofit2.http.Body;
import retrofit2.http.GET; import retrofit2.http.GET;
import retrofit2.http.Multipart;
import retrofit2.http.POST; import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Part;
//Api for logging in and getting current user //Api for logging in and getting current user
public interface AuthApi { 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") @POST("api/v1/auth/login")
Call<AuthDTO.LoginResponse> login(@Body AuthDTO.LoginRequest loginRequest); Call<AuthDTO.LoginResponse> login(@Body AuthDTO.LoginRequest loginRequest);
//get current user endpoint
@GET("api/v1/auth/me") @GET("api/v1/auth/me")
Call<AuthDTO.UserResponse> getCurrentUser(); Call<UserDTO> getMe();
//update current user endpoint
@PUT("api/v1/auth/me")
Call<UserDTO> updateMe(@Body Map<String, String> updates);
//upload avatar endpoint
@Multipart
@POST("api/v1/auth/me/avatar")
Call<UserDTO> uploadAvatar(@Part MultipartBody.Part avatar);
} }

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -25,6 +25,7 @@ import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.SendMessageRequest; import com.example.petstoremobile.dtos.SendMessageRequest;
import com.example.petstoremobile.models.Chat; import com.example.petstoremobile.models.Chat;
import com.example.petstoremobile.models.Message; import com.example.petstoremobile.models.Message;
import com.example.petstoremobile.services.ChatNotificationService;
import com.example.petstoremobile.websocket.StompChatManager; import com.example.petstoremobile.websocket.StompChatManager;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -40,6 +41,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
private RecyclerView rvChatList, rvMessages; private RecyclerView rvChatList, rvMessages;
private EditText etMessage; private EditText etMessage;
private Button btnSend; private Button btnSend;
private TextView tvChatTitle;
// Adapters // Adapters
private ChatAdapter chatAdapter; private ChatAdapter chatAdapter;
@@ -75,6 +77,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
rvMessages = view.findViewById(R.id.rvMessages); rvMessages = view.findViewById(R.id.rvMessages);
etMessage = view.findViewById(R.id.etMessage); etMessage = view.findViewById(R.id.etMessage);
btnSend = view.findViewById(R.id.btnSend); btnSend = view.findViewById(R.id.btnSend);
tvChatTitle = view.findViewById(R.id.tvChatTitle);
ImageButton hamburger = view.findViewById(R.id.btnHamburger); ImageButton hamburger = view.findViewById(R.id.btnHamburger);
hamburger.setOnClickListener(v -> drawerLayout.openDrawer(GravityCompat.START)); 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"); Log.e(TAG, "No token found");
} }
if (getArguments() != null && getArguments().containsKey("conversation_id")) {
activeConversationId = getArguments().getLong("conversation_id");
}
loadCustomers(); loadCustomers();
} }
@@ -165,7 +172,21 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
.collect(Collectors.toList()); .collect(Collectors.toList());
chatList.addAll(loaded); chatList.addAll(loaded);
chatAdapter.notifyDataSetChanged(); 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(); messageList.clear();
messageAdapter.notifyDataSetChanged(); messageAdapter.notifyDataSetChanged();
setConversationActive(false); setConversationActive(false);
@@ -186,6 +207,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
public void onChatClick(Chat chat) { public void onChatClick(Chat chat) {
activeConversationId = Long.parseLong(chat.getChatId()); activeConversationId = Long.parseLong(chat.getChatId());
setConversationActive(true); setConversationActive(true);
tvChatTitle.setText(chat.getCustomerName());
drawerLayout.closeDrawer(GravityCompat.START); drawerLayout.closeDrawer(GravityCompat.START);
if (stompChatManager != null) { if (stompChatManager != null) {
@@ -305,6 +327,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
if (activeConversationId != null && activeConversationId.equals(dto.getId())) { if (activeConversationId != null && activeConversationId.equals(dto.getId())) {
setConversationActive(true); setConversationActive(true);
tvChatTitle.setText(name);
} }
} }
@@ -386,6 +409,8 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
etMessage.setEnabled(active); etMessage.setEnabled(active);
if (!active) { if (!active) {
activeConversationId = null; activeConversationId = null;
ChatNotificationService.activeConversationIdInUi = null;
if (tvChatTitle != null) tvChatTitle.setText("Customer Chat");
if (stompChatManager != null) { if (stompChatManager != null) {
stompChatManager.clearConversationSubscription(); stompChatManager.clearConversationSubscription();
} }
@@ -395,6 +420,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
etMessage.setHint("Select a chat to start messaging"); etMessage.setHint("Select a chat to start messaging");
} else { } else {
etMessage.setHint("Type a message..."); etMessage.setHint("Type a message...");
ChatNotificationService.activeConversationIdInUi = activeConversationId;
} }
} }
@@ -402,6 +428,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
@Override @Override
public void onDestroyView() { public void onDestroyView() {
super.onDestroyView(); super.onDestroyView();
ChatNotificationService.activeConversationIdInUi = null;
if (stompChatManager != null) stompChatManager.disconnect(); if (stompChatManager != null) stompChatManager.disconnect();
} }
} }

View File

@@ -14,6 +14,7 @@ import android.widget.LinearLayout;
import com.example.petstoremobile.R; 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.PetFragment;
import com.example.petstoremobile.fragments.listfragments.ServiceFragment; import com.example.petstoremobile.fragments.listfragments.ServiceFragment;
import com.example.petstoremobile.fragments.listfragments.SupplierFragment; import com.example.petstoremobile.fragments.listfragments.SupplierFragment;
@@ -56,6 +57,13 @@ public class ListFragment extends Fragment {
drawerPurchaseOrderView=view.findViewById(R.id.drawerPurchaseOrderView); 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 //needed to disable touches on the innerContainer while the drawer is open
touchBlocker = view.findViewById(R.id.touchBlocker); touchBlocker = view.findViewById(R.id.touchBlocker);
@@ -170,4 +178,4 @@ public class ListFragment extends Fragment {
.addToBackStack(null) .addToBackStack(null)
.commit(); .commit();
} }
} }

View File

@@ -16,6 +16,7 @@ import androidx.fragment.app.Fragment;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.text.InputType; import android.text.InputType;
import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@@ -23,20 +24,43 @@ import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; 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.R;
import com.example.petstoremobile.activities.MainActivity; 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.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.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 { public class ProfileFragment extends Fragment {
//initialize the view/controls //initialize the view/controls
private ImageView imgProfile; private ImageView imgProfile;
private TextView tvProfileName, tvProfileEmail, tvProfilePhone, tvProfileRole; private TextView tvProfileName, tvProfileEmail, tvProfilePhone, tvProfileRole;
private Button btnChangePhoto, btnEditEmail, btnEditPhone, btnLogout;
private Uri photoUri; private Uri photoUri;
private UserDTO currentUser;
//Initialize the launchers for camera and gallery //Initialize the launchers for camera and gallery
private ActivityResultLauncher<Intent> galleryLauncher; private ActivityResultLauncher<Intent> galleryLauncher;
@@ -58,8 +82,7 @@ public class ProfileFragment extends Fragment {
&& result.getData() != null) { && result.getData() != null) {
//get the selected image and set the image to the profile //get the selected image and set the image to the profile
Uri selectedImage = result.getData().getData(); Uri selectedImage = result.getData().getData();
imgProfile.setImageURI(selectedImage); uploadAvatar(selectedImage);
//TODO: SAVE CHANGED PHOTO TO DATABASE
} }
} }
); );
@@ -71,10 +94,7 @@ public class ProfileFragment extends Fragment {
success -> { success -> {
//if a photo is taken set the image profile to it otherwise do nothing //if a photo is taken set the image profile to it otherwise do nothing
if (success) { if (success) {
//Clear the old image and set the new one uploadAvatar(photoUri);
imgProfile.setImageURI(null);
imgProfile.setImageURI(photoUri);
//TODO: SAVE CHANGED PHOTO TO DATABASE
} }
} }
); );
@@ -107,7 +127,6 @@ public class ProfileFragment extends Fragment {
); );
} }
//TODO: MAKE PROFILE VIEW DISPLAY PROFILE DATA FROM DATABASE
@Override @Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) { Bundle savedInstanceState) {
@@ -119,10 +138,13 @@ public class ProfileFragment extends Fragment {
tvProfileEmail = view.findViewById(R.id.tvProfileEmail); tvProfileEmail = view.findViewById(R.id.tvProfileEmail);
tvProfilePhone = view.findViewById(R.id.tvProfilePhone); tvProfilePhone = view.findViewById(R.id.tvProfilePhone);
tvProfileRole = view.findViewById(R.id.tvProfileRole); tvProfileRole = view.findViewById(R.id.tvProfileRole);
btnChangePhoto = view.findViewById(R.id.btnChangePhoto); Button btnChangePhoto = view.findViewById(R.id.btnChangePhoto);
btnEditEmail = view.findViewById(R.id.btnEditEmail); Button btnEditEmail = view.findViewById(R.id.btnEditEmail);
btnEditPhone = view.findViewById(R.id.btnEditPhone); Button btnEditPhone = view.findViewById(R.id.btnEditPhone);
btnLogout = view.findViewById(R.id.btnLogout); Button btnLogout = view.findViewById(R.id.btnLogout);
//Load Profile Data from backend
loadProfileData();
//Set up listeners for the buttons //Set up listeners for the buttons
//Change photo button //Change photo button
@@ -150,7 +172,6 @@ public class ProfileFragment extends Fragment {
} }
}) })
.show(); .show();
//TODO: UPDATE PHOTO IN DATABASE
}); });
//Edit email button //Edit email button
@@ -170,19 +191,10 @@ public class ProfileFragment extends Fragment {
.setTitle("Edit Email") .setTitle("Edit Email")
.setView(input) .setView(input)
.setPositiveButton("Save", (dialog, which) -> { .setPositiveButton("Save", (dialog, which) -> {
String newEmail = input.getText().toString(); if (InputValidator.isValidEmail(input)) {
//if the new value is a valid email then set the email to the new value updateProfileField("email", input.getText().toString());
if (android.util.Patterns.EMAIL_ADDRESS.matcher(newEmail).matches()) { } else {
tvProfileEmail.setText(newEmail); Toast.makeText(requireContext(), "Email is invalid", Toast.LENGTH_SHORT).show();
//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();
} }
}) })
.setNegativeButton("Cancel", null) .setNegativeButton("Cancel", null)
@@ -210,19 +222,10 @@ public class ProfileFragment extends Fragment {
.setTitle("Edit Phone Number") .setTitle("Edit Phone Number")
.setView(input) .setView(input)
.setPositiveButton("Save", (dialog, which) -> { .setPositiveButton("Save", (dialog, which) -> {
String newPhone = input.getText().toString(); if (InputValidator.isValidPhone(input)) {
//if the new value is format: (XXX) XXX-XXXX then set the phone to the new value updateProfileField("phone", input.getText().toString());
if (newPhone.matches("\\(\\d{3}\\) \\d{3}-\\d{4}")) { //TODO MAKE VALIDATION CLASS INSTEAD FOR THIS } else {
tvProfilePhone.setText(newPhone); Toast.makeText(requireContext(), "Phone number is invalid", Toast.LENGTH_SHORT).show();
//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();
} }
}) })
.setNegativeButton("Cancel", null) .setNegativeButton("Cancel", null)
@@ -231,6 +234,10 @@ public class ProfileFragment extends Fragment {
//Logout button //Logout button
btnLogout.setOnClickListener(v -> { 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 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 //get the intent to the main activity and clear the back stack so the back button won't allow the user to go back to the previous screen
Intent intent = new Intent(getActivity(), MainActivity.class); Intent 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 //launch the camera to capture the photo and save the photo to photoUri
cameraLauncher.launch(photoUri); cameraLauncher.launch(photoUri);
} }
}
//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<UserDTO>() {
@Override
public void onResponse(Call<UserDTO> call, Response<UserDTO> 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<UserDTO> 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<UserDTO>() {
@Override
public void onResponse(Call<UserDTO> call, Response<UserDTO> 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<UserDTO> 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<String, String> updates = new HashMap<>();
updates.put(fieldName, value);
authApi.updateMe(updates).enqueue(new Callback<UserDTO>() {
@Override
public void onResponse(Call<UserDTO> call, Response<UserDTO> 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<UserDTO> call, Throwable t) {
Log.e("UPDATE_PROFILE", "Failure: " + t.getMessage());
Toast.makeText(requireContext(), "Network error", Toast.LENGTH_SHORT).show();
}
});
}
}

View File

@@ -13,8 +13,11 @@ import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.EditText; import android.widget.EditText;
import android.widget.ImageButton; import android.widget.ImageButton;
import android.widget.Spinner;
import android.widget.Toast; import android.widget.Toast;
import com.example.petstoremobile.R; import com.example.petstoremobile.R;
@@ -43,6 +46,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
private PetApi api; private PetApi api;
private SwipeRefreshLayout swipeRefreshLayout; private SwipeRefreshLayout swipeRefreshLayout;
private EditText etSearch; private EditText etSearch;
private Spinner spinnerStatus;
//load pet view //load pet view
@Override @Override
@@ -57,6 +61,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
setupRecyclerView(view); setupRecyclerView(view);
setupSearch(view); setupSearch(view);
setupStatusFilter(view);
setupSwipeRefresh(view); setupSwipeRefresh(view);
loadPetData(); loadPetData();
@@ -82,24 +87,48 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
etSearch.addTextChangedListener(new TextWatcher() { etSearch.addTextChangedListener(new TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override public void onTextChanged(CharSequence s, int start, int before, int count) { @Override public void onTextChanged(CharSequence s, int start, int before, int count) {
filterPets(s.toString()); filterPets();
} }
@Override public void afterTextChanged(Editable s) {} @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<String> 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(); filteredList.clear();
if (query.isEmpty()) { for (PetDTO p : petList) {
filteredList.addAll(petList); boolean matchesSearch = query.isEmpty() ||
} else { p.getPetName().toLowerCase().contains(query) ||
String lower = query.toLowerCase(); p.getPetSpecies().toLowerCase().contains(query) ||
for (PetDTO p : petList) { p.getPetBreed().toLowerCase().contains(query);
if (p.getPetName().toLowerCase().contains(lower)
|| p.getPetSpecies().toLowerCase().contains(lower) boolean matchesStatus = selectedStatus.equals("All Statuses") ||
|| p.getPetBreed().toLowerCase().contains(lower)) { p.getPetStatus().equalsIgnoreCase(selectedStatus);
filteredList.add(p);
} if (matchesSearch && matchesStatus) {
filteredList.add(p);
} }
} }
adapter.notifyDataSetChanged(); adapter.notifyDataSetChanged();
@@ -173,7 +202,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
if (response.isSuccessful() && response.body() != null) { if (response.isSuccessful() && response.body() != null) {
petList.clear(); petList.clear();
petList.addAll(response.body().getContent()); petList.addAll(response.body().getContent());
filterPets(etSearch.getText().toString()); filterPets();
} else { } else {
Log.e("onResponse: ", response.message()); Log.e("onResponse: ", response.message());

View File

@@ -25,6 +25,8 @@ import com.example.petstoremobile.api.RetrofitClient;
import com.example.petstoremobile.dtos.PetDTO; import com.example.petstoremobile.dtos.PetDTO;
import com.example.petstoremobile.fragments.ListFragment; import com.example.petstoremobile.fragments.ListFragment;
import com.example.petstoremobile.fragments.listfragments.PetFragment; import com.example.petstoremobile.fragments.listfragments.PetFragment;
import com.example.petstoremobile.utils.ActivityLogger;
import com.example.petstoremobile.utils.InputValidator;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.Callback; import retrofit2.Callback;
@@ -65,26 +67,27 @@ public class PetDetailFragment extends Fragment {
//Method to Update or Add a pet //Method to Update or Add a pet
private void savePet() { 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 //get all the values from the fields
String name = etPetName.getText().toString().trim(); String name = etPetName.getText().toString().trim();
String species = etPetSpecies.getText().toString().trim(); String species = etPetSpecies.getText().toString().trim();
String breed = etPetBreed.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 priceStr = etPetPrice.getText().toString().trim();
String status = spinnerPetStatus.getSelectedItem().toString(); 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 //create a pet object to send to the API
PetDTO petDTO = new PetDTO(); PetDTO petDTO = new PetDTO();
petDTO.setPetName(name); petDTO.setPetName(name);
petDTO.setPetSpecies(species); petDTO.setPetSpecies(species);
petDTO.setPetBreed(breed); petDTO.setPetBreed(breed);
petDTO.setPetAge(Integer.parseInt(ageStr)); petDTO.setPetAge(age);
petDTO.setPetPrice(priceStr); petDTO.setPetPrice(priceStr);
petDTO.setPetStatus(status); petDTO.setPetStatus(status);
@@ -98,6 +101,7 @@ public class PetDetailFragment extends Fragment {
@Override @Override
public void onResponse(Call<PetDTO> call, Response<PetDTO> response) { public void onResponse(Call<PetDTO> call, Response<PetDTO> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
ActivityLogger.logChange(requireContext(), "Pet", "UPDATED", petId);
Toast.makeText(getContext(), "Pet updated successfully!", Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Pet updated successfully!", Toast.LENGTH_SHORT).show();
navigateBack(); navigateBack();
} else { } else {
@@ -107,6 +111,7 @@ public class PetDetailFragment extends Fragment {
@Override @Override
public void onFailure(Call<PetDTO> call, Throwable t) { public void onFailure(Call<PetDTO> call, Throwable t) {
ActivityLogger.logException(requireContext(), "PetDetailFragment.updatePet", new Exception(t));
Log.e("PetDetailFragment", "Error updating pet", t); Log.e("PetDetailFragment", "Error updating pet", t);
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
} }
@@ -117,6 +122,7 @@ public class PetDetailFragment extends Fragment {
@Override @Override
public void onResponse(Call<PetDTO> call, Response<PetDTO> response) { public void onResponse(Call<PetDTO> call, Response<PetDTO> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
ActivityLogger.log(requireContext(), "Added new Pet: " + name);
Toast.makeText(getContext(), "Pet added successfully!", Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Pet added successfully!", Toast.LENGTH_SHORT).show();
navigateBack(); navigateBack();
} else { } else {
@@ -126,6 +132,7 @@ public class PetDetailFragment extends Fragment {
@Override @Override
public void onFailure(Call<PetDTO> call, Throwable t) { public void onFailure(Call<PetDTO> call, Throwable t) {
ActivityLogger.logException(requireContext(), "PetDetailFragment.createPet", new Exception(t));
Log.e("PetDetailFragment", "Error adding pet", t); Log.e("PetDetailFragment", "Error adding pet", t);
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
} }
@@ -146,6 +153,7 @@ public class PetDetailFragment extends Fragment {
@Override @Override
public void onResponse(Call<Void> call, Response<Void> response) { public void onResponse(Call<Void> call, Response<Void> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
ActivityLogger.logChange(requireContext(), "Pet", "DELETED", petId);
Toast.makeText(getContext(), "Pet deleted successfully!", Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Pet deleted successfully!", Toast.LENGTH_SHORT).show();
navigateBack(); navigateBack();
} else { } else {
@@ -155,6 +163,7 @@ public class PetDetailFragment extends Fragment {
@Override @Override
public void onFailure(Call<Void> call, Throwable t) { public void onFailure(Call<Void> call, Throwable t) {
ActivityLogger.logException(requireContext(), "PetDetailFragment.deletePet", new Exception(t));
Log.e("PetDetailFragment", "Error deleting pet", t); Log.e("PetDetailFragment", "Error deleting pet", t);
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
} }

View File

@@ -20,6 +20,8 @@ import com.example.petstoremobile.api.ServiceApi;
import com.example.petstoremobile.dtos.ServiceDTO; import com.example.petstoremobile.dtos.ServiceDTO;
import com.example.petstoremobile.fragments.ListFragment; import com.example.petstoremobile.fragments.ListFragment;
import com.example.petstoremobile.fragments.listfragments.ServiceFragment; import com.example.petstoremobile.fragments.listfragments.ServiceFragment;
import com.example.petstoremobile.utils.ActivityLogger;
import com.example.petstoremobile.utils.InputValidator;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.Callback; import retrofit2.Callback;
@@ -58,24 +60,24 @@ public class ServiceDetailFragment extends Fragment {
//Method to Update or Add a service //Method to Update or Add a service
private void saveService() { 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 //get all the values from the fields
String name = etServiceName.getText().toString().trim(); String name = etServiceName.getText().toString().trim();
String desc = etServiceDesc.getText().toString().trim(); String desc = etServiceDesc.getText().toString().trim();
String durationStr = etServiceDuration.getText().toString().trim(); int duration = Integer.parseInt(etServiceDuration.getText().toString().trim());
String priceStr = etServicePrice.getText().toString().trim(); double price = Double.parseDouble(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;
}
//create a service object to send to the API //create a service object to send to the API
ServiceDTO serviceDTO = new ServiceDTO(); ServiceDTO serviceDTO = new ServiceDTO();
serviceDTO.setServiceName(name); serviceDTO.setServiceName(name);
serviceDTO.setServiceDesc(desc); serviceDTO.setServiceDesc(desc);
serviceDTO.setServiceDuration(Integer.parseInt(durationStr)); serviceDTO.setServiceDuration(duration);
serviceDTO.setServicePrice(Double.parseDouble(priceStr)); serviceDTO.setServicePrice(price);
ServiceApi serviceApi = RetrofitClient.getServiceApi(requireContext()); ServiceApi serviceApi = RetrofitClient.getServiceApi(requireContext());
@@ -87,6 +89,7 @@ public class ServiceDetailFragment extends Fragment {
@Override @Override
public void onResponse(Call<ServiceDTO> call, Response<ServiceDTO> response) { public void onResponse(Call<ServiceDTO> call, Response<ServiceDTO> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
ActivityLogger.logChange(requireContext(), "Service", "UPDATED", serviceId);
Toast.makeText(getContext(), "Service updated successfully!", Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Service updated successfully!", Toast.LENGTH_SHORT).show();
navigateBack(); navigateBack();
} else { } else {
@@ -96,6 +99,7 @@ public class ServiceDetailFragment extends Fragment {
@Override @Override
public void onFailure(Call<ServiceDTO> call, Throwable t) { public void onFailure(Call<ServiceDTO> call, Throwable t) {
ActivityLogger.logException(requireContext(), "ServiceDetailFragment.updateService", new Exception(t));
Log.e("ServiceDetailFragment", "Error updating service", t); Log.e("ServiceDetailFragment", "Error updating service", t);
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
} }
@@ -106,6 +110,7 @@ public class ServiceDetailFragment extends Fragment {
@Override @Override
public void onResponse(Call<ServiceDTO> call, Response<ServiceDTO> response) { public void onResponse(Call<ServiceDTO> call, Response<ServiceDTO> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
ActivityLogger.log(requireContext(), "Added new Service: " + name);
Toast.makeText(getContext(), "Service added successfully!", Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Service added successfully!", Toast.LENGTH_SHORT).show();
navigateBack(); navigateBack();
} else { } else {
@@ -115,6 +120,7 @@ public class ServiceDetailFragment extends Fragment {
@Override @Override
public void onFailure(Call<ServiceDTO> call, Throwable t) { public void onFailure(Call<ServiceDTO> call, Throwable t) {
ActivityLogger.logException(requireContext(), "ServiceDetailFragment.createService", new Exception(t));
Log.e("ServiceDetailFragment", "Error adding service", t); Log.e("ServiceDetailFragment", "Error adding service", t);
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
} }
@@ -134,6 +140,7 @@ public class ServiceDetailFragment extends Fragment {
@Override @Override
public void onResponse(Call<Void> call, Response<Void> response) { public void onResponse(Call<Void> call, Response<Void> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
ActivityLogger.logChange(requireContext(), "Service", "DELETED", serviceId);
Toast.makeText(getContext(), "Service deleted successfully!", Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Service deleted successfully!", Toast.LENGTH_SHORT).show();
navigateBack(); navigateBack();
} else { } else {
@@ -143,6 +150,7 @@ public class ServiceDetailFragment extends Fragment {
@Override @Override
public void onFailure(Call<Void> call, Throwable t) { public void onFailure(Call<Void> call, Throwable t) {
ActivityLogger.logException(requireContext(), "ServiceDetailFragment.deleteService", new Exception(t));
Log.e("ServiceDetailFragment", "Error deleting service", t); Log.e("ServiceDetailFragment", "Error deleting service", t);
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
} }

View File

@@ -20,6 +20,8 @@ import com.example.petstoremobile.api.SupplierApi;
import com.example.petstoremobile.dtos.SupplierDTO; import com.example.petstoremobile.dtos.SupplierDTO;
import com.example.petstoremobile.fragments.ListFragment; import com.example.petstoremobile.fragments.ListFragment;
import com.example.petstoremobile.fragments.listfragments.SupplierFragment; import com.example.petstoremobile.fragments.listfragments.SupplierFragment;
import com.example.petstoremobile.utils.ActivityLogger;
import com.example.petstoremobile.utils.InputValidator;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.Callback; import retrofit2.Callback;
@@ -58,6 +60,13 @@ public class SupplierDetailFragment extends Fragment {
//Method to Update or Add a supplier //Method to Update or Add a supplier
private void saveSupplier() { 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 //get all the values from the fields
String company = etSupCompany.getText().toString().trim(); String company = etSupCompany.getText().toString().trim();
String firstName = etSupContactFirstName.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 email = etSupEmail.getText().toString().trim();
String phone = etSupPhone.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 //create a supplier object to send to the API
SupplierDTO supplierDTO = new SupplierDTO(); SupplierDTO supplierDTO = new SupplierDTO();
supplierDTO.setSupCompany(company); supplierDTO.setSupCompany(company);
@@ -89,6 +92,7 @@ public class SupplierDetailFragment extends Fragment {
@Override @Override
public void onResponse(Call<SupplierDTO> call, Response<SupplierDTO> response) { public void onResponse(Call<SupplierDTO> call, Response<SupplierDTO> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
ActivityLogger.logChange(requireContext(), "Supplier", "UPDATED", supId);
Toast.makeText(getContext(), "Supplier updated successfully!", Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Supplier updated successfully!", Toast.LENGTH_SHORT).show();
navigateBack(); navigateBack();
} else { } else {
@@ -98,6 +102,7 @@ public class SupplierDetailFragment extends Fragment {
@Override @Override
public void onFailure(Call<SupplierDTO> call, Throwable t) { public void onFailure(Call<SupplierDTO> call, Throwable t) {
ActivityLogger.logException(requireContext(), "SupplierDetailFragment.updateSupplier", new Exception(t));
Log.e("SupplierDetailFragment", "Error updating supplier", t); Log.e("SupplierDetailFragment", "Error updating supplier", t);
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
} }
@@ -108,6 +113,7 @@ public class SupplierDetailFragment extends Fragment {
@Override @Override
public void onResponse(Call<SupplierDTO> call, Response<SupplierDTO> response) { public void onResponse(Call<SupplierDTO> call, Response<SupplierDTO> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
ActivityLogger.log(requireContext(), "Added new Supplier: " + company);
Toast.makeText(getContext(), "Supplier added successfully!", Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Supplier added successfully!", Toast.LENGTH_SHORT).show();
navigateBack(); navigateBack();
} else { } else {
@@ -117,6 +123,7 @@ public class SupplierDetailFragment extends Fragment {
@Override @Override
public void onFailure(Call<SupplierDTO> call, Throwable t) { public void onFailure(Call<SupplierDTO> call, Throwable t) {
ActivityLogger.logException(requireContext(), "SupplierDetailFragment.createSupplier", new Exception(t));
Log.e("SupplierDetailFragment", "Error adding supplier", t); Log.e("SupplierDetailFragment", "Error adding supplier", t);
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
} }
@@ -136,6 +143,7 @@ public class SupplierDetailFragment extends Fragment {
@Override @Override
public void onResponse(Call<Void> call, Response<Void> response) { public void onResponse(Call<Void> call, Response<Void> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
ActivityLogger.logChange(requireContext(), "Supplier", "DELETED", supId);
Toast.makeText(getContext(), "Supplier deleted successfully!", Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), "Supplier deleted successfully!", Toast.LENGTH_SHORT).show();
navigateBack(); navigateBack();
} else { } else {
@@ -145,6 +153,7 @@ public class SupplierDetailFragment extends Fragment {
@Override @Override
public void onFailure(Call<Void> call, Throwable t) { public void onFailure(Call<Void> call, Throwable t) {
ActivityLogger.logException(requireContext(), "SupplierDetailFragment.deleteSupplier", new Exception(t));
Log.e("SupplierDetailFragment", "Error deleting supplier", t); Log.e("SupplierDetailFragment", "Error deleting supplier", t);
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); 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); etSupContactLastName = view.findViewById(R.id.etSupContactLastName);
etSupEmail = view.findViewById(R.id.etSupEmail); etSupEmail = view.findViewById(R.id.etSupEmail);
etSupPhone = view.findViewById(R.id.etSupPhone); 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); btnSaveSupplier = view.findViewById(R.id.btnSaveSupplier);
btnDeleteSupplier = view.findViewById(R.id.btnDeleteSupplier); btnDeleteSupplier = view.findViewById(R.id.btnDeleteSupplier);
btnBack = view.findViewById(R.id.btnBack); btnBack = view.findViewById(R.id.btnBack);

View File

@@ -16,26 +16,42 @@ import androidx.core.content.FileProvider;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.Button; import android.widget.Button;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; 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.R;
import com.example.petstoremobile.api.PetApi;
import com.example.petstoremobile.api.RetrofitClient;
import com.example.petstoremobile.fragments.ListFragment; import com.example.petstoremobile.fragments.ListFragment;
import com.example.petstoremobile.fragments.listfragments.detailfragments.PetDetailFragment; import com.example.petstoremobile.fragments.listfragments.detailfragments.PetDetailFragment;
import java.io.File; import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.util.Locale; 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 { public class PetProfileFragment extends Fragment {
private TextView tvPetName, tvPetSpecies, tvPetBreed, tvPetAge, tvPetPrice; private TextView tvPetName, tvPetSpecies, tvPetBreed, tvPetAge, tvPetPrice;
private Button btnBack, btnEditPet, btnChangePhoto; private Button btnBack, btnEditPet, btnChangePhoto;
private ImageView imgPet; private ImageView imgPet;
private Uri photoUri; private Uri photoUri;
private int petId;
// launchers for camera and gallery // launchers for camera and gallery
private ActivityResultLauncher<Intent> galleryLauncher; private ActivityResultLauncher<Intent> galleryLauncher;
@@ -53,8 +69,7 @@ public class PetProfileFragment extends Fragment {
result -> { result -> {
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
Uri selectedImage = result.getData().getData(); Uri selectedImage = result.getData().getData();
imgPet.setImageURI(selectedImage); uploadPetImage(selectedImage);
// TODO: SAVE CHANGED PHOTO TO DATABASE
} }
} }
); );
@@ -64,9 +79,7 @@ public class PetProfileFragment extends Fragment {
new ActivityResultContracts.TakePicture(), new ActivityResultContracts.TakePicture(),
success -> { success -> {
if (success) { if (success) {
imgPet.setImageURI(null); uploadPetImage(photoUri);
imgPet.setImageURI(photoUri);
// TODO: SAVE CHANGED PHOTO TO DATABASE
} }
} }
); );
@@ -112,11 +125,15 @@ public class PetProfileFragment extends Fragment {
// Set pet details to display // Set pet details to display
if (getArguments() != null) { if (getArguments() != null) {
petId = getArguments().getInt("petId");
tvPetName.setText(getArguments().getString("petName")); tvPetName.setText(getArguments().getString("petName"));
tvPetSpecies.setText(getArguments().getString("petSpecies")); tvPetSpecies.setText(getArguments().getString("petSpecies"));
tvPetBreed.setText(getArguments().getString("petBreed")); tvPetBreed.setText(getArguments().getString("petBreed"));
tvPetAge.setText(String.format(Locale.getDefault(), "%d yr(s)", getArguments().getInt("petAge"))); tvPetAge.setText(String.format(Locale.getDefault(), "%d yr(s)", getArguments().getInt("petAge")));
tvPetPrice.setText(String.format(Locale.getDefault(), "$%.2f", getArguments().getDouble("petPrice"))); tvPetPrice.setText(String.format(Locale.getDefault(), "$%.2f", getArguments().getDouble("petPrice")));
// Load pet image from backend
loadPetImage(petId);
} }
//set button click listeners //set button click listeners
@@ -169,6 +186,74 @@ public class PetProfileFragment extends Fragment {
return view; 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<Void>() {
@Override
public void onResponse(Call<Void> call, Response<Void> 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<Void> 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() { private void launchCamera() {
File photoFile = new File(requireContext().getCacheDir(), "pet_photo.jpg"); File photoFile = new File(requireContext().getCacheDir(), "pet_photo.jpg");
photoUri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".fileprovider", photoFile); photoUri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".fileprovider", photoFile);

View File

@@ -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<Long> knownConversationIds = new HashSet<>();
private final Map<Long, Long> conversationToCustomerId = new HashMap<>();
private final Map<Long, String> 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<PageResponse<CustomerDTO>>() {
@Override
public void onResponse(@NonNull Call<PageResponse<CustomerDTO>> call, @NonNull Response<PageResponse<CustomerDTO>> 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<PageResponse<CustomerDTO>> 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<List<ConversationDTO>>() {
@Override
public void onResponse(@NonNull Call<List<ConversationDTO>> call, @NonNull Response<List<ConversationDTO>> 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<List<ConversationDTO>> 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<CustomerDTO>() {
@Override
public void onResponse(@NonNull Call<CustomerDTO> call, @NonNull Response<CustomerDTO> response) {
if (response.isSuccessful() && response.body() != null) {
customerIdToName.put(customerId, response.body().getFullName());
}
}
@Override
public void onFailure(@NonNull Call<CustomerDTO> 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;
}
}

View File

@@ -61,10 +61,11 @@ public class InputValidator {
return true; 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) { public static boolean isValidPhone(EditText field) {
String phone = field.getText().toString().trim(); 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.setError("Enter a valid phone number");
field.requestFocus(); field.requestFocus();
return false; return false;
@@ -94,4 +95,3 @@ public class InputValidator {
return true; return true;
} }
} }

View File

@@ -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());
}
}

View File

@@ -28,12 +28,15 @@
android:contentDescription="Open menu"/> android:contentDescription="Open menu"/>
<TextView <TextView
android:id="@+id/tvChatTitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Customer Chat" android:text="Customer Chat"
android:textColor="@color/white" android:textColor="@color/white"
android:textSize="20sp" android:textSize="20sp"
android:textStyle="bold"/> android:textStyle="bold"
android:paddingStart="8dp"
android:paddingEnd="8dp"/>
</LinearLayout> </LinearLayout>

View File

@@ -38,18 +38,34 @@
</LinearLayout> </LinearLayout>
<EditText <LinearLayout
android:id="@+id/etSearchPet"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="8dp" android:orientation="horizontal"
android:hint="Search by name, species or breed..." android:padding="8dp"
android:inputType="text" android:gravity="center_vertical">
android:drawableStart="@android:drawable/ic_menu_search"
android:drawablePadding="8dp" <EditText
android:background="@android:color/white" android:id="@+id/etSearchPet"
android:padding="12dp" android:layout_width="0dp"
android:textColor="@color/text_dark"/> android:layout_height="48dp"
android:layout_weight="1"
android:hint="Search..."
android:inputType="text"
android:drawableStart="@android:drawable/ic_menu_search"
android:drawablePadding="8dp"
android:background="@android:color/white"
android:padding="12dp"
android:textColor="@color/text_dark"/>
<Spinner
android:id="@+id/spinnerStatus"
android:layout_width="140dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:background="@android:color/white"
android:padding="10dp"/>
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshPet" android:id="@+id/swipeRefreshPet"

View File

@@ -83,7 +83,7 @@
android:id="@+id/tvProfileEmail" android:id="@+id/tvProfileEmail"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="example@email.com" android:text="No email loaded"
android:textColor="@color/text_dark" android:textColor="@color/text_dark"
android:textSize="16sp" /> android:textSize="16sp" />
@@ -129,7 +129,7 @@
android:id="@+id/tvProfilePhone" android:id="@+id/tvProfilePhone"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="(123) 123-1234" android:text="No phone loaded"
android:textColor="@color/text_dark" android:textColor="@color/text_dark"
android:textSize="16sp" /> android:textSize="16sp" />
@@ -174,7 +174,7 @@
android:id="@+id/tvProfileRole" android:id="@+id/tvProfileRole"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Manager" android:text="No role loaded"
android:textSize="16sp" android:textSize="16sp"
android:textColor="@color/accent_coral"/> android:textColor="@color/accent_coral"/>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" /> <monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" /> <monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FDE0E0</color>
</resources>

1
backend/.gitignore vendored
View File

@@ -1,4 +1,5 @@
target/ target/
nohup.out
!.mvn/wrapper/maven-wrapper.jar !.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/ !**/src/main/**/target/
!**/src/test/**/target/ !**/src/test/**/target/

View File

@@ -90,6 +90,10 @@
"key": "avatarFile", "key": "avatarFile",
"value": "postman/avatar.png" "value": "postman/avatar.png"
}, },
{
"key": "avatarUrl",
"value": ""
},
{ {
"key": "bulkPetId", "key": "bulkPetId",
"value": "" "value": ""
@@ -117,6 +121,10 @@
{ {
"key": "bulkInventoryId", "key": "bulkInventoryId",
"value": "" "value": ""
},
{
"key": "adoptedPetId",
"value": "4"
} }
], ],
"item": [ "item": [
@@ -212,6 +220,7 @@
" pm.response.to.have.status(200);", " pm.response.to.have.status(200);",
"});", "});",
"var jsonData = pm.response.json();", "var jsonData = pm.response.json();",
"if (jsonData.id !== undefined) pm.collectionVariables.set('userId', jsonData.id);",
"if (jsonData.token) pm.collectionVariables.set('customerToken', jsonData.token);" "if (jsonData.token) pm.collectionVariables.set('customerToken', jsonData.token);"
] ]
} }
@@ -307,7 +316,9 @@
"exec": [ "exec": [
"pm.test('Status code is 200', function () {", "pm.test('Status code is 200', function () {",
" pm.response.to.have.status(200);", " 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);", " pm.response.to.have.status(200);",
"});", "});",
"var jsonData = pm.response.json();", "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);", " pm.response.to.have.status(200);",
"});", "});",
"var jsonData = pm.response.json();", "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", "name": "Delete Pet",
"request": { "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 @@
] ]
} }
] ]
} }

View File

@@ -10,6 +10,9 @@ import java.util.Arrays;
public class FlywayContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> { public class FlywayContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
private static final int MAX_RETRIES = 15;
private static final long RETRY_DELAY_MILLIS = 1000L;
@Override @Override
public void initialize(ConfigurableApplicationContext applicationContext) { public void initialize(ConfigurableApplicationContext applicationContext) {
ConfigurableEnvironment environment = applicationContext.getEnvironment(); ConfigurableEnvironment environment = applicationContext.getEnvironment();
@@ -29,12 +32,33 @@ public class FlywayContextInitializer implements ApplicationContextInitializer<C
.filter(location -> !location.isEmpty()) .filter(location -> !location.isEmpty())
.toArray(String[]::new); .toArray(String[]::new);
Flyway.configure() RuntimeException lastFailure = null;
.dataSource(url, username, password) for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
.locations(locations) try {
.baselineOnMigrate(environment.getProperty("spring.flyway.baseline-on-migrate", Boolean.class, false)) Flyway.configure()
.baselineVersion(MigrationVersion.fromVersion(environment.getProperty("spring.flyway.baseline-version", "1"))) .dataSource(url, username, password)
.load() .locations(locations)
.migrate(); .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;
}
} }
} }

View File

@@ -13,10 +13,13 @@ import com.petshop.backend.repository.EmployeeRepository;
import com.petshop.backend.repository.EmployeeStoreRepository; import com.petshop.backend.repository.EmployeeStoreRepository;
import com.petshop.backend.repository.UserRepository; import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.JwtUtil; import com.petshop.backend.security.JwtUtil;
import com.petshop.backend.service.AvatarStorageService;
import com.petshop.backend.service.UserBusinessLinkageService; import com.petshop.backend.service.UserBusinessLinkageService;
import com.petshop.backend.util.AuthenticationHelper; import com.petshop.backend.util.AuthenticationHelper;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException; 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.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException; 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.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID;
@RestController @RestController
@RequestMapping("/api/v1/auth") @RequestMapping("/api/v1/auth")
@@ -49,8 +46,9 @@ public class AuthController {
private final UserBusinessLinkageService userBusinessLinkageService; private final UserBusinessLinkageService userBusinessLinkageService;
private final EmployeeRepository employeeRepository; private final EmployeeRepository employeeRepository;
private final EmployeeStoreRepository employeeStoreRepository; 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.authenticationManager = authenticationManager;
this.userRepository = userRepository; this.userRepository = userRepository;
this.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
@@ -58,6 +56,7 @@ public class AuthController {
this.userBusinessLinkageService = userBusinessLinkageService; this.userBusinessLinkageService = userBusinessLinkageService;
this.employeeRepository = employeeRepository; this.employeeRepository = employeeRepository;
this.employeeStoreRepository = employeeStoreRepository; this.employeeStoreRepository = employeeStoreRepository;
this.avatarStorageService = avatarStorageService;
} }
@PostMapping("/register") @PostMapping("/register")
@@ -155,7 +154,7 @@ public class AuthController {
user.getEmail(), user.getEmail(),
user.getFullName(), user.getFullName(),
user.getPhone(), user.getPhone(),
user.getAvatarUrl(), avatarStorageService.toOwnerAvatarUrl(user),
user.getRole().name(), user.getRole().name(),
employeeStore != null ? employeeStore.getStore().getStoreId() : null, employeeStore != null ? employeeStore.getStore().getStoreId() : null,
employeeStore != null ? employeeStore.getStore().getStoreName() : null employeeStore != null ? employeeStore.getStore().getStoreName() : null
@@ -224,7 +223,7 @@ public class AuthController {
updatedUser.getEmail(), updatedUser.getEmail(),
updatedUser.getFullName(), updatedUser.getFullName(),
updatedUser.getPhone(), updatedUser.getPhone(),
updatedUser.getAvatarUrl(), avatarStorageService.toOwnerAvatarUrl(updatedUser),
updatedUser.getRole().name(), updatedUser.getRole().name(),
employeeStore != null ? employeeStore.getStore().getStoreId() : null, employeeStore != null ? employeeStore.getStore().getStoreId() : null,
employeeStore != null ? employeeStore.getStore().getStoreName() : null employeeStore != null ? employeeStore.getStore().getStoreName() : null
@@ -273,26 +272,12 @@ public class AuthController {
} }
try { try {
String uploadDir = "uploads/avatars"; avatarStorageService.deleteAvatar(user);
File directory = new File(uploadDir); String avatarPath = avatarStorageService.storeAvatar(file);
if (!directory.exists()) { user.setAvatarUrl(avatarPath);
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);
userRepository.save(user); 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) { } catch (IOException e) {
Map<String, String> error = new HashMap<>(); Map<String, String> error = new HashMap<>();
@@ -305,25 +290,41 @@ public class AuthController {
public ResponseEntity<?> getAvatar() { public ResponseEntity<?> getAvatar() {
User user = getAuthenticatedUser(); User user = getAuthenticatedUser();
if (user.getAvatarUrl() == null || user.getAvatarUrl().isEmpty()) { if (!avatarStorageService.hasAvatar(user)) {
Map<String, String> error = new HashMap<>(); Map<String, String> error = new HashMap<>();
error.put("message", "No avatar uploaded"); error.put("message", "No avatar uploaded");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
} }
Map<String, String> response = new HashMap<>(); Map<String, String> response = new HashMap<>();
response.put("avatarUrl", user.getAvatarUrl()); response.put("avatarUrl", avatarStorageService.toOwnerAvatarUrl(user));
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@GetMapping("/me/avatar/file")
public ResponseEntity<Resource> 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") @DeleteMapping("/me/avatar")
public ResponseEntity<?> deleteAvatar() { public ResponseEntity<?> deleteAvatar() {
User user = getAuthenticatedUser(); User user = getAuthenticatedUser();
if (user.getAvatarUrl() != null && !user.getAvatarUrl().isEmpty()) { if (avatarStorageService.hasAvatar(user)) {
try { try {
Path filePath = Paths.get("." + user.getAvatarUrl()); avatarStorageService.deleteAvatar(user);
Files.deleteIfExists(filePath);
} catch (IOException e) { } catch (IOException e) {
} }
user.setAvatarUrl(null); user.setAvatarUrl(null);

View File

@@ -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<Resource> 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<PetResponse> deletePetImage(@PathVariable Long id) {
return ResponseEntity.ok(petService.deletePetImage(id));
}
private ResponseEntity<Map<String, String>> badRequest(String message) {
Map<String, String> 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;
}
}

View File

@@ -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<Resource> 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<ProductResponse> deleteProductImage(@PathVariable Long id) {
return ResponseEntity.ok(productService.deleteProductImage(id));
}
private ResponseEntity<Map<String, String>> badRequest(String message) {
Map<String, String> error = new HashMap<>();
error.put("message", message);
return ResponseEntity.badRequest().body(error);
}
}

View File

@@ -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<Resource> 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();
}
}
}

View File

@@ -12,13 +12,14 @@ public class PetResponse {
private Integer petAge; private Integer petAge;
private String petStatus; private String petStatus;
private BigDecimal petPrice; private BigDecimal petPrice;
private String imageUrl;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
public PetResponse() { 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.petId = petId;
this.petName = petName; this.petName = petName;
this.petSpecies = petSpecies; this.petSpecies = petSpecies;
@@ -26,6 +27,7 @@ public class PetResponse {
this.petAge = petAge; this.petAge = petAge;
this.petStatus = petStatus; this.petStatus = petStatus;
this.petPrice = petPrice; this.petPrice = petPrice;
this.imageUrl = imageUrl;
this.createdAt = createdAt; this.createdAt = createdAt;
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
@@ -86,6 +88,14 @@ public class PetResponse {
this.petPrice = petPrice; this.petPrice = petPrice;
} }
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
public LocalDateTime getCreatedAt() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }
@@ -107,12 +117,12 @@ public class PetResponse {
if (this == o) return true; if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false; if (o == null || getClass() != o.getClass()) return false;
PetResponse that = (PetResponse) o; 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 @Override
public int hashCode() { 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 @Override
@@ -125,6 +135,7 @@ public class PetResponse {
", petAge=" + petAge + ", petAge=" + petAge +
", petStatus='" + petStatus + '\'' + ", petStatus='" + petStatus + '\'' +
", petPrice=" + petPrice + ", petPrice=" + petPrice +
", imageUrl='" + imageUrl + '\'' +
", createdAt=" + createdAt + ", createdAt=" + createdAt +
", updatedAt=" + updatedAt + ", updatedAt=" + updatedAt +
'}'; '}';

View File

@@ -11,19 +11,21 @@ public class ProductResponse {
private String categoryName; private String categoryName;
private String prodDesc; private String prodDesc;
private BigDecimal prodPrice; private BigDecimal prodPrice;
private String imageUrl;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
public ProductResponse() { 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.prodId = prodId;
this.prodName = prodName; this.prodName = prodName;
this.categoryId = categoryId; this.categoryId = categoryId;
this.categoryName = categoryName; this.categoryName = categoryName;
this.prodDesc = prodDesc; this.prodDesc = prodDesc;
this.prodPrice = prodPrice; this.prodPrice = prodPrice;
this.imageUrl = imageUrl;
this.createdAt = createdAt; this.createdAt = createdAt;
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
@@ -76,6 +78,14 @@ public class ProductResponse {
this.prodPrice = prodPrice; this.prodPrice = prodPrice;
} }
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
public LocalDateTime getCreatedAt() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }
@@ -97,12 +107,12 @@ public class ProductResponse {
if (this == o) return true; if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false; if (o == null || getClass() != o.getClass()) return false;
ProductResponse that = (ProductResponse) o; 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 @Override
public int hashCode() { 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 @Override
@@ -114,6 +124,7 @@ public class ProductResponse {
", categoryName='" + categoryName + '\'' + ", categoryName='" + categoryName + '\'' +
", prodDesc='" + prodDesc + '\'' + ", prodDesc='" + prodDesc + '\'' +
", prodPrice=" + prodPrice + ", prodPrice=" + prodPrice +
", imageUrl='" + imageUrl + '\'' +
", createdAt=" + createdAt + ", createdAt=" + createdAt +
", updatedAt=" + updatedAt + ", updatedAt=" + updatedAt +
'}'; '}';

View File

@@ -35,6 +35,9 @@ public class Pet {
@Column(nullable = false, precision = 10, scale = 2) @Column(nullable = false, precision = 10, scale = 2)
private BigDecimal petPrice; private BigDecimal petPrice;
@Column(length = 255)
private String imageUrl;
@CreationTimestamp @CreationTimestamp
@Column(name = "created_at", updatable = false) @Column(name = "created_at", updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -46,7 +49,7 @@ public class Pet {
public 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.id = id;
this.petName = petName; this.petName = petName;
this.petSpecies = petSpecies; this.petSpecies = petSpecies;
@@ -54,6 +57,7 @@ public class Pet {
this.petAge = petAge; this.petAge = petAge;
this.petStatus = petStatus; this.petStatus = petStatus;
this.petPrice = petPrice; this.petPrice = petPrice;
this.imageUrl = imageUrl;
this.createdAt = createdAt; this.createdAt = createdAt;
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
@@ -114,6 +118,14 @@ public class Pet {
this.petPrice = petPrice; this.petPrice = petPrice;
} }
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
public LocalDateTime getCreatedAt() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }
@@ -153,6 +165,7 @@ public class Pet {
", petAge=" + petAge + ", petAge=" + petAge +
", petStatus='" + petStatus + '\'' + ", petStatus='" + petStatus + '\'' +
", petPrice=" + petPrice + ", petPrice=" + petPrice +
", imageUrl='" + imageUrl + '\'' +
", createdAt=" + createdAt + ", createdAt=" + createdAt +
", updatedAt=" + updatedAt + ", updatedAt=" + updatedAt +
'}'; '}';

View File

@@ -29,6 +29,9 @@ public class Product {
@Column(nullable = false, precision = 10, scale = 2) @Column(nullable = false, precision = 10, scale = 2)
private BigDecimal prodPrice; private BigDecimal prodPrice;
@Column(length = 255)
private String imageUrl;
@CreationTimestamp @CreationTimestamp
@Column(name = "created_at", updatable = false) @Column(name = "created_at", updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -40,12 +43,13 @@ public class Product {
public 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.prodId = prodId;
this.prodName = prodName; this.prodName = prodName;
this.category = category; this.category = category;
this.prodDesc = prodDesc; this.prodDesc = prodDesc;
this.prodPrice = prodPrice; this.prodPrice = prodPrice;
this.imageUrl = imageUrl;
this.createdAt = createdAt; this.createdAt = createdAt;
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
@@ -90,6 +94,14 @@ public class Product {
this.prodPrice = prodPrice; this.prodPrice = prodPrice;
} }
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
public LocalDateTime getCreatedAt() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }
@@ -127,6 +139,7 @@ public class Product {
", category=" + category + ", category=" + category +
", prodDesc='" + prodDesc + '\'' + ", prodDesc='" + prodDesc + '\'' +
", prodPrice=" + prodPrice + ", prodPrice=" + prodPrice +
", imageUrl='" + imageUrl + '\'' +
", createdAt=" + createdAt + ", createdAt=" + createdAt +
", updatedAt=" + updatedAt + ", updatedAt=" + updatedAt +
'}'; '}';

View File

@@ -8,6 +8,8 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository @Repository
public interface AdoptionRepository extends JpaRepository<Adoption, Long> { public interface AdoptionRepository extends JpaRepository<Adoption, Long> {
@@ -24,4 +26,6 @@ public interface AdoptionRepository extends JpaRepository<Adoption, Long> {
"LOWER(a.customer.lastName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + "LOWER(a.customer.lastName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
"LOWER(a.pet.petName) LIKE LOWER(CONCAT('%', :q, '%')))") "LOWER(a.pet.petName) LIKE LOWER(CONCAT('%', :q, '%')))")
Page<Adoption> searchAdoptionsByCustomer(@Param("customerId") Long customerId, @Param("q") String query, Pageable pageable); Page<Adoption> searchAdoptionsByCustomer(@Param("customerId") Long customerId, @Param("q") String query, Pageable pageable);
Optional<Adoption> findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(Long petId, String adoptionStatus);
} }

View File

@@ -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";
};
}
}

View File

@@ -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";
};
}
}

View File

@@ -3,21 +3,34 @@ package com.petshop.backend.service;
import com.petshop.backend.dto.common.BulkDeleteRequest; import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.dto.pet.PetRequest; import com.petshop.backend.dto.pet.PetRequest;
import com.petshop.backend.dto.pet.PetResponse; import com.petshop.backend.dto.pet.PetResponse;
import com.petshop.backend.entity.Adoption;
import com.petshop.backend.entity.Pet; import com.petshop.backend.entity.Pet;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.AdoptionRepository;
import com.petshop.backend.repository.PetRepository; 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.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Locale;
@Service @Service
public class PetService { public class PetService {
private final PetRepository petRepository; 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.petRepository = petRepository;
this.adoptionRepository = adoptionRepository;
this.catalogImageStorageService = catalogImageStorageService;
} }
public Page<PetResponse> getAllPets(String query, Pageable pageable) { public Page<PetResponse> getAllPets(String query, Pageable pageable) {
@@ -68,17 +81,107 @@ public class PetService {
@Transactional @Transactional
public void deletePet(Long id) { public void deletePet(Long id) {
if (!petRepository.existsById(id)) { Pet pet = petRepository.findById(id)
throw new ResourceNotFoundException("Pet not found with id: " + id); .orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id));
} deleteStoredImageIfPresent(pet.getImageUrl());
petRepository.deleteById(id); petRepository.delete(pet);
} }
@Transactional @Transactional
public void bulkDeletePets(BulkDeleteRequest request) { public void bulkDeletePets(BulkDeleteRequest request) {
petRepository.findAllById(request.getIds()).forEach(pet -> deleteStoredImageIfPresent(pet.getImageUrl()));
petRepository.deleteAllById(request.getIds()); 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) { private PetResponse mapToResponse(Pet pet) {
return new PetResponse( return new PetResponse(
pet.getPetId(), pet.getPetId(),
@@ -88,8 +191,15 @@ public class PetService {
pet.getPetAge(), pet.getPetAge(),
pet.getPetStatus(), pet.getPetStatus(),
pet.getPetPrice(), pet.getPetPrice(),
pet.getImageUrl() != null && !pet.getImageUrl().isBlank() ? "/api/v1/pets/" + pet.getPetId() + "/image" : null,
pet.getCreatedAt(), pet.getCreatedAt(),
pet.getUpdatedAt() pet.getUpdatedAt()
); );
} }
public record ImagePayload(Resource resource, MediaType mediaType) {
}
public static class ForbiddenImageAccessException extends RuntimeException {
}
} }

View File

@@ -8,20 +8,28 @@ import com.petshop.backend.entity.Product;
import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.CategoryRepository; import com.petshop.backend.repository.CategoryRepository;
import com.petshop.backend.repository.ProductRepository; 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.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Locale;
@Service @Service
public class ProductService { public class ProductService {
private final ProductRepository productRepository; private final ProductRepository productRepository;
private final CategoryRepository categoryRepository; 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.productRepository = productRepository;
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.catalogImageStorageService = catalogImageStorageService;
} }
public Page<ProductResponse> getAllProducts(String query, Pageable pageable) { public Page<ProductResponse> getAllProducts(String query, Pageable pageable) {
@@ -74,17 +82,76 @@ public class ProductService {
@Transactional @Transactional
public void deleteProduct(Long id) { public void deleteProduct(Long id) {
if (!productRepository.existsById(id)) { Product product = findProduct(id);
throw new ResourceNotFoundException("Product not found with id: " + id); deleteStoredImageIfPresent(product.getImageUrl());
} productRepository.delete(product);
productRepository.deleteById(id);
} }
@Transactional @Transactional
public void bulkDeleteProducts(BulkDeleteRequest request) { public void bulkDeleteProducts(BulkDeleteRequest request) {
productRepository.findAllById(request.getIds()).forEach(product -> deleteStoredImageIfPresent(product.getImageUrl()));
productRepository.deleteAllById(request.getIds()); 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) { private ProductResponse mapToResponse(Product product) {
return new ProductResponse( return new ProductResponse(
product.getProdId(), product.getProdId(),
@@ -93,8 +160,12 @@ public class ProductService {
product.getCategory().getCategoryName(), product.getCategory().getCategoryName(),
product.getProdDesc(), product.getProdDesc(),
product.getProdPrice(), product.getProdPrice(),
product.getImageUrl() != null && !product.getImageUrl().isBlank() ? "/api/v1/products/" + product.getProdId() + "/image" : null,
product.getCreatedAt(), product.getCreatedAt(),
product.getUpdatedAt() product.getUpdatedAt()
); );
} }
public record ImagePayload(Resource resource, MediaType mediaType) {
}
} }

View File

@@ -0,0 +1,5 @@
ALTER TABLE pet
ADD COLUMN imageUrl VARCHAR(255) NULL;
ALTER TABLE product
ADD COLUMN imageUrl VARCHAR(255) NULL;

1
desktop/.gitignore vendored
View File

@@ -1,4 +1,5 @@
target/ target/
nohup.out
!.mvn/wrapper/maven-wrapper.jar !.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/ !**/src/main/**/target/
!**/src/test/**/target/ !**/src/test/**/target/

View File

@@ -76,6 +76,14 @@
<release>25</release> <release>25</release>
</configuration> </configuration>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<argLine>--add-opens org.example.petshopdesktop/org.example.petshopdesktop=ALL-UNNAMED</argLine>
</configuration>
</plugin>
<plugin> <plugin>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-maven-plugin</artifactId> <artifactId>javafx-maven-plugin</artifactId>

View File

@@ -2,6 +2,7 @@ module org.example.petshopdesktop {
requires javafx.controls; requires javafx.controls;
requires javafx.fxml; requires javafx.fxml;
requires javafx.web; requires javafx.web;
requires java.desktop;
requires java.sql; requires java.sql;
requires java.net.http; requires java.net.http;
requires com.fasterxml.jackson.databind; requires com.fasterxml.jackson.databind;

View File

@@ -15,15 +15,17 @@ public class ProductDTO {
private SimpleIntegerProperty categoryId; //used for edit and delete private SimpleIntegerProperty categoryId; //used for edit and delete
private SimpleStringProperty categoryName; private SimpleStringProperty categoryName;
private SimpleStringProperty prodDesc; private SimpleStringProperty prodDesc;
private SimpleStringProperty imageUrl;
//constructor //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.prodId = new SimpleIntegerProperty(prodId);
this.prodName = new SimpleStringProperty(prodName); this.prodName = new SimpleStringProperty(prodName);
this.prodPrice = new SimpleDoubleProperty(prodPrice); this.prodPrice = new SimpleDoubleProperty(prodPrice);
this.categoryId = new SimpleIntegerProperty(categoryId); this.categoryId = new SimpleIntegerProperty(categoryId);
this.categoryName = new SimpleStringProperty(categoryName); this.categoryName = new SimpleStringProperty(categoryName);
this.prodDesc = new SimpleStringProperty(prodDesc); this.prodDesc = new SimpleStringProperty(prodDesc);
this.imageUrl = new SimpleStringProperty(imageUrl);
} }
//getter and setters //getter and setters
@@ -99,6 +101,18 @@ public class ProductDTO {
this.categoryId.set(categoryId); 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 * Converts DTO into product for editing and deleting
* @return * @return

View File

@@ -13,6 +13,7 @@ public class Validator {
if (value == null || value.isBlank()){ if (value == null || value.isBlank()){
msg += name + " is required. \n"; msg += name + " is required. \n";
} }
return msg; return msg;
} }
@@ -24,8 +25,13 @@ public class Validator {
*/ */
public static String isNonNegativeDouble(String value, String name){ public static String isNonNegativeDouble(String value, String name){
String msg =""; String msg ="";
if (value == null) {
msg += name + " must be a number.\n";
return msg;
}
double result; double result;
try{ try {
result = Double.parseDouble(value); result = Double.parseDouble(value);
if (result < 0){ if (result < 0){
msg += name + " must be greater than or equal 0. \n"; msg += name + " must be greater than or equal 0. \n";
@@ -34,6 +40,34 @@ public class Validator {
catch (NumberFormatException e){ catch (NumberFormatException e){
msg += name + " must be a number.\n"; 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; return msg;
} }
@@ -47,8 +81,13 @@ public class Validator {
*/ */
public static String isDoubleInRange(String value, String name, double minValue, double maxValue){ public static String isDoubleInRange(String value, String name, double minValue, double maxValue){
String msg =""; String msg ="";
if (value == null) {
msg += name + " must be a number.\n";
return msg;
}
double result; double result;
try{ try {
result = Double.parseDouble(value); result = Double.parseDouble(value);
if (result < minValue || result > maxValue){ if (result < minValue || result > maxValue){
msg += name + " must be between " + minValue + " and " + maxValue + "\n"; msg += name + " must be between " + minValue + " and " + maxValue + "\n";
@@ -57,6 +96,7 @@ public class Validator {
catch (NumberFormatException e){ catch (NumberFormatException e){
msg += name + " must be a number.\n"; msg += name + " must be a number.\n";
} }
return msg; return msg;
} }
@@ -69,7 +109,7 @@ public class Validator {
public static String isNonNegativeInteger(String value, String name){ public static String isNonNegativeInteger(String value, String name){
String msg =""; String msg ="";
int result; int result;
try{ try {
result = Integer.parseInt(value); result = Integer.parseInt(value);
if (result < 0){ if (result < 0){
msg += name + " must be greater than or equal 0. \n"; msg += name + " must be greater than or equal 0. \n";
@@ -78,6 +118,29 @@ public class Validator {
catch (NumberFormatException e){ catch (NumberFormatException e){
msg += name + " must be a whole number.\n"; 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; return msg;
} }
@@ -90,9 +153,10 @@ public class Validator {
*/ */
public static String isLessThanVarChars(String value, String name, int length){ public static String isLessThanVarChars(String value, String name, int length){
String msg =""; String msg ="";
if (value.length() > length){ if (value == null || value.length() > length){
msg += name + " must be less than " + length + " characters. \n"; msg += name + " must be less than " + length + " characters. \n";
} }
return msg; return msg;
} }
@@ -104,11 +168,17 @@ public class Validator {
*/ */
public static String isValidEmail(String value, String name){ public static String isValidEmail(String value, String name){
String msg = ""; 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,}$"; String regex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$";
if (!value.matches(regex)){ if (!value.matches(regex)){
msg += name + " is not in a valid format. \n"; msg += name + " is not in a valid format. \n";
} }
return msg; return msg;
} }
@@ -120,11 +190,17 @@ public class Validator {
*/ */
public static String isValidPhoneNumber(String value, String name){ public static String isValidPhoneNumber(String value, String name){
String msg = ""; 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}$"; String regex = "^\\d{3}-\\d{3}-\\d{4}$";
if (!value.matches(regex)){ if (!value.matches(regex)){
msg += name + " must be in format XXX-XXX-XXXX. \n"; msg += name + " must be in format XXX-XXX-XXXX. \n";
} }
return msg; return msg;
} }
} }

View File

@@ -48,6 +48,31 @@ public class ApiClient {
return handleResponse(response, responseClass); 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<byte[]> 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 { public String getRawResponse(String path) throws Exception {
HttpRequest.Builder builder = HttpRequest.newBuilder() HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + path)) .uri(URI.create(baseUrl + path))
@@ -199,15 +224,21 @@ public class ApiClient {
try { try {
if (response.body() != null && !response.body().isEmpty()) { if (response.body() != null && !response.body().isEmpty()) {
var errorNode = objectMapper.readTree(response.body()); var errorNode = objectMapper.readTree(response.body());
if (errorNode.has("message")) {
return errorNode.get("message").asText();
}
if (errorNode.has("errors")) { if (errorNode.has("errors")) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
errorNode.get("errors").fields().forEachRemaining(entry -> { 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) { } catch (Exception e) {

View File

@@ -11,6 +11,7 @@ public class PetResponse {
private Integer petAge; private Integer petAge;
private String petStatus; private String petStatus;
private BigDecimal petPrice; private BigDecimal petPrice;
private String imageUrl;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@@ -73,6 +74,14 @@ public class PetResponse {
this.petPrice = petPrice; this.petPrice = petPrice;
} }
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
public LocalDateTime getCreatedAt() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }

View File

@@ -8,6 +8,7 @@ public class ProductResponse {
private String categoryName; private String categoryName;
private BigDecimal prodPrice; private BigDecimal prodPrice;
private String prodDesc; private String prodDesc;
private String imageUrl;
public ProductResponse() { public ProductResponse() {
} }
@@ -51,4 +52,12 @@ public class ProductResponse {
public void setProdDesc(String prodDesc) { public void setProdDesc(String prodDesc) {
this.prodDesc = prodDesc; this.prodDesc = prodDesc;
} }
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
} }

View File

@@ -26,6 +26,10 @@ public class AuthApi {
return apiClient.postMultipart("/api/v1/auth/me/avatar", "avatar", filePath, AvatarUploadResponse.class); 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 { public void deleteAvatar() throws Exception {
apiClient.delete("/api/v1/auth/me/avatar"); apiClient.delete("/api/v1/auth/me/avatar");
} }

View File

@@ -9,6 +9,7 @@ import org.example.petshopdesktop.api.dto.pet.PetResponse;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.List; import java.util.List;
public class PetApi { public class PetApi {
@@ -47,6 +48,18 @@ public class PetApi {
return apiClient.put("/api/v1/pets/" + id, request, PetResponse.class); 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<Long> ids) throws Exception { public void deletePets(List<Long> ids) throws Exception {
apiClient.deleteWithBody("/api/v1/pets", new BulkDeleteRequest(ids)); apiClient.deleteWithBody("/api/v1/pets", new BulkDeleteRequest(ids));
} }

View File

@@ -9,6 +9,7 @@ import org.example.petshopdesktop.api.dto.product.ProductResponse;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.List; import java.util.List;
public class ProductApi { public class ProductApi {
@@ -47,6 +48,18 @@ public class ProductApi {
return apiClient.put("/api/v1/products/" + id, request, ProductResponse.class); 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<Long> ids) throws Exception { public void deleteProducts(List<Long> ids) throws Exception {
apiClient.deleteWithBody("/api/v1/products", new BulkDeleteRequest(ids)); apiClient.deleteWithBody("/api/v1/products", new BulkDeleteRequest(ids));
} }

View File

@@ -16,17 +16,18 @@ import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color; import javafx.scene.paint.Color;
import javafx.scene.paint.ImagePattern; import javafx.scene.paint.ImagePattern;
import javafx.scene.shape.Circle; import javafx.scene.shape.Circle;
import javafx.stage.FileChooser;
import javafx.stage.Stage; import javafx.stage.Stage;
import org.example.petshopdesktop.api.ApiConfig;
import org.example.petshopdesktop.api.ChatRealtimeClient; import org.example.petshopdesktop.api.ChatRealtimeClient;
import org.example.petshopdesktop.api.dto.auth.AvatarUploadResponse; import org.example.petshopdesktop.api.dto.auth.AvatarUploadResponse;
import org.example.petshopdesktop.api.dto.auth.UserInfoResponse; import org.example.petshopdesktop.api.dto.auth.UserInfoResponse;
import org.example.petshopdesktop.api.endpoints.AuthApi; import org.example.petshopdesktop.api.endpoints.AuthApi;
import org.example.petshopdesktop.auth.UserSession; import org.example.petshopdesktop.auth.UserSession;
import org.example.petshopdesktop.util.FilePickerSupport;
import org.example.petshopdesktop.ui.SvgWebViewFactory; import org.example.petshopdesktop.ui.SvgWebViewFactory;
import org.example.petshopdesktop.util.ActivityLogger; import org.example.petshopdesktop.util.ActivityLogger;
import java.io.ByteArrayInputStream;
public class MainLayoutController { public class MainLayoutController {
private static final String NAV_BASE_STYLE = "-fx-background-color: transparent; " + private static final String NAV_BASE_STYLE = "-fx-background-color: transparent; " +
@@ -205,12 +206,7 @@ public class MainLayoutController {
@FXML @FXML
void btnChangeAvatarClicked(ActionEvent event) { void btnChangeAvatarClicked(ActionEvent event) {
FileChooser chooser = new FileChooser(); java.io.File file = FilePickerSupport.pickImageFile(btnChangeAvatar.getScene().getWindow());
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());
if (file == null) { if (file == null) {
return; return;
} }
@@ -218,8 +214,7 @@ public class MainLayoutController {
try { try {
AvatarUploadResponse response = AuthApi.getInstance().uploadAvatar(file.toPath()); AvatarUploadResponse response = AuthApi.getInstance().uploadAvatar(file.toPath());
UserSession.getInstance().setAvatarUrl(response.getAvatarUrl()); UserSession.getInstance().setAvatarUrl(response.getAvatarUrl());
renderAvatar(UserSession.getInstance().getEmployeeName(), response.getAvatarUrl()); refreshProfileHeader();
btnRemoveAvatar.setDisable(response.getAvatarUrl() == null || response.getAvatarUrl().isBlank());
} catch (Exception e) { } catch (Exception e) {
ActivityLogger.getInstance().logException("MainLayoutController.btnChangeAvatarClicked", e, "Uploading avatar"); ActivityLogger.getInstance().logException("MainLayoutController.btnChangeAvatarClicked", e, "Uploading avatar");
showAvatarError(e.getMessage() != null ? e.getMessage() : "Could not upload profile picture."); showAvatarError(e.getMessage() != null ? e.getMessage() : "Could not upload profile picture.");
@@ -263,7 +258,7 @@ public class MainLayoutController {
@FXML @FXML
public void initialize() { public void initialize() {
logoContainer.getChildren().setAll(SvgWebViewFactory.build("/org/example/petshopdesktop/images/leons-pet-store-badge-light.svg", 94)); 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()); btnRemoveAvatar.setDisable(UserSession.getInstance().getAvatarUrl() == null || UserSession.getInstance().getAvatarUrl().isBlank());
refreshProfileHeader(); refreshProfileHeader();
applyRBAC(); applyRBAC();
@@ -285,20 +280,35 @@ public class MainLayoutController {
String displayName = userInfo.getFullName() == null || userInfo.getFullName().isBlank() String displayName = userInfo.getFullName() == null || userInfo.getFullName().isBlank()
? UserSession.getInstance().getUsername() ? UserSession.getInstance().getUsername()
: userInfo.getFullName(); : userInfo.getFullName();
Image avatarImage = loadAvatarImage(userInfo.getAvatarUrl());
Platform.runLater(() -> { Platform.runLater(() -> {
UserSession.getInstance().setEmployeeName(displayName); UserSession.getInstance().setEmployeeName(displayName);
UserSession.getInstance().setAvatarUrl(userInfo.getAvatarUrl()); UserSession.getInstance().setAvatarUrl(userInfo.getAvatarUrl());
lblUsername.setText(displayName); lblUsername.setText(displayName);
renderAvatar(displayName, userInfo.getAvatarUrl()); renderAvatar(displayName, avatarImage);
btnRemoveAvatar.setDisable(userInfo.getAvatarUrl() == null || userInfo.getAvatarUrl().isBlank()); btnRemoveAvatar.setDisable(userInfo.getAvatarUrl() == null || userInfo.getAvatarUrl().isBlank());
}); });
} catch (Exception e) { } catch (Exception e) {
Platform.runLater(() -> renderAvatar(UserSession.getInstance().getEmployeeName(), UserSession.getInstance().getAvatarUrl())); Platform.runLater(() -> renderAvatar(UserSession.getInstance().getEmployeeName(), null));
} }
}).start(); }).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); Circle border = new Circle(29);
border.setFill(Color.web("#dbe4ee")); border.setFill(Color.web("#dbe4ee"));
@@ -306,21 +316,9 @@ public class MainLayoutController {
Label initials = new Label(initials(displayName)); Label initials = new Label(initials(displayName));
initials.setStyle("-fx-text-fill: white; -fx-font-weight: bold; -fx-font-size: 16px;"); initials.setStyle("-fx-text-fill: white; -fx-font-weight: bold; -fx-font-size: 16px;");
if (avatarUrl != null && !avatarUrl.isBlank()) { if (avatarImage != null) {
try { circle.setFill(new ImagePattern(avatarImage));
String resolvedUrl = avatarUrl.startsWith("http") ? avatarUrl : ApiConfig.getInstance().getBaseUrl() + avatarUrl; initials.setVisible(false);
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);
}
} else { } else {
circle.setFill(Color.web("#4ECDC4")); circle.setFill(Color.web("#4ECDC4"));
initials.setVisible(true); initials.setVisible(true);

View File

@@ -6,9 +6,12 @@ import javafx.collections.ObservableList;
import javafx.event.ActionEvent; import javafx.event.ActionEvent;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader; import javafx.fxml.FXMLLoader;
import javafx.geometry.Pos;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.control.cell.PropertyValueFactory; import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.stage.Modality; import javafx.stage.Modality;
import javafx.stage.Stage; import javafx.stage.Stage;
import org.example.petshopdesktop.api.dto.pet.PetResponse; 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.controllers.dialogcontrollers.PetDialogController;
import org.example.petshopdesktop.models.Pet; import org.example.petshopdesktop.models.Pet;
import org.example.petshopdesktop.util.ActivityLogger; import org.example.petshopdesktop.util.ActivityLogger;
import org.example.petshopdesktop.util.DesktopImageSupport;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@@ -42,6 +46,9 @@ public class PetController {
@FXML @FXML
private TableColumn<Pet, Integer> colPetId; private TableColumn<Pet, Integer> colPetId;
@FXML
private TableColumn<Pet, String> colPetImage;
@FXML @FXML
private TableColumn<Pet, String> colPetName; private TableColumn<Pet, String> colPetName;
@@ -134,12 +141,14 @@ public class PetController {
tvPets.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE); tvPets.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE);
colPetId.setCellValueFactory(new PropertyValueFactory<Pet,Integer>("petId")); colPetId.setCellValueFactory(new PropertyValueFactory<Pet,Integer>("petId"));
colPetImage.setCellValueFactory(new PropertyValueFactory<Pet, String>("imageUrl"));
colPetName.setCellValueFactory(new PropertyValueFactory<Pet,String>("petName")); colPetName.setCellValueFactory(new PropertyValueFactory<Pet,String>("petName"));
colPetSpecies.setCellValueFactory(new PropertyValueFactory<Pet,String>("petSpecies")); colPetSpecies.setCellValueFactory(new PropertyValueFactory<Pet,String>("petSpecies"));
colPetBreed.setCellValueFactory(new PropertyValueFactory<Pet,String>("petBreed")); colPetBreed.setCellValueFactory(new PropertyValueFactory<Pet,String>("petBreed"));
colPetAge.setCellValueFactory(new PropertyValueFactory<Pet,Integer>("petAge")); colPetAge.setCellValueFactory(new PropertyValueFactory<Pet,Integer>("petAge"));
colPetStatus.setCellValueFactory(new PropertyValueFactory<Pet,String>("petStatus")); colPetStatus.setCellValueFactory(new PropertyValueFactory<Pet,String>("petStatus"));
colPetPrice.setCellValueFactory(new PropertyValueFactory<Pet,Double>("petPrice")); colPetPrice.setCellValueFactory(new PropertyValueFactory<Pet,Double>("petPrice"));
configureImageColumn(colPetImage);
displayPets(); displayPets();
@@ -262,8 +271,30 @@ public class PetController {
response.getPetBreed(), response.getPetBreed(),
response.getPetAge() != null ? response.getPetAge() : 0, response.getPetAge() != null ? response.getPetAge() : 0,
response.getPetStatus(), response.getPetStatus(),
response.getPetPrice().doubleValue() response.getPetPrice().doubleValue(),
response.getImageUrl()
); );
} }
private void configureImageColumn(TableColumn<Pet, String> 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);
}
});
}
} }

View File

@@ -6,9 +6,12 @@ import javafx.collections.ObservableList;
import javafx.event.ActionEvent; import javafx.event.ActionEvent;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader; import javafx.fxml.FXMLLoader;
import javafx.geometry.Pos;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.control.cell.PropertyValueFactory; import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.stage.Modality; import javafx.stage.Modality;
import javafx.stage.Stage; import javafx.stage.Stage;
import org.example.petshopdesktop.DTOs.ProductDTO; 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.api.endpoints.ProductApi;
import org.example.petshopdesktop.controllers.dialogcontrollers.ProductDialogController; import org.example.petshopdesktop.controllers.dialogcontrollers.ProductDialogController;
import org.example.petshopdesktop.util.ActivityLogger; import org.example.petshopdesktop.util.ActivityLogger;
import org.example.petshopdesktop.util.DesktopImageSupport;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
@@ -46,6 +50,9 @@ public class ProductController {
@FXML @FXML
private TableColumn<ProductDTO, Integer> colProductId; private TableColumn<ProductDTO, Integer> colProductId;
@FXML
private TableColumn<ProductDTO, String> colProductImage;
@FXML @FXML
private TableColumn<ProductDTO, String> colProductName; private TableColumn<ProductDTO, String> colProductName;
@@ -74,10 +81,12 @@ public class ProductController {
tvProducts.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE); tvProducts.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE);
//set up table columns //set up table columns
colProductId.setCellValueFactory(new PropertyValueFactory<ProductDTO,Integer>("prodId")); colProductId.setCellValueFactory(new PropertyValueFactory<ProductDTO,Integer>("prodId"));
colProductImage.setCellValueFactory(new PropertyValueFactory<ProductDTO,String>("imageUrl"));
colProductName.setCellValueFactory(new PropertyValueFactory<ProductDTO,String>("prodName")); colProductName.setCellValueFactory(new PropertyValueFactory<ProductDTO,String>("prodName"));
colProductPrice.setCellValueFactory(new PropertyValueFactory<ProductDTO,Double>("prodPrice")); colProductPrice.setCellValueFactory(new PropertyValueFactory<ProductDTO,Double>("prodPrice"));
colProductCategory.setCellValueFactory(new PropertyValueFactory<ProductDTO,String>("categoryName")); colProductCategory.setCellValueFactory(new PropertyValueFactory<ProductDTO,String>("categoryName"));
colProductDesc.setCellValueFactory(new PropertyValueFactory<ProductDTO,String>("prodDesc")); colProductDesc.setCellValueFactory(new PropertyValueFactory<ProductDTO,String>("prodDesc"));
configureImageColumn(colProductImage);
displayProduct(); displayProduct();
@@ -292,8 +301,30 @@ public class ProductController {
response.getProdPrice().doubleValue(), response.getProdPrice().doubleValue(),
0, 0,
response.getCategoryName(), response.getCategoryName(),
response.getProdDesc() response.getProdDesc(),
response.getImageUrl()
); );
} }
private void configureImageColumn(TableColumn<ProductDTO, String> 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);
}
});
}
} }

View File

@@ -6,6 +6,7 @@ import javafx.event.EventHandler;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent; import javafx.scene.input.MouseEvent;
import javafx.stage.Stage; import javafx.stage.Stage;
import org.example.petshopdesktop.Validator; 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.api.endpoints.PetApi;
import org.example.petshopdesktop.models.Pet; import org.example.petshopdesktop.models.Pet;
import org.example.petshopdesktop.util.ActivityLogger; 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.math.BigDecimal;
public class PetDialogController { public class PetDialogController {
@FXML @FXML
@@ -24,6 +29,12 @@ public class PetDialogController {
@FXML @FXML
private Button btnSave; private Button btnSave;
@FXML
private Button btnChangeImage;
@FXML
private Button btnRemoveImage;
@FXML @FXML
private ComboBox<String> cbPetStatus; private ComboBox<String> cbPetStatus;
@@ -33,6 +44,12 @@ public class PetDialogController {
@FXML @FXML
private Label lblPetId; private Label lblPetId;
@FXML
private Label lblImageStatus;
@FXML
private ImageView imgPetPreview;
@FXML @FXML
private TextField txtPetAge; private TextField txtPetAge;
@@ -49,6 +66,9 @@ public class PetDialogController {
private TextField txtPetSpecies; private TextField txtPetSpecies;
private String mode = null; private String mode = null;
private File selectedImageFile;
private String currentImageUrl;
private boolean removeImageRequested;
private ObservableList<String> statusList = FXCollections.observableArrayList( private ObservableList<String> statusList = FXCollections.observableArrayList(
"Available", "Adopted" "Available", "Adopted"
@@ -73,6 +93,10 @@ public class PetDialogController {
closeStage(mouseEvent); closeStage(mouseEvent);
} }
}); });
btnChangeImage.setOnMouseClicked(mouseEvent -> handleChangeImage());
btnRemoveImage.setOnMouseClicked(mouseEvent -> handleRemoveImage());
refreshImagePreview();
} }
private void buttonSaveClicked(MouseEvent mouseEvent) { private void buttonSaveClicked(MouseEvent mouseEvent) {
@@ -97,13 +121,14 @@ public class PetDialogController {
//Check validation (format) //Check validation (format)
errorMsg += Validator.isNonNegativeDouble(txtPetPrice.getText(), "Price"); errorMsg += Validator.isNonNegativeDouble(txtPetPrice.getText(), "Price");
errorMsg += Validator.isNonNegativeInteger(txtPetAge.getText(), "Age"); errorMsg += Validator.isPositiveInteger(txtPetAge.getText(), "Age");
if(errorMsg.isEmpty()){ if(errorMsg.isEmpty()){
PetRequest request = buildPetRequest(); PetRequest request = buildPetRequest();
try { try {
if(mode.equals("Add")) { if(mode.equals("Add")) {
PetApi.getInstance().createPet(request); PetResponse response = PetApi.getInstance().createPet(request);
applyImageChanges(response.getPetId());
} else { } else {
String[] parts = lblPetId.getText().split(": "); String[] parts = lblPetId.getText().split(": ");
if (parts.length < 2) { if (parts.length < 2) {
@@ -111,6 +136,7 @@ public class PetDialogController {
} }
Long petId = Long.parseLong(parts[1]); Long petId = Long.parseLong(parts[1]);
PetApi.getInstance().updatePet(petId, request); PetApi.getInstance().updatePet(petId, request);
applyImageChanges(petId);
} }
//tell the user operation was successful //tell the user operation was successful
@@ -175,6 +201,10 @@ public class PetDialogController {
txtPetBreed.setText(pet.getPetBreed()); txtPetBreed.setText(pet.getPetBreed());
txtPetAge.setText(pet.getPetAge() + ""); txtPetAge.setText(pet.getPetAge() + "");
txtPetPrice.setText(pet.getPetPrice() + ""); txtPetPrice.setText(pet.getPetPrice() + "");
currentImageUrl = pet.getImageUrl();
selectedImageFile = null;
removeImageRequested = false;
refreshImagePreview();
//get the right combobox selection //get the right combobox selection
for (String status : cbPetStatus.getItems()) { for (String status : cbPetStatus.getItems()) {
@@ -192,10 +222,76 @@ public class PetDialogController {
lblMode.setText(mode + " Pet"); lblMode.setText(mode + " Pet");
if(mode.equals("Add")) { if(mode.equals("Add")) {
lblPetId.setVisible(false); lblPetId.setVisible(false);
currentImageUrl = null;
selectedImageFile = null;
removeImageRequested = false;
refreshImagePreview();
} }
else if(mode.equals("Edit")) { else if(mode.equals("Edit")) {
lblPetId.setVisible(true); 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);
}
} }

View File

@@ -6,16 +6,21 @@ import javafx.event.EventHandler;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent; import javafx.scene.input.MouseEvent;
import javafx.stage.Stage; import javafx.stage.Stage;
import org.example.petshopdesktop.DTOs.ProductDTO; import org.example.petshopdesktop.DTOs.ProductDTO;
import org.example.petshopdesktop.Validator; import org.example.petshopdesktop.Validator;
import org.example.petshopdesktop.api.dto.common.DropdownOption; import org.example.petshopdesktop.api.dto.common.DropdownOption;
import org.example.petshopdesktop.api.dto.product.ProductRequest; 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.DropdownApi;
import org.example.petshopdesktop.api.endpoints.ProductApi; import org.example.petshopdesktop.api.endpoints.ProductApi;
import org.example.petshopdesktop.util.ActivityLogger; 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.math.BigDecimal;
import java.util.List; import java.util.List;
@@ -27,6 +32,12 @@ public class ProductDialogController {
@FXML @FXML
private Button btnSave; private Button btnSave;
@FXML
private Button btnChangeImage;
@FXML
private Button btnRemoveImage;
@FXML @FXML
private ComboBox<DropdownOption> cbProdCategory; private ComboBox<DropdownOption> cbProdCategory;
@@ -36,6 +47,12 @@ public class ProductDialogController {
@FXML @FXML
private Label lblProdId; private Label lblProdId;
@FXML
private Label lblImageStatus;
@FXML
private ImageView imgProductPreview;
@FXML @FXML
private TextField txtProdDesc; private TextField txtProdDesc;
@@ -46,6 +63,9 @@ public class ProductDialogController {
private TextField txtProdPrice; private TextField txtProdPrice;
private String mode = null; private String mode = null;
private File selectedImageFile;
private String currentImageUrl;
private boolean removeImageRequested;
/** /**
* Add event listeners to buttons when dialog loads * Add event listeners to buttons when dialog loads
@@ -82,6 +102,10 @@ public class ProductDialogController {
System.out.println("Error loading categories: " + e.getMessage()); 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); errorMsg += Validator.isLessThanVarChars(txtProdPrice.getText(), "Product Price", 12);
//Check Validation (format) //Check Validation (format)
errorMsg += Validator.isNonNegativeDouble(txtProdPrice.getText(), "Product Price"); errorMsg += Validator.isPositiveDouble(txtProdPrice.getText(), "Product Price");
if (errorMsg.isEmpty()) { if (errorMsg.isEmpty()) {
try { try {
@@ -123,7 +147,8 @@ public class ProductDialogController {
request.setProdDesc(txtProdDesc.getText()); request.setProdDesc(txtProdDesc.getText());
if (mode.equals("Add")) { if (mode.equals("Add")) {
ProductApi.getInstance().createProduct(request); ProductResponse response = ProductApi.getInstance().createProduct(request);
applyImageChanges(response.getProdId());
} else { } else {
String[] parts = lblProdId.getText().split(": "); String[] parts = lblProdId.getText().split(": ");
if (parts.length < 2) { if (parts.length < 2) {
@@ -131,6 +156,7 @@ public class ProductDialogController {
} }
Long productId = Long.parseLong(parts[1]); Long productId = Long.parseLong(parts[1]);
ProductApi.getInstance().updateProduct(productId, request); ProductApi.getInstance().updateProduct(productId, request);
applyImageChanges(productId);
} }
Alert alert = new Alert(Alert.AlertType.INFORMATION); Alert alert = new Alert(Alert.AlertType.INFORMATION);
@@ -167,6 +193,10 @@ public class ProductDialogController {
txtProdName.setText(product.getProdName()); txtProdName.setText(product.getProdName());
txtProdDesc.setText(product.getProdDesc()); txtProdDesc.setText(product.getProdDesc());
txtProdPrice.setText(product.getProdPrice() + ""); txtProdPrice.setText(product.getProdPrice() + "");
currentImageUrl = product.getImageUrl();
selectedImageFile = null;
removeImageRequested = false;
refreshImagePreview();
for (DropdownOption category : cbProdCategory.getItems()) { for (DropdownOption category : cbProdCategory.getItems()) {
if(category.getLabel().equals(product.getCategoryName())){ if(category.getLabel().equals(product.getCategoryName())){
@@ -197,10 +227,76 @@ public class ProductDialogController {
lblMode.setText(mode + " Product"); lblMode.setText(mode + " Product");
if(mode.equals("Add")) { if(mode.equals("Add")) {
lblProdId.setVisible(false); lblProdId.setVisible(false);
currentImageUrl = null;
selectedImageFile = null;
removeImageRequested = false;
refreshImagePreview();
} }
else if(mode.equals("Edit")) { else if(mode.equals("Edit")) {
lblProdId.setVisible(true); 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);
}
} }

View File

@@ -12,8 +12,9 @@ public class Pet {
private SimpleIntegerProperty petAge; private SimpleIntegerProperty petAge;
private SimpleStringProperty petStatus; private SimpleStringProperty petStatus;
private SimpleDoubleProperty petPrice; 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.petId = new SimpleIntegerProperty(petId);
this.petName = new SimpleStringProperty(petName); this.petName = new SimpleStringProperty(petName);
this.petSpecies = new SimpleStringProperty(petSpecies); this.petSpecies = new SimpleStringProperty(petSpecies);
@@ -21,6 +22,7 @@ public class Pet {
this.petAge = new SimpleIntegerProperty(petAge); this.petAge = new SimpleIntegerProperty(petAge);
this.petStatus = new SimpleStringProperty(petStatus); this.petStatus = new SimpleStringProperty(petStatus);
this.petPrice = new SimpleDoubleProperty(petPrice); this.petPrice = new SimpleDoubleProperty(petPrice);
this.imageUrl = new SimpleStringProperty(imageUrl);
} }
public int getPetId() { public int getPetId() {
@@ -106,4 +108,16 @@ public class Pet {
public SimpleDoubleProperty petPriceProperty() { public SimpleDoubleProperty petPriceProperty() {
return petPrice; return petPrice;
} }
public String getImageUrl() {
return imageUrl.get();
}
public void setImageUrl(String imageUrl) {
this.imageUrl.set(imageUrl);
}
public SimpleStringProperty imageUrlProperty() {
return imageUrl;
}
} }

View File

@@ -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<String, Image> 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);
}
}
}

View File

@@ -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<File> 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();
}
}

View File

@@ -5,15 +5,15 @@
<?import javafx.scene.control.ComboBox?> <?import javafx.scene.control.ComboBox?>
<?import javafx.scene.control.Label?> <?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?> <?import javafx.scene.control.TextField?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.layout.ColumnConstraints?> <?import javafx.scene.layout.ColumnConstraints?>
<?import javafx.scene.layout.GridPane?> <?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.HBox?> <?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.Region?> <?import javafx.scene.layout.Region?>
<?import javafx.scene.layout.RowConstraints?>
<?import javafx.scene.layout.VBox?> <?import javafx.scene.layout.VBox?>
<?import javafx.scene.text.Font?> <?import javafx.scene.text.Font?>
<VBox minHeight="-Infinity" minWidth="-Infinity" prefHeight="523.0" prefWidth="790.0" spacing="20.0" style="-fx-font-size: 14px;" xmlns="http://javafx.com/javafx/17" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.dialogcontrollers.PetDialogController"> <VBox minHeight="-Infinity" minWidth="-Infinity" prefHeight="560.0" prefWidth="790.0" spacing="20.0" style="-fx-font-size: 14px;" xmlns="http://javafx.com/javafx/17" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.dialogcontrollers.PetDialogController">
<children> <children>
<HBox alignment="CENTER_LEFT" prefHeight="79.0" prefWidth="727.0" spacing="20.0" style="-fx-background-color: #2C3E50; -fx-background-radius: 14;"> <HBox alignment="CENTER_LEFT" prefHeight="79.0" prefWidth="727.0" spacing="20.0" style="-fx-background-color: #2C3E50; -fx-background-radius: 14;">
<children> <children>
@@ -62,18 +62,13 @@
<Insets left="15.0" right="15.0" /> <Insets left="15.0" right="15.0" />
</padding> </padding>
</HBox> </HBox>
<VBox prefHeight="370.0" prefWidth="750.0" style="-fx-background-color: white; -fx-background-radius: 14; -fx-border-width: 2; -fx-border-color: #5580b5; -fx-border-radius: 14;"> <VBox prefHeight="405.0" prefWidth="750.0" style="-fx-background-color: white; -fx-background-radius: 14; -fx-border-width: 2; -fx-border-color: #5580b5; -fx-border-radius: 14;">
<children> <children>
<GridPane hgap="25.0" VBox.vgrow="ALWAYS"> <GridPane hgap="25.0" vgap="10.0">
<columnConstraints> <columnConstraints>
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" /> <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" /> <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
</columnConstraints> </columnConstraints>
<rowConstraints>
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
</rowConstraints>
<children> <children>
<VBox prefHeight="200.0" prefWidth="100.0" spacing="8.0"> <VBox prefHeight="200.0" prefWidth="100.0" spacing="8.0">
<children> <children>
@@ -163,6 +158,22 @@
<Insets bottom="15.0" left="15.0" right="15.0" top="15.0" /> <Insets bottom="15.0" left="15.0" right="15.0" top="15.0" />
</VBox.margin> </VBox.margin>
</GridPane> </GridPane>
<HBox alignment="CENTER_LEFT" spacing="15.0">
<children>
<ImageView fx:id="imgPetPreview" fitHeight="120.0" fitWidth="120.0" pickOnBounds="true" preserveRatio="true" />
<VBox spacing="10.0">
<children>
<Label fx:id="lblImageStatus" text="No image selected" textFill="#2c3e50" />
<HBox spacing="10.0">
<children>
<Button fx:id="btnChangeImage" mnemonicParsing="false" text="Change Image" />
<Button fx:id="btnRemoveImage" mnemonicParsing="false" text="Remove Image" />
</children>
</HBox>
</children>
</VBox>
</children>
</HBox>
</children> </children>
<padding> <padding>
<Insets bottom="15.0" left="15.0" right="15.0" top="15.0" /> <Insets bottom="15.0" left="15.0" right="15.0" top="15.0" />

View File

@@ -5,15 +5,15 @@
<?import javafx.scene.control.ComboBox?> <?import javafx.scene.control.ComboBox?>
<?import javafx.scene.control.Label?> <?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?> <?import javafx.scene.control.TextField?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.layout.ColumnConstraints?> <?import javafx.scene.layout.ColumnConstraints?>
<?import javafx.scene.layout.GridPane?> <?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.HBox?> <?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.Region?> <?import javafx.scene.layout.Region?>
<?import javafx.scene.layout.RowConstraints?>
<?import javafx.scene.layout.VBox?> <?import javafx.scene.layout.VBox?>
<?import javafx.scene.text.Font?> <?import javafx.scene.text.Font?>
<VBox minHeight="-Infinity" minWidth="-Infinity" prefHeight="523.0" prefWidth="790.0" spacing="20.0" style="-fx-font-size: 14px;" xmlns="http://javafx.com/javafx/17" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.dialogcontrollers.ProductDialogController"> <VBox minHeight="-Infinity" minWidth="-Infinity" prefHeight="560.0" prefWidth="790.0" spacing="20.0" style="-fx-font-size: 14px;" xmlns="http://javafx.com/javafx/17" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.dialogcontrollers.ProductDialogController">
<children> <children>
<HBox alignment="CENTER_LEFT" prefHeight="79.0" prefWidth="727.0" spacing="20.0" style="-fx-background-color: #2C3E50; -fx-background-radius: 14;"> <HBox alignment="CENTER_LEFT" prefHeight="79.0" prefWidth="727.0" spacing="20.0" style="-fx-background-color: #2C3E50; -fx-background-radius: 14;">
<children> <children>
@@ -62,19 +62,14 @@
<Insets left="15.0" right="15.0" /> <Insets left="15.0" right="15.0" />
</padding> </padding>
</HBox> </HBox>
<VBox prefHeight="370.0" prefWidth="750.0" style="-fx-background-color: white; -fx-background-radius: 14; -fx-border-width: 2; -fx-border-color: #5580b5; -fx-border-radius: 14;"> <VBox prefHeight="405.0" prefWidth="750.0" style="-fx-background-color: white; -fx-background-radius: 14; -fx-border-width: 2; -fx-border-color: #5580b5; -fx-border-radius: 14;">
<children> <children>
<GridPane hgap="25.0" VBox.vgrow="ALWAYS"> <GridPane hgap="25.0" vgap="10.0">
<columnConstraints> <columnConstraints>
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" /> <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" /> <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
</columnConstraints> </columnConstraints>
<rowConstraints> <children>
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
</rowConstraints>
<children>
<VBox prefHeight="200.0" prefWidth="100.0" spacing="8.0"> <VBox prefHeight="200.0" prefWidth="100.0" spacing="8.0">
<children> <children>
<Label text="Product Name:" textFill="#2c3e50"> <Label text="Product Name:" textFill="#2c3e50">
@@ -136,8 +131,24 @@
<VBox.margin> <VBox.margin>
<Insets bottom="15.0" left="15.0" right="15.0" top="15.0" /> <Insets bottom="15.0" left="15.0" right="15.0" top="15.0" />
</VBox.margin> </VBox.margin>
</GridPane> </GridPane>
</children> <HBox alignment="CENTER_LEFT" spacing="15.0">
<children>
<ImageView fx:id="imgProductPreview" fitHeight="120.0" fitWidth="120.0" pickOnBounds="true" preserveRatio="true" />
<VBox spacing="10.0">
<children>
<Label fx:id="lblImageStatus" text="No image selected" textFill="#2c3e50" />
<HBox spacing="10.0">
<children>
<Button fx:id="btnChangeImage" mnemonicParsing="false" text="Change Image" />
<Button fx:id="btnRemoveImage" mnemonicParsing="false" text="Remove Image" />
</children>
</HBox>
</children>
</VBox>
</children>
</HBox>
</children>
<padding> <padding>
<Insets bottom="15.0" left="15.0" right="15.0" top="15.0" /> <Insets bottom="15.0" left="15.0" right="15.0" top="15.0" />
</padding> </padding>

View File

@@ -6,6 +6,7 @@
<?import javafx.scene.control.TableColumn?> <?import javafx.scene.control.TableColumn?>
<?import javafx.scene.control.TableView?> <?import javafx.scene.control.TableView?>
<?import javafx.scene.control.TextField?> <?import javafx.scene.control.TextField?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.layout.HBox?> <?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.Region?> <?import javafx.scene.layout.Region?>
<?import javafx.scene.layout.VBox?> <?import javafx.scene.layout.VBox?>
@@ -67,14 +68,15 @@
</HBox> </HBox>
<TableView fx:id="tvPets" prefHeight="362.0" prefWidth="752.0" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS"> <TableView fx:id="tvPets" prefHeight="362.0" prefWidth="752.0" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS">
<columns> <columns>
<TableColumn fx:id="colPetId" prefWidth="60.0" text="ID" /> <TableColumn fx:id="colPetId" prefWidth="55.0" text="ID" />
<TableColumn fx:id="colPetName" prefWidth="113.14285278320312" text="Name" /> <TableColumn fx:id="colPetImage" prefWidth="80.0" text="Image" />
<TableColumn fx:id="colPetSpecies" prefWidth="110.28570556640625" text="Species" /> <TableColumn fx:id="colPetName" prefWidth="110.0" text="Name" />
<TableColumn fx:id="colPetBreed" prefWidth="174.85711669921875" text="Breed" /> <TableColumn fx:id="colPetSpecies" prefWidth="105.0" text="Species" />
<TableColumn fx:id="colPetAge" prefWidth="72.0" text="Age" /> <TableColumn fx:id="colPetBreed" prefWidth="145.0" text="Breed" />
<TableColumn fx:id="colPetStatus" prefWidth="133.142822265625" text="Status" /> <TableColumn fx:id="colPetAge" prefWidth="60.0" text="Age" />
<TableColumn fx:id="colPetPrice" prefWidth="89.142822265625" text="Price" /> <TableColumn fx:id="colPetStatus" prefWidth="110.0" text="Status" />
</columns> <TableColumn fx:id="colPetPrice" prefWidth="80.0" text="Price" />
</columns>
</TableView> </TableView>
</children> </children>
</VBox> </VBox>

View File

@@ -67,12 +67,13 @@
</HBox> </HBox>
<TableView fx:id="tvProducts" prefHeight="362.0" prefWidth="752.0" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS"> <TableView fx:id="tvProducts" prefHeight="362.0" prefWidth="752.0" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS">
<columns> <columns>
<TableColumn fx:id="colProductId" prefWidth="60.0" text="ID" /> <TableColumn fx:id="colProductId" prefWidth="55.0" text="ID" />
<TableColumn fx:id="colProductName" prefWidth="170.85714721679688" text="Name" /> <TableColumn fx:id="colProductImage" prefWidth="80.0" text="Image" />
<TableColumn fx:id="colProductCategory" prefWidth="195.4285888671875" text="Category" /> <TableColumn fx:id="colProductName" prefWidth="150.0" text="Name" />
<TableColumn fx:id="colProductDesc" prefWidth="210.28570556640625" text="Description" /> <TableColumn fx:id="colProductCategory" prefWidth="160.0" text="Category" />
<TableColumn fx:id="colProductPrice" prefWidth="115.4285888671875" text="Price" /> <TableColumn fx:id="colProductDesc" prefWidth="195.0" text="Description" />
</columns> <TableColumn fx:id="colProductPrice" prefWidth="110.0" text="Price" />
</columns>
</TableView> </TableView>
</children> </children>
</VBox> </VBox>

View File

@@ -0,0 +1,213 @@
//Validator JUnits tests
package org.example.petshopdesktop;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ValidatorTest {
//isPresent
@Test
void isPresent_nullValue_returnsError() {
String result = Validator.isPresent(null, "Name");
assertFalse(result.isEmpty(), "null value should produce an error message");
}
@Test
void isPresent_blankValue_returnsError() {
assertFalse(Validator.isPresent(" ", "Name").isEmpty());
}
@Test
void isPresent_emptyString_returnsError() {
assertFalse(Validator.isPresent("", "Name").isEmpty());
}
@Test
void isPresent_validValue_returnsEmpty() {
assertTrue(Validator.isPresent("Leon", "Name").isEmpty());
}
//isNonNegativeDouble
@Test
void isNonNegativeDouble_positiveValue_returnsEmpty() {
assertTrue(Validator.isNonNegativeDouble("5.5", "Price").isEmpty());
}
@Test
void isNonNegativeDouble_zero_returnsEmpty() {
assertTrue(Validator.isNonNegativeDouble("0", "Price").isEmpty());
}
@Test
void isNonNegativeDouble_negativeValue_returnsError() {
assertFalse(Validator.isNonNegativeDouble("-1.0", "Price").isEmpty());
}
@Test
void isNonNegativeDouble_nonNumeric_returnsError() {
assertFalse(Validator.isNonNegativeDouble("abc", "Price").isEmpty());
}
@Test
void isNonNegativeDouble_nullInput_returnsError() {
assertFalse(Validator.isNonNegativeDouble(null, "Price").isEmpty());
}
//isDoubleInRange
@Test
void isDoubleInRange_withinRange_returnsEmpty() {
assertTrue(Validator.isDoubleInRange("5.0", "Discount", 0.0, 10.0).isEmpty());
}
@Test
void isDoubleInRange_atMinBoundary_returnsEmpty() {
assertTrue(Validator.isDoubleInRange("0.0", "Discount", 0.0, 10.0).isEmpty());
}
@Test
void isDoubleInRange_atMaxBoundary_returnsEmpty() {
assertTrue(Validator.isDoubleInRange("10.0", "Discount", 0.0, 10.0).isEmpty());
}
@Test
void isDoubleInRange_belowMin_returnsError() {
assertFalse(Validator.isDoubleInRange("-1.0", "Discount", 0.0, 10.0).isEmpty());
}
@Test
void isDoubleInRange_aboveMax_returnsError() {
assertFalse(Validator.isDoubleInRange("11.0", "Discount", 0.0, 10.0).isEmpty());
}
@Test
void isDoubleInRange_nonNumeric_returnsError() {
assertFalse(Validator.isDoubleInRange("abc", "Discount", 0.0, 10.0).isEmpty());
}
@Test
void isDoubleInRange_nullInput_returnsError() {
assertFalse(Validator.isDoubleInRange(null, "Discount", 0.0, 10.0).isEmpty());
}
//isNonNegativeInteger
@Test
void isNonNegativeInteger_positiveValue_returnsEmpty() {
assertTrue(Validator.isNonNegativeInteger("10", "Quantity").isEmpty());
}
@Test
void isNonNegativeInteger_zero_returnsEmpty() {
assertTrue(Validator.isNonNegativeInteger("0", "Quantity").isEmpty());
}
@Test
void isNonNegativeInteger_negativeValue_returnsError() {
assertFalse(Validator.isNonNegativeInteger("-1", "Quantity").isEmpty());
}
@Test
void isNonNegativeInteger_decimal_returnsError() {
assertFalse(Validator.isNonNegativeInteger("1.5", "Quantity").isEmpty());
}
@Test
void isNonNegativeInteger_nonNumeric_returnsError() {
assertFalse(Validator.isNonNegativeInteger("abc", "Quantity").isEmpty());
}
@Test
void isNonNegativeInteger_nullInput_returnsError() {
assertFalse(Validator.isNonNegativeInteger(null, "Quantity").isEmpty());
}
//isLessThanVarChars
@Test
void isLessThanVarChars_withinLimit_returnsEmpty() {
assertTrue(Validator.isLessThanVarChars("Hello", "Name", 10).isEmpty());
}
@Test
void isLessThanVarChars_exactlyAtLimit_returnsEmpty() {
assertTrue(Validator.isLessThanVarChars("Hello", "Name", 5).isEmpty());
}
@Test
void isLessThanVarChars_exceedsLimit_returnsError() {
assertFalse(Validator.isLessThanVarChars("Hello World", "Name", 5).isEmpty());
}
@Test
void isLessThanVarChars_nullInput_returnsError() {
assertFalse(Validator.isLessThanVarChars(null, "Name", 10).isEmpty());
}
@Test
void isLessThanVarChars_emptyString_returnsEmpty() {
assertTrue(Validator.isLessThanVarChars("", "Name", 5).isEmpty());
}
//isValidEmail
@Test
void isValidEmail_validEmail_returnsEmpty() {
assertTrue(Validator.isValidEmail("user@example.com", "Email").isEmpty());
}
@Test
void isValidEmail_missingAtSign_returnsError() {
assertFalse(Validator.isValidEmail("userexample.com", "Email").isEmpty());
}
@Test
void isValidEmail_missingDomain_returnsError() {
assertFalse(Validator.isValidEmail("user@", "Email").isEmpty());
}
@Test
void isValidEmail_shortTld_returnsError() {
assertFalse(Validator.isValidEmail("user@example.c", "Email").isEmpty());
}
@Test
void isValidEmail_nullInput_returnsError() {
assertFalse(Validator.isValidEmail(null, "Email").isEmpty());
}
@Test
void isValidEmail_emptyString_returnsError() {
assertFalse(Validator.isValidEmail("", "Email").isEmpty());
}
//isValidPhoneNumber
@Test
void isValidPhoneNumber_validFormat_returnsEmpty() {
assertTrue(Validator.isValidPhoneNumber("403-555-1234", "Phone").isEmpty());
}
@Test
void isValidPhoneNumber_missingDashes_returnsError() {
assertFalse(Validator.isValidPhoneNumber("4035551234", "Phone").isEmpty());
}
@Test
void isValidPhoneNumber_lettersPresent_returnsError() {
assertFalse(Validator.isValidPhoneNumber("abc-def-ghij", "Phone").isEmpty());
}
@Test
void isValidPhoneNumber_wrongSegmentLength_returnsError() {
assertFalse(Validator.isValidPhoneNumber("40-5551-234", "Phone").isEmpty());
}
@Test
void isValidPhoneNumber_nullInput_returnsError() {
assertFalse(Validator.isValidPhoneNumber(null, "Phone").isEmpty());
}
@Test
void isValidPhoneNumber_emptyString_returnsError() {
assertFalse(Validator.isValidPhoneNumber("", "Phone").isEmpty());
}
}

42
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
nohup.out
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

36
web/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

BIN
web/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

290
web/app/globals.css Normal file
View File

@@ -0,0 +1,290 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
.navbar {
position: fixed;
top: 0;
left: 0;
width: 100%;
background: orange;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
padding: 0.5rem 2rem;
display: flex;
align-items: center;
height: 70px;
border-radius: 0px 0px 10px 10px;
}
/* Add padding to body to account for fixed header */
body {
padding-top: 70px;
margin: 0;
font-family: Arial, sans-serif;
}
/* Logo Styles */
#logo {
border-radius: 50%;
transition: transform 0.5s ease;
}
#logo:hover {
transform: scale(1.1);
}
/* Navigation Links Container */
.nav-links {
display: flex;
align-items: center;
gap: 2rem;
margin-left: 2rem;
}
/* Indivdual Link Styles */
.nav-link {
color: rgb(255, 255, 255);
text-decoration: none;
font-size: 1.1rem;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: all 0.3s ease;
position: relative;
}
/* Alternative Hover Effect - Background */
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.171);
}
/* Home Page */
.home-page {
min-height: 100vh;
}
/* Slideshow Styles */
.slideshow-container {
position: relative;
width: 100%;
height: 500px;
overflow: hidden;
/* margin-bottom: 4rem; */
}
.slide {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 1s ease-in-out;
z-index: 1;
}
.slide.active {
opacity: 1;
z-index: 2;
}
.slide-image {
object-fit: cover;
}
.slideshow-indicators {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
z-index: 3;
}
.indicator {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid white;
background: transparent;
cursor: pointer;
padding: 0;
transition: all 0.3s ease;
}
.indicator.active {
background: white;
transform: scale(1.2);
}
.indicator:hover {
background: rgba(255, 255, 255, 0.5);
}
/* Four Image Links Section */
.image-links-section {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.image-links-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2rem;
justify-content: center;
align-items: stretch;
}
.image-link-card {
text-decoration: none;
color: inherit;
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
}
.image-link-card:hover {
transform: translateY(-5px);
}
.image-wrapper {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 1rem;
}
.linked-image {
object-fit: cover;
transition: transform 0.3s ease;
}
.image-link-card:hover .linked-image {
transform: scale(1.1);
}
.image-title {
text-align: center;
font-size: 1.2rem;
font-weight: 600;
color: #333;
margin-top: 0.5rem;
}
/* Centered Title Section */
.centered-title-section {
text-align: center;
padding: 4rem 2rem;
background: linear-gradient(to bottom, #f9f9f9, #ffffff);
/* margin-top: 2re; */
}
.main-title {
font-size: 3rem;
color: #333;
margin-bottom: 1rem;
font-weight: 700;
letter-spacing: -0.5px;
}
.subtitle {
font-size: 1.5rem;
color: #666;
margin-bottom: 2rem;
font-weight: 300;
}
.title-decoration {
width: 100px;
height: 4px;
background: orange;
margin: 2rem auto 0;
border-radius: 2px;
}
/* Responsive Design */
@media (max-width: 1024px) {
.slideshow-container {
height: 400px;
}
.main-title {
font-size: 2.5rem;
}
.subtitle {
font-size: 1.25rem;
}
}
@media (max-width: 768px) {
.slideshow-container {
height: 300px;
}
.image-links-container {
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.main-title {
font-size: 2rem;
}
.image-links-section {
padding: 1rem;
}
}
@media (max-width: 480px) {
.slideshow-container {
height: 250px;
}
.image-links-container {
grid-template-columns: 1fr;
gap: 1rem;
}
.main-title {
font-size: 1.5rem;
}
.subtitle {
font-size: 1rem;
}
.centered-title-section {
padding: 2rem 1rem;
}
}

19
web/app/layout.js Normal file
View File

@@ -0,0 +1,19 @@
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import DisplayNav from "@/components/Navigation";
export const metadata = {
title: "Leon's Pet Store",
description: "Generated by create next app",
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<DisplayNav />
{children}
</body>
</html>
);
}

83
web/app/page.js Normal file
View File

@@ -0,0 +1,83 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useState, useEffect } from "react";
export default function Home() {
// Slideshow images array
const slideshowImages = [
{ src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets" },
{ src: "/images/home/slideshow/pet2.jpg", alt: "Pet supplies" },
{ src: "/images/home/slideshow/pet3.jpg", alt: "Pet grooming" },
{ src: "/images/home/slideshow/pet4.jpg", alt: "Pet food" },
];
const [currentSlide, setCurrentSlide] = useState(0);
// Auto-advance slideshow
useEffect(() => {
//Change slide every 7.5 seconds
const timer = setInterval(() => {setCurrentSlide((prev) => (prev + 1) % slideshowImages.length);}, 7500);
return () => clearInterval(timer);
}, [slideshowImages.length]);
// Four images that link to other pages
const navImages = [
{ src: "/images/home/navimages/adopt.jpg", alt: "Adopt a Pet", link: "/adopt", title: "Adopt a Pet" },
{ src: "/images/home/navimages/store.jpg", alt: "Online Store", link: "/store", title: "Online Store" },
{ src: "/images/home/navimages/appointments.jpg", alt: "Appointments", link: "/appointments", title: "Appointments" },
{ src: "/images/home/navimages/about.jpg", alt: "About Us", link: "/about", title: "About Us" },
];
return (
<main className="home-page">
{/* Slideshow Section */}
<section className="slideshow-container">
{slideshowImages.map((image, index) => (
<div
key={index}
className={`slide ${index === currentSlide ? "active" : ""}`}
>
<Image
src={image.src}
alt={image.alt}
fill
priority={index === 0}
className="slide-image"
sizes="100vw"
/>
</div>
))}
</section>
{/* Centered Title Section */}
<section className="centered-title-section">
<h1 className="main-title">Welcome to Leon's Pet Store</h1>
<p className="subtitle">Your One-Stop Shop for All Things Pets</p>
<div className="title-decoration"></div>
</section>
{/* Four Image Links Section */}
<section className="image-links-section">
<div className="image-links-container">
{navImages.map((item, index) => (
<Link href={item.link} key={index} className="image-link-card">
<div className="image-wrapper">
<Image
src={item.src}
alt={item.alt}
fill
className="linked-image"
sizes="(max-width: 768px) 100vw, 25vw"
/>
</div>
<h3 className="image-title">{item.title}</h3>
</Link>
))}
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,26 @@
import Link from "next/link";
import Image from "next/image";
export default function DisplayNav() {
return (
<nav className="navbar">
<Image
className="mx-3"
src="/logo_simple.png"
alt="store_logo"
width={50}
height={50}
id="logo"
/>
<div className="nav-links">
<a href="/" className="nav-link">Home</a>
<a href="/pets" className="nav-link">Adopt a Pet</a>
<a href="/" className="nav-link">Online Store</a>
<a href="/appointments" className="nav-link">Schedule an Appointment</a>
<a href="/contact" className="nav-link">Contact Us</a>
<a href="/aboutus" className="nav-link">About Us</a>
</div>
</nav>
);
}

16
web/eslint.config.mjs Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
const eslintConfig = defineConfig([
...nextVitals,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
web/jsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./*"]
}
}
}

7
web/next.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
/* config options here */
reactCompiler: true,
};
export default nextConfig;

6556
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
web/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "threaded-pets",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4"
}
}

7
web/postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Some files were not shown because too many files have changed in this diff Show More