From 021643522173f85284b75c12f9fb7d352608f112 Mon Sep 17 00:00:00 2001 From: Alex <78383757+Lextical@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:23:49 -0600 Subject: [PATCH 1/8] made it so we can put attachments to chat - Sending not implemented until backend is complete --- .../petstoremobile/dtos/MessageDTO.java | 18 +++ .../fragments/ChatFragment.java | 121 +++++++++++++++++- .../petstoremobile/models/Message.java | 12 ++ .../app/src/main/res/layout/fragment_chat.xml | 47 +++++++ .../main/res/layout/item_message_received.xml | 36 +++++- .../src/main/res/layout/item_message_sent.xml | 36 +++++- 6 files changed, 257 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/MessageDTO.java b/android/app/src/main/java/com/example/petstoremobile/dtos/MessageDTO.java index 1080b600..fea4cf66 100644 --- a/android/app/src/main/java/com/example/petstoremobile/dtos/MessageDTO.java +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/MessageDTO.java @@ -22,6 +22,15 @@ public class MessageDTO { @SerializedName("isRead") private Boolean isRead; + @SerializedName("attachmentUrl") + private String attachmentUrl; + + @SerializedName("attachmentName") + private String attachmentName; + + @SerializedName("attachmentType") + private String attachmentType; + public MessageDTO() {} public Long getId() { return id; } @@ -41,4 +50,13 @@ public class MessageDTO { public Boolean getIsRead() { return isRead; } public void setIsRead(Boolean isRead) { this.isRead = isRead; } + + public String getAttachmentUrl() { return attachmentUrl; } + public void setAttachmentUrl(String attachmentUrl) { this.attachmentUrl = attachmentUrl; } + + public String getAttachmentName() { return attachmentName; } + public void setAttachmentName(String attachmentName) { this.attachmentName = attachmentName; } + + public String getAttachmentType() { return attachmentType; } + public void setAttachmentType(String attachmentType) { this.attachmentType = attachmentType; } } \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/ChatFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/ChatFragment.java index 0e405c63..f52d5ea6 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/ChatFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/ChatFragment.java @@ -1,15 +1,23 @@ package com.example.petstoremobile.fragments; +import android.app.Activity; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; import android.os.Bundle; +import android.provider.OpenableColumns; import android.util.Log; import android.view.*; import android.widget.*; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.core.view.GravityCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import com.bumptech.glide.Glide; import com.example.petstoremobile.R; import com.example.petstoremobile.adapters.ChatAdapter; import com.example.petstoremobile.adapters.MessageAdapter; @@ -41,8 +49,15 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis private RecyclerView rvChatList, rvMessages; private EditText etMessage; private Button btnSend; + private ImageButton btnAttach; private TextView tvChatTitle; + // Preview views + private View layoutAttachmentPreview; + private ImageView ivPreview; + private TextView tvPreviewName; + private ImageButton btnRemoveAttachment; + // Adapters private ChatAdapter chatAdapter; private MessageAdapter messageAdapter; @@ -51,6 +66,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis private final List chatList = new ArrayList<>(); private final List messageList = new ArrayList<>(); private final Map customerNames = new HashMap<>(); + private Uri pendingAttachmentUri; // APIs private ChatApi chatApi; @@ -61,6 +77,24 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis private Long currentUserId; private Long activeConversationId; private StompChatManager stompChatManager; + private ActivityResultLauncher attachmentLauncher; + + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + attachmentLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + Uri uri = result.getData().getData(); + if (uri != null) { + showAttachmentPreview(uri); + } + } + } + ); + } @Override public View onCreateView(@NonNull LayoutInflater inflater, @@ -77,11 +111,28 @@ 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); + btnAttach = view.findViewById(R.id.btnAttach); tvChatTitle = view.findViewById(R.id.tvChatTitle); + layoutAttachmentPreview = view.findViewById(R.id.layoutAttachmentPreview); + ivPreview = view.findViewById(R.id.ivPreview); + tvPreviewName = view.findViewById(R.id.tvPreviewName); + btnRemoveAttachment = view.findViewById(R.id.btnRemoveAttachment); + ImageButton hamburger = view.findViewById(R.id.btnHamburger); hamburger.setOnClickListener(v -> drawerLayout.openDrawer(GravityCompat.START)); - btnSend.setOnClickListener(v -> sendMessage()); + //When the send button is clicked check if there is an attachment and send using the correct helper function + btnSend.setOnClickListener(v -> { + if (pendingAttachmentUri != null) { + sendWithAttachment(pendingAttachmentUri); + } else { + sendMessage(); + } + }); + + //When the attachment button is clicked open the file picker + btnAttach.setOnClickListener(v -> selectAttachment()); + btnRemoveAttachment.setOnClickListener(v -> removeAttachment()); setupRecyclerViews(); loadInitialData(); @@ -89,6 +140,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis return view; } + // Helper function to setup recycler views for chat and messages private void setupRecyclerViews() { // Set up Drawer menu to select conversation chatAdapter = new ChatAdapter(chatList, this); @@ -273,6 +325,68 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis }); } + //Helper function to open file picker when the attachment button is clicked + private void selectAttachment() { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("*/*"); + attachmentLauncher.launch(intent); + } + + //Helper function to show the attachment preview + private void showAttachmentPreview(Uri uri) { + pendingAttachmentUri = uri; + layoutAttachmentPreview.setVisibility(View.VISIBLE); + + String mimeType = requireContext().getContentResolver().getType(uri); + String fileName = getFileName(uri); + tvPreviewName.setText(fileName); + + // If the file is an image, display a thumbnail of the image as well + if (mimeType != null && mimeType.startsWith("image/")) { + ivPreview.setVisibility(View.VISIBLE); + Glide.with(this).load(uri).into(ivPreview); + } else { + ivPreview.setVisibility(View.GONE); + } + } + + //Helper function to remove the attachment + private void removeAttachment() { + pendingAttachmentUri = null; + layoutAttachmentPreview.setVisibility(View.GONE); + } + + //Helper function to get the file name from the uri to display in attachment preview + private String getFileName(Uri uri) { + String result = null; + if (uri.getScheme().equals("content")) { + try (Cursor cursor = requireContext().getContentResolver().query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (index != -1) { + result = cursor.getString(index); + } + } + } + } + if (result == null) { + result = uri.getPath(); + int cut = result.lastIndexOf('/'); + if (cut != -1) { + result = result.substring(cut + 1); + } + } + return result; + } + + //Helper function to send the message with attachment + private void sendWithAttachment(Uri uri) { + if (activeConversationId == null) return; + + //TODO: send the message with attachment when backend is done + Log.d(TAG, "Send with attachment happening"); + } + // When a message is received updates the chat preview @Override public void onMessageReceived(MessageDTO dto) { @@ -370,6 +484,9 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis m.setContent(dto.getContent()); m.setTimestamp(dto.getTimestamp()); m.setIsRead(dto.getIsRead()); + m.setAttachmentUrl(dto.getAttachmentUrl()); + m.setAttachmentName(dto.getAttachmentName()); + m.setAttachmentType(dto.getAttachmentType()); return m; } @@ -407,9 +524,11 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis private void setConversationActive(boolean active) { btnSend.setEnabled(active); etMessage.setEnabled(active); + btnAttach.setEnabled(active); if (!active) { activeConversationId = null; ChatNotificationService.activeConversationIdInUi = null; + removeAttachment(); if (tvChatTitle != null) tvChatTitle.setText("Customer Chat"); if (stompChatManager != null) { stompChatManager.clearConversationSubscription(); diff --git a/android/app/src/main/java/com/example/petstoremobile/models/Message.java b/android/app/src/main/java/com/example/petstoremobile/models/Message.java index 18ec549a..bf76b4c4 100644 --- a/android/app/src/main/java/com/example/petstoremobile/models/Message.java +++ b/android/app/src/main/java/com/example/petstoremobile/models/Message.java @@ -7,6 +7,9 @@ public class Message { private String content; private String timestamp; private Boolean isRead; + private String attachmentUrl; + private String attachmentName; + private String attachmentType; public Message() {} @@ -33,4 +36,13 @@ public class Message { public Boolean getIsRead() { return isRead; } public void setIsRead(Boolean isRead) { this.isRead = isRead; } + + public String getAttachmentUrl() { return attachmentUrl; } + public void setAttachmentUrl(String attachmentUrl) { this.attachmentUrl = attachmentUrl; } + + public String getAttachmentName() { return attachmentName; } + public void setAttachmentName(String attachmentName) { this.attachmentName = attachmentName; } + + public String getAttachmentType() { return attachmentType; } + public void setAttachmentType(String attachmentType) { this.attachmentType = attachmentType; } } \ No newline at end of file diff --git a/android/app/src/main/res/layout/fragment_chat.xml b/android/app/src/main/res/layout/fragment_chat.xml index 875bccfd..786f53bc 100644 --- a/android/app/src/main/res/layout/fragment_chat.xml +++ b/android/app/src/main/res/layout/fragment_chat.xml @@ -49,12 +49,59 @@ android:clipToPadding="false" /> + + + + + + + + + + + + - + android:padding="8dp" + android:maxWidth="300dp"> + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/item_message_sent.xml b/android/app/src/main/res/layout/item_message_sent.xml index ab99c033..19b221b0 100644 --- a/android/app/src/main/res/layout/item_message_sent.xml +++ b/android/app/src/main/res/layout/item_message_sent.xml @@ -6,14 +6,38 @@ android:padding="8dp" android:gravity="end"> - + android:padding="8dp" + android:maxWidth="300dp"> + + + + + + + \ No newline at end of file -- 2.49.1 From 8401d9ef620f146c0bbb31d7ba626bae3a494ada Mon Sep 17 00:00:00 2001 From: Alex <78383757+Lextical@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:21:55 -0600 Subject: [PATCH 2/8] Added images to products for android - also added the option to delete the images to profile and pets --- .../adapters/ProductAdapter.java | 20 ++ .../example/petstoremobile/api/PetApi.java | 4 + .../petstoremobile/api/ProductApi.java | 9 + .../petstoremobile/api/auth/AuthApi.java | 5 + .../fragments/ProfileFragment.java | 55 ++++- .../listfragments/ProductFragment.java | 26 +- .../ProductDetailFragment.java | 229 +++++++++++++++++- .../PetProfileFragment.java | 54 ++++- .../src/main/res/drawable/placeholder2.png | Bin 0 -> 8806 bytes .../res/layout/fragment_product_detail.xml | 26 ++ .../app/src/main/res/layout/item_product.xml | 110 +++++---- 11 files changed, 467 insertions(+), 71 deletions(-) create mode 100644 android/app/src/main/res/drawable/placeholder2.png diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/ProductAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/ProductAdapter.java index 300b7e57..a44ec993 100644 --- a/android/app/src/main/java/com/example/petstoremobile/adapters/ProductAdapter.java +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/ProductAdapter.java @@ -1,10 +1,16 @@ package com.example.petstoremobile.adapters; import android.view.*; +import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.example.petstoremobile.R; +import com.example.petstoremobile.api.ProductApi; +import com.example.petstoremobile.api.RetrofitClient; import com.example.petstoremobile.dtos.ProductDTO; import java.util.List; @@ -24,6 +30,7 @@ public class ProductAdapter extends RecyclerView.Adapter listener.onProductClick(position)); } diff --git a/android/app/src/main/java/com/example/petstoremobile/api/PetApi.java b/android/app/src/main/java/com/example/petstoremobile/api/PetApi.java index e2eb3090..ff7b79a7 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/PetApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/PetApi.java @@ -48,4 +48,8 @@ public interface PetApi { @POST("api/v1/pets/{id}/image") Call uploadPetImage(@Path("id") Long id, @Part MultipartBody.Part image); + // Delete pet image + @DELETE("api/v1/pets/{id}/image") + Call deletePetImage(@Path("id") Long id); + } diff --git a/android/app/src/main/java/com/example/petstoremobile/api/ProductApi.java b/android/app/src/main/java/com/example/petstoremobile/api/ProductApi.java index 422a39e5..dc02fd6c 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/ProductApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/ProductApi.java @@ -2,10 +2,12 @@ package com.example.petstoremobile.api; import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.dtos.ProductDTO; +import okhttp3.MultipartBody; import retrofit2.Call; import retrofit2.http.*; public interface ProductApi { + String PRODUCT_IMAGE_PATH = "api/v1/products/%d/image"; @GET("api/v1/products") Call> getAllProducts( @@ -24,4 +26,11 @@ public interface ProductApi { @DELETE("api/v1/products/{id}") Call deleteProduct(@Path("id") Long id); + + @Multipart + @POST("api/v1/products/{id}/image") + Call uploadProductImage(@Path("id") Long id, @Part MultipartBody.Part image); + + @DELETE("api/v1/products/{id}/image") + Call deleteProductImage(@Path("id") Long id); } \ No newline at end of file diff --git a/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java b/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java index 75605083..88c5312c 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java @@ -8,6 +8,7 @@ import java.util.Map; import okhttp3.MultipartBody; import retrofit2.Call; import retrofit2.http.Body; +import retrofit2.http.DELETE; import retrofit2.http.GET; import retrofit2.http.Multipart; import retrofit2.http.POST; @@ -37,4 +38,8 @@ public interface AuthApi { @POST("api/v1/auth/me/avatar") Call uploadAvatar(@Part MultipartBody.Part avatar); + //delete avatar endpoint + @DELETE("api/v1/auth/me/avatar") + Call deleteAvatar(); + } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java index c7253c70..b29c038d 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java @@ -44,7 +44,9 @@ import com.google.gson.Gson; import java.io.File; import java.io.FileOutputStream; import java.io.InputStream; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import okhttp3.MediaType; @@ -61,6 +63,7 @@ public class ProfileFragment extends Fragment { private TextView tvProfileName, tvProfileEmail, tvProfilePhone, tvProfileRole; private Uri photoUri; private UserDTO currentUser; + private boolean hasImage = false; //Initialize the launchers for camera and gallery private ActivityResultLauncher galleryLauncher; @@ -149,12 +152,20 @@ public class ProfileFragment extends Fragment { //Set up listeners for the buttons //Change photo button btnChangePhoto.setOnClickListener(v -> { + List options = new ArrayList<>(); + options.add("Take Photo"); + options.add("Choose from Gallery"); + if (hasImage) { + options.add("Remove Photo"); + } + //Show alert dialog to user to select from gallery or camera new AlertDialog.Builder(requireContext()) .setTitle("Change Profile Photo") //set the options for the alert dialog - .setItems(new String[]{"Take Photo", "Choose from Gallery"}, (dialog, which) -> { - if (which == 0) { + .setItems(options.toArray(new String[0]), (dialog, which) -> { + String selected = options.get(which); + if (selected.equals("Take Photo")) { // Choose Camera //Checks if the user has granted the camera permission already if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { @@ -164,11 +175,13 @@ public class ProfileFragment extends Fragment { //otherwise request the permission permissionLauncher.launch(Manifest.permission.CAMERA); } - } else { + } else if (selected.equals("Choose from Gallery")) { // Choose Gallery Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); galleryLauncher.launch(intent); + } else if (selected.equals("Remove Photo")) { + deleteAvatar(); } }) .show(); @@ -294,9 +307,23 @@ public class ProfileFragment extends Fragment { .skipMemoryCache(true) .placeholder(R.drawable.placeholder) .error(R.drawable.placeholder) + .listener(new com.bumptech.glide.request.RequestListener() { + @Override + public boolean onLoadFailed(@androidx.annotation.Nullable com.bumptech.glide.load.engine.GlideException e, Object model, com.bumptech.glide.request.target.Target target, boolean isFirstResource) { + hasImage = false; + return false; + } + + @Override + public boolean onResourceReady(android.graphics.drawable.Drawable resource, Object model, com.bumptech.glide.request.target.Target target, com.bumptech.glide.load.DataSource dataSource, boolean isFirstResource) { + hasImage = true; + return false; + } + }) .into(imgProfile); } else { // load placeholder image if token is null + hasImage = false; Glide.with(ProfileFragment.this) .load(R.drawable.placeholder) .into(imgProfile); @@ -352,6 +379,28 @@ public class ProfileFragment extends Fragment { } } + private void deleteAvatar() { + AuthApi authApi = RetrofitClient.getAuthApi(requireContext()); + authApi.deleteAvatar().enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + Toast.makeText(requireContext(), "Avatar removed successfully", Toast.LENGTH_SHORT).show(); + hasImage = false; + imgProfile.setImageResource(R.drawable.placeholder); + } else { + Toast.makeText(requireContext(), "Failed to remove avatar", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + Log.e("DELETE_AVATAR", "Failure: " + t.getMessage()); + Toast.makeText(requireContext(), "Network error", Toast.LENGTH_SHORT).show(); + } + }); + } + // Helper function to create a temporary File object from a Uri for uploading the avatar private File getFileFromUri(Uri uri) { try { diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ProductFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ProductFragment.java index 4e72b6cd..e8b29611 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ProductFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/ProductFragment.java @@ -36,6 +36,7 @@ public class ProductFragment extends Fragment implements ProductAdapter.OnProduc setupRecyclerView(view); setupSearch(view); setupSwipeRefresh(view); + loadProducts(); FloatingActionButton fab = view.findViewById(R.id.fabAddProduct); @@ -63,7 +64,7 @@ public class ProductFragment extends Fragment implements ProductAdapter.OnProduc public void beforeTextChanged(CharSequence s, int a, int b, int c) {} public void afterTextChanged(Editable s) {} public void onTextChanged(CharSequence s, int a, int b, int c) { - filter(s.toString()); + filter(); } }); } @@ -73,17 +74,18 @@ public class ProductFragment extends Fragment implements ProductAdapter.OnProduc swipeRefresh.setOnRefreshListener(this::loadProducts); } - private void filter(String query) { + private void filter() { + String query = etSearch.getText().toString().toLowerCase(); + filteredList.clear(); - if (query.isEmpty()) { - filteredList.addAll(productList); - } else { - String lower = query.toLowerCase(); - for (ProductDTO p : productList) { - if ((p.getProdName() != null && p.getProdName().toLowerCase().contains(lower)) - || (p.getCategoryName() != null && p.getCategoryName().toLowerCase().contains(lower))) { - filteredList.add(p); - } + for (ProductDTO p : productList) { + boolean matchesSearch = query.isEmpty() || + (p.getProdName() != null && p.getProdName().toLowerCase().contains(query)) || + (p.getCategoryName() != null && p.getCategoryName().toLowerCase().contains(query)) || + (p.getProdDesc() != null && p.getProdDesc().toLowerCase().contains(query)); + + if (matchesSearch) { + filteredList.add(p); } } adapter.notifyDataSetChanged(); @@ -99,7 +101,7 @@ public class ProductFragment extends Fragment implements ProductAdapter.OnProduc if (r.isSuccessful() && r.body() != null) { productList.clear(); productList.addAll(r.body().getContent()); - filter(etSearch != null ? etSearch.getText().toString() : ""); + filter(); } else { Toast.makeText(getContext(), "Failed to load products", Toast.LENGTH_SHORT).show(); diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ProductDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ProductDetailFragment.java index 8427ab65..40bdc91b 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ProductDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ProductDetailFragment.java @@ -1,19 +1,37 @@ package com.example.petstoremobile.fragments.listfragments.detailfragments; +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; import android.os.Bundle; +import android.provider.MediaStore; import android.util.Log; import android.view.*; import android.widget.*; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; import androidx.fragment.app.Fragment; +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.example.petstoremobile.R; import com.example.petstoremobile.adapters.BlackTextArrayAdapter; import com.example.petstoremobile.api.*; import com.example.petstoremobile.dtos.*; import com.example.petstoremobile.fragments.ListFragment; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; import java.math.BigDecimal; import java.util.*; +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.RequestBody; import retrofit2.*; public class ProductDetailFragment extends Fragment { @@ -22,12 +40,59 @@ public class ProductDetailFragment extends Fragment { private EditText etProductName, etProductDesc, etProductPrice; private Spinner spinnerCategory; private Button btnSave, btnDelete, btnBack; + private ImageView ivProductImage; private long prodId = -1; private boolean isEditing = false; private long preselectedCategoryId = -1; + private boolean hasImage = false; private List categoryList = new ArrayList<>(); + private Uri photoUri; + + private ActivityResultLauncher galleryLauncher; + private ActivityResultLauncher cameraLauncher; + private ActivityResultLauncher permissionLauncher; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + galleryLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + Uri selectedImage = result.getData().getData(); + if (isEditing) { + uploadProductImage(selectedImage); + } else { + ivProductImage.setImageURI(selectedImage); + photoUri = selectedImage; + hasImage = true; + } + } + } + ); + cameraLauncher = registerForActivityResult( + new ActivityResultContracts.TakePicture(), + success -> { + if (success) { + if (isEditing) { + uploadProductImage(photoUri); + } else { + ivProductImage.setImageURI(photoUri); + hasImage = true; + } + } + } + ); + permissionLauncher = registerForActivityResult( + new ActivityResultContracts.RequestPermission(), + granted -> { + if (granted) launchCamera(); + else Toast.makeText(getContext(), "Camera permission denied", Toast.LENGTH_SHORT).show(); + } + ); + } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, @@ -40,6 +105,7 @@ public class ProductDetailFragment extends Fragment { btnBack.setOnClickListener(v -> navigateBack()); btnSave.setOnClickListener(v -> saveProduct()); btnDelete.setOnClickListener(v -> confirmDelete()); + ivProductImage.setOnClickListener(v -> showImagePickerDialog()); return view; } @@ -53,6 +119,71 @@ public class ProductDetailFragment extends Fragment { btnSave = v.findViewById(R.id.btnSaveProduct); btnDelete = v.findViewById(R.id.btnDeleteProduct); btnBack = v.findViewById(R.id.btnProductBack); + ivProductImage = v.findViewById(R.id.ivProductImage); + } + + // Helper function to show the image picker dialog + private void showImagePickerDialog() { + List options = new ArrayList<>(); + options.add("Take Photo"); + options.add("Choose from Gallery"); + if (hasImage) { + options.add("Remove Photo"); + } + + new AlertDialog.Builder(requireContext()) + .setTitle("Select Product Image") + .setItems(options.toArray(new String[0]), (dialog, which) -> { + String selectedOption = options.get(which); + if (selectedOption.equals("Take Photo")) { + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED) { + launchCamera(); + } else { + permissionLauncher.launch(Manifest.permission.CAMERA); + } + } else if (selectedOption.equals("Choose from Gallery")) { + Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); + galleryLauncher.launch(intent); + } else if (selectedOption.equals("Remove Photo")) { + removePhoto(); + } + }) + .show(); + } + + // Helper function to remove the photo + private void removePhoto() { + if (isEditing) { + RetrofitClient.getProductApi(requireContext()).deleteProductImage(prodId) + .enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + Toast.makeText(getContext(), "Photo removed", Toast.LENGTH_SHORT).show(); + ivProductImage.setImageResource(R.drawable.placeholder2); + hasImage = false; + } else { + Toast.makeText(getContext(), "Failed to remove photo", Toast.LENGTH_SHORT).show(); + } + } + @Override + public void onFailure(Call call, Throwable t) { + Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); + } + }); + } else { + photoUri = null; + hasImage = false; + ivProductImage.setImageResource(R.drawable.placeholder2); + } + } + + // Helper function to launch the camera + private void launchCamera() { + File photoFile = new File(requireContext().getCacheDir(), "product_photo.jpg"); + photoUri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".fileprovider", photoFile); + cameraLauncher.launch(photoUri); } private void loadCategories() { @@ -92,6 +223,7 @@ public class ProductDetailFragment extends Fragment { isEditing = true; prodId = a.getLong("prodId"); preselectedCategoryId = a.getLong("categoryId", -1); + hasImage = true; tvMode.setText("Edit Product"); tvProductId.setText("ID: " + prodId); @@ -100,10 +232,74 @@ public class ProductDetailFragment extends Fragment { etProductDesc.setText(a.getString("prodDesc")); etProductPrice.setText(a.getString("prodPrice")); btnDelete.setVisibility(View.VISIBLE); + loadProductImage(); } else { tvMode.setText("Add Product"); btnDelete.setVisibility(View.GONE); tvProductId.setVisibility(View.GONE); + hasImage = false; + } + } + + //load the product image from the backend + private void loadProductImage() { + String imageUrl = RetrofitClient.BASE_URL + String.format(Locale.US, ProductApi.PRODUCT_IMAGE_PATH, prodId); + Glide.with(this) + .load(imageUrl) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .placeholder(R.drawable.placeholder2) + .error(R.drawable.placeholder2) + .into(ivProductImage); + } + + // Function to upload the product image by calling the backend + private void uploadProductImage(Uri uri) { + try { + File file = getFileFromUri(uri); + if (file == null) return; + + RequestBody requestFile = RequestBody.create(file, MediaType.parse(requireContext().getContentResolver().getType(uri))); + MultipartBody.Part body = MultipartBody.Part.createFormData("image", file.getName(), requestFile); + + RetrofitClient.getProductApi(requireContext()).uploadProductImage(prodId, body) + .enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + Toast.makeText(getContext(), "Image uploaded", Toast.LENGTH_SHORT).show(); + hasImage = true; + loadProductImage(); + } else { + Toast.makeText(getContext(), "Upload failed", Toast.LENGTH_SHORT).show(); + } + } + @Override + public void onFailure(Call call, Throwable t) { + Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); + } + }); + } catch (Exception e) { + Log.e("ProductDetail", "Error uploading image", e); + } + } + + // Helper function to get the File from the Uri + private File getFileFromUri(Uri uri) { + try { + InputStream inputStream = requireContext().getContentResolver().openInputStream(uri); + File tempFile = new File(requireContext().getCacheDir(), "upload_product_image.jpg"); + FileOutputStream outputStream = new FileOutputStream(tempFile); + byte[] buffer = new byte[1024]; + int length; + while ((length = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, length); + } + outputStream.close(); + inputStream.close(); + return tempFile; + } catch (Exception e) { + return null; } } @@ -132,14 +328,30 @@ public class ProductDetailFragment extends Fragment { ProductDTO dto = new ProductDTO(name, category.getCategoryId(), desc, price); - Log.d("PRODUCT_SAVE", "name=" + name + " categoryId=" + category.getCategoryId() - + " price=" + price); - ProductApi api = RetrofitClient.getProductApi(requireContext()); if (isEditing) { api.updateProduct(prodId, dto).enqueue(simpleCallback("Updated")); } else { - api.createProduct(dto).enqueue(simpleCallback("Saved")); + api.createProduct(dto).enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful() && response.body() != null) { + long newId = response.body().getProdId(); + if (photoUri != null) { + prodId = newId; + uploadProductImage(photoUri); + } + Toast.makeText(getContext(), "Saved", Toast.LENGTH_SHORT).show(); + navigateBack(); + } else { + Toast.makeText(getContext(), "Error saving", Toast.LENGTH_SHORT).show(); + } + } + @Override + public void onFailure(Call call, Throwable t) { + Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); + } + }); } } @@ -150,17 +362,10 @@ public class ProductDetailFragment extends Fragment { Toast.makeText(getContext(), msg, Toast.LENGTH_SHORT).show(); navigateBack(); } else { - try { - String err = r.errorBody().string(); - Log.e("PRODUCT_SAVE", "Error: " + err); - Toast.makeText(getContext(), "Error " + r.code(), Toast.LENGTH_SHORT).show(); - } catch (Exception e) { - Log.e("PRODUCT_SAVE", "Failed to read error"); - } + Toast.makeText(getContext(), "Error " + r.code(), Toast.LENGTH_SHORT).show(); } } public void onFailure(Call c, Throwable t) { - Log.e("PRODUCT_SAVE", "Failure: " + t.getMessage()); Toast.makeText(getContext(), "Network error", Toast.LENGTH_SHORT).show(); } }; diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/listprofilefragments/PetProfileFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/listprofilefragments/PetProfileFragment.java index 454d263b..c4c47d31 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/listprofilefragments/PetProfileFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/listprofilefragments/PetProfileFragment.java @@ -36,6 +36,8 @@ import com.example.petstoremobile.fragments.listfragments.detailfragments.PetDet import java.io.File; import java.io.FileOutputStream; import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; import okhttp3.MediaType; @@ -52,6 +54,7 @@ public class PetProfileFragment extends Fragment { private ImageView imgPet; private Uri photoUri; private int petId; + private boolean hasImage = false; // launchers for camera and gallery private ActivityResultLauncher galleryLauncher; @@ -162,10 +165,18 @@ public class PetProfileFragment extends Fragment { //Make change photo button ask user to select a new photo btnChangePhoto.setOnClickListener(v -> { + List options = new ArrayList<>(); + options.add("Take Photo"); + options.add("Choose from Gallery"); + if (hasImage) { + options.add("Remove Photo"); + } + new AlertDialog.Builder(requireContext()) .setTitle("Change Pet Photo") - .setItems(new String[]{"Take Photo", "Choose from Gallery"}, (dialog, which) -> { - if (which == 0) { + .setItems(options.toArray(new String[0]), (dialog, which) -> { + String selected = options.get(which); + if (selected.equals("Take Photo")) { // Choose Camera //Checks if the user has granted the camera permission already if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { @@ -175,9 +186,11 @@ public class PetProfileFragment extends Fragment { //otherwise request the permission permissionLauncher.launch(Manifest.permission.CAMERA); } - } else { + } else if (selected.equals("Choose from Gallery")) { Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); galleryLauncher.launch(intent); + } else if (selected.equals("Remove Photo")) { + deletePetImage(); } }) .show(); @@ -196,6 +209,19 @@ public class PetProfileFragment extends Fragment { .skipMemoryCache(true) .placeholder(R.drawable.placeholder) .error(R.drawable.placeholder) + .listener(new com.bumptech.glide.request.RequestListener() { + @Override + public boolean onLoadFailed(@androidx.annotation.Nullable com.bumptech.glide.load.engine.GlideException e, Object model, com.bumptech.glide.request.target.Target target, boolean isFirstResource) { + hasImage = false; + return false; + } + + @Override + public boolean onResourceReady(android.graphics.drawable.Drawable resource, Object model, com.bumptech.glide.request.target.Target target, com.bumptech.glide.load.DataSource dataSource, boolean isFirstResource) { + hasImage = true; + return false; + } + }) .into(imgPet); } @@ -234,6 +260,28 @@ public class PetProfileFragment extends Fragment { } } + private void deletePetImage() { + PetApi petApi = RetrofitClient.getPetApi(requireContext()); + petApi.deletePetImage((long) petId).enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + Toast.makeText(requireContext(), "Pet photo removed", Toast.LENGTH_SHORT).show(); + hasImage = false; + imgPet.setImageResource(R.drawable.placeholder); + } else { + Toast.makeText(requireContext(), "Failed to remove pet photo", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + Log.e("DELETE_PET_IMAGE", "Failure: " + t.getMessage()); + Toast.makeText(requireContext(), "Network error", Toast.LENGTH_SHORT).show(); + } + }); + } + // Helper function to create a temporary File object from a Uri for uploading private File getFileFromUri(Uri uri) { try { diff --git a/android/app/src/main/res/drawable/placeholder2.png b/android/app/src/main/res/drawable/placeholder2.png new file mode 100644 index 0000000000000000000000000000000000000000..dec35ec57f578998726aba4e5bb1541929e29832 GIT binary patch literal 8806 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/layout/fragment_product_detail.xml b/android/app/src/main/res/layout/fragment_product_detail.xml index b7d14a9d..7ab22faa 100644 --- a/android/app/src/main/res/layout/fragment_product_detail.xml +++ b/android/app/src/main/res/layout/fragment_product_detail.xml @@ -64,6 +64,32 @@ android:layout_gravity="end" android:layout_marginBottom="8dp"/> + + + + + + + + + - - - - - - + android:gravity="center_vertical"> - + + + android:orientation="vertical"> + + + + + + + + + + + + Date: Thu, 2 Apr 2026 08:54:24 -0600 Subject: [PATCH 3/8] Fix web routing --- web/app/about/page.js | 37 ++++ web/app/adopt/[id]/page.js | 2 - web/app/adopt/page.js | 36 +--- web/app/appointments/page.js | 329 ++++++++++++++++++---------------- web/app/contact/page.js | 73 ++++++++ web/app/globals.css | 70 +++++++- web/app/login/page.js | 5 +- web/app/page.js | 38 ++-- web/app/products/[id]/page.js | 3 - web/app/products/page.js | 36 +--- web/app/register/page.js | 5 +- web/components/Navigation.js | 25 +-- 12 files changed, 407 insertions(+), 252 deletions(-) create mode 100644 web/app/about/page.js create mode 100644 web/app/contact/page.js diff --git a/web/app/about/page.js b/web/app/about/page.js new file mode 100644 index 00000000..5ba61fe2 --- /dev/null +++ b/web/app/about/page.js @@ -0,0 +1,37 @@ +export default function AboutPage() { + return ( +
+
+

About Leon's Pet Store

+

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

+
+
+ +
+
+

What We Do

+

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

+
+ +
+

Our Focus

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

Visit the Store

+

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

+
+
+
+ ); +} diff --git a/web/app/adopt/[id]/page.js b/web/app/adopt/[id]/page.js index 0834ca01..6c7b70e0 100644 --- a/web/app/adopt/[id]/page.js +++ b/web/app/adopt/[id]/page.js @@ -15,8 +15,6 @@ export default function PetDetailPage() { useEffect(() => { if (!id) return; - setLoading(true); - setError(null); fetch(`${API_BASE}/api/v1/pets/${id}`) .then((res) => { diff --git a/web/app/adopt/page.js b/web/app/adopt/page.js index 430a1653..90293d8c 100644 --- a/web/app/adopt/page.js +++ b/web/app/adopt/page.js @@ -12,10 +12,8 @@ export default function AdoptPage() { const [health, setHealth] = useState(null); const [search, setSearch] = useState(""); const [query, setQuery] = useState(""); - const [page, setPage] = useState(0); - const [totalPages, setTotalPages] = useState(0); - const PAGE_SIZE = 12; + const PAGE_SIZE = 200; useEffect(() => { fetch(`${API_BASE}/api/v1/health`) @@ -24,10 +22,7 @@ export default function AdoptPage() { }, []); useEffect(() => { - setLoading(true); - setError(null); - - const params = new URLSearchParams({ page, size: PAGE_SIZE, sort: "id,asc" }); + const params = new URLSearchParams({ page: 0, size: PAGE_SIZE, sort: "petId,asc" }); if (query) params.set("q", query); fetch(`${API_BASE}/api/v1/pets?${params}`) @@ -37,15 +32,15 @@ export default function AdoptPage() { }) .then((data) => { setPets(data.content ?? []); - setTotalPages(data.totalPages ?? 0); }) .catch((err) => setError(err.message)) .finally(() => setLoading(false)); - }, [page, query]); + }, [query]); function handleSearch(e) { e.preventDefault(); - setPage(0); + setLoading(true); + setError(null); setQuery(search.trim()); } @@ -72,7 +67,7 @@ export default function AdoptPage() { @@ -125,25 +120,6 @@ export default function AdoptPage() { )} - {!loading && totalPages > 1 && ( -
- - Page {page + 1} of {totalPages} - -
- )} ); diff --git a/web/app/appointments/page.js b/web/app/appointments/page.js index d3f7943e..bec8bd78 100644 --- a/web/app/appointments/page.js +++ b/web/app/appointments/page.js @@ -1,5 +1,6 @@ "use client"; +import dynamic from "next/dynamic"; import { useState, useEffect, useCallback, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; @@ -68,8 +69,12 @@ function DatePicker({ value, minDate, onChange }) { } const cells = []; - for (let i = 0; i < firstDay; i++) cells.push(null); - for (let d = 1; d <= daysInMonth; d++) cells.push(d); + for (let i = 0; i < firstDay; i++) { + cells.push({ key: `empty-${viewYear}-${viewMonth}-${String(i)}`, day: null }); + } + for (let d = 1; d <= daysInMonth; d++) { + cells.push({ key: `day-${viewYear}-${viewMonth}-${String(d)}`, day: d }); + } const s = { widget: { @@ -160,12 +165,12 @@ function DatePicker({ value, minDate, onChange }) { {DAYS.map((d) => ( {d} ))} - {cells.map((day, i) => + {cells.map(({ key, day }) => day === null ? ( - + ) : ( + ))} + + )} + + )} + + {serviceId && ( +
+ {petSectionLabel} + {petsToShow.length === 0 ? ( +

{noPetsMessage}

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

Appointment Booking

-

{selectedService.serviceDesc}

+

Web appointment booking is currently available for customer accounts only.

+

Admin and staff accounts can still review appointment activity below.

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

Checking availability...

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

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

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

{noPetsMessage}

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

Your Appointments

+

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

{loadingAppointments ? (

Loading appointments...

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

Contact Us

+

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

+
+
+ +
+
+

General Contact

+

Email: support@petshop.com

+

Phone: (000) 000-0000

+

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

+
+ +
+

Store Locations

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

{location.name}

+

{location.address}

+

{location.phone}

+

{location.email}

+
+ ))} +
+
+ +
+

Store Personnel

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

{person.name}

+

{person.role}

+
+ ))} +
+
+
+
+ ); +} diff --git a/web/app/globals.css b/web/app/globals.css index 500ccbb0..da8ce03b 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -617,6 +617,74 @@ body { min-height: 100vh; } +.info-page { + min-height: 100vh; + background: linear-gradient(to bottom, #f9f9f9, #ffffff); +} + +.info-hero { + text-align: center; + padding: 4rem 2rem 3rem; +} + +.info-title { + font-size: 3rem; + color: #333; + margin-bottom: 1rem; + font-weight: 700; +} + +.info-subtitle { + font-size: 1.25rem; + color: #666; + margin-bottom: 1.5rem; +} + +.info-content { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem 4rem; + display: grid; + gap: 1.5rem; +} + +.info-card { + background: white; + border-radius: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + padding: 1.5rem; +} + +.info-card h2 { + margin-top: 0; + margin-bottom: 1rem; + color: #222; +} + +.info-list { + margin: 0; + padding-left: 1.2rem; + display: grid; + gap: 0.5rem; +} + +.info-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.info-mini-card { + background: #fff8ee; + border-radius: 12px; + padding: 1rem; +} + +.info-mini-card h3 { + margin-top: 0; + margin-bottom: 0.5rem; +} + .products-hero { text-align: center; padding: 4rem 2rem 3rem; @@ -1698,4 +1766,4 @@ body { .profile-pet-cancel-btn:hover { border-color: #999; color: #333; -} \ No newline at end of file +} diff --git a/web/app/login/page.js b/web/app/login/page.js index 32c05c52..dcc78d02 100644 --- a/web/app/login/page.js +++ b/web/app/login/page.js @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; @@ -64,10 +65,10 @@ export default function LoginPage() { {loading ? "Logging in…" : "Log In"} - +

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

diff --git a/web/app/page.js b/web/app/page.js index 4819bd7d..91f71946 100644 --- a/web/app/page.js +++ b/web/app/page.js @@ -4,15 +4,21 @@ import Image from "next/image"; import Link from "next/link"; import { useState, useEffect } from "react"; -export default function Home() { - //Slideshow images array - const slideshowImages = [ - {src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets"}, - {src: "/images/home/slideshow/pet2.jpg", alt: "Pet supplies"}, - {src: "/images/home/slideshow/pet3.jpg", alt: "Pet grooming"}, - {src: "/images/home/slideshow/pet4.jpg", alt: "Pet food"}, - ]; +const slideshowImages = [ + {id: "slide-1", src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets"}, + {id: "slide-2", src: "/images/home/slideshow/pet2.jpg", alt: "Pet supplies"}, + {id: "slide-3", src: "/images/home/slideshow/pet3.jpg", alt: "Pet grooming"}, + {id: "slide-4", src: "/images/home/slideshow/pet4.jpg", alt: "Pet food"}, +]; +const navImages = [ + {id: "nav-adopt", src: "/images/home/navimages/adopt.jpg", alt: "Adopt a Pet", link: "/adopt", title: "Adopt a Pet"}, + {id: "nav-products", src: "/images/home/navimages/store.jpg", alt: "Online Store", link: "/products", title: "Online Store"}, + {id: "nav-appointments", src: "/images/home/navimages/appointments.jpg", alt: "Appointments", link: "/appointments", title: "Appointments"}, + {id: "nav-about", src: "/images/home/navimages/about.jpg", alt: "About Us", link: "/about", title: "About Us"}, +]; + +export default function Home() { const [currentSlide, setCurrentSlide] = useState(0); //Auto-advance slideshow @@ -21,15 +27,7 @@ export default function Home() { const timer = setInterval(() => {setCurrentSlide((prev) => (prev + 1) % slideshowImages.length);}, 7500); return () => clearInterval(timer); - }, [slideshowImages.length]); - - //Hyperlinks 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 (
@@ -37,7 +35,7 @@ export default function Home() {
{slideshowImages.map((image, index) => (
{navImages.map((item, index) => ( - +
); -} \ No newline at end of file +} diff --git a/web/app/products/[id]/page.js b/web/app/products/[id]/page.js index 833cbd24..47a3059b 100644 --- a/web/app/products/[id]/page.js +++ b/web/app/products/[id]/page.js @@ -17,9 +17,6 @@ export default function ProductDetailPage() { if (!id) { return; } - - setLoading(true); - setError(null); fetch(`${API_BASE}/api/v1/products/${id}`) .then((res) => { diff --git a/web/app/products/page.js b/web/app/products/page.js index f44475f1..b3416326 100644 --- a/web/app/products/page.js +++ b/web/app/products/page.js @@ -11,16 +11,11 @@ export default function ProductsPage() { const [error, setError] = useState(null); const [search, setSearch] = useState(""); const [query, setQuery] = useState(""); - const [page, setPage] = useState(0); - const [totalPages, setTotalPages] = useState(0); - const PAGE_SIZE = 12; + const PAGE_SIZE = 200; useEffect(() => { - setLoading(true); - setError(null); - - const params = new URLSearchParams({ page, size: PAGE_SIZE, sort: "prodId,asc" }); + const params = new URLSearchParams({ page: 0, size: PAGE_SIZE, sort: "prodId,asc" }); if (query) { params.set("q", query); } @@ -35,15 +30,15 @@ export default function ProductsPage() { }) .then((data) => { setProducts(data.content ?? []); - setTotalPages(data.totalPages ?? 0); }) .catch((err) => setError(err.message)) .finally(() => setLoading(false)); - }, [page, query]); + }, [query]); function handleSearch(e) { e.preventDefault(); - setPage(0); + setLoading(true); + setError(null); setQuery(search.trim()); } @@ -70,7 +65,7 @@ export default function ProductsPage() { @@ -108,25 +103,6 @@ export default function ProductsPage() {
)} - {!loading && totalPages > 1 && ( -
- - Page {page + 1} of {totalPages} - -
- )} ); diff --git a/web/app/register/page.js b/web/app/register/page.js index 20dcd2c1..eb9de480 100644 --- a/web/app/register/page.js +++ b/web/app/register/page.js @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; @@ -140,10 +141,10 @@ export default function RegisterPage() { {loading ? "Creating account…" : "Register"} - +

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

diff --git a/web/components/Navigation.js b/web/components/Navigation.js index cfd17924..3259c1d1 100644 --- a/web/components/Navigation.js +++ b/web/components/Navigation.js @@ -1,6 +1,7 @@ "use client"; import Image from "next/image"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; @@ -23,31 +24,31 @@ export default function DisplayNav() { id="logo"/>
- Home - Adopt a Pet - Online Store - Schedule an Appointment - Contact Us - About Us + Home + Adopt a Pet + Online Store + Schedule an Appointment + Contact Us + About Us
{loading ? null : user ? ( <> - + Hello, {user.fullName || user.username} - - ) : ( <> - Log In - Register + Log In + Register )}
); -} \ No newline at end of file +} -- 2.49.1 From a76895434d28d7b4ea233f3b68dbd40da536319d Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Thu, 2 Apr 2026 09:06:24 -0600 Subject: [PATCH 4/8] Improve auth flows --- web/app/appointments/page.js | 5 +- web/app/globals.css | 38 +++++++ web/app/login/page.js | 24 ++++- web/app/profile/page.js | 194 +++++++++++++++++++++++++++++++++-- web/app/register/page.js | 24 ++++- web/context/AuthContext.js | 54 ++++++---- 6 files changed, 300 insertions(+), 39 deletions(-) diff --git a/web/app/appointments/page.js b/web/app/appointments/page.js index bec8bd78..fac5a86f 100644 --- a/web/app/appointments/page.js +++ b/web/app/appointments/page.js @@ -226,10 +226,11 @@ function AppointmentsPage() { useEffect(() => { if (!authLoading && !user) { - router.push("/login"); + const target = preselectedPetId ? `/appointments?petId=${encodeURIComponent(preselectedPetId)}` : "/appointments"; + router.push(`/login?next=${encodeURIComponent(target)}`); } - }, [authLoading, user, router]); + }, [authLoading, user, router, preselectedPetId]); useEffect(() => { if (!token) { diff --git a/web/app/globals.css b/web/app/globals.css index da8ce03b..8571bfeb 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1076,6 +1076,13 @@ body { align-items: center; justify-content: center; margin-bottom: 0.25rem; + overflow: hidden; +} + +.profile-avatar-image { + width: 100%; + height: 100%; + object-fit: cover; } .profile-name { @@ -1105,6 +1112,37 @@ body { padding-top: 1rem; } +.profile-update-form { + width: 100%; + display: grid; + gap: 0.9rem; +} + +.profile-update-title { + margin: 0.25rem 0 0; + font-size: 1.1rem; + color: #222; +} + +.profile-avatar-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; +} + +.profile-avatar-upload-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.65rem 1rem; + border-radius: 8px; + background: #fff3e0; + color: #a65c00; + font-weight: 600; + cursor: pointer; +} + .profile-field-row { display: flex; justify-content: space-between; diff --git a/web/app/login/page.js b/web/app/login/page.js index dcc78d02..2e7d1fe1 100644 --- a/web/app/login/page.js +++ b/web/app/login/page.js @@ -1,13 +1,25 @@ "use client"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; -export default function LoginPage() { +function resolveNextPath(candidate) { + if (!candidate || !candidate.startsWith("/")) { + return "/"; + } + if (candidate.startsWith("//") || candidate.startsWith("/login") || candidate.startsWith("/register")) { + return "/"; + } + return candidate; +} + +function LoginPage() { const {login} = useAuth(); const router = useRouter(); + const searchParams = useSearchParams(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -21,7 +33,7 @@ export default function LoginPage() { try { await login(username, password); - router.push("/"); + router.push(resolveNextPath(searchParams.get("next"))); } catch (err) { @@ -68,9 +80,13 @@ export default function LoginPage() {

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

); } + +export default dynamic(() => Promise.resolve(LoginPage), { + ssr: false, +}); diff --git a/web/app/profile/page.js b/web/app/profile/page.js index 6b28aa35..d0b78c5e 100644 --- a/web/app/profile/page.js +++ b/web/app/profile/page.js @@ -7,7 +7,7 @@ import { useAuth } from "@/context/AuthContext"; const API_BASE = ""; export default function ProfilePage() { - const {user, token, loading, logout} = useAuth(); + const {user, token, loading, logout, refreshUser} = useAuth(); const router = useRouter(); const [pets, setPets] = useState([]); @@ -19,14 +19,27 @@ export default function ProfilePage() { const [breed, setBreed] = useState(""); const [submitting, setSubmitting] = useState(false); const [petError, setPetError] = useState(null); + const [profileForm, setProfileForm] = useState({ fullName: "", email: "", phone: "" }); + const [profileSubmitting, setProfileSubmitting] = useState(false); + const [profileError, setProfileError] = useState(null); + const [profileSuccess, setProfileSuccess] = useState(null); + const [avatarSubmitting, setAvatarSubmitting] = useState(false); useEffect(() => { if (!loading && !user) { - router.replace("/login"); + router.replace(`/login?next=${encodeURIComponent("/profile")}`); } }, [user, loading, router]); + useEffect(() => { + setProfileForm({ + fullName: user?.fullName || "", + email: user?.email || "", + phone: user?.phone || "", + }); + }, [user]); + const loadPets = useCallback(() => { if (!token) return; setLoadingPets(true); @@ -50,6 +63,105 @@ export default function ProfilePage() { router.push("/"); } + async function handleProfileSubmit(e) { + e.preventDefault(); + setProfileSubmitting(true); + setProfileError(null); + setProfileSuccess(null); + + try { + const res = await fetch(`${API_BASE}/api/v1/auth/me`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(profileForm), + }); + + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.message || `Request failed (${res.status})`); + } + + await refreshUser(); + setProfileSuccess("Profile updated successfully."); + } + + catch (err) { + setProfileError(err.message); + } + + finally { + setProfileSubmitting(false); + } + } + + async function handleAvatarUpload(file) { + if (!file) { + return; + } + + const formData = new FormData(); + formData.append("avatar", file); + setAvatarSubmitting(true); + setProfileError(null); + setProfileSuccess(null); + + try { + const res = await fetch(`${API_BASE}/api/v1/auth/me/avatar`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.message || "Failed to upload avatar"); + } + + await refreshUser(); + setProfileSuccess(data?.message || "Avatar updated successfully."); + } + + catch (err) { + setProfileError(err.message); + } + + finally { + setAvatarSubmitting(false); + } + } + + async function handleAvatarDelete() { + setAvatarSubmitting(true); + setProfileError(null); + setProfileSuccess(null); + + try { + const res = await fetch(`${API_BASE}/api/v1/auth/me/avatar`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.message || "Failed to delete avatar"); + } + + await refreshUser(); + setProfileSuccess(data?.message || "Avatar removed successfully."); + } + + catch (err) { + setProfileError(err.message); + } + + finally { + setAvatarSubmitting(false); + } + } + function openAddForm() { setEditingPet(null); setPetName(""); @@ -170,7 +282,11 @@ export default function ProfilePage() {
- {(user.fullName || user.username).charAt(0).toUpperCase()} + {user.avatarUrl ? ( + {user.fullName + ) : ( + (user.fullName || user.username).charAt(0).toUpperCase() + )}

{user.fullName || user.username}

@@ -185,7 +301,65 @@ export default function ProfilePage() { ))} - + )} +
+ + + + @@ -194,7 +368,7 @@ export default function ProfilePage() {

My Pets

- +
{showForm && ( @@ -285,11 +459,11 @@ export default function ProfilePage() { {pet.breed && {pet.breed}}
- - -
- - ))} + + + + + ))} )} diff --git a/web/app/register/page.js b/web/app/register/page.js index eb9de480..17f49672 100644 --- a/web/app/register/page.js +++ b/web/app/register/page.js @@ -1,13 +1,25 @@ "use client"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; -export default function RegisterPage() { +function resolveNextPath(candidate) { + if (!candidate || !candidate.startsWith("/")) { + return "/"; + } + if (candidate.startsWith("//") || candidate.startsWith("/login") || candidate.startsWith("/register")) { + return "/"; + } + return candidate; +} + +function RegisterPage() { const {register} = useAuth(); const router = useRouter(); + const searchParams = useSearchParams(); const [form, setForm] = useState({ fullName: "", @@ -41,7 +53,7 @@ export default function RegisterPage() { phone: form.phone, password: form.password, }); - router.push("/"); + router.push(resolveNextPath(searchParams.get("next"))); } catch (err) { @@ -144,9 +156,13 @@ export default function RegisterPage() {

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

); } + +export default dynamic(() => Promise.resolve(RegisterPage), { + ssr: false, +}); diff --git a/web/context/AuthContext.js b/web/context/AuthContext.js index 5a9b0d08..e861f624 100644 --- a/web/context/AuthContext.js +++ b/web/context/AuthContext.js @@ -19,6 +19,28 @@ export function AuthProvider({ children }) { const [token, setToken] = useState(null); const [loading, setLoading] = useState(true); + const refreshUser = useCallback(async (providedToken) => { + const activeToken = providedToken ?? token; + if (!activeToken) { + setUser(null); + return null; + } + + const userInfo = await fetchCurrentUser(activeToken); + if (!userInfo) { + localStorage.removeItem(TOKEN_KEY); + setToken(null); + setUser(null); + return null; + } + + if (!token) { + setToken(activeToken); + } + setUser(userInfo); + return userInfo; + }, [token]); + useEffect(() => { const stored = localStorage.getItem(TOKEN_KEY); if (!stored) { @@ -26,17 +48,14 @@ export function AuthProvider({ children }) { return; } - fetchCurrentUser(stored) - .then((data) => { - if (data) { - setToken(stored); - setUser(data); - } - - else { - localStorage.removeItem(TOKEN_KEY); - } - }).catch(() => localStorage.removeItem(TOKEN_KEY)).finally(() => setLoading(false));}, []); + refreshUser(stored) + .catch(() => { + localStorage.removeItem(TOKEN_KEY); + setToken(null); + setUser(null); + }) + .finally(() => setLoading(false)); + }, [refreshUser]); const login = useCallback(async (username, password) => { const res = await fetch("/api/v1/auth/login", { @@ -55,12 +74,10 @@ export function AuthProvider({ children }) { localStorage.setItem(TOKEN_KEY, jwt); setToken(jwt); - const userInfo = await fetchCurrentUser(jwt); - - setUser(userInfo); + const userInfo = await refreshUser(jwt); return userInfo; - }, []); + }, [refreshUser]); const register = useCallback(async ({ username, password, email, fullName, phone }) => { const res = await fetch("/api/v1/auth/register", { @@ -79,11 +96,10 @@ export function AuthProvider({ children }) { localStorage.setItem(TOKEN_KEY, jwt); setToken(jwt); - const userInfo = await fetchCurrentUser(jwt); - setUser(userInfo); + const userInfo = await refreshUser(jwt); return userInfo; - }, []); + }, [refreshUser]); const logout = useCallback(() => { localStorage.removeItem(TOKEN_KEY); @@ -91,7 +107,7 @@ export function AuthProvider({ children }) { setUser(null);}, []); return ( - + {children} ); -- 2.49.1 From 781eb48ca9b73bfccc10367810b27aab90eafc13 Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Thu, 2 Apr 2026 09:08:00 -0600 Subject: [PATCH 5/8] Fix item loading --- web/app/adopt/page.js | 30 +++++++++++++++++++----------- web/app/products/page.js | 32 +++++++++++++++++--------------- web/components/PetCard.js | 10 +++++++++- web/components/PetProfile.js | 10 +++++++++- web/components/ProductCard.js | 10 +++++++++- web/components/ProductProfile.js | 10 +++++++++- web/lib/fetchAllPages.js | 19 +++++++++++++++++++ 7 files changed, 91 insertions(+), 30 deletions(-) create mode 100644 web/lib/fetchAllPages.js diff --git a/web/app/adopt/page.js b/web/app/adopt/page.js index 90293d8c..97fe1472 100644 --- a/web/app/adopt/page.js +++ b/web/app/adopt/page.js @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import PetCard from "@/components/PetCard"; +import { fetchAllPages } from "@/lib/fetchAllPages"; const API_BASE = ""; @@ -13,7 +14,7 @@ export default function AdoptPage() { const [search, setSearch] = useState(""); const [query, setQuery] = useState(""); - const PAGE_SIZE = 200; + const PAGE_SIZE = 100; useEffect(() => { fetch(`${API_BASE}/api/v1/health`) @@ -22,16 +23,23 @@ export default function AdoptPage() { }, []); useEffect(() => { - const params = new URLSearchParams({ page: 0, size: PAGE_SIZE, sort: "petId,asc" }); - if (query) params.set("q", query); + setLoading(true); + setError(null); - fetch(`${API_BASE}/api/v1/pets?${params}`) - .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status} – ${res.statusText}`); - return res.json(); - }) - .then((data) => { - setPets(data.content ?? []); + fetchAllPages((page) => { + const params = new URLSearchParams({ + page: String(page), + size: String(PAGE_SIZE), + sort: "petId,asc", + status: "Available", + }); + if (query) { + params.set("q", query); + } + return `${API_BASE}/api/v1/pets?${params}`; + }) + .then((allPets) => { + setPets(allPets); }) .catch((err) => setError(err.message)) .finally(() => setLoading(false)); @@ -58,7 +66,7 @@ export default function AdoptPage() { setSearch(e.target.value)} /> diff --git a/web/app/products/page.js b/web/app/products/page.js index b3416326..19cf8670 100644 --- a/web/app/products/page.js +++ b/web/app/products/page.js @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import ProductCard from "@/components/ProductCard"; +import { fetchAllPages } from "@/lib/fetchAllPages"; const API_BASE = ""; @@ -12,24 +13,25 @@ export default function ProductsPage() { const [search, setSearch] = useState(""); const [query, setQuery] = useState(""); - const PAGE_SIZE = 200; + const PAGE_SIZE = 100; useEffect(() => { - const params = new URLSearchParams({ page: 0, size: PAGE_SIZE, sort: "prodId,asc" }); - if (query) { - params.set("q", query); - } + setLoading(true); + setError(null); - fetch(`${API_BASE}/api/v1/products?${params}`) - .then((res) => { - if (!res.ok) { - throw new Error(`HTTP ${res.status} – ${res.statusText}`); - } - - return res.json(); - }) - .then((data) => { - setProducts(data.content ?? []); + fetchAllPages((page) => { + const params = new URLSearchParams({ + page: String(page), + size: String(PAGE_SIZE), + sort: "prodId,asc", + }); + if (query) { + params.set("q", query); + } + return `${API_BASE}/api/v1/products?${params}`; + }) + .then((allProducts) => { + setProducts(allProducts); }) .catch((err) => setError(err.message)) .finally(() => setLoading(false)); diff --git a/web/components/PetCard.js b/web/components/PetCard.js index 4370bca1..4c6410b9 100644 --- a/web/components/PetCard.js +++ b/web/components/PetCard.js @@ -5,7 +5,15 @@ export default function PetCard({petId, petName, petSpecies, petStatus, imageUrl return (
- {petName} + {petName} { + e.currentTarget.onerror = null; + e.currentTarget.src = "/images/pet-placeholder.png"; + }} + />

{petName}

diff --git a/web/components/PetProfile.js b/web/components/PetProfile.js index a00732a4..8b7fd6f0 100644 --- a/web/components/PetProfile.js +++ b/web/components/PetProfile.js @@ -5,7 +5,15 @@ export default function PetProfile({ petId, petName, petSpecies, petBreed, petAg return (
- {petName} + {petName} { + e.currentTarget.onerror = null; + e.currentTarget.src = "/images/pet-placeholder.png"; + }} + />
diff --git a/web/components/ProductCard.js b/web/components/ProductCard.js index e27abbbd..0b4c76ed 100644 --- a/web/components/ProductCard.js +++ b/web/components/ProductCard.js @@ -4,7 +4,15 @@ export default function ProductCard({ prodId, prodName, categoryName, prodPrice, return (
- {prodName} + {prodName} { + e.currentTarget.onerror = null; + e.currentTarget.src = "/images/pet-placeholder.png"; + }} + />

{prodName}

diff --git a/web/components/ProductProfile.js b/web/components/ProductProfile.js index b8d0a9b2..fda41420 100644 --- a/web/components/ProductProfile.js +++ b/web/components/ProductProfile.js @@ -4,7 +4,15 @@ export default function ProductProfile({ prodName, categoryName, prodDesc, prodP return (
- {prodName} + {prodName} { + e.currentTarget.onerror = null; + e.currentTarget.src = "/images/pet-placeholder.png"; + }} + />
diff --git a/web/lib/fetchAllPages.js b/web/lib/fetchAllPages.js new file mode 100644 index 00000000..624d172c --- /dev/null +++ b/web/lib/fetchAllPages.js @@ -0,0 +1,19 @@ +export async function fetchAllPages(urlBuilder) { + const items = []; + let page = 0; + let totalPages = 1; + + while (page < totalPages) { + const res = await fetch(urlBuilder(page)); + if (!res.ok) { + throw new Error(`HTTP ${res.status} – ${res.statusText}`); + } + + const data = await res.json(); + items.push(...(data.content ?? [])); + totalPages = Math.max(data.totalPages ?? 1, 1); + page += 1; + } + + return items; +} -- 2.49.1 From 99855a6e99907ef827aa4385ee580644c01ccdec Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Thu, 2 Apr 2026 09:11:53 -0600 Subject: [PATCH 6/8] Fix pet sorting --- web/app/adopt/page.js | 2 +- web/app/appointments/page.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/adopt/page.js b/web/app/adopt/page.js index 97fe1472..a6a2784a 100644 --- a/web/app/adopt/page.js +++ b/web/app/adopt/page.js @@ -30,7 +30,7 @@ export default function AdoptPage() { const params = new URLSearchParams({ page: String(page), size: String(PAGE_SIZE), - sort: "petId,asc", + sort: "id,asc", status: "Available", }); if (query) { diff --git a/web/app/appointments/page.js b/web/app/appointments/page.js index fac5a86f..676b953e 100644 --- a/web/app/appointments/page.js +++ b/web/app/appointments/page.js @@ -249,7 +249,7 @@ function AppointmentsPage() { .then((data) => setServices(data.content ?? [])) .catch(() => {}); - fetch(`${API_BASE}/api/v1/pets?size=200&sort=petId,asc`) + fetch(`${API_BASE}/api/v1/pets?size=200&sort=id,asc&status=Available`) .then((r) => r.json()) .then((data) => setAllPets(data.content ?? [])) .catch(() => {}); -- 2.49.1 From 63162487b540aaa08b312171d912b1192f2c9639 Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Thu, 2 Apr 2026 09:32:42 -0600 Subject: [PATCH 7/8] Fix profile images --- web/app/appointments/page.js | 10 +---- web/app/profile/page.js | 75 +++++++++++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/web/app/appointments/page.js b/web/app/appointments/page.js index 676b953e..4643bf0e 100644 --- a/web/app/appointments/page.js +++ b/web/app/appointments/page.js @@ -615,15 +615,7 @@ function AppointmentsPage() { {submitting ? "Booking..." : isAdoptionService ? "Schedule Adoption Visit" : "Book Appointment"} - ) : ( -
-

Appointment Booking

-
-

Web appointment booking is currently available for customer accounts only.

-

Admin and staff accounts can still review appointment activity below.

-
-
- )} + ) : null}

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

diff --git a/web/app/profile/page.js b/web/app/profile/page.js index d0b78c5e..ff79521e 100644 --- a/web/app/profile/page.js +++ b/web/app/profile/page.js @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; @@ -9,6 +9,7 @@ const API_BASE = ""; export default function ProfilePage() { const {user, token, loading, logout, refreshUser} = useAuth(); const router = useRouter(); + const petImageObjectUrlsRef = useRef([]); const [pets, setPets] = useState([]); const [loadingPets, setLoadingPets] = useState(false); @@ -25,6 +26,13 @@ export default function ProfilePage() { const [profileSuccess, setProfileSuccess] = useState(null); const [avatarSubmitting, setAvatarSubmitting] = useState(false); + const clearPetImageObjectUrls = useCallback(() => { + for (const objectUrl of petImageObjectUrlsRef.current) { + URL.revokeObjectURL(objectUrl); + } + petImageObjectUrlsRef.current = []; + }, []); + useEffect(() => { if (!loading && !user) { router.replace(`/login?next=${encodeURIComponent("/profile")}`); @@ -40,17 +48,64 @@ export default function ProfilePage() { }); }, [user]); - const loadPets = useCallback(() => { + const loadPets = useCallback(async () => { if (!token) return; setLoadingPets(true); - fetch(`${API_BASE}/api/v1/my-pets`, { - headers: { Authorization: `Bearer ${token}` }, - }) - .then((r) => r.json()) - .then(setPets) - .catch(() => {}) - .finally(() => setLoadingPets(false)); - }, [token]); + + try { + const response = await fetch(`${API_BASE}/api/v1/my-pets`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`Request failed (${response.status})`); + } + + const petData = await response.json(); + clearPetImageObjectUrls(); + + const petsWithResolvedImages = await Promise.all( + (Array.isArray(petData) ? petData : []).map(async (pet) => { + if (!pet.imageUrl) { + return pet; + } + + try { + const imageResponse = await fetch(`${API_BASE}${pet.imageUrl}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!imageResponse.ok) { + return { ...pet, imageUrl: null }; + } + + const blob = await imageResponse.blob(); + const objectUrl = URL.createObjectURL(blob); + petImageObjectUrlsRef.current.push(objectUrl); + + return { ...pet, imageUrl: objectUrl }; + } catch { + return { ...pet, imageUrl: null }; + } + }) + ); + + setPets(petsWithResolvedImages); + } + + catch { + } + + finally { + setLoadingPets(false); + } + }, [token, clearPetImageObjectUrls]); + + useEffect(() => { + return () => { + clearPetImageObjectUrls(); + }; + }, [clearPetImageObjectUrls]); useEffect(() => { if (user?.role === "CUSTOMER") { -- 2.49.1 From 5fa9cfd5d6328cc4bf5949aa288aeebb81e526e8 Mon Sep 17 00:00:00 2001 From: Alex <78383757+Lextical@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:37:43 -0600 Subject: [PATCH 8/8] added calendar view to appointments - NOTE: may have to change appointments abit after backend is updated --- android/app/build.gradle.kts | 2 + .../listfragments/AppointmentFragment.java | 113 +++++++++++++++--- .../petstoremobile/utils/EventDecorator.java | 30 +++++ .../main/res/layout/fragment_appointment.xml | 22 +++- android/gradle.properties | 2 + 5 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 android/app/src/main/java/com/example/petstoremobile/utils/EventDecorator.java diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e3955e2f..a6d27404 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -82,6 +82,8 @@ dependencies { implementation("com.github.bumptech.glide:glide:4.16.0") annotationProcessor("com.github.bumptech.glide:compiler:4.16.0") + implementation("com.github.prolificinteractive:material-calendarview:2.0.1") + testImplementation(libs.junit) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AppointmentFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AppointmentFragment.java index f8aa734f..49da714c 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AppointmentFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AppointmentFragment.java @@ -1,5 +1,6 @@ package com.example.petstoremobile.fragments.listfragments; +import android.graphics.Color; import android.os.Bundle; import androidx.annotation.NonNull; @@ -29,10 +30,21 @@ import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.dtos.PetDTO; import com.example.petstoremobile.fragments.ListFragment; import com.example.petstoremobile.fragments.listfragments.detailfragments.AppointmentDetailFragment; +import com.example.petstoremobile.utils.EventDecorator; import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.prolificinteractive.materialcalendarview.CalendarDay; +import com.prolificinteractive.materialcalendarview.CalendarMode; +import com.prolificinteractive.materialcalendarview.MaterialCalendarView; +import com.prolificinteractive.materialcalendarview.OnDateSelectedListener; +import java.text.ParseException; +import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.HashSet; import java.util.List; +import java.util.Locale; import retrofit2.Call; import retrofit2.Callback; @@ -50,6 +62,11 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. private SwipeRefreshLayout swipeRefreshLayout; private EditText etSearch; private ImageButton hamburger; + private ImageButton btnToggleCalendarMode; + private MaterialCalendarView calendarView; + private CalendarDay selectedCalendarDay; + private boolean isMonthMode = false; + private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()); @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, @@ -58,10 +75,13 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. api = RetrofitClient.getAppointmentApi(requireContext()); hamburger = view.findViewById(R.id.btnHamburger); + calendarView = view.findViewById(R.id.calendarView); + btnToggleCalendarMode = view.findViewById(R.id.btnToggleCalendarMode); setupRecyclerView(view); setupSearch(view); setupSwipeRefresh(view); + setupCalendar(); loadAppointmentData(); loadPets(); loadServices(); @@ -76,9 +96,60 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. listFragment.openDrawer(); }); + btnToggleCalendarMode.setOnClickListener(v -> toggleCalendarMode()); + return view; } + // Toggle Calendar Mode from week to month and other way around + private void toggleCalendarMode() { + isMonthMode = !isMonthMode; + calendarView.state().edit() + .setCalendarDisplayMode(isMonthMode ? CalendarMode.MONTHS : CalendarMode.WEEKS) + .commit(); + } + + private void setupCalendar() { + calendarView.setOnDateChangedListener(new OnDateSelectedListener() { + @Override + public void onDateSelected(@NonNull MaterialCalendarView widget, @NonNull CalendarDay date, boolean selected) { + if (selected) { + if (date.equals(selectedCalendarDay)) { + selectedCalendarDay = null; + calendarView.clearSelection(); + } else { + selectedCalendarDay = date; + } + } else { + selectedCalendarDay = null; + } + filterAppointments(etSearch.getText().toString()); + } + }); + } + + //Set indicators for dates with appointments on the calendar + private void updateCalendarDecorators() { + HashSet datesWithAppointments = new HashSet<>(); + for (AppointmentDTO appointment : appointmentList) { + try { + //Get the appointment date + Date date = dateFormat.parse(appointment.getAppointmentDate()); + //if the date is not null, add it to the hashset + if (date != null) { + Calendar cal = Calendar.getInstance(); + cal.setTime(date); + datesWithAppointments.add(CalendarDay.from(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH))); + } + } catch (ParseException e) { + Log.e("AppointmentFragment", "Error parsing date: " + appointment.getAppointmentDate()); + } + } + //update the indicators to the calendar + calendarView.removeDecorators(); + calendarView.addDecorator(new EventDecorator(Color.RED, datesWithAppointments)); + } + private void setupSearch(View view) { etSearch = view.findViewById(R.id.etSearchAppointment); etSearch.addTextChangedListener(new TextWatcher() { @@ -99,16 +170,25 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. private void filterAppointments(String query) { filteredList.clear(); - if (query.isEmpty()) { - filteredList.addAll(appointmentList); - } else { - String lower = query.toLowerCase(); - for (AppointmentDTO a : appointmentList) { - if ((a.getCustomerName() != null && a.getCustomerName().toLowerCase().contains(lower)) - || (a.getServiceType() != null && a.getServiceType().toLowerCase().contains(lower)) - || (a.getPetName() != null && a.getPetName().toLowerCase().contains(lower))) { - filteredList.add(a); - } + String lowerQuery = query.toLowerCase(); + + String selectedDateString = null; + if (selectedCalendarDay != null) { + selectedDateString = String.format(Locale.getDefault(), "%04d-%02d-%02d", + selectedCalendarDay.getYear(), selectedCalendarDay.getMonth(), selectedCalendarDay.getDay()); + } + + for (AppointmentDTO a : appointmentList) { + boolean matchesSearch = query.isEmpty() || + (a.getCustomerName() != null && a.getCustomerName().toLowerCase().contains(lowerQuery)) || + (a.getServiceType() != null && a.getServiceType().toLowerCase().contains(lowerQuery)) || + (a.getPetName() != null && a.getPetName().toLowerCase().contains(lowerQuery)); + + boolean matchesDate = (selectedDateString == null) || + (a.getAppointmentDate() != null && a.getAppointmentDate().equals(selectedDateString)); + + if (matchesSearch && matchesDate) { + filteredList.add(a); } } adapter.notifyDataSetChanged(); @@ -141,17 +221,11 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. if (lf != null) lf.loadFragment(detailFragment); } public void onAppointmentSaved(int position, AppointmentDTO appointment) { - if (position == -1) { - appointmentList.add(appointment); - } else { - appointmentList.set(position, appointment); - } - filterAppointments(etSearch.getText().toString()); + loadAppointmentData(); } public void onAppointmentDeleted(int position) { - appointmentList.remove(position); - filterAppointments(etSearch.getText().toString()); + loadAppointmentData(); } @Override @@ -162,7 +236,7 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. private void loadAppointmentData() { if (swipeRefreshLayout != null) swipeRefreshLayout.setRefreshing(true); - api.getAllAppointments(0, 100).enqueue(new Callback>() { + api.getAllAppointments(0, 500).enqueue(new Callback>() { @Override public void onResponse(Call> call, Response> response) { @@ -171,6 +245,7 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter. if (response.isSuccessful() && response.body() != null) { appointmentList.clear(); appointmentList.addAll(response.body().getContent()); + updateCalendarDecorators(); filterAppointments(etSearch != null ? etSearch.getText().toString() : ""); } else { Log.e("AppointmentFragment", "Error: " + response.message()); diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/EventDecorator.java b/android/app/src/main/java/com/example/petstoremobile/utils/EventDecorator.java new file mode 100644 index 00000000..b58f38d4 --- /dev/null +++ b/android/app/src/main/java/com/example/petstoremobile/utils/EventDecorator.java @@ -0,0 +1,30 @@ +package com.example.petstoremobile.utils; + +import com.prolificinteractive.materialcalendarview.CalendarDay; +import com.prolificinteractive.materialcalendarview.DayViewDecorator; +import com.prolificinteractive.materialcalendarview.DayViewFacade; +import com.prolificinteractive.materialcalendarview.spans.DotSpan; + +import java.util.Collection; +import java.util.HashSet; + +public class EventDecorator implements DayViewDecorator { + + private final int color; + private final HashSet dates; + + public EventDecorator(int color, Collection dates) { + this.color = color; + this.dates = new HashSet<>(dates); + } + + @Override + public boolean shouldDecorate(CalendarDay day) { + return dates.contains(day); + } + + @Override + public void decorate(DayViewFacade view) { + view.addSpan(new DotSpan(8, color)); + } +} diff --git a/android/app/src/main/res/layout/fragment_appointment.xml b/android/app/src/main/res/layout/fragment_appointment.xml index a8da443e..10e17d48 100644 --- a/android/app/src/main/res/layout/fragment_appointment.xml +++ b/android/app/src/main/res/layout/fragment_appointment.xml @@ -29,15 +29,35 @@ android:contentDescription="Open menu"/> + + + +