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/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/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/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/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/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/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/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/drawable/placeholder2.png b/android/app/src/main/res/drawable/placeholder2.png new file mode 100644 index 00000000..dec35ec5 Binary files /dev/null and b/android/app/src/main/res/drawable/placeholder2.png differ 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"/> + + + + + + + + + + + + + + + + + + + + + + + + + - + 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 diff --git a/android/app/src/main/res/layout/item_product.xml b/android/app/src/main/res/layout/item_product.xml index fad14129..2fe5768b 100644 --- a/android/app/src/main/res/layout/item_product.xml +++ b/android/app/src/main/res/layout/item_product.xml @@ -1,5 +1,6 @@ - - - - - - + android:gravity="center_vertical"> - + + + android:orientation="vertical"> + + + + + + + + + + + +