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/3] 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 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/3] 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: Fri, 3 Apr 2026 19:37:43 -0600 Subject: [PATCH 3/3] 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"/> + + + +