Azure deployment setup #297
3
android/.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
*.iml
|
||||
nohup.out
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/*
|
||||
@@ -16,6 +17,8 @@
|
||||
/app/src/androidTest/
|
||||
/app/src/test/
|
||||
.DS_Store
|
||||
/.project
|
||||
/.settings/
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
|
||||
4
android/app/.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
/build
|
||||
/nohup.out
|
||||
/.classpath
|
||||
/.project
|
||||
/.settings/
|
||||
/src/test/
|
||||
/src/androidTest/
|
||||
|
||||
@@ -56,6 +56,9 @@ dependencies {
|
||||
implementation("io.reactivex.rxjava2:rxjava:2.2.21")
|
||||
implementation("io.reactivex.rxjava2:rxandroid:2.1.1")
|
||||
|
||||
implementation("com.github.bumptech.glide:glide:4.16.0")
|
||||
annotationProcessor("com.github.bumptech.glide:compiler:4.16.0")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.ext.junit)
|
||||
androidTestImplementation(libs.espresso.core)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
@@ -24,6 +25,11 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.PetStoreMobile">
|
||||
|
||||
<service
|
||||
android:name=".services.ChatNotificationService"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".activities.HomeActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
|
||||
BIN
android/app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
@@ -7,7 +7,5 @@ public class PetStoreApplication extends Application {
|
||||
@Override
|
||||
public void 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();
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,40 @@
|
||||
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.util.Log;
|
||||
|
||||
import androidx.activity.EdgeToEdge;
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import com.example.petstoremobile.R;
|
||||
import com.example.petstoremobile.fragments.ChatFragment;
|
||||
import com.example.petstoremobile.fragments.ListFragment;
|
||||
import com.example.petstoremobile.fragments.ProfileFragment;
|
||||
import com.example.petstoremobile.services.ChatNotificationService;
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView;
|
||||
|
||||
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
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
@@ -34,17 +49,15 @@ public class HomeActivity extends AppCompatActivity {
|
||||
});
|
||||
|
||||
//get the bottom navbar from the layout
|
||||
BottomNavigationView bottomNav = findViewById(R.id.bottom_navigation);
|
||||
|
||||
// Load ListFragment by default only if this is a fresh start
|
||||
bottomNav = findViewById(R.id.bottom_navigation);
|
||||
|
||||
//load the list fragment by default if it's a fresh start
|
||||
if (savedInstanceState == null) {
|
||||
loadFragment(new ListFragment());
|
||||
bottomNav.setSelectedItemId(R.id.nav_list);
|
||||
handleIntent(getIntent());
|
||||
}
|
||||
|
||||
//when an item in the bar is selected, load the corresponding fragment
|
||||
//when an item in the bottom bar is selected, load the corresponding fragment
|
||||
bottomNav.setOnItemSelectedListener(item -> {
|
||||
|
||||
if (item.getItemId() == R.id.nav_list) {
|
||||
loadFragment(new ListFragment());
|
||||
return true;
|
||||
@@ -57,13 +70,59 @@ public class HomeActivity extends AppCompatActivity {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Start the notification service and request for notification permission
|
||||
startNotificationService();
|
||||
requestNotificationPermission();
|
||||
}
|
||||
|
||||
// Handle new intents when the activity is already running,
|
||||
// like clicking a notification while the app is in use
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
handleIntent(intent);
|
||||
}
|
||||
|
||||
//helper function to load a fragment
|
||||
// Helper function to process intents for navigation.
|
||||
// like clicking a notification or just launching the app from a fresh start
|
||||
private void handleIntent(Intent intent) {
|
||||
if (intent != null && "chat".equals(intent.getStringExtra("navigate_to"))) {
|
||||
ChatFragment chatFragment = new ChatFragment();
|
||||
if (intent.hasExtra("conversation_id")) {
|
||||
Bundle args = new Bundle();
|
||||
args.putLong("conversation_id", intent.getLongExtra("conversation_id", -1));
|
||||
chatFragment.setArguments(args);
|
||||
}
|
||||
loadFragment(chatFragment);
|
||||
bottomNav.setSelectedItemId(R.id.nav_chat);
|
||||
} else {
|
||||
loadFragment(new ListFragment());
|
||||
bottomNav.setSelectedItemId(R.id.nav_list);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to start the notification service in the background
|
||||
// to receive notifications when a new conversation is created
|
||||
private void startNotificationService() {
|
||||
Intent serviceIntent = new Intent(this, ChatNotificationService.class);
|
||||
startService(serviceIntent);
|
||||
}
|
||||
|
||||
//Helper function to request for notification permission
|
||||
private void requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Helper function to load a fragment
|
||||
private void loadFragment(Fragment fragment) {
|
||||
getSupportFragmentManager()
|
||||
.beginTransaction()
|
||||
.replace(R.id.fragment_container, fragment)
|
||||
.commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.example.petstoremobile.api.auth.AuthApi;
|
||||
import com.example.petstoremobile.api.auth.TokenManager;
|
||||
import com.example.petstoremobile.api.RetrofitClient;
|
||||
import com.example.petstoremobile.dtos.AuthDTO;
|
||||
import com.example.petstoremobile.dtos.UserDTO;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
@@ -38,11 +39,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// Check if user is already logged in
|
||||
if (TokenManager.getInstance(this).isLoggedIn()) {
|
||||
Intent intent = new Intent(this, HomeActivity.class);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
return;
|
||||
TokenManager tokenManager = TokenManager.getInstance(this);
|
||||
if (tokenManager.isLoggedIn()) {
|
||||
if ("CUSTOMER".equalsIgnoreCase(tokenManager.getRole())) {
|
||||
// If a customer somehow remained logged in, clear them out
|
||||
tokenManager.clearLoginData();
|
||||
} else {
|
||||
Intent intent = new Intent(this, HomeActivity.class);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
EdgeToEdge.enable(this);
|
||||
@@ -82,19 +89,28 @@ public class MainActivity extends AppCompatActivity {
|
||||
@Override
|
||||
public void onResponse(Call<AuthDTO.LoginResponse> call, Response<AuthDTO.LoginResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
String role = response.body().getRole();
|
||||
|
||||
// Check if the user is a CUSTOMER and deny login if so
|
||||
if ("CUSTOMER".equalsIgnoreCase(role)) {
|
||||
Toast.makeText(MainActivity.this, "Access denied: Customers are not allowed to log in.", Toast.LENGTH_LONG).show();
|
||||
tvLoginStatus.setText("Customers are not allowed to log in");
|
||||
return;
|
||||
}
|
||||
|
||||
//save login data in shared preferences
|
||||
TokenManager.getInstance(MainActivity.this).saveLoginData(
|
||||
response.body().getToken(),
|
||||
response.body().getUsername(),
|
||||
response.body().getRole()
|
||||
role
|
||||
);
|
||||
|
||||
//fetch user id from api then login to home activity
|
||||
RetrofitClient.getAuthApi(MainActivity.this).getCurrentUser()
|
||||
.enqueue(new Callback<AuthDTO.UserResponse>() {
|
||||
RetrofitClient.getAuthApi(MainActivity.this).getMe()
|
||||
.enqueue(new Callback<UserDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<AuthDTO.UserResponse> call,
|
||||
Response<AuthDTO.UserResponse> response) {
|
||||
public void onResponse(Call<UserDTO> call,
|
||||
Response<UserDTO> response) {
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
TokenManager.getInstance(MainActivity.this)
|
||||
.saveUserId(response.body().getId());
|
||||
@@ -106,7 +122,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<AuthDTO.UserResponse> call,
|
||||
public void onFailure(Call<UserDTO> call,
|
||||
Throwable t) {
|
||||
Log.e("MainActivity", "Failed to fetch userId", t);
|
||||
|
||||
@@ -129,4 +145,4 @@ public class MainActivity extends AppCompatActivity {
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,23 @@ package com.example.petstoremobile.api;
|
||||
import com.example.petstoremobile.dtos.PageResponse;
|
||||
import com.example.petstoremobile.dtos.PetDTO;
|
||||
|
||||
import okhttp3.MultipartBody;
|
||||
import retrofit2.Call;
|
||||
import retrofit2.http.Body;
|
||||
import retrofit2.http.DELETE;
|
||||
import retrofit2.http.GET;
|
||||
import retrofit2.http.Multipart;
|
||||
import retrofit2.http.POST;
|
||||
import retrofit2.http.PUT;
|
||||
import retrofit2.http.Part;
|
||||
import retrofit2.http.Path;
|
||||
import retrofit2.http.Query;
|
||||
|
||||
//api calls to CRUD pets
|
||||
public interface PetApi {
|
||||
// endpoint for downloading the pet's image file
|
||||
String PET_IMAGE_PATH = "api/v1/pets/%d/image";
|
||||
|
||||
// Get all pets
|
||||
@GET("api/v1/pets")
|
||||
Call<PageResponse<PetDTO>> getAllPets(
|
||||
@@ -37,4 +43,9 @@ public interface PetApi {
|
||||
@DELETE("api/v1/pets/{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);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.example.petstoremobile.api;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
|
||||
import com.example.petstoremobile.api.auth.AuthApi;
|
||||
import com.example.petstoremobile.api.auth.AuthInterceptor;
|
||||
@@ -12,9 +13,23 @@ import retrofit2.converter.gson.GsonConverterFactory;
|
||||
|
||||
//Retrofit client Used for API calls
|
||||
public class RetrofitClient {
|
||||
//base URL
|
||||
public static final String BASE_URL = "http://10.0.2.2:8080"; //for emulator testing
|
||||
// public static final String BASE_URL = "http://10.0.0.200:8080/"; //for hardware testing
|
||||
public static final String BASE_URL = getBaseUrl();
|
||||
|
||||
// Helper function to determine BASE_URL based on whether we are testing on an emulator or a real device
|
||||
private static String getBaseUrl() {
|
||||
if (Build.FINGERPRINT.contains("generic")
|
||||
|| Build.FINGERPRINT.contains("unknown")
|
||||
|| Build.MODEL.contains("google_sdk")
|
||||
|| Build.MODEL.contains("Emulator")
|
||||
|| Build.MODEL.contains("Android SDK built for x86")
|
||||
|| Build.MANUFACTURER.contains("Genymotion")
|
||||
|| (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
|
||||
|| "google_sdk".equals(Build.PRODUCT)) {
|
||||
return "http://10.0.2.2:8080/"; //emulator testing
|
||||
} else {
|
||||
return "http://10.0.0.200:8080/"; //Hardware testing
|
||||
}
|
||||
}
|
||||
|
||||
private static Retrofit retrofit = null;
|
||||
|
||||
@@ -95,7 +110,6 @@ public class RetrofitClient {
|
||||
return getClient(context).create(MessageApi.class);
|
||||
}
|
||||
|
||||
|
||||
public static StoreApi getStoreApi(Context context) {
|
||||
return getClient(context).create(StoreApi.class);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
package com.example.petstoremobile.api.auth;
|
||||
|
||||
import com.example.petstoremobile.dtos.AuthDTO;
|
||||
import com.example.petstoremobile.dtos.UserDTO;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import okhttp3.MultipartBody;
|
||||
import retrofit2.Call;
|
||||
import retrofit2.http.Body;
|
||||
import retrofit2.http.GET;
|
||||
import retrofit2.http.Multipart;
|
||||
import retrofit2.http.POST;
|
||||
import retrofit2.http.PUT;
|
||||
import retrofit2.http.Part;
|
||||
|
||||
//Api for logging in and getting current user
|
||||
public interface AuthApi {
|
||||
|
||||
// endpoint for downloading the current user's avatar file
|
||||
String AVATAR_FILE_PATH = "api/v1/auth/me/avatar/file";
|
||||
|
||||
//login endpoint
|
||||
@POST("api/v1/auth/login")
|
||||
Call<AuthDTO.LoginResponse> login(@Body AuthDTO.LoginRequest loginRequest);
|
||||
|
||||
//get current user endpoint
|
||||
@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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import com.example.petstoremobile.dtos.PageResponse;
|
||||
import com.example.petstoremobile.dtos.SendMessageRequest;
|
||||
import com.example.petstoremobile.models.Chat;
|
||||
import com.example.petstoremobile.models.Message;
|
||||
import com.example.petstoremobile.services.ChatNotificationService;
|
||||
import com.example.petstoremobile.websocket.StompChatManager;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -40,6 +41,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
private RecyclerView rvChatList, rvMessages;
|
||||
private EditText etMessage;
|
||||
private Button btnSend;
|
||||
private TextView tvChatTitle;
|
||||
|
||||
// Adapters
|
||||
private ChatAdapter chatAdapter;
|
||||
@@ -75,6 +77,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
rvMessages = view.findViewById(R.id.rvMessages);
|
||||
etMessage = view.findViewById(R.id.etMessage);
|
||||
btnSend = view.findViewById(R.id.btnSend);
|
||||
tvChatTitle = view.findViewById(R.id.tvChatTitle);
|
||||
|
||||
ImageButton hamburger = view.findViewById(R.id.btnHamburger);
|
||||
hamburger.setOnClickListener(v -> drawerLayout.openDrawer(GravityCompat.START));
|
||||
@@ -121,6 +124,10 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
Log.e(TAG, "No token found");
|
||||
}
|
||||
|
||||
if (getArguments() != null && getArguments().containsKey("conversation_id")) {
|
||||
activeConversationId = getArguments().getLong("conversation_id");
|
||||
}
|
||||
|
||||
loadCustomers();
|
||||
}
|
||||
|
||||
@@ -165,7 +172,21 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
.collect(Collectors.toList());
|
||||
chatList.addAll(loaded);
|
||||
chatAdapter.notifyDataSetChanged();
|
||||
if (activeConversationId == null) {
|
||||
|
||||
if (activeConversationId != null) {
|
||||
setConversationActive(true);
|
||||
// Update title to customer name of active conversation
|
||||
for (Chat chat : chatList) {
|
||||
if (chat.getChatId().equals(String.valueOf(activeConversationId))) {
|
||||
tvChatTitle.setText(chat.getCustomerName());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (stompChatManager != null) {
|
||||
stompChatManager.subscribeToConversation(activeConversationId);
|
||||
}
|
||||
loadMessageHistory(activeConversationId);
|
||||
} else {
|
||||
messageList.clear();
|
||||
messageAdapter.notifyDataSetChanged();
|
||||
setConversationActive(false);
|
||||
@@ -186,6 +207,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
public void onChatClick(Chat chat) {
|
||||
activeConversationId = Long.parseLong(chat.getChatId());
|
||||
setConversationActive(true);
|
||||
tvChatTitle.setText(chat.getCustomerName());
|
||||
drawerLayout.closeDrawer(GravityCompat.START);
|
||||
|
||||
if (stompChatManager != null) {
|
||||
@@ -305,6 +327,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
|
||||
if (activeConversationId != null && activeConversationId.equals(dto.getId())) {
|
||||
setConversationActive(true);
|
||||
tvChatTitle.setText(name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +409,8 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
etMessage.setEnabled(active);
|
||||
if (!active) {
|
||||
activeConversationId = null;
|
||||
ChatNotificationService.activeConversationIdInUi = null;
|
||||
if (tvChatTitle != null) tvChatTitle.setText("Customer Chat");
|
||||
if (stompChatManager != null) {
|
||||
stompChatManager.clearConversationSubscription();
|
||||
}
|
||||
@@ -395,6 +420,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
etMessage.setHint("Select a chat to start messaging");
|
||||
} else {
|
||||
etMessage.setHint("Type a message...");
|
||||
ChatNotificationService.activeConversationIdInUi = activeConversationId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +428,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
ChatNotificationService.activeConversationIdInUi = null;
|
||||
if (stompChatManager != null) stompChatManager.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import android.widget.LinearLayout;
|
||||
|
||||
import com.example.petstoremobile.R;
|
||||
|
||||
import com.example.petstoremobile.api.auth.TokenManager;
|
||||
import com.example.petstoremobile.fragments.listfragments.PetFragment;
|
||||
import com.example.petstoremobile.fragments.listfragments.ServiceFragment;
|
||||
import com.example.petstoremobile.fragments.listfragments.SupplierFragment;
|
||||
@@ -56,6 +57,13 @@ public class ListFragment extends Fragment {
|
||||
drawerPurchaseOrderView=view.findViewById(R.id.drawerPurchaseOrderView);
|
||||
|
||||
|
||||
// Check user role and restrict access for STAFF
|
||||
String role = TokenManager.getInstance(requireContext()).getRole();
|
||||
if ("STAFF".equalsIgnoreCase(role)) {
|
||||
drawerSuppliers.setVisibility(View.GONE);
|
||||
drawerInventory.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
//needed to disable touches on the innerContainer while the drawer is open
|
||||
touchBlocker = view.findViewById(R.id.touchBlocker);
|
||||
|
||||
@@ -170,4 +178,4 @@ public class ListFragment extends Fragment {
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.fragment.app.Fragment;
|
||||
|
||||
import android.provider.MediaStore;
|
||||
import android.text.InputType;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -23,20 +24,43 @@ import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.model.GlideUrl;
|
||||
import com.bumptech.glide.load.model.LazyHeaders;
|
||||
import com.example.petstoremobile.R;
|
||||
import com.example.petstoremobile.activities.MainActivity;
|
||||
import com.example.petstoremobile.api.RetrofitClient;
|
||||
import com.example.petstoremobile.api.auth.AuthApi;
|
||||
import com.example.petstoremobile.api.auth.TokenManager;
|
||||
import com.example.petstoremobile.dtos.ErrorResponse;
|
||||
import com.example.petstoremobile.dtos.UserDTO;
|
||||
import com.example.petstoremobile.services.ChatNotificationService;
|
||||
import com.example.petstoremobile.utils.InputValidator;
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.RequestBody;
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class ProfileFragment extends Fragment {
|
||||
|
||||
//initialize the view/controls
|
||||
private ImageView imgProfile;
|
||||
private TextView tvProfileName, tvProfileEmail, tvProfilePhone, tvProfileRole;
|
||||
private Button btnChangePhoto, btnEditEmail, btnEditPhone, btnLogout;
|
||||
private Uri photoUri;
|
||||
private UserDTO currentUser;
|
||||
|
||||
//Initialize the launchers for camera and gallery
|
||||
private ActivityResultLauncher<Intent> galleryLauncher;
|
||||
@@ -58,8 +82,7 @@ public class ProfileFragment extends Fragment {
|
||||
&& result.getData() != null) {
|
||||
//get the selected image and set the image to the profile
|
||||
Uri selectedImage = result.getData().getData();
|
||||
imgProfile.setImageURI(selectedImage);
|
||||
//TODO: SAVE CHANGED PHOTO TO DATABASE
|
||||
uploadAvatar(selectedImage);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -71,10 +94,7 @@ public class ProfileFragment extends Fragment {
|
||||
success -> {
|
||||
//if a photo is taken set the image profile to it otherwise do nothing
|
||||
if (success) {
|
||||
//Clear the old image and set the new one
|
||||
imgProfile.setImageURI(null);
|
||||
imgProfile.setImageURI(photoUri);
|
||||
//TODO: SAVE CHANGED PHOTO TO DATABASE
|
||||
uploadAvatar(photoUri);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -107,7 +127,6 @@ public class ProfileFragment extends Fragment {
|
||||
);
|
||||
}
|
||||
|
||||
//TODO: MAKE PROFILE VIEW DISPLAY PROFILE DATA FROM DATABASE
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container,
|
||||
Bundle savedInstanceState) {
|
||||
@@ -119,10 +138,13 @@ public class ProfileFragment extends Fragment {
|
||||
tvProfileEmail = view.findViewById(R.id.tvProfileEmail);
|
||||
tvProfilePhone = view.findViewById(R.id.tvProfilePhone);
|
||||
tvProfileRole = view.findViewById(R.id.tvProfileRole);
|
||||
btnChangePhoto = view.findViewById(R.id.btnChangePhoto);
|
||||
btnEditEmail = view.findViewById(R.id.btnEditEmail);
|
||||
btnEditPhone = view.findViewById(R.id.btnEditPhone);
|
||||
btnLogout = view.findViewById(R.id.btnLogout);
|
||||
Button btnChangePhoto = view.findViewById(R.id.btnChangePhoto);
|
||||
Button btnEditEmail = view.findViewById(R.id.btnEditEmail);
|
||||
Button btnEditPhone = view.findViewById(R.id.btnEditPhone);
|
||||
Button btnLogout = view.findViewById(R.id.btnLogout);
|
||||
|
||||
//Load Profile Data from backend
|
||||
loadProfileData();
|
||||
|
||||
//Set up listeners for the buttons
|
||||
//Change photo button
|
||||
@@ -150,7 +172,6 @@ public class ProfileFragment extends Fragment {
|
||||
}
|
||||
})
|
||||
.show();
|
||||
//TODO: UPDATE PHOTO IN DATABASE
|
||||
});
|
||||
|
||||
//Edit email button
|
||||
@@ -170,19 +191,10 @@ public class ProfileFragment extends Fragment {
|
||||
.setTitle("Edit Email")
|
||||
.setView(input)
|
||||
.setPositiveButton("Save", (dialog, which) -> {
|
||||
String newEmail = input.getText().toString();
|
||||
//if the new value is a valid email then set the email to the new value
|
||||
if (android.util.Patterns.EMAIL_ADDRESS.matcher(newEmail).matches()) {
|
||||
tvProfileEmail.setText(newEmail);
|
||||
//TODO: UPDATE THE EMAIL IN DATABASE
|
||||
}
|
||||
else {
|
||||
//tell the user to email is invalid
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle("Error")
|
||||
.setMessage("Email is invalid")
|
||||
.setPositiveButton("OK", null)
|
||||
.show();
|
||||
if (InputValidator.isValidEmail(input)) {
|
||||
updateProfileField("email", input.getText().toString());
|
||||
} else {
|
||||
Toast.makeText(requireContext(), "Email is invalid", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
})
|
||||
.setNegativeButton("Cancel", null)
|
||||
@@ -210,19 +222,10 @@ public class ProfileFragment extends Fragment {
|
||||
.setTitle("Edit Phone Number")
|
||||
.setView(input)
|
||||
.setPositiveButton("Save", (dialog, which) -> {
|
||||
String newPhone = input.getText().toString();
|
||||
//if the new value is format: (XXX) XXX-XXXX then set the phone to the new value
|
||||
if (newPhone.matches("\\(\\d{3}\\) \\d{3}-\\d{4}")) { //TODO MAKE VALIDATION CLASS INSTEAD FOR THIS
|
||||
tvProfilePhone.setText(newPhone);
|
||||
//TODO: UPDATE PHONE IN DATABASE
|
||||
}
|
||||
else {
|
||||
//tell the user to email cannot be empty
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle("Error")
|
||||
.setMessage("Phone number is invalid. Format: (XXX) XXX-XXXX")
|
||||
.setPositiveButton("OK", null)
|
||||
.show();
|
||||
if (InputValidator.isValidPhone(input)) {
|
||||
updateProfileField("phone", input.getText().toString());
|
||||
} else {
|
||||
Toast.makeText(requireContext(), "Phone number is invalid", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
})
|
||||
.setNegativeButton("Cancel", null)
|
||||
@@ -231,6 +234,10 @@ public class ProfileFragment extends Fragment {
|
||||
|
||||
//Logout button
|
||||
btnLogout.setOnClickListener(v -> {
|
||||
// Stop notification service before logging out so notifications stop
|
||||
Intent serviceIntent = new Intent(requireContext(), ChatNotificationService.class);
|
||||
requireContext().stopService(serviceIntent);
|
||||
|
||||
TokenManager.getInstance(requireContext()).clearLoginData(); // clear the token for next login
|
||||
//get the intent to the main activity and clear the back stack so the back button won't allow the user to go back to the previous screen
|
||||
Intent intent = new Intent(getActivity(), MainActivity.class);
|
||||
@@ -252,4 +259,152 @@ public class ProfileFragment extends Fragment {
|
||||
//launch the camera to capture the photo and save the photo to photoUri
|
||||
cameraLauncher.launch(photoUri);
|
||||
}
|
||||
}
|
||||
|
||||
//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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,11 @@ import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.example.petstoremobile.R;
|
||||
@@ -43,6 +46,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
|
||||
private PetApi api;
|
||||
private SwipeRefreshLayout swipeRefreshLayout;
|
||||
private EditText etSearch;
|
||||
private Spinner spinnerStatus;
|
||||
|
||||
//load pet view
|
||||
@Override
|
||||
@@ -57,6 +61,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
|
||||
|
||||
setupRecyclerView(view);
|
||||
setupSearch(view);
|
||||
setupStatusFilter(view);
|
||||
setupSwipeRefresh(view);
|
||||
loadPetData();
|
||||
|
||||
@@ -82,24 +87,48 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
|
||||
etSearch.addTextChangedListener(new TextWatcher() {
|
||||
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
|
||||
@Override public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
filterPets(s.toString());
|
||||
filterPets();
|
||||
}
|
||||
@Override public void afterTextChanged(Editable s) {}
|
||||
});
|
||||
}
|
||||
|
||||
private void filterPets(String query) {
|
||||
//Setup the status filter spinner
|
||||
private void setupStatusFilter(View view) {
|
||||
spinnerStatus = view.findViewById(R.id.spinnerStatus);
|
||||
String[] statuses = {"All Statuses", "Available", "Adopted"};
|
||||
ArrayAdapter<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();
|
||||
if (query.isEmpty()) {
|
||||
filteredList.addAll(petList);
|
||||
} else {
|
||||
String lower = query.toLowerCase();
|
||||
for (PetDTO p : petList) {
|
||||
if (p.getPetName().toLowerCase().contains(lower)
|
||||
|| p.getPetSpecies().toLowerCase().contains(lower)
|
||||
|| p.getPetBreed().toLowerCase().contains(lower)) {
|
||||
filteredList.add(p);
|
||||
}
|
||||
for (PetDTO p : petList) {
|
||||
boolean matchesSearch = query.isEmpty() ||
|
||||
p.getPetName().toLowerCase().contains(query) ||
|
||||
p.getPetSpecies().toLowerCase().contains(query) ||
|
||||
p.getPetBreed().toLowerCase().contains(query);
|
||||
|
||||
boolean matchesStatus = selectedStatus.equals("All Statuses") ||
|
||||
p.getPetStatus().equalsIgnoreCase(selectedStatus);
|
||||
|
||||
if (matchesSearch && matchesStatus) {
|
||||
filteredList.add(p);
|
||||
}
|
||||
}
|
||||
adapter.notifyDataSetChanged();
|
||||
@@ -173,7 +202,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
petList.clear();
|
||||
petList.addAll(response.body().getContent());
|
||||
filterPets(etSearch.getText().toString());
|
||||
filterPets();
|
||||
|
||||
} else {
|
||||
Log.e("onResponse: ", response.message());
|
||||
|
||||
@@ -25,6 +25,8 @@ import com.example.petstoremobile.api.RetrofitClient;
|
||||
import com.example.petstoremobile.dtos.PetDTO;
|
||||
import com.example.petstoremobile.fragments.ListFragment;
|
||||
import com.example.petstoremobile.fragments.listfragments.PetFragment;
|
||||
import com.example.petstoremobile.utils.ActivityLogger;
|
||||
import com.example.petstoremobile.utils.InputValidator;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
@@ -65,26 +67,27 @@ public class PetDetailFragment extends Fragment {
|
||||
|
||||
//Method to Update or Add a pet
|
||||
private void savePet() {
|
||||
// Validates all fields using InputValidator
|
||||
if (!InputValidator.isNotEmpty(etPetName, "Pet Name")) return;
|
||||
if (!InputValidator.isNotEmpty(etPetSpecies, "Species")) return;
|
||||
if (!InputValidator.isNotEmpty(etPetBreed, "Breed")) return;
|
||||
if (!InputValidator.isPositiveInteger(etPetAge, "Age")) return;
|
||||
if (!InputValidator.isPositiveDecimal(etPetPrice, "Price")) return;
|
||||
|
||||
//get all the values from the fields
|
||||
String name = etPetName.getText().toString().trim();
|
||||
String species = etPetSpecies.getText().toString().trim();
|
||||
String breed = etPetBreed.getText().toString().trim();
|
||||
String ageStr = etPetAge.getText().toString().trim();
|
||||
int age = Integer.parseInt(etPetAge.getText().toString().trim());
|
||||
String priceStr = etPetPrice.getText().toString().trim();
|
||||
String status = spinnerPetStatus.getSelectedItem().toString();
|
||||
|
||||
//check if all the fields are filled
|
||||
if (name.isEmpty() || species.isEmpty() || breed.isEmpty() || ageStr.isEmpty() || priceStr.isEmpty()) {
|
||||
Toast.makeText(getContext(), "Please fill in all fields", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
//create a pet object to send to the API
|
||||
PetDTO petDTO = new PetDTO();
|
||||
petDTO.setPetName(name);
|
||||
petDTO.setPetSpecies(species);
|
||||
petDTO.setPetBreed(breed);
|
||||
petDTO.setPetAge(Integer.parseInt(ageStr));
|
||||
petDTO.setPetAge(age);
|
||||
petDTO.setPetPrice(priceStr);
|
||||
petDTO.setPetStatus(status);
|
||||
|
||||
@@ -98,6 +101,7 @@ public class PetDetailFragment extends Fragment {
|
||||
@Override
|
||||
public void onResponse(Call<PetDTO> call, Response<PetDTO> response) {
|
||||
if (response.isSuccessful()) {
|
||||
ActivityLogger.logChange(requireContext(), "Pet", "UPDATED", petId);
|
||||
Toast.makeText(getContext(), "Pet updated successfully!", Toast.LENGTH_SHORT).show();
|
||||
navigateBack();
|
||||
} else {
|
||||
@@ -107,6 +111,7 @@ public class PetDetailFragment extends Fragment {
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<PetDTO> call, Throwable t) {
|
||||
ActivityLogger.logException(requireContext(), "PetDetailFragment.updatePet", new Exception(t));
|
||||
Log.e("PetDetailFragment", "Error updating pet", t);
|
||||
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
@@ -117,6 +122,7 @@ public class PetDetailFragment extends Fragment {
|
||||
@Override
|
||||
public void onResponse(Call<PetDTO> call, Response<PetDTO> response) {
|
||||
if (response.isSuccessful()) {
|
||||
ActivityLogger.log(requireContext(), "Added new Pet: " + name);
|
||||
Toast.makeText(getContext(), "Pet added successfully!", Toast.LENGTH_SHORT).show();
|
||||
navigateBack();
|
||||
} else {
|
||||
@@ -126,6 +132,7 @@ public class PetDetailFragment extends Fragment {
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<PetDTO> call, Throwable t) {
|
||||
ActivityLogger.logException(requireContext(), "PetDetailFragment.createPet", new Exception(t));
|
||||
Log.e("PetDetailFragment", "Error adding pet", t);
|
||||
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
@@ -146,6 +153,7 @@ public class PetDetailFragment extends Fragment {
|
||||
@Override
|
||||
public void onResponse(Call<Void> call, Response<Void> response) {
|
||||
if (response.isSuccessful()) {
|
||||
ActivityLogger.logChange(requireContext(), "Pet", "DELETED", petId);
|
||||
Toast.makeText(getContext(), "Pet deleted successfully!", Toast.LENGTH_SHORT).show();
|
||||
navigateBack();
|
||||
} else {
|
||||
@@ -155,6 +163,7 @@ public class PetDetailFragment extends Fragment {
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<Void> call, Throwable t) {
|
||||
ActivityLogger.logException(requireContext(), "PetDetailFragment.deletePet", new Exception(t));
|
||||
Log.e("PetDetailFragment", "Error deleting pet", t);
|
||||
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import com.example.petstoremobile.api.ServiceApi;
|
||||
import com.example.petstoremobile.dtos.ServiceDTO;
|
||||
import com.example.petstoremobile.fragments.ListFragment;
|
||||
import com.example.petstoremobile.fragments.listfragments.ServiceFragment;
|
||||
import com.example.petstoremobile.utils.ActivityLogger;
|
||||
import com.example.petstoremobile.utils.InputValidator;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
@@ -58,24 +60,24 @@ public class ServiceDetailFragment extends Fragment {
|
||||
|
||||
//Method to Update or Add a service
|
||||
private void saveService() {
|
||||
// Validates all fields using InputValidator
|
||||
if (!InputValidator.isNotEmpty(etServiceName, "Service Name")) return;
|
||||
if (!InputValidator.isNotEmpty(etServiceDesc, "Description")) return;
|
||||
if (!InputValidator.isPositiveInteger(etServiceDuration, "Duration")) return;
|
||||
if (!InputValidator.isPositiveDecimal(etServicePrice, "Price")) return;
|
||||
|
||||
//get all the values from the fields
|
||||
String name = etServiceName.getText().toString().trim();
|
||||
String desc = etServiceDesc.getText().toString().trim();
|
||||
String durationStr = etServiceDuration.getText().toString().trim();
|
||||
String priceStr = etServicePrice.getText().toString().trim();
|
||||
|
||||
//check if all the fields are filled (desc is optional)
|
||||
if (name.isEmpty() || durationStr.isEmpty() || priceStr.isEmpty()) {
|
||||
Toast.makeText(getContext(), "Please fill in all fields", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
int duration = Integer.parseInt(etServiceDuration.getText().toString().trim());
|
||||
double price = Double.parseDouble(etServicePrice.getText().toString().trim());
|
||||
|
||||
//create a service object to send to the API
|
||||
ServiceDTO serviceDTO = new ServiceDTO();
|
||||
serviceDTO.setServiceName(name);
|
||||
serviceDTO.setServiceDesc(desc);
|
||||
serviceDTO.setServiceDuration(Integer.parseInt(durationStr));
|
||||
serviceDTO.setServicePrice(Double.parseDouble(priceStr));
|
||||
serviceDTO.setServiceDuration(duration);
|
||||
serviceDTO.setServicePrice(price);
|
||||
|
||||
ServiceApi serviceApi = RetrofitClient.getServiceApi(requireContext());
|
||||
|
||||
@@ -87,6 +89,7 @@ public class ServiceDetailFragment extends Fragment {
|
||||
@Override
|
||||
public void onResponse(Call<ServiceDTO> call, Response<ServiceDTO> response) {
|
||||
if (response.isSuccessful()) {
|
||||
ActivityLogger.logChange(requireContext(), "Service", "UPDATED", serviceId);
|
||||
Toast.makeText(getContext(), "Service updated successfully!", Toast.LENGTH_SHORT).show();
|
||||
navigateBack();
|
||||
} else {
|
||||
@@ -96,6 +99,7 @@ public class ServiceDetailFragment extends Fragment {
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<ServiceDTO> call, Throwable t) {
|
||||
ActivityLogger.logException(requireContext(), "ServiceDetailFragment.updateService", new Exception(t));
|
||||
Log.e("ServiceDetailFragment", "Error updating service", t);
|
||||
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
@@ -106,6 +110,7 @@ public class ServiceDetailFragment extends Fragment {
|
||||
@Override
|
||||
public void onResponse(Call<ServiceDTO> call, Response<ServiceDTO> response) {
|
||||
if (response.isSuccessful()) {
|
||||
ActivityLogger.log(requireContext(), "Added new Service: " + name);
|
||||
Toast.makeText(getContext(), "Service added successfully!", Toast.LENGTH_SHORT).show();
|
||||
navigateBack();
|
||||
} else {
|
||||
@@ -115,6 +120,7 @@ public class ServiceDetailFragment extends Fragment {
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<ServiceDTO> call, Throwable t) {
|
||||
ActivityLogger.logException(requireContext(), "ServiceDetailFragment.createService", new Exception(t));
|
||||
Log.e("ServiceDetailFragment", "Error adding service", t);
|
||||
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
@@ -134,6 +140,7 @@ public class ServiceDetailFragment extends Fragment {
|
||||
@Override
|
||||
public void onResponse(Call<Void> call, Response<Void> response) {
|
||||
if (response.isSuccessful()) {
|
||||
ActivityLogger.logChange(requireContext(), "Service", "DELETED", serviceId);
|
||||
Toast.makeText(getContext(), "Service deleted successfully!", Toast.LENGTH_SHORT).show();
|
||||
navigateBack();
|
||||
} else {
|
||||
@@ -143,6 +150,7 @@ public class ServiceDetailFragment extends Fragment {
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<Void> call, Throwable t) {
|
||||
ActivityLogger.logException(requireContext(), "ServiceDetailFragment.deleteService", new Exception(t));
|
||||
Log.e("ServiceDetailFragment", "Error deleting service", t);
|
||||
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import com.example.petstoremobile.api.SupplierApi;
|
||||
import com.example.petstoremobile.dtos.SupplierDTO;
|
||||
import com.example.petstoremobile.fragments.ListFragment;
|
||||
import com.example.petstoremobile.fragments.listfragments.SupplierFragment;
|
||||
import com.example.petstoremobile.utils.ActivityLogger;
|
||||
import com.example.petstoremobile.utils.InputValidator;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
@@ -58,6 +60,13 @@ public class SupplierDetailFragment extends Fragment {
|
||||
|
||||
//Method to Update or Add a supplier
|
||||
private void saveSupplier() {
|
||||
// Validates all fields using InputValidator
|
||||
if (!InputValidator.isNotEmpty(etSupCompany, "Company Name")) return;
|
||||
if (!InputValidator.isNotEmpty(etSupContactFirstName, "First Name")) return;
|
||||
if (!InputValidator.isNotEmpty(etSupContactLastName, "Last Name")) return;
|
||||
if (!InputValidator.isValidEmail(etSupEmail)) return;
|
||||
if (!InputValidator.isValidPhone(etSupPhone)) return;
|
||||
|
||||
//get all the values from the fields
|
||||
String company = etSupCompany.getText().toString().trim();
|
||||
String firstName = etSupContactFirstName.getText().toString().trim();
|
||||
@@ -65,12 +74,6 @@ public class SupplierDetailFragment extends Fragment {
|
||||
String email = etSupEmail.getText().toString().trim();
|
||||
String phone = etSupPhone.getText().toString().trim();
|
||||
|
||||
//check if all the fields are filled
|
||||
if (company.isEmpty() || firstName.isEmpty() || lastName.isEmpty() || email.isEmpty() || phone.isEmpty()) {
|
||||
Toast.makeText(getContext(), "Please fill in all fields", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
//create a supplier object to send to the API
|
||||
SupplierDTO supplierDTO = new SupplierDTO();
|
||||
supplierDTO.setSupCompany(company);
|
||||
@@ -89,6 +92,7 @@ public class SupplierDetailFragment extends Fragment {
|
||||
@Override
|
||||
public void onResponse(Call<SupplierDTO> call, Response<SupplierDTO> response) {
|
||||
if (response.isSuccessful()) {
|
||||
ActivityLogger.logChange(requireContext(), "Supplier", "UPDATED", supId);
|
||||
Toast.makeText(getContext(), "Supplier updated successfully!", Toast.LENGTH_SHORT).show();
|
||||
navigateBack();
|
||||
} else {
|
||||
@@ -98,6 +102,7 @@ public class SupplierDetailFragment extends Fragment {
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<SupplierDTO> call, Throwable t) {
|
||||
ActivityLogger.logException(requireContext(), "SupplierDetailFragment.updateSupplier", new Exception(t));
|
||||
Log.e("SupplierDetailFragment", "Error updating supplier", t);
|
||||
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
@@ -108,6 +113,7 @@ public class SupplierDetailFragment extends Fragment {
|
||||
@Override
|
||||
public void onResponse(Call<SupplierDTO> call, Response<SupplierDTO> response) {
|
||||
if (response.isSuccessful()) {
|
||||
ActivityLogger.log(requireContext(), "Added new Supplier: " + company);
|
||||
Toast.makeText(getContext(), "Supplier added successfully!", Toast.LENGTH_SHORT).show();
|
||||
navigateBack();
|
||||
} else {
|
||||
@@ -117,6 +123,7 @@ public class SupplierDetailFragment extends Fragment {
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<SupplierDTO> call, Throwable t) {
|
||||
ActivityLogger.logException(requireContext(), "SupplierDetailFragment.createSupplier", new Exception(t));
|
||||
Log.e("SupplierDetailFragment", "Error adding supplier", t);
|
||||
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
@@ -136,6 +143,7 @@ public class SupplierDetailFragment extends Fragment {
|
||||
@Override
|
||||
public void onResponse(Call<Void> call, Response<Void> response) {
|
||||
if (response.isSuccessful()) {
|
||||
ActivityLogger.logChange(requireContext(), "Supplier", "DELETED", supId);
|
||||
Toast.makeText(getContext(), "Supplier deleted successfully!", Toast.LENGTH_SHORT).show();
|
||||
navigateBack();
|
||||
} else {
|
||||
@@ -145,6 +153,7 @@ public class SupplierDetailFragment extends Fragment {
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<Void> call, Throwable t) {
|
||||
ActivityLogger.logException(requireContext(), "SupplierDetailFragment.deleteSupplier", new Exception(t));
|
||||
Log.e("SupplierDetailFragment", "Error deleting supplier", t);
|
||||
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
@@ -197,6 +206,11 @@ public class SupplierDetailFragment extends Fragment {
|
||||
etSupContactLastName = view.findViewById(R.id.etSupContactLastName);
|
||||
etSupEmail = view.findViewById(R.id.etSupEmail);
|
||||
etSupPhone = view.findViewById(R.id.etSupPhone);
|
||||
|
||||
// Add phone number formatting (CA) and limit length to 14 characters
|
||||
etSupPhone.addTextChangedListener(new android.telephony.PhoneNumberFormattingTextWatcher("CA"));
|
||||
etSupPhone.setFilters(new android.text.InputFilter[]{new android.text.InputFilter.LengthFilter(14)});
|
||||
|
||||
btnSaveSupplier = view.findViewById(R.id.btnSaveSupplier);
|
||||
btnDeleteSupplier = view.findViewById(R.id.btnDeleteSupplier);
|
||||
btnBack = view.findViewById(R.id.btnBack);
|
||||
|
||||
@@ -16,26 +16,42 @@ import androidx.core.content.FileProvider;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import android.provider.MediaStore;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.example.petstoremobile.R;
|
||||
import com.example.petstoremobile.api.PetApi;
|
||||
import com.example.petstoremobile.api.RetrofitClient;
|
||||
import com.example.petstoremobile.fragments.ListFragment;
|
||||
import com.example.petstoremobile.fragments.listfragments.detailfragments.PetDetailFragment;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.Locale;
|
||||
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.RequestBody;
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class PetProfileFragment extends Fragment {
|
||||
|
||||
private TextView tvPetName, tvPetSpecies, tvPetBreed, tvPetAge, tvPetPrice;
|
||||
private Button btnBack, btnEditPet, btnChangePhoto;
|
||||
private ImageView imgPet;
|
||||
private Uri photoUri;
|
||||
private int petId;
|
||||
|
||||
// launchers for camera and gallery
|
||||
private ActivityResultLauncher<Intent> galleryLauncher;
|
||||
@@ -53,8 +69,7 @@ public class PetProfileFragment extends Fragment {
|
||||
result -> {
|
||||
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
|
||||
Uri selectedImage = result.getData().getData();
|
||||
imgPet.setImageURI(selectedImage);
|
||||
// TODO: SAVE CHANGED PHOTO TO DATABASE
|
||||
uploadPetImage(selectedImage);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -64,9 +79,7 @@ public class PetProfileFragment extends Fragment {
|
||||
new ActivityResultContracts.TakePicture(),
|
||||
success -> {
|
||||
if (success) {
|
||||
imgPet.setImageURI(null);
|
||||
imgPet.setImageURI(photoUri);
|
||||
// TODO: SAVE CHANGED PHOTO TO DATABASE
|
||||
uploadPetImage(photoUri);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -112,11 +125,15 @@ public class PetProfileFragment extends Fragment {
|
||||
|
||||
// Set pet details to display
|
||||
if (getArguments() != null) {
|
||||
petId = getArguments().getInt("petId");
|
||||
tvPetName.setText(getArguments().getString("petName"));
|
||||
tvPetSpecies.setText(getArguments().getString("petSpecies"));
|
||||
tvPetBreed.setText(getArguments().getString("petBreed"));
|
||||
tvPetAge.setText(String.format(Locale.getDefault(), "%d yr(s)", getArguments().getInt("petAge")));
|
||||
tvPetPrice.setText(String.format(Locale.getDefault(), "$%.2f", getArguments().getDouble("petPrice")));
|
||||
|
||||
// Load pet image from backend
|
||||
loadPetImage(petId);
|
||||
}
|
||||
|
||||
//set button click listeners
|
||||
@@ -169,6 +186,74 @@ public class PetProfileFragment extends Fragment {
|
||||
return view;
|
||||
}
|
||||
|
||||
// Helper function to load pet image from backend
|
||||
private void loadPetImage(int petId) {
|
||||
String imageUrl = RetrofitClient.BASE_URL + String.format(Locale.US, PetApi.PET_IMAGE_PATH, petId);
|
||||
|
||||
Glide.with(this)
|
||||
.load(imageUrl)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
.placeholder(R.drawable.placeholder)
|
||||
.error(R.drawable.placeholder)
|
||||
.into(imgPet);
|
||||
}
|
||||
|
||||
// Helper function to upload pet image to backend
|
||||
private void uploadPetImage(Uri uri) {
|
||||
try {
|
||||
File file = getFileFromUri(uri);
|
||||
if (file == null) return;
|
||||
|
||||
// Create RequestBody for file upload
|
||||
RequestBody requestFile = RequestBody.create(file, MediaType.parse(requireContext().getContentResolver().getType(uri)));
|
||||
MultipartBody.Part body = MultipartBody.Part.createFormData("image", file.getName(), requestFile);
|
||||
|
||||
// Call the backend to upload the image
|
||||
PetApi petApi = RetrofitClient.getPetApi(requireContext());
|
||||
petApi.uploadPetImage((long) petId, body).enqueue(new Callback<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() {
|
||||
File photoFile = new File(requireContext().getCacheDir(), "pet_photo.jpg");
|
||||
photoUri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".fileprovider", photoFile);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -61,10 +61,11 @@ public class InputValidator {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Checks if the phone number is valid (digits, spaces, dashes, brackets allowed)
|
||||
// Checks if the phone number is valid
|
||||
public static boolean isValidPhone(EditText field) {
|
||||
String phone = field.getText().toString().trim();
|
||||
if (phone.isEmpty() || !phone.matches("[0-9\\-\\s\\(\\)\\+]+")) {
|
||||
// Android built in phone validation pattern
|
||||
if (phone.isEmpty() || !android.util.Patterns.PHONE.matcher(phone).matches()) {
|
||||
field.setError("Enter a valid phone number");
|
||||
field.requestFocus();
|
||||
return false;
|
||||
@@ -94,4 +95,3 @@ public class InputValidator {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,15 @@
|
||||
android:contentDescription="Open menu"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvChatTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Customer Chat"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"/>
|
||||
android:textStyle="bold"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -38,18 +38,34 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etSearchPet"
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:hint="Search by name, species or breed..."
|
||||
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"/>
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etSearchPet"
|
||||
android:layout_width="0dp"
|
||||
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
|
||||
android:id="@+id/swipeRefreshPet"
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
android:id="@+id/tvProfileEmail"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="example@email.com"
|
||||
android:text="No email loaded"
|
||||
android:textColor="@color/text_dark"
|
||||
android:textSize="16sp" />
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
android:id="@+id/tvProfilePhone"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="(123) 123-1234"
|
||||
android:text="No phone loaded"
|
||||
android:textColor="@color/text_dark"
|
||||
android:textSize="16sp" />
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
android:id="@+id/tvProfileRole"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Manager"
|
||||
android:text="No role loaded"
|
||||
android:textSize="16sp"
|
||||
android:textColor="@color/accent_coral"/>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.6 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 1.6 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FDE0E0</color>
|
||||
</resources>
|
||||
1
backend/.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
target/
|
||||
nohup.out
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
|
||||
@@ -90,6 +90,10 @@
|
||||
"key": "avatarFile",
|
||||
"value": "postman/avatar.png"
|
||||
},
|
||||
{
|
||||
"key": "avatarUrl",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "bulkPetId",
|
||||
"value": ""
|
||||
@@ -117,6 +121,10 @@
|
||||
{
|
||||
"key": "bulkInventoryId",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "adoptedPetId",
|
||||
"value": "4"
|
||||
}
|
||||
],
|
||||
"item": [
|
||||
@@ -212,6 +220,7 @@
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"var jsonData = pm.response.json();",
|
||||
"if (jsonData.id !== undefined) pm.collectionVariables.set('userId', jsonData.id);",
|
||||
"if (jsonData.token) pm.collectionVariables.set('customerToken', jsonData.token);"
|
||||
]
|
||||
}
|
||||
@@ -307,7 +316,9 @@
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});"
|
||||
"});",
|
||||
"var jsonData = pm.response.json();",
|
||||
"if (jsonData.id !== undefined) pm.collectionVariables.set('userId', jsonData.id);"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -381,7 +392,8 @@
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"var jsonData = pm.response.json();",
|
||||
"pm.expect(jsonData.avatarUrl).to.be.a('string');"
|
||||
"pm.expect(jsonData.avatarUrl).to.be.a('string');",
|
||||
"pm.collectionVariables.set('avatarUrl', jsonData.avatarUrl);"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -414,7 +426,68 @@
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"var jsonData = pm.response.json();",
|
||||
"pm.expect(jsonData.avatarUrl).to.be.a('string');"
|
||||
"pm.expect(jsonData.avatarUrl).to.be.a('string');",
|
||||
"pm.collectionVariables.set('avatarUrl', jsonData.avatarUrl);"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get My Avatar File",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}{{avatarUrl}}",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{customerToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"pm.test('Avatar response is an image', function () {",
|
||||
" pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get User Avatar File As Staff",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/users/{{userId}}/avatar/file",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{staffToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"pm.test('Avatar response is an image', function () {",
|
||||
" pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -662,6 +735,95 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Upload Pet Image",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "{{baseUrl}}/api/v1/pets/{{petId}}/image",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{staffToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "image",
|
||||
"type": "file",
|
||||
"src": "{{avatarFile}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"var jsonData = pm.response.json();",
|
||||
"pm.expect(jsonData.imageUrl).to.be.a('string');"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get Pet Image Public",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/pets/{{petId}}/image"
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"pm.test('Pet image response is an image', function () {",
|
||||
" pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Delete Pet Image",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": "{{baseUrl}}/api/v1/pets/{{petId}}/image",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{staffToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Delete Pet",
|
||||
"request": {
|
||||
@@ -769,6 +931,120 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Upload Adopted Pet Image",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{staffToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "image",
|
||||
"type": "file",
|
||||
"src": "{{avatarFile}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get Adopted Pet Image Public",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image"
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 403', function () {",
|
||||
" pm.response.to.have.status(403);",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get Adopted Pet Image As Staff",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{staffToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"pm.test('Pet image response is an image', function () {",
|
||||
" pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Delete Adopted Pet Image",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{staffToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1015,6 +1291,95 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Upload Product Image",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "{{baseUrl}}/api/v1/products/{{productId}}/image",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{staffToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "image",
|
||||
"type": "file",
|
||||
"src": "{{avatarFile}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"var jsonData = pm.response.json();",
|
||||
"pm.expect(jsonData.imageUrl).to.be.a('string');"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get Product Image",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/products/{{productId}}/image"
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"pm.test('Product image response is an image', function () {",
|
||||
" pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Delete Product Image",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": "{{baseUrl}}/api/v1/products/{{productId}}/image",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{adminToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -4468,4 +4833,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import java.util.Arrays;
|
||||
|
||||
public class FlywayContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
|
||||
|
||||
private static final int MAX_RETRIES = 15;
|
||||
private static final long RETRY_DELAY_MILLIS = 1000L;
|
||||
|
||||
@Override
|
||||
public void initialize(ConfigurableApplicationContext applicationContext) {
|
||||
ConfigurableEnvironment environment = applicationContext.getEnvironment();
|
||||
@@ -29,12 +32,33 @@ public class FlywayContextInitializer implements ApplicationContextInitializer<C
|
||||
.filter(location -> !location.isEmpty())
|
||||
.toArray(String[]::new);
|
||||
|
||||
Flyway.configure()
|
||||
.dataSource(url, username, password)
|
||||
.locations(locations)
|
||||
.baselineOnMigrate(environment.getProperty("spring.flyway.baseline-on-migrate", Boolean.class, false))
|
||||
.baselineVersion(MigrationVersion.fromVersion(environment.getProperty("spring.flyway.baseline-version", "1")))
|
||||
.load()
|
||||
.migrate();
|
||||
RuntimeException lastFailure = null;
|
||||
for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
Flyway.configure()
|
||||
.dataSource(url, username, password)
|
||||
.locations(locations)
|
||||
.baselineOnMigrate(environment.getProperty("spring.flyway.baseline-on-migrate", Boolean.class, false))
|
||||
.baselineVersion(MigrationVersion.fromVersion(environment.getProperty("spring.flyway.baseline-version", "1")))
|
||||
.load()
|
||||
.migrate();
|
||||
return;
|
||||
} catch (RuntimeException ex) {
|
||||
lastFailure = ex;
|
||||
if (attempt == MAX_RETRIES) {
|
||||
throw ex;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(RETRY_DELAY_MILLIS);
|
||||
} catch (InterruptedException interruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted while waiting for database startup", interruptedException);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastFailure != null) {
|
||||
throw lastFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,13 @@ import com.petshop.backend.repository.EmployeeRepository;
|
||||
import com.petshop.backend.repository.EmployeeStoreRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import com.petshop.backend.security.JwtUtil;
|
||||
import com.petshop.backend.service.AvatarStorageService;
|
||||
import com.petshop.backend.service.UserBusinessLinkageService;
|
||||
import com.petshop.backend.util.AuthenticationHelper;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
@@ -28,15 +31,9 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
@@ -49,8 +46,9 @@ public class AuthController {
|
||||
private final UserBusinessLinkageService userBusinessLinkageService;
|
||||
private final EmployeeRepository employeeRepository;
|
||||
private final EmployeeStoreRepository employeeStoreRepository;
|
||||
private final AvatarStorageService avatarStorageService;
|
||||
|
||||
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService, EmployeeRepository employeeRepository, EmployeeStoreRepository employeeStoreRepository) {
|
||||
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService, EmployeeRepository employeeRepository, EmployeeStoreRepository employeeStoreRepository, AvatarStorageService avatarStorageService) {
|
||||
this.authenticationManager = authenticationManager;
|
||||
this.userRepository = userRepository;
|
||||
this.jwtUtil = jwtUtil;
|
||||
@@ -58,6 +56,7 @@ public class AuthController {
|
||||
this.userBusinessLinkageService = userBusinessLinkageService;
|
||||
this.employeeRepository = employeeRepository;
|
||||
this.employeeStoreRepository = employeeStoreRepository;
|
||||
this.avatarStorageService = avatarStorageService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
@@ -155,7 +154,7 @@ public class AuthController {
|
||||
user.getEmail(),
|
||||
user.getFullName(),
|
||||
user.getPhone(),
|
||||
user.getAvatarUrl(),
|
||||
avatarStorageService.toOwnerAvatarUrl(user),
|
||||
user.getRole().name(),
|
||||
employeeStore != null ? employeeStore.getStore().getStoreId() : null,
|
||||
employeeStore != null ? employeeStore.getStore().getStoreName() : null
|
||||
@@ -224,7 +223,7 @@ public class AuthController {
|
||||
updatedUser.getEmail(),
|
||||
updatedUser.getFullName(),
|
||||
updatedUser.getPhone(),
|
||||
updatedUser.getAvatarUrl(),
|
||||
avatarStorageService.toOwnerAvatarUrl(updatedUser),
|
||||
updatedUser.getRole().name(),
|
||||
employeeStore != null ? employeeStore.getStore().getStoreId() : null,
|
||||
employeeStore != null ? employeeStore.getStore().getStoreName() : null
|
||||
@@ -273,26 +272,12 @@ public class AuthController {
|
||||
}
|
||||
|
||||
try {
|
||||
String uploadDir = "uploads/avatars";
|
||||
File directory = new File(uploadDir);
|
||||
if (!directory.exists()) {
|
||||
directory.mkdirs();
|
||||
}
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String extension = originalFilename != null && originalFilename.contains(".")
|
||||
? originalFilename.substring(originalFilename.lastIndexOf("."))
|
||||
: ".jpg";
|
||||
String filename = UUID.randomUUID().toString() + extension;
|
||||
Path filePath = Paths.get(uploadDir, filename);
|
||||
|
||||
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
String avatarUrl = "/uploads/avatars/" + filename;
|
||||
user.setAvatarUrl(avatarUrl);
|
||||
avatarStorageService.deleteAvatar(user);
|
||||
String avatarPath = avatarStorageService.storeAvatar(file);
|
||||
user.setAvatarUrl(avatarPath);
|
||||
userRepository.save(user);
|
||||
|
||||
return ResponseEntity.ok(new AvatarUploadResponse(avatarUrl, "Avatar uploaded successfully"));
|
||||
return ResponseEntity.ok(new AvatarUploadResponse(avatarStorageService.toOwnerAvatarUrl(user), "Avatar uploaded successfully"));
|
||||
|
||||
} catch (IOException e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
@@ -305,25 +290,41 @@ public class AuthController {
|
||||
public ResponseEntity<?> getAvatar() {
|
||||
User user = getAuthenticatedUser();
|
||||
|
||||
if (user.getAvatarUrl() == null || user.getAvatarUrl().isEmpty()) {
|
||||
if (!avatarStorageService.hasAvatar(user)) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", "No avatar uploaded");
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
|
||||
}
|
||||
|
||||
Map<String, String> response = new HashMap<>();
|
||||
response.put("avatarUrl", user.getAvatarUrl());
|
||||
response.put("avatarUrl", avatarStorageService.toOwnerAvatarUrl(user));
|
||||
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")
|
||||
public ResponseEntity<?> deleteAvatar() {
|
||||
User user = getAuthenticatedUser();
|
||||
|
||||
if (user.getAvatarUrl() != null && !user.getAvatarUrl().isEmpty()) {
|
||||
if (avatarStorageService.hasAvatar(user)) {
|
||||
try {
|
||||
Path filePath = Paths.get("." + user.getAvatarUrl());
|
||||
Files.deleteIfExists(filePath);
|
||||
avatarStorageService.deleteAvatar(user);
|
||||
} catch (IOException e) {
|
||||
}
|
||||
user.setAvatarUrl(null);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,14 @@ public class PetResponse {
|
||||
private Integer petAge;
|
||||
private String petStatus;
|
||||
private BigDecimal petPrice;
|
||||
private String imageUrl;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public PetResponse() {
|
||||
}
|
||||
|
||||
public PetResponse(Long petId, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
public PetResponse(Long petId, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
this.petId = petId;
|
||||
this.petName = petName;
|
||||
this.petSpecies = petSpecies;
|
||||
@@ -26,6 +27,7 @@ public class PetResponse {
|
||||
this.petAge = petAge;
|
||||
this.petStatus = petStatus;
|
||||
this.petPrice = petPrice;
|
||||
this.imageUrl = imageUrl;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
@@ -86,6 +88,14 @@ public class PetResponse {
|
||||
this.petPrice = petPrice;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
@@ -107,12 +117,12 @@ public class PetResponse {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
PetResponse that = (PetResponse) o;
|
||||
return Objects.equals(petId, that.petId) && Objects.equals(petName, that.petName) && Objects.equals(petSpecies, that.petSpecies) && Objects.equals(petBreed, that.petBreed) && Objects.equals(petAge, that.petAge) && Objects.equals(petStatus, that.petStatus) && Objects.equals(petPrice, that.petPrice) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
|
||||
return Objects.equals(petId, that.petId) && Objects.equals(petName, that.petName) && Objects.equals(petSpecies, that.petSpecies) && Objects.equals(petBreed, that.petBreed) && Objects.equals(petAge, that.petAge) && Objects.equals(petStatus, that.petStatus) && Objects.equals(petPrice, that.petPrice) && Objects.equals(imageUrl, that.imageUrl) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, createdAt, updatedAt);
|
||||
return Objects.hash(petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl, createdAt, updatedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -125,6 +135,7 @@ public class PetResponse {
|
||||
", petAge=" + petAge +
|
||||
", petStatus='" + petStatus + '\'' +
|
||||
", petPrice=" + petPrice +
|
||||
", imageUrl='" + imageUrl + '\'' +
|
||||
", createdAt=" + createdAt +
|
||||
", updatedAt=" + updatedAt +
|
||||
'}';
|
||||
|
||||
@@ -11,19 +11,21 @@ public class ProductResponse {
|
||||
private String categoryName;
|
||||
private String prodDesc;
|
||||
private BigDecimal prodPrice;
|
||||
private String imageUrl;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public ProductResponse() {
|
||||
}
|
||||
|
||||
public ProductResponse(Long prodId, String prodName, Long categoryId, String categoryName, String prodDesc, BigDecimal prodPrice, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
public ProductResponse(Long prodId, String prodName, Long categoryId, String categoryName, String prodDesc, BigDecimal prodPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
this.prodId = prodId;
|
||||
this.prodName = prodName;
|
||||
this.categoryId = categoryId;
|
||||
this.categoryName = categoryName;
|
||||
this.prodDesc = prodDesc;
|
||||
this.prodPrice = prodPrice;
|
||||
this.imageUrl = imageUrl;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
@@ -76,6 +78,14 @@ public class ProductResponse {
|
||||
this.prodPrice = prodPrice;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
@@ -97,12 +107,12 @@ public class ProductResponse {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
ProductResponse that = (ProductResponse) o;
|
||||
return Objects.equals(prodId, that.prodId) && Objects.equals(prodName, that.prodName) && Objects.equals(categoryId, that.categoryId) && Objects.equals(categoryName, that.categoryName) && Objects.equals(prodDesc, that.prodDesc) && Objects.equals(prodPrice, that.prodPrice) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
|
||||
return Objects.equals(prodId, that.prodId) && Objects.equals(prodName, that.prodName) && Objects.equals(categoryId, that.categoryId) && Objects.equals(categoryName, that.categoryName) && Objects.equals(prodDesc, that.prodDesc) && Objects.equals(prodPrice, that.prodPrice) && Objects.equals(imageUrl, that.imageUrl) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(prodId, prodName, categoryId, categoryName, prodDesc, prodPrice, createdAt, updatedAt);
|
||||
return Objects.hash(prodId, prodName, categoryId, categoryName, prodDesc, prodPrice, imageUrl, createdAt, updatedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -114,6 +124,7 @@ public class ProductResponse {
|
||||
", categoryName='" + categoryName + '\'' +
|
||||
", prodDesc='" + prodDesc + '\'' +
|
||||
", prodPrice=" + prodPrice +
|
||||
", imageUrl='" + imageUrl + '\'' +
|
||||
", createdAt=" + createdAt +
|
||||
", updatedAt=" + updatedAt +
|
||||
'}';
|
||||
|
||||
@@ -35,6 +35,9 @@ public class Pet {
|
||||
@Column(nullable = false, precision = 10, scale = 2)
|
||||
private BigDecimal petPrice;
|
||||
|
||||
@Column(length = 255)
|
||||
private String imageUrl;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
@@ -46,7 +49,7 @@ public class Pet {
|
||||
public Pet() {
|
||||
}
|
||||
|
||||
public Pet(Long id, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
public Pet(Long id, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
this.id = id;
|
||||
this.petName = petName;
|
||||
this.petSpecies = petSpecies;
|
||||
@@ -54,6 +57,7 @@ public class Pet {
|
||||
this.petAge = petAge;
|
||||
this.petStatus = petStatus;
|
||||
this.petPrice = petPrice;
|
||||
this.imageUrl = imageUrl;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
@@ -114,6 +118,14 @@ public class Pet {
|
||||
this.petPrice = petPrice;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
@@ -153,6 +165,7 @@ public class Pet {
|
||||
", petAge=" + petAge +
|
||||
", petStatus='" + petStatus + '\'' +
|
||||
", petPrice=" + petPrice +
|
||||
", imageUrl='" + imageUrl + '\'' +
|
||||
", createdAt=" + createdAt +
|
||||
", updatedAt=" + updatedAt +
|
||||
'}';
|
||||
|
||||
@@ -29,6 +29,9 @@ public class Product {
|
||||
@Column(nullable = false, precision = 10, scale = 2)
|
||||
private BigDecimal prodPrice;
|
||||
|
||||
@Column(length = 255)
|
||||
private String imageUrl;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
@@ -40,12 +43,13 @@ public class Product {
|
||||
public Product() {
|
||||
}
|
||||
|
||||
public Product(Long prodId, String prodName, Category category, String prodDesc, BigDecimal prodPrice, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
public Product(Long prodId, String prodName, Category category, String prodDesc, BigDecimal prodPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
this.prodId = prodId;
|
||||
this.prodName = prodName;
|
||||
this.category = category;
|
||||
this.prodDesc = prodDesc;
|
||||
this.prodPrice = prodPrice;
|
||||
this.imageUrl = imageUrl;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
@@ -90,6 +94,14 @@ public class Product {
|
||||
this.prodPrice = prodPrice;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
@@ -127,6 +139,7 @@ public class Product {
|
||||
", category=" + category +
|
||||
", prodDesc='" + prodDesc + '\'' +
|
||||
", prodPrice=" + prodPrice +
|
||||
", imageUrl='" + imageUrl + '\'' +
|
||||
", createdAt=" + createdAt +
|
||||
", updatedAt=" + updatedAt +
|
||||
'}';
|
||||
|
||||
@@ -8,6 +8,8 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface AdoptionRepository extends JpaRepository<Adoption, Long> {
|
||||
|
||||
@@ -24,4 +26,6 @@ public interface AdoptionRepository extends JpaRepository<Adoption, Long> {
|
||||
"LOWER(a.customer.lastName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
|
||||
"LOWER(a.pet.petName) LIKE LOWER(CONCAT('%', :q, '%')))")
|
||||
Page<Adoption> searchAdoptionsByCustomer(@Param("customerId") Long customerId, @Param("q") String query, Pageable pageable);
|
||||
|
||||
Optional<Adoption> findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(Long petId, String adoptionStatus);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,34 @@ package com.petshop.backend.service;
|
||||
import com.petshop.backend.dto.common.BulkDeleteRequest;
|
||||
import com.petshop.backend.dto.pet.PetRequest;
|
||||
import com.petshop.backend.dto.pet.PetResponse;
|
||||
import com.petshop.backend.entity.Adoption;
|
||||
import com.petshop.backend.entity.Pet;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.repository.AdoptionRepository;
|
||||
import com.petshop.backend.repository.PetRepository;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
public class PetService {
|
||||
|
||||
private final PetRepository petRepository;
|
||||
private final AdoptionRepository adoptionRepository;
|
||||
private final CatalogImageStorageService catalogImageStorageService;
|
||||
|
||||
public PetService(PetRepository petRepository) {
|
||||
public PetService(PetRepository petRepository, AdoptionRepository adoptionRepository, CatalogImageStorageService catalogImageStorageService) {
|
||||
this.petRepository = petRepository;
|
||||
this.adoptionRepository = adoptionRepository;
|
||||
this.catalogImageStorageService = catalogImageStorageService;
|
||||
}
|
||||
|
||||
public Page<PetResponse> getAllPets(String query, Pageable pageable) {
|
||||
@@ -68,17 +81,107 @@ public class PetService {
|
||||
|
||||
@Transactional
|
||||
public void deletePet(Long id) {
|
||||
if (!petRepository.existsById(id)) {
|
||||
throw new ResourceNotFoundException("Pet not found with id: " + id);
|
||||
}
|
||||
petRepository.deleteById(id);
|
||||
Pet pet = petRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id));
|
||||
deleteStoredImageIfPresent(pet.getImageUrl());
|
||||
petRepository.delete(pet);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void bulkDeletePets(BulkDeleteRequest request) {
|
||||
petRepository.findAllById(request.getIds()).forEach(pet -> deleteStoredImageIfPresent(pet.getImageUrl()));
|
||||
petRepository.deleteAllById(request.getIds());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PetResponse uploadPetImage(Long id, MultipartFile file) throws IOException {
|
||||
validateImageFile(file);
|
||||
Pet pet = findPet(id);
|
||||
deleteStoredImageIfPresent(pet.getImageUrl());
|
||||
pet.setImageUrl(catalogImageStorageService.storePetImage(file));
|
||||
return mapToResponse(petRepository.save(pet));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PetResponse deletePetImage(Long id) {
|
||||
Pet pet = findPet(id);
|
||||
deleteStoredImageIfPresent(pet.getImageUrl());
|
||||
pet.setImageUrl(null);
|
||||
return mapToResponse(petRepository.save(pet));
|
||||
}
|
||||
|
||||
public ImagePayload loadPetImage(Long id, Long requesterUserId, User.Role requesterRole) {
|
||||
Pet pet = findPet(id);
|
||||
if (pet.getImageUrl() == null || pet.getImageUrl().isBlank()) {
|
||||
throw new ResourceNotFoundException("Pet image not found for id: " + id);
|
||||
}
|
||||
if (!canViewPetImage(pet, requesterUserId, requesterRole)) {
|
||||
throw new ForbiddenImageAccessException();
|
||||
}
|
||||
Resource resource = catalogImageStorageService.loadPetImage(pet.getImageUrl());
|
||||
MediaType mediaType = catalogImageStorageService.resolveMediaType(resource);
|
||||
return new ImagePayload(resource, mediaType);
|
||||
}
|
||||
|
||||
public boolean isPubliclyVisible(Pet pet) {
|
||||
return "available".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()));
|
||||
}
|
||||
|
||||
private boolean canViewPetImage(Pet pet, Long requesterUserId, User.Role requesterRole) {
|
||||
if (isPubliclyVisible(pet)) {
|
||||
return true;
|
||||
}
|
||||
if (requesterRole == User.Role.STAFF || requesterRole == User.Role.ADMIN) {
|
||||
return true;
|
||||
}
|
||||
if (requesterUserId == null) {
|
||||
return false;
|
||||
}
|
||||
if (!"adopted".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()))) {
|
||||
return false;
|
||||
}
|
||||
return adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(pet.getPetId(), "Completed")
|
||||
.map(Adoption::getCustomer)
|
||||
.map(customer -> requesterUserId.equals(customer.getUserId()))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
private Pet findPet(Long id) {
|
||||
return petRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id));
|
||||
}
|
||||
|
||||
private void validateImageFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("Please select an image to upload");
|
||||
}
|
||||
if (file.getSize() > 5 * 1024 * 1024) {
|
||||
throw new IllegalArgumentException("Image file size must be less than 5MB");
|
||||
}
|
||||
String contentType = file.getContentType();
|
||||
if (contentType == null) {
|
||||
throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed");
|
||||
}
|
||||
String normalized = contentType.toLowerCase(Locale.ROOT);
|
||||
if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/gif")) {
|
||||
throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed");
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteStoredImageIfPresent(String storedImagePath) {
|
||||
if (storedImagePath == null || storedImagePath.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
catalogImageStorageService.deletePetImage(storedImagePath);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeStatus(String status) {
|
||||
return status == null ? "" : status.trim();
|
||||
}
|
||||
|
||||
private PetResponse mapToResponse(Pet pet) {
|
||||
return new PetResponse(
|
||||
pet.getPetId(),
|
||||
@@ -88,8 +191,15 @@ public class PetService {
|
||||
pet.getPetAge(),
|
||||
pet.getPetStatus(),
|
||||
pet.getPetPrice(),
|
||||
pet.getImageUrl() != null && !pet.getImageUrl().isBlank() ? "/api/v1/pets/" + pet.getPetId() + "/image" : null,
|
||||
pet.getCreatedAt(),
|
||||
pet.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
public record ImagePayload(Resource resource, MediaType mediaType) {
|
||||
}
|
||||
|
||||
public static class ForbiddenImageAccessException extends RuntimeException {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,20 +8,28 @@ import com.petshop.backend.entity.Product;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.repository.CategoryRepository;
|
||||
import com.petshop.backend.repository.ProductRepository;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
public class ProductService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final CatalogImageStorageService catalogImageStorageService;
|
||||
|
||||
public ProductService(ProductRepository productRepository, CategoryRepository categoryRepository) {
|
||||
public ProductService(ProductRepository productRepository, CategoryRepository categoryRepository, CatalogImageStorageService catalogImageStorageService) {
|
||||
this.productRepository = productRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.catalogImageStorageService = catalogImageStorageService;
|
||||
}
|
||||
|
||||
public Page<ProductResponse> getAllProducts(String query, Pageable pageable) {
|
||||
@@ -74,17 +82,76 @@ public class ProductService {
|
||||
|
||||
@Transactional
|
||||
public void deleteProduct(Long id) {
|
||||
if (!productRepository.existsById(id)) {
|
||||
throw new ResourceNotFoundException("Product not found with id: " + id);
|
||||
}
|
||||
productRepository.deleteById(id);
|
||||
Product product = findProduct(id);
|
||||
deleteStoredImageIfPresent(product.getImageUrl());
|
||||
productRepository.delete(product);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void bulkDeleteProducts(BulkDeleteRequest request) {
|
||||
productRepository.findAllById(request.getIds()).forEach(product -> deleteStoredImageIfPresent(product.getImageUrl()));
|
||||
productRepository.deleteAllById(request.getIds());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ProductResponse uploadProductImage(Long id, MultipartFile file) throws IOException {
|
||||
validateImageFile(file);
|
||||
Product product = findProduct(id);
|
||||
deleteStoredImageIfPresent(product.getImageUrl());
|
||||
product.setImageUrl(catalogImageStorageService.storeProductImage(file));
|
||||
return mapToResponse(productRepository.save(product));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ProductResponse deleteProductImage(Long id) {
|
||||
Product product = findProduct(id);
|
||||
deleteStoredImageIfPresent(product.getImageUrl());
|
||||
product.setImageUrl(null);
|
||||
return mapToResponse(productRepository.save(product));
|
||||
}
|
||||
|
||||
public ImagePayload loadProductImage(Long id) {
|
||||
Product product = findProduct(id);
|
||||
if (product.getImageUrl() == null || product.getImageUrl().isBlank()) {
|
||||
throw new ResourceNotFoundException("Product image not found with id: " + id);
|
||||
}
|
||||
Resource resource = catalogImageStorageService.loadProductImage(product.getImageUrl());
|
||||
MediaType mediaType = catalogImageStorageService.resolveMediaType(resource);
|
||||
return new ImagePayload(resource, mediaType);
|
||||
}
|
||||
|
||||
private Product findProduct(Long id) {
|
||||
return productRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
|
||||
}
|
||||
|
||||
private void validateImageFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("Please select an image to upload");
|
||||
}
|
||||
if (file.getSize() > 5 * 1024 * 1024) {
|
||||
throw new IllegalArgumentException("Image file size must be less than 5MB");
|
||||
}
|
||||
String contentType = file.getContentType();
|
||||
if (contentType == null) {
|
||||
throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed");
|
||||
}
|
||||
String normalized = contentType.toLowerCase(Locale.ROOT);
|
||||
if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/gif")) {
|
||||
throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed");
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteStoredImageIfPresent(String storedImagePath) {
|
||||
if (storedImagePath == null || storedImagePath.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
catalogImageStorageService.deleteProductImage(storedImagePath);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private ProductResponse mapToResponse(Product product) {
|
||||
return new ProductResponse(
|
||||
product.getProdId(),
|
||||
@@ -93,8 +160,12 @@ public class ProductService {
|
||||
product.getCategory().getCategoryName(),
|
||||
product.getProdDesc(),
|
||||
product.getProdPrice(),
|
||||
product.getImageUrl() != null && !product.getImageUrl().isBlank() ? "/api/v1/products/" + product.getProdId() + "/image" : null,
|
||||
product.getCreatedAt(),
|
||||
product.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
public record ImagePayload(Resource resource, MediaType mediaType) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,4 +1,5 @@
|
||||
target/
|
||||
nohup.out
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
|
||||
@@ -76,6 +76,14 @@
|
||||
<release>25</release>
|
||||
</configuration>
|
||||
</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>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-maven-plugin</artifactId>
|
||||
|
||||
@@ -2,6 +2,7 @@ module org.example.petshopdesktop {
|
||||
requires javafx.controls;
|
||||
requires javafx.fxml;
|
||||
requires javafx.web;
|
||||
requires java.desktop;
|
||||
requires java.sql;
|
||||
requires java.net.http;
|
||||
requires com.fasterxml.jackson.databind;
|
||||
|
||||
@@ -15,15 +15,17 @@ public class ProductDTO {
|
||||
private SimpleIntegerProperty categoryId; //used for edit and delete
|
||||
private SimpleStringProperty categoryName;
|
||||
private SimpleStringProperty prodDesc;
|
||||
private SimpleStringProperty imageUrl;
|
||||
|
||||
//constructor
|
||||
public ProductDTO(int prodId, String prodName, double prodPrice, int categoryId, String categoryName, String prodDesc) {
|
||||
public ProductDTO(int prodId, String prodName, double prodPrice, int categoryId, String categoryName, String prodDesc, String imageUrl) {
|
||||
this.prodId = new SimpleIntegerProperty(prodId);
|
||||
this.prodName = new SimpleStringProperty(prodName);
|
||||
this.prodPrice = new SimpleDoubleProperty(prodPrice);
|
||||
this.categoryId = new SimpleIntegerProperty(categoryId);
|
||||
this.categoryName = new SimpleStringProperty(categoryName);
|
||||
this.prodDesc = new SimpleStringProperty(prodDesc);
|
||||
this.imageUrl = new SimpleStringProperty(imageUrl);
|
||||
}
|
||||
|
||||
//getter and setters
|
||||
@@ -99,6 +101,18 @@ public class ProductDTO {
|
||||
this.categoryId.set(categoryId);
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl.get();
|
||||
}
|
||||
|
||||
public SimpleStringProperty imageUrlProperty() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl.set(imageUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts DTO into product for editing and deleting
|
||||
* @return
|
||||
|
||||
@@ -13,6 +13,7 @@ public class Validator {
|
||||
if (value == null || value.isBlank()){
|
||||
msg += name + " is required. \n";
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -24,8 +25,13 @@ public class Validator {
|
||||
*/
|
||||
public static String isNonNegativeDouble(String value, String name){
|
||||
String msg ="";
|
||||
if (value == null) {
|
||||
msg += name + " must be a number.\n";
|
||||
|
||||
return msg;
|
||||
}
|
||||
double result;
|
||||
try{
|
||||
try {
|
||||
result = Double.parseDouble(value);
|
||||
if (result < 0){
|
||||
msg += name + " must be greater than or equal 0. \n";
|
||||
@@ -34,6 +40,34 @@ public class Validator {
|
||||
catch (NumberFormatException e){
|
||||
msg += name + " must be a number.\n";
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the input is a positive double
|
||||
* @param value input of string
|
||||
* @param name name of input
|
||||
* @return error msg if input is not a number or not positive, otherwise empty
|
||||
*/
|
||||
public static String isPositiveDouble(String value, String name){
|
||||
String msg ="";
|
||||
if (value == null) {
|
||||
msg += name + " must be a number.\n";
|
||||
|
||||
return msg;
|
||||
}
|
||||
double result;
|
||||
try {
|
||||
result = Double.parseDouble(value);
|
||||
if (result <= 0){
|
||||
msg += name + " must be greater than 0. \n";
|
||||
}
|
||||
}
|
||||
catch (NumberFormatException e){
|
||||
msg += name + " must be a number.\n";
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -47,8 +81,13 @@ public class Validator {
|
||||
*/
|
||||
public static String isDoubleInRange(String value, String name, double minValue, double maxValue){
|
||||
String msg ="";
|
||||
if (value == null) {
|
||||
msg += name + " must be a number.\n";
|
||||
|
||||
return msg;
|
||||
}
|
||||
double result;
|
||||
try{
|
||||
try {
|
||||
result = Double.parseDouble(value);
|
||||
if (result < minValue || result > maxValue){
|
||||
msg += name + " must be between " + minValue + " and " + maxValue + "\n";
|
||||
@@ -57,6 +96,7 @@ public class Validator {
|
||||
catch (NumberFormatException e){
|
||||
msg += name + " must be a number.\n";
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -69,7 +109,7 @@ public class Validator {
|
||||
public static String isNonNegativeInteger(String value, String name){
|
||||
String msg ="";
|
||||
int result;
|
||||
try{
|
||||
try {
|
||||
result = Integer.parseInt(value);
|
||||
if (result < 0){
|
||||
msg += name + " must be greater than or equal 0. \n";
|
||||
@@ -78,6 +118,29 @@ public class Validator {
|
||||
catch (NumberFormatException e){
|
||||
msg += name + " must be a whole number.\n";
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the input is a positive integer
|
||||
* @param value input of string
|
||||
* @param name name of input
|
||||
* @return error msg if input is not a number or not positive, otherwise empty
|
||||
*/
|
||||
public static String isPositiveInteger(String value, String name){
|
||||
String msg ="";
|
||||
int result;
|
||||
try {
|
||||
result = Integer.parseInt(value);
|
||||
if (result <= 0){
|
||||
msg += name + " must be greater than 0. \n";
|
||||
}
|
||||
}
|
||||
catch (NumberFormatException e){
|
||||
msg += name + " must be a whole number.\n";
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -90,9 +153,10 @@ public class Validator {
|
||||
*/
|
||||
public static String isLessThanVarChars(String value, String name, int length){
|
||||
String msg ="";
|
||||
if (value.length() > length){
|
||||
if (value == null || value.length() > length){
|
||||
msg += name + " must be less than " + length + " characters. \n";
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -104,11 +168,17 @@ public class Validator {
|
||||
*/
|
||||
public static String isValidEmail(String value, String name){
|
||||
String msg = "";
|
||||
if (value == null) {
|
||||
msg += name + " is not in a valid format. \n";
|
||||
|
||||
return msg;
|
||||
}
|
||||
String regex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$";
|
||||
|
||||
if (!value.matches(regex)){
|
||||
msg += name + " is not in a valid format. \n";
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -120,11 +190,17 @@ public class Validator {
|
||||
*/
|
||||
public static String isValidPhoneNumber(String value, String name){
|
||||
String msg = "";
|
||||
if (value == null) {
|
||||
msg += name + " must be in format XXX-XXX-XXXX. \n";
|
||||
|
||||
return msg;
|
||||
}
|
||||
String regex = "^\\d{3}-\\d{3}-\\d{4}$";
|
||||
|
||||
if (!value.matches(regex)){
|
||||
msg += name + " must be in format XXX-XXX-XXXX. \n";
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,31 @@ public class ApiClient {
|
||||
return handleResponse(response, responseClass);
|
||||
}
|
||||
|
||||
public byte[] getBytes(String path) throws Exception {
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + path))
|
||||
.GET()
|
||||
.timeout(Duration.ofSeconds(30));
|
||||
|
||||
addAuthHeader(builder);
|
||||
|
||||
HttpRequest request = builder.build();
|
||||
HttpResponse<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 {
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + path))
|
||||
@@ -199,15 +224,21 @@ public class ApiClient {
|
||||
try {
|
||||
if (response.body() != null && !response.body().isEmpty()) {
|
||||
var errorNode = objectMapper.readTree(response.body());
|
||||
if (errorNode.has("message")) {
|
||||
return errorNode.get("message").asText();
|
||||
}
|
||||
if (errorNode.has("errors")) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
errorNode.get("errors").fields().forEachRemaining(entry -> {
|
||||
sb.append(entry.getValue().asText()).append("\n");
|
||||
String errorText = entry.getValue().asText();
|
||||
if (errorText != null && !errorText.isBlank()) {
|
||||
sb.append(errorText).append("\n");
|
||||
}
|
||||
});
|
||||
return sb.toString().trim();
|
||||
if (sb.length() > 0) {
|
||||
String message = errorNode.has("message") ? errorNode.get("message").asText() : null;
|
||||
return (message != null && !message.isBlank() ? message + "\n" : "") + sb.toString().trim();
|
||||
}
|
||||
}
|
||||
if (errorNode.has("message")) {
|
||||
return errorNode.get("message").asText();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -11,6 +11,7 @@ public class PetResponse {
|
||||
private Integer petAge;
|
||||
private String petStatus;
|
||||
private BigDecimal petPrice;
|
||||
private String imageUrl;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@@ -73,6 +74,14 @@ public class PetResponse {
|
||||
this.petPrice = petPrice;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ public class ProductResponse {
|
||||
private String categoryName;
|
||||
private BigDecimal prodPrice;
|
||||
private String prodDesc;
|
||||
private String imageUrl;
|
||||
|
||||
public ProductResponse() {
|
||||
}
|
||||
@@ -51,4 +52,12 @@ public class ProductResponse {
|
||||
public void setProdDesc(String prodDesc) {
|
||||
this.prodDesc = prodDesc;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ public class AuthApi {
|
||||
return apiClient.postMultipart("/api/v1/auth/me/avatar", "avatar", filePath, AvatarUploadResponse.class);
|
||||
}
|
||||
|
||||
public byte[] getMyAvatarFile() throws Exception {
|
||||
return apiClient.getBytes("/api/v1/auth/me/avatar/file");
|
||||
}
|
||||
|
||||
public void deleteAvatar() throws Exception {
|
||||
apiClient.delete("/api/v1/auth/me/avatar");
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.example.petshopdesktop.api.dto.pet.PetResponse;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
public class PetApi {
|
||||
@@ -47,6 +48,18 @@ public class PetApi {
|
||||
return apiClient.put("/api/v1/pets/" + id, request, PetResponse.class);
|
||||
}
|
||||
|
||||
public PetResponse uploadPetImage(Long id, Path imagePath) throws Exception {
|
||||
return apiClient.postMultipart("/api/v1/pets/" + id + "/image", "image", imagePath, PetResponse.class);
|
||||
}
|
||||
|
||||
public void deletePetImage(Long id) throws Exception {
|
||||
apiClient.delete("/api/v1/pets/" + id + "/image");
|
||||
}
|
||||
|
||||
public byte[] getPetImage(Long id) throws Exception {
|
||||
return apiClient.getBytes("/api/v1/pets/" + id + "/image");
|
||||
}
|
||||
|
||||
public void deletePets(List<Long> ids) throws Exception {
|
||||
apiClient.deleteWithBody("/api/v1/pets", new BulkDeleteRequest(ids));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.example.petshopdesktop.api.dto.product.ProductResponse;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
public class ProductApi {
|
||||
@@ -47,6 +48,18 @@ public class ProductApi {
|
||||
return apiClient.put("/api/v1/products/" + id, request, ProductResponse.class);
|
||||
}
|
||||
|
||||
public ProductResponse uploadProductImage(Long id, Path imagePath) throws Exception {
|
||||
return apiClient.postMultipart("/api/v1/products/" + id + "/image", "image", imagePath, ProductResponse.class);
|
||||
}
|
||||
|
||||
public void deleteProductImage(Long id) throws Exception {
|
||||
apiClient.delete("/api/v1/products/" + id + "/image");
|
||||
}
|
||||
|
||||
public byte[] getProductImage(Long id) throws Exception {
|
||||
return apiClient.getBytes("/api/v1/products/" + id + "/image");
|
||||
}
|
||||
|
||||
public void deleteProducts(List<Long> ids) throws Exception {
|
||||
apiClient.deleteWithBody("/api/v1/products", new BulkDeleteRequest(ids));
|
||||
}
|
||||
|
||||
@@ -16,17 +16,18 @@ import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.ImagePattern;
|
||||
import javafx.scene.shape.Circle;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.api.ApiConfig;
|
||||
import org.example.petshopdesktop.api.ChatRealtimeClient;
|
||||
import org.example.petshopdesktop.api.dto.auth.AvatarUploadResponse;
|
||||
import org.example.petshopdesktop.api.dto.auth.UserInfoResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.AuthApi;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import org.example.petshopdesktop.util.FilePickerSupport;
|
||||
import org.example.petshopdesktop.ui.SvgWebViewFactory;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
|
||||
public class MainLayoutController {
|
||||
|
||||
private static final String NAV_BASE_STYLE = "-fx-background-color: transparent; " +
|
||||
@@ -205,12 +206,7 @@ public class MainLayoutController {
|
||||
|
||||
@FXML
|
||||
void btnChangeAvatarClicked(ActionEvent event) {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle("Choose Profile Picture");
|
||||
chooser.getExtensionFilters().addAll(
|
||||
new FileChooser.ExtensionFilter("Image Files", "*.png", "*.jpg", "*.jpeg", "*.gif")
|
||||
);
|
||||
java.io.File file = chooser.showOpenDialog(btnChangeAvatar.getScene().getWindow());
|
||||
java.io.File file = FilePickerSupport.pickImageFile(btnChangeAvatar.getScene().getWindow());
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
@@ -218,8 +214,7 @@ public class MainLayoutController {
|
||||
try {
|
||||
AvatarUploadResponse response = AuthApi.getInstance().uploadAvatar(file.toPath());
|
||||
UserSession.getInstance().setAvatarUrl(response.getAvatarUrl());
|
||||
renderAvatar(UserSession.getInstance().getEmployeeName(), response.getAvatarUrl());
|
||||
btnRemoveAvatar.setDisable(response.getAvatarUrl() == null || response.getAvatarUrl().isBlank());
|
||||
refreshProfileHeader();
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("MainLayoutController.btnChangeAvatarClicked", e, "Uploading avatar");
|
||||
showAvatarError(e.getMessage() != null ? e.getMessage() : "Could not upload profile picture.");
|
||||
@@ -263,7 +258,7 @@ public class MainLayoutController {
|
||||
@FXML
|
||||
public void initialize() {
|
||||
logoContainer.getChildren().setAll(SvgWebViewFactory.build("/org/example/petshopdesktop/images/leons-pet-store-badge-light.svg", 94));
|
||||
renderAvatar(UserSession.getInstance().getEmployeeName(), UserSession.getInstance().getAvatarUrl());
|
||||
renderAvatar(UserSession.getInstance().getEmployeeName(), null);
|
||||
btnRemoveAvatar.setDisable(UserSession.getInstance().getAvatarUrl() == null || UserSession.getInstance().getAvatarUrl().isBlank());
|
||||
refreshProfileHeader();
|
||||
applyRBAC();
|
||||
@@ -285,20 +280,35 @@ public class MainLayoutController {
|
||||
String displayName = userInfo.getFullName() == null || userInfo.getFullName().isBlank()
|
||||
? UserSession.getInstance().getUsername()
|
||||
: userInfo.getFullName();
|
||||
Image avatarImage = loadAvatarImage(userInfo.getAvatarUrl());
|
||||
Platform.runLater(() -> {
|
||||
UserSession.getInstance().setEmployeeName(displayName);
|
||||
UserSession.getInstance().setAvatarUrl(userInfo.getAvatarUrl());
|
||||
lblUsername.setText(displayName);
|
||||
renderAvatar(displayName, userInfo.getAvatarUrl());
|
||||
renderAvatar(displayName, avatarImage);
|
||||
btnRemoveAvatar.setDisable(userInfo.getAvatarUrl() == null || userInfo.getAvatarUrl().isBlank());
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> renderAvatar(UserSession.getInstance().getEmployeeName(), UserSession.getInstance().getAvatarUrl()));
|
||||
Platform.runLater(() -> renderAvatar(UserSession.getInstance().getEmployeeName(), null));
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void renderAvatar(String displayName, String avatarUrl) {
|
||||
private Image loadAvatarImage(String avatarUrl) {
|
||||
if (avatarUrl == null || avatarUrl.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] imageBytes = AuthApi.getInstance().getMyAvatarFile();
|
||||
Image image = new Image(new ByteArrayInputStream(imageBytes), 52, 52, true, true);
|
||||
return image.isError() ? null : image;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void renderAvatar(String displayName, Image avatarImage) {
|
||||
Circle border = new Circle(29);
|
||||
border.setFill(Color.web("#dbe4ee"));
|
||||
|
||||
@@ -306,21 +316,9 @@ public class MainLayoutController {
|
||||
Label initials = new Label(initials(displayName));
|
||||
initials.setStyle("-fx-text-fill: white; -fx-font-weight: bold; -fx-font-size: 16px;");
|
||||
|
||||
if (avatarUrl != null && !avatarUrl.isBlank()) {
|
||||
try {
|
||||
String resolvedUrl = avatarUrl.startsWith("http") ? avatarUrl : ApiConfig.getInstance().getBaseUrl() + avatarUrl;
|
||||
Image image = new Image(resolvedUrl, 52, 52, true, true, true);
|
||||
if (!image.isError()) {
|
||||
circle.setFill(new ImagePattern(image));
|
||||
initials.setVisible(false);
|
||||
} else {
|
||||
circle.setFill(Color.web("#4ECDC4"));
|
||||
initials.setVisible(true);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
circle.setFill(Color.web("#4ECDC4"));
|
||||
initials.setVisible(true);
|
||||
}
|
||||
if (avatarImage != null) {
|
||||
circle.setFill(new ImagePattern(avatarImage));
|
||||
initials.setVisible(false);
|
||||
} else {
|
||||
circle.setFill(Color.web("#4ECDC4"));
|
||||
initials.setVisible(true);
|
||||
|
||||
@@ -6,9 +6,12 @@ import javafx.collections.ObservableList;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.control.cell.PropertyValueFactory;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.api.dto.pet.PetResponse;
|
||||
@@ -16,6 +19,7 @@ import org.example.petshopdesktop.api.endpoints.PetApi;
|
||||
import org.example.petshopdesktop.controllers.dialogcontrollers.PetDialogController;
|
||||
import org.example.petshopdesktop.models.Pet;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
import org.example.petshopdesktop.util.DesktopImageSupport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
@@ -42,6 +46,9 @@ public class PetController {
|
||||
@FXML
|
||||
private TableColumn<Pet, Integer> colPetId;
|
||||
|
||||
@FXML
|
||||
private TableColumn<Pet, String> colPetImage;
|
||||
|
||||
@FXML
|
||||
private TableColumn<Pet, String> colPetName;
|
||||
|
||||
@@ -134,12 +141,14 @@ public class PetController {
|
||||
tvPets.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE);
|
||||
|
||||
colPetId.setCellValueFactory(new PropertyValueFactory<Pet,Integer>("petId"));
|
||||
colPetImage.setCellValueFactory(new PropertyValueFactory<Pet, String>("imageUrl"));
|
||||
colPetName.setCellValueFactory(new PropertyValueFactory<Pet,String>("petName"));
|
||||
colPetSpecies.setCellValueFactory(new PropertyValueFactory<Pet,String>("petSpecies"));
|
||||
colPetBreed.setCellValueFactory(new PropertyValueFactory<Pet,String>("petBreed"));
|
||||
colPetAge.setCellValueFactory(new PropertyValueFactory<Pet,Integer>("petAge"));
|
||||
colPetStatus.setCellValueFactory(new PropertyValueFactory<Pet,String>("petStatus"));
|
||||
colPetPrice.setCellValueFactory(new PropertyValueFactory<Pet,Double>("petPrice"));
|
||||
configureImageColumn(colPetImage);
|
||||
|
||||
displayPets();
|
||||
|
||||
@@ -262,8 +271,30 @@ public class PetController {
|
||||
response.getPetBreed(),
|
||||
response.getPetAge() != null ? response.getPetAge() : 0,
|
||||
response.getPetStatus(),
|
||||
response.getPetPrice().doubleValue()
|
||||
response.getPetPrice().doubleValue(),
|
||||
response.getImageUrl()
|
||||
);
|
||||
}
|
||||
|
||||
private void configureImageColumn(TableColumn<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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ import javafx.collections.ObservableList;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.control.cell.PropertyValueFactory;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.DTOs.ProductDTO;
|
||||
@@ -16,6 +19,7 @@ import org.example.petshopdesktop.api.dto.product.ProductResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.ProductApi;
|
||||
import org.example.petshopdesktop.controllers.dialogcontrollers.ProductDialogController;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
import org.example.petshopdesktop.util.DesktopImageSupport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
@@ -46,6 +50,9 @@ public class ProductController {
|
||||
@FXML
|
||||
private TableColumn<ProductDTO, Integer> colProductId;
|
||||
|
||||
@FXML
|
||||
private TableColumn<ProductDTO, String> colProductImage;
|
||||
|
||||
@FXML
|
||||
private TableColumn<ProductDTO, String> colProductName;
|
||||
|
||||
@@ -74,10 +81,12 @@ public class ProductController {
|
||||
tvProducts.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE);
|
||||
//set up table columns
|
||||
colProductId.setCellValueFactory(new PropertyValueFactory<ProductDTO,Integer>("prodId"));
|
||||
colProductImage.setCellValueFactory(new PropertyValueFactory<ProductDTO,String>("imageUrl"));
|
||||
colProductName.setCellValueFactory(new PropertyValueFactory<ProductDTO,String>("prodName"));
|
||||
colProductPrice.setCellValueFactory(new PropertyValueFactory<ProductDTO,Double>("prodPrice"));
|
||||
colProductCategory.setCellValueFactory(new PropertyValueFactory<ProductDTO,String>("categoryName"));
|
||||
colProductDesc.setCellValueFactory(new PropertyValueFactory<ProductDTO,String>("prodDesc"));
|
||||
configureImageColumn(colProductImage);
|
||||
|
||||
displayProduct();
|
||||
|
||||
@@ -292,8 +301,30 @@ public class ProductController {
|
||||
response.getProdPrice().doubleValue(),
|
||||
0,
|
||||
response.getCategoryName(),
|
||||
response.getProdDesc()
|
||||
response.getProdDesc(),
|
||||
response.getImageUrl()
|
||||
);
|
||||
}
|
||||
|
||||
private void configureImageColumn(TableColumn<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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import javafx.event.EventHandler;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.Validator;
|
||||
@@ -14,8 +15,12 @@ import org.example.petshopdesktop.api.dto.pet.PetResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.PetApi;
|
||||
import org.example.petshopdesktop.models.Pet;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
import org.example.petshopdesktop.util.DesktopImageSupport;
|
||||
import org.example.petshopdesktop.util.FilePickerSupport;
|
||||
|
||||
import java.io.File;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public class PetDialogController {
|
||||
|
||||
@FXML
|
||||
@@ -24,6 +29,12 @@ public class PetDialogController {
|
||||
@FXML
|
||||
private Button btnSave;
|
||||
|
||||
@FXML
|
||||
private Button btnChangeImage;
|
||||
|
||||
@FXML
|
||||
private Button btnRemoveImage;
|
||||
|
||||
@FXML
|
||||
private ComboBox<String> cbPetStatus;
|
||||
|
||||
@@ -33,6 +44,12 @@ public class PetDialogController {
|
||||
@FXML
|
||||
private Label lblPetId;
|
||||
|
||||
@FXML
|
||||
private Label lblImageStatus;
|
||||
|
||||
@FXML
|
||||
private ImageView imgPetPreview;
|
||||
|
||||
@FXML
|
||||
private TextField txtPetAge;
|
||||
|
||||
@@ -49,6 +66,9 @@ public class PetDialogController {
|
||||
private TextField txtPetSpecies;
|
||||
|
||||
private String mode = null;
|
||||
private File selectedImageFile;
|
||||
private String currentImageUrl;
|
||||
private boolean removeImageRequested;
|
||||
|
||||
private ObservableList<String> statusList = FXCollections.observableArrayList(
|
||||
"Available", "Adopted"
|
||||
@@ -73,6 +93,10 @@ public class PetDialogController {
|
||||
closeStage(mouseEvent);
|
||||
}
|
||||
});
|
||||
|
||||
btnChangeImage.setOnMouseClicked(mouseEvent -> handleChangeImage());
|
||||
btnRemoveImage.setOnMouseClicked(mouseEvent -> handleRemoveImage());
|
||||
refreshImagePreview();
|
||||
}
|
||||
|
||||
private void buttonSaveClicked(MouseEvent mouseEvent) {
|
||||
@@ -97,13 +121,14 @@ public class PetDialogController {
|
||||
|
||||
//Check validation (format)
|
||||
errorMsg += Validator.isNonNegativeDouble(txtPetPrice.getText(), "Price");
|
||||
errorMsg += Validator.isNonNegativeInteger(txtPetAge.getText(), "Age");
|
||||
errorMsg += Validator.isPositiveInteger(txtPetAge.getText(), "Age");
|
||||
|
||||
if(errorMsg.isEmpty()){
|
||||
PetRequest request = buildPetRequest();
|
||||
try {
|
||||
if(mode.equals("Add")) {
|
||||
PetApi.getInstance().createPet(request);
|
||||
PetResponse response = PetApi.getInstance().createPet(request);
|
||||
applyImageChanges(response.getPetId());
|
||||
} else {
|
||||
String[] parts = lblPetId.getText().split(": ");
|
||||
if (parts.length < 2) {
|
||||
@@ -111,6 +136,7 @@ public class PetDialogController {
|
||||
}
|
||||
Long petId = Long.parseLong(parts[1]);
|
||||
PetApi.getInstance().updatePet(petId, request);
|
||||
applyImageChanges(petId);
|
||||
}
|
||||
|
||||
//tell the user operation was successful
|
||||
@@ -175,6 +201,10 @@ public class PetDialogController {
|
||||
txtPetBreed.setText(pet.getPetBreed());
|
||||
txtPetAge.setText(pet.getPetAge() + "");
|
||||
txtPetPrice.setText(pet.getPetPrice() + "");
|
||||
currentImageUrl = pet.getImageUrl();
|
||||
selectedImageFile = null;
|
||||
removeImageRequested = false;
|
||||
refreshImagePreview();
|
||||
|
||||
//get the right combobox selection
|
||||
for (String status : cbPetStatus.getItems()) {
|
||||
@@ -192,10 +222,76 @@ public class PetDialogController {
|
||||
lblMode.setText(mode + " Pet");
|
||||
if(mode.equals("Add")) {
|
||||
lblPetId.setVisible(false);
|
||||
currentImageUrl = null;
|
||||
selectedImageFile = null;
|
||||
removeImageRequested = false;
|
||||
refreshImagePreview();
|
||||
}
|
||||
else if(mode.equals("Edit")) {
|
||||
lblPetId.setVisible(true);
|
||||
refreshImagePreview();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleChangeImage() {
|
||||
File file = FilePickerSupport.pickImageFile(btnSave.getScene().getWindow());
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
selectedImageFile = file;
|
||||
removeImageRequested = false;
|
||||
lblImageStatus.setText("Selected: " + file.getName());
|
||||
DesktopImageSupport.loadImageInto(imgPetPreview, file.toURI().toString(), 120, 120);
|
||||
btnRemoveImage.setDisable(false);
|
||||
}
|
||||
|
||||
private void handleRemoveImage() {
|
||||
selectedImageFile = null;
|
||||
removeImageRequested = true;
|
||||
currentImageUrl = null;
|
||||
refreshImagePreview();
|
||||
}
|
||||
|
||||
private void applyImageChanges(Long petId) throws Exception {
|
||||
String previousImageUrl = currentImageUrl;
|
||||
if (removeImageRequested) {
|
||||
try {
|
||||
PetApi.getInstance().deletePetImage(petId);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
if (selectedImageFile != null) {
|
||||
PetApi.getInstance().uploadPetImage(petId, selectedImageFile.toPath());
|
||||
currentImageUrl = "/api/v1/pets/" + petId + "/image";
|
||||
} else if (removeImageRequested) {
|
||||
currentImageUrl = null;
|
||||
}
|
||||
DesktopImageSupport.evict(previousImageUrl);
|
||||
DesktopImageSupport.evict(currentImageUrl);
|
||||
selectedImageFile = null;
|
||||
removeImageRequested = false;
|
||||
refreshImagePreview();
|
||||
}
|
||||
|
||||
private void refreshImagePreview() {
|
||||
if (imgPetPreview == null || lblImageStatus == null || btnRemoveImage == null) {
|
||||
return;
|
||||
}
|
||||
imgPetPreview.setImage(null);
|
||||
if (selectedImageFile != null) {
|
||||
lblImageStatus.setText("Selected: " + selectedImageFile.getName());
|
||||
DesktopImageSupport.loadImageInto(imgPetPreview, selectedImageFile.toURI().toString(), 120, 120);
|
||||
btnRemoveImage.setDisable(false);
|
||||
return;
|
||||
}
|
||||
if (currentImageUrl != null && !currentImageUrl.isBlank()) {
|
||||
lblImageStatus.setText("Current image loaded");
|
||||
DesktopImageSupport.loadImageInto(imgPetPreview, currentImageUrl, 120, 120);
|
||||
btnRemoveImage.setDisable(false);
|
||||
return;
|
||||
}
|
||||
lblImageStatus.setText("No image selected");
|
||||
btnRemoveImage.setDisable(true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,16 +6,21 @@ import javafx.event.EventHandler;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.DTOs.ProductDTO;
|
||||
import org.example.petshopdesktop.Validator;
|
||||
import org.example.petshopdesktop.api.dto.common.DropdownOption;
|
||||
import org.example.petshopdesktop.api.dto.product.ProductRequest;
|
||||
import org.example.petshopdesktop.api.dto.product.ProductResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.DropdownApi;
|
||||
import org.example.petshopdesktop.api.endpoints.ProductApi;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
import org.example.petshopdesktop.util.DesktopImageSupport;
|
||||
import org.example.petshopdesktop.util.FilePickerSupport;
|
||||
|
||||
import java.io.File;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
@@ -27,6 +32,12 @@ public class ProductDialogController {
|
||||
@FXML
|
||||
private Button btnSave;
|
||||
|
||||
@FXML
|
||||
private Button btnChangeImage;
|
||||
|
||||
@FXML
|
||||
private Button btnRemoveImage;
|
||||
|
||||
@FXML
|
||||
private ComboBox<DropdownOption> cbProdCategory;
|
||||
|
||||
@@ -36,6 +47,12 @@ public class ProductDialogController {
|
||||
@FXML
|
||||
private Label lblProdId;
|
||||
|
||||
@FXML
|
||||
private Label lblImageStatus;
|
||||
|
||||
@FXML
|
||||
private ImageView imgProductPreview;
|
||||
|
||||
@FXML
|
||||
private TextField txtProdDesc;
|
||||
|
||||
@@ -46,6 +63,9 @@ public class ProductDialogController {
|
||||
private TextField txtProdPrice;
|
||||
|
||||
private String mode = null;
|
||||
private File selectedImageFile;
|
||||
private String currentImageUrl;
|
||||
private boolean removeImageRequested;
|
||||
|
||||
/**
|
||||
* Add event listeners to buttons when dialog loads
|
||||
@@ -82,6 +102,10 @@ public class ProductDialogController {
|
||||
System.out.println("Error loading categories: " + e.getMessage());
|
||||
}
|
||||
|
||||
btnChangeImage.setOnMouseClicked(mouseEvent -> handleChangeImage());
|
||||
btnRemoveImage.setOnMouseClicked(mouseEvent -> handleRemoveImage());
|
||||
refreshImagePreview();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,7 +130,7 @@ public class ProductDialogController {
|
||||
errorMsg += Validator.isLessThanVarChars(txtProdPrice.getText(), "Product Price", 12);
|
||||
|
||||
//Check Validation (format)
|
||||
errorMsg += Validator.isNonNegativeDouble(txtProdPrice.getText(), "Product Price");
|
||||
errorMsg += Validator.isPositiveDouble(txtProdPrice.getText(), "Product Price");
|
||||
|
||||
if (errorMsg.isEmpty()) {
|
||||
try {
|
||||
@@ -123,7 +147,8 @@ public class ProductDialogController {
|
||||
request.setProdDesc(txtProdDesc.getText());
|
||||
|
||||
if (mode.equals("Add")) {
|
||||
ProductApi.getInstance().createProduct(request);
|
||||
ProductResponse response = ProductApi.getInstance().createProduct(request);
|
||||
applyImageChanges(response.getProdId());
|
||||
} else {
|
||||
String[] parts = lblProdId.getText().split(": ");
|
||||
if (parts.length < 2) {
|
||||
@@ -131,6 +156,7 @@ public class ProductDialogController {
|
||||
}
|
||||
Long productId = Long.parseLong(parts[1]);
|
||||
ProductApi.getInstance().updateProduct(productId, request);
|
||||
applyImageChanges(productId);
|
||||
}
|
||||
|
||||
Alert alert = new Alert(Alert.AlertType.INFORMATION);
|
||||
@@ -167,6 +193,10 @@ public class ProductDialogController {
|
||||
txtProdName.setText(product.getProdName());
|
||||
txtProdDesc.setText(product.getProdDesc());
|
||||
txtProdPrice.setText(product.getProdPrice() + "");
|
||||
currentImageUrl = product.getImageUrl();
|
||||
selectedImageFile = null;
|
||||
removeImageRequested = false;
|
||||
refreshImagePreview();
|
||||
|
||||
for (DropdownOption category : cbProdCategory.getItems()) {
|
||||
if(category.getLabel().equals(product.getCategoryName())){
|
||||
@@ -197,10 +227,76 @@ public class ProductDialogController {
|
||||
lblMode.setText(mode + " Product");
|
||||
if(mode.equals("Add")) {
|
||||
lblProdId.setVisible(false);
|
||||
currentImageUrl = null;
|
||||
selectedImageFile = null;
|
||||
removeImageRequested = false;
|
||||
refreshImagePreview();
|
||||
}
|
||||
else if(mode.equals("Edit")) {
|
||||
lblProdId.setVisible(true);
|
||||
refreshImagePreview();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleChangeImage() {
|
||||
File file = FilePickerSupport.pickImageFile(btnSave.getScene().getWindow());
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
selectedImageFile = file;
|
||||
removeImageRequested = false;
|
||||
lblImageStatus.setText("Selected: " + file.getName());
|
||||
DesktopImageSupport.loadImageInto(imgProductPreview, file.toURI().toString(), 120, 120);
|
||||
btnRemoveImage.setDisable(false);
|
||||
}
|
||||
|
||||
private void handleRemoveImage() {
|
||||
selectedImageFile = null;
|
||||
removeImageRequested = true;
|
||||
currentImageUrl = null;
|
||||
refreshImagePreview();
|
||||
}
|
||||
|
||||
private void applyImageChanges(Long productId) throws Exception {
|
||||
String previousImageUrl = currentImageUrl;
|
||||
if (removeImageRequested) {
|
||||
try {
|
||||
ProductApi.getInstance().deleteProductImage(productId);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
if (selectedImageFile != null) {
|
||||
ProductApi.getInstance().uploadProductImage(productId, selectedImageFile.toPath());
|
||||
currentImageUrl = "/api/v1/products/" + productId + "/image";
|
||||
} else if (removeImageRequested) {
|
||||
currentImageUrl = null;
|
||||
}
|
||||
DesktopImageSupport.evict(previousImageUrl);
|
||||
DesktopImageSupport.evict(currentImageUrl);
|
||||
selectedImageFile = null;
|
||||
removeImageRequested = false;
|
||||
refreshImagePreview();
|
||||
}
|
||||
|
||||
private void refreshImagePreview() {
|
||||
if (imgProductPreview == null || lblImageStatus == null || btnRemoveImage == null) {
|
||||
return;
|
||||
}
|
||||
imgProductPreview.setImage(null);
|
||||
if (selectedImageFile != null) {
|
||||
lblImageStatus.setText("Selected: " + selectedImageFile.getName());
|
||||
DesktopImageSupport.loadImageInto(imgProductPreview, selectedImageFile.toURI().toString(), 120, 120);
|
||||
btnRemoveImage.setDisable(false);
|
||||
return;
|
||||
}
|
||||
if (currentImageUrl != null && !currentImageUrl.isBlank()) {
|
||||
lblImageStatus.setText("Current image loaded");
|
||||
DesktopImageSupport.loadImageInto(imgProductPreview, currentImageUrl, 120, 120);
|
||||
btnRemoveImage.setDisable(false);
|
||||
return;
|
||||
}
|
||||
lblImageStatus.setText("No image selected");
|
||||
btnRemoveImage.setDisable(true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ public class Pet {
|
||||
private SimpleIntegerProperty petAge;
|
||||
private SimpleStringProperty petStatus;
|
||||
private SimpleDoubleProperty petPrice;
|
||||
private SimpleStringProperty imageUrl;
|
||||
|
||||
public Pet(int petId, String petName, String petSpecies, String petBreed, int petAge, String petStatus, double petPrice) {
|
||||
public Pet(int petId, String petName, String petSpecies, String petBreed, int petAge, String petStatus, double petPrice, String imageUrl) {
|
||||
this.petId = new SimpleIntegerProperty(petId);
|
||||
this.petName = new SimpleStringProperty(petName);
|
||||
this.petSpecies = new SimpleStringProperty(petSpecies);
|
||||
@@ -21,6 +22,7 @@ public class Pet {
|
||||
this.petAge = new SimpleIntegerProperty(petAge);
|
||||
this.petStatus = new SimpleStringProperty(petStatus);
|
||||
this.petPrice = new SimpleDoubleProperty(petPrice);
|
||||
this.imageUrl = new SimpleStringProperty(imageUrl);
|
||||
}
|
||||
|
||||
public int getPetId() {
|
||||
@@ -106,4 +108,16 @@ public class Pet {
|
||||
public SimpleDoubleProperty petPriceProperty() {
|
||||
return petPrice;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl.get();
|
||||
}
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl.set(imageUrl);
|
||||
}
|
||||
|
||||
public SimpleStringProperty imageUrlProperty() {
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,15 @@
|
||||
<?import javafx.scene.control.ComboBox?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<?import javafx.scene.image.ImageView?>
|
||||
<?import javafx.scene.layout.ColumnConstraints?>
|
||||
<?import javafx.scene.layout.GridPane?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<?import javafx.scene.layout.Region?>
|
||||
<?import javafx.scene.layout.RowConstraints?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?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>
|
||||
<HBox alignment="CENTER_LEFT" prefHeight="79.0" prefWidth="727.0" spacing="20.0" style="-fx-background-color: #2C3E50; -fx-background-radius: 14;">
|
||||
<children>
|
||||
@@ -62,18 +62,13 @@
|
||||
<Insets left="15.0" right="15.0" />
|
||||
</padding>
|
||||
</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>
|
||||
<GridPane hgap="25.0" VBox.vgrow="ALWAYS">
|
||||
<GridPane hgap="25.0" vgap="10.0">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
|
||||
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
|
||||
</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>
|
||||
<VBox prefHeight="200.0" prefWidth="100.0" spacing="8.0">
|
||||
<children>
|
||||
@@ -163,6 +158,22 @@
|
||||
<Insets bottom="15.0" left="15.0" right="15.0" top="15.0" />
|
||||
</VBox.margin>
|
||||
</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>
|
||||
<padding>
|
||||
<Insets bottom="15.0" left="15.0" right="15.0" top="15.0" />
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
<?import javafx.scene.control.ComboBox?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<?import javafx.scene.image.ImageView?>
|
||||
<?import javafx.scene.layout.ColumnConstraints?>
|
||||
<?import javafx.scene.layout.GridPane?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<?import javafx.scene.layout.Region?>
|
||||
<?import javafx.scene.layout.RowConstraints?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?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>
|
||||
<HBox alignment="CENTER_LEFT" prefHeight="79.0" prefWidth="727.0" spacing="20.0" style="-fx-background-color: #2C3E50; -fx-background-radius: 14;">
|
||||
<children>
|
||||
@@ -62,19 +62,14 @@
|
||||
<Insets left="15.0" right="15.0" />
|
||||
</padding>
|
||||
</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>
|
||||
<GridPane hgap="25.0" VBox.vgrow="ALWAYS">
|
||||
<GridPane hgap="25.0" vgap="10.0">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
|
||||
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
|
||||
</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">
|
||||
<children>
|
||||
<Label text="Product Name:" textFill="#2c3e50">
|
||||
@@ -136,8 +131,24 @@
|
||||
<VBox.margin>
|
||||
<Insets bottom="15.0" left="15.0" right="15.0" top="15.0" />
|
||||
</VBox.margin>
|
||||
</GridPane>
|
||||
</children>
|
||||
</GridPane>
|
||||
<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>
|
||||
<Insets bottom="15.0" left="15.0" right="15.0" top="15.0" />
|
||||
</padding>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<?import javafx.scene.control.TableColumn?>
|
||||
<?import javafx.scene.control.TableView?>
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<?import javafx.scene.image.ImageView?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<?import javafx.scene.layout.Region?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
@@ -67,14 +68,15 @@
|
||||
</HBox>
|
||||
<TableView fx:id="tvPets" prefHeight="362.0" prefWidth="752.0" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS">
|
||||
<columns>
|
||||
<TableColumn fx:id="colPetId" prefWidth="60.0" text="ID" />
|
||||
<TableColumn fx:id="colPetName" prefWidth="113.14285278320312" text="Name" />
|
||||
<TableColumn fx:id="colPetSpecies" prefWidth="110.28570556640625" text="Species" />
|
||||
<TableColumn fx:id="colPetBreed" prefWidth="174.85711669921875" text="Breed" />
|
||||
<TableColumn fx:id="colPetAge" prefWidth="72.0" text="Age" />
|
||||
<TableColumn fx:id="colPetStatus" prefWidth="133.142822265625" text="Status" />
|
||||
<TableColumn fx:id="colPetPrice" prefWidth="89.142822265625" text="Price" />
|
||||
</columns>
|
||||
<TableColumn fx:id="colPetId" prefWidth="55.0" text="ID" />
|
||||
<TableColumn fx:id="colPetImage" prefWidth="80.0" text="Image" />
|
||||
<TableColumn fx:id="colPetName" prefWidth="110.0" text="Name" />
|
||||
<TableColumn fx:id="colPetSpecies" prefWidth="105.0" text="Species" />
|
||||
<TableColumn fx:id="colPetBreed" prefWidth="145.0" text="Breed" />
|
||||
<TableColumn fx:id="colPetAge" prefWidth="60.0" text="Age" />
|
||||
<TableColumn fx:id="colPetStatus" prefWidth="110.0" text="Status" />
|
||||
<TableColumn fx:id="colPetPrice" prefWidth="80.0" text="Price" />
|
||||
</columns>
|
||||
</TableView>
|
||||
</children>
|
||||
</VBox>
|
||||
|
||||
@@ -67,12 +67,13 @@
|
||||
</HBox>
|
||||
<TableView fx:id="tvProducts" prefHeight="362.0" prefWidth="752.0" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS">
|
||||
<columns>
|
||||
<TableColumn fx:id="colProductId" prefWidth="60.0" text="ID" />
|
||||
<TableColumn fx:id="colProductName" prefWidth="170.85714721679688" text="Name" />
|
||||
<TableColumn fx:id="colProductCategory" prefWidth="195.4285888671875" text="Category" />
|
||||
<TableColumn fx:id="colProductDesc" prefWidth="210.28570556640625" text="Description" />
|
||||
<TableColumn fx:id="colProductPrice" prefWidth="115.4285888671875" text="Price" />
|
||||
</columns>
|
||||
<TableColumn fx:id="colProductId" prefWidth="55.0" text="ID" />
|
||||
<TableColumn fx:id="colProductImage" prefWidth="80.0" text="Image" />
|
||||
<TableColumn fx:id="colProductName" prefWidth="150.0" text="Name" />
|
||||
<TableColumn fx:id="colProductCategory" prefWidth="160.0" text="Category" />
|
||||
<TableColumn fx:id="colProductDesc" prefWidth="195.0" text="Description" />
|
||||
<TableColumn fx:id="colProductPrice" prefWidth="110.0" text="Price" />
|
||||
</columns>
|
||||
</TableView>
|
||||
</children>
|
||||
</VBox>
|
||||
|
||||
@@ -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
@@ -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
|
||||
36
web/README.md
Normal 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
|
After Width: | Height: | Size: 25 KiB |
290
web/app/globals.css
Normal 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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
26
web/components/Navigation.js
Normal 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
@@ -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
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
7
web/next.config.mjs
Normal 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
23
web/package.json
Normal 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
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||