Merge main into nomorebreaking

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

3
android/.gitignore vendored
View File

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

View File

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

View File

@@ -56,6 +56,9 @@ dependencies {
implementation("io.reactivex.rxjava2:rxjava:2.2.21")
implementation("io.reactivex.rxjava2:rxandroid:2.1.1")
implementation("com.github.bumptech.glide:glide:4.16.0")
annotationProcessor("com.github.bumptech.glide:compiler:4.16.0")
testImplementation(libs.junit)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

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

View File

@@ -1,25 +1,40 @@
package com.example.petstoremobile.activities;
import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import androidx.activity.EdgeToEdge;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.fragment.app.Fragment;
import com.example.petstoremobile.R;
import com.example.petstoremobile.fragments.ChatFragment;
import com.example.petstoremobile.fragments.ListFragment;
import com.example.petstoremobile.fragments.ProfileFragment;
import com.example.petstoremobile.services.ChatNotificationService;
import com.google.android.material.bottomnavigation.BottomNavigationView;
public class HomeActivity extends AppCompatActivity {
private BottomNavigationView bottomNav;
// Launcher to ask for notification permission
private final ActivityResultLauncher<String> requestPermissionLauncher =
registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
if (!isGranted) {
Log.w("HomeActivity", "Notification permission denied");
}
});
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -34,17 +49,15 @@ public class HomeActivity extends AppCompatActivity {
});
//get the bottom navbar from the layout
BottomNavigationView bottomNav = findViewById(R.id.bottom_navigation);
// Load ListFragment by default only if this is a fresh start
bottomNav = findViewById(R.id.bottom_navigation);
//load the list fragment by default if it's a fresh start
if (savedInstanceState == null) {
loadFragment(new ListFragment());
bottomNav.setSelectedItemId(R.id.nav_list);
handleIntent(getIntent());
}
//when an item in the bar is selected, load the corresponding fragment
//when an item in the bottom bar is selected, load the corresponding fragment
bottomNav.setOnItemSelectedListener(item -> {
if (item.getItemId() == R.id.nav_list) {
loadFragment(new ListFragment());
return true;
@@ -57,13 +70,59 @@ public class HomeActivity extends AppCompatActivity {
}
return false;
});
// Start the notification service and request for notification permission
startNotificationService();
requestNotificationPermission();
}
// Handle new intents when the activity is already running,
// like clicking a notification while the app is in use
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
handleIntent(intent);
}
//helper function to load a fragment
// Helper function to process intents for navigation.
// like clicking a notification or just launching the app from a fresh start
private void handleIntent(Intent intent) {
if (intent != null && "chat".equals(intent.getStringExtra("navigate_to"))) {
ChatFragment chatFragment = new ChatFragment();
if (intent.hasExtra("conversation_id")) {
Bundle args = new Bundle();
args.putLong("conversation_id", intent.getLongExtra("conversation_id", -1));
chatFragment.setArguments(args);
}
loadFragment(chatFragment);
bottomNav.setSelectedItemId(R.id.nav_chat);
} else {
loadFragment(new ListFragment());
bottomNav.setSelectedItemId(R.id.nav_list);
}
}
// Helper function to start the notification service in the background
// to receive notifications when a new conversation is created
private void startNotificationService() {
Intent serviceIntent = new Intent(this, ChatNotificationService.class);
startService(serviceIntent);
}
//Helper function to request for notification permission
private void requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
}
}
}
//Helper function to load a fragment
private void loadFragment(Fragment fragment) {
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.fragment_container, fragment)
.commit();
}
}
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package com.example.petstoremobile.api;
import android.content.Context;
import android.os.Build;
import com.example.petstoremobile.api.auth.AuthApi;
import com.example.petstoremobile.api.auth.AuthInterceptor;
@@ -12,9 +13,23 @@ import retrofit2.converter.gson.GsonConverterFactory;
//Retrofit client Used for API calls
public class RetrofitClient {
//base URL
public static final String BASE_URL = "http://10.0.2.2:8080"; //for emulator testing
// public static final String BASE_URL = "http://10.0.0.200:8080/"; //for hardware testing
public static final String BASE_URL = getBaseUrl();
// Helper function to determine BASE_URL based on whether we are testing on an emulator or a real device
private static String getBaseUrl() {
if (Build.FINGERPRINT.contains("generic")
|| Build.FINGERPRINT.contains("unknown")
|| Build.MODEL.contains("google_sdk")
|| Build.MODEL.contains("Emulator")
|| Build.MODEL.contains("Android SDK built for x86")
|| Build.MANUFACTURER.contains("Genymotion")
|| (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
|| "google_sdk".equals(Build.PRODUCT)) {
return "http://10.0.2.2:8080/"; //emulator testing
} else {
return "http://10.0.0.200:8080/"; //Hardware testing
}
}
private static Retrofit retrofit = null;
@@ -95,7 +110,6 @@ public class RetrofitClient {
return getClient(context).create(MessageApi.class);
}
public static StoreApi getStoreApi(Context context) {
return getClient(context).create(StoreApi.class);
}

View File

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

View File

@@ -0,0 +1,15 @@
package com.example.petstoremobile.dtos;
//Used to get messages of any errors from the backend
public class ErrorResponse {
private String message;
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}

View File

@@ -0,0 +1,50 @@
package com.example.petstoremobile.dtos;
public class UserDTO {
private Long id;
private String username;
private String email;
private String fullName;
private String phone;
private String avatarUrl;
private String role;
private Long storeId;
private String storeName;
// Getters
public Long getId() {
return id;
}
public String getUsername() {
return username;
}
public String getEmail() {
return email;
}
public String getFullName() {
return fullName;
}
public String getPhone() {
return phone;
}
public String getAvatarUrl() {
return avatarUrl;
}
public String getRole() {
return role;
}
public Long getStoreId() {
return storeId;
}
public String getStoreName() {
return storeName;
}
}

View File

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

View File

@@ -14,6 +14,7 @@ import android.widget.LinearLayout;
import com.example.petstoremobile.R;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.fragments.listfragments.PetFragment;
import com.example.petstoremobile.fragments.listfragments.ServiceFragment;
import com.example.petstoremobile.fragments.listfragments.SupplierFragment;
@@ -56,6 +57,13 @@ public class ListFragment extends Fragment {
drawerPurchaseOrderView=view.findViewById(R.id.drawerPurchaseOrderView);
// Check user role and restrict access for STAFF
String role = TokenManager.getInstance(requireContext()).getRole();
if ("STAFF".equalsIgnoreCase(role)) {
drawerSuppliers.setVisibility(View.GONE);
drawerInventory.setVisibility(View.GONE);
}
//needed to disable touches on the innerContainer while the drawer is open
touchBlocker = view.findViewById(R.id.touchBlocker);
@@ -170,4 +178,4 @@ public class ListFragment extends Fragment {
.addToBackStack(null)
.commit();
}
}
}

View File

@@ -16,6 +16,7 @@ import androidx.fragment.app.Fragment;
import android.provider.MediaStore;
import android.text.InputType;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -23,20 +24,43 @@ import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.model.GlideUrl;
import com.bumptech.glide.load.model.LazyHeaders;
import com.example.petstoremobile.R;
import com.example.petstoremobile.activities.MainActivity;
import com.example.petstoremobile.api.RetrofitClient;
import com.example.petstoremobile.api.auth.AuthApi;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.dtos.ErrorResponse;
import com.example.petstoremobile.dtos.UserDTO;
import com.example.petstoremobile.services.ChatNotificationService;
import com.example.petstoremobile.utils.InputValidator;
import com.google.gson.Gson;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class ProfileFragment extends Fragment {
//initialize the view/controls
private ImageView imgProfile;
private TextView tvProfileName, tvProfileEmail, tvProfilePhone, tvProfileRole;
private Button btnChangePhoto, btnEditEmail, btnEditPhone, btnLogout;
private Uri photoUri;
private UserDTO currentUser;
//Initialize the launchers for camera and gallery
private ActivityResultLauncher<Intent> galleryLauncher;
@@ -58,8 +82,7 @@ public class ProfileFragment extends Fragment {
&& result.getData() != null) {
//get the selected image and set the image to the profile
Uri selectedImage = result.getData().getData();
imgProfile.setImageURI(selectedImage);
//TODO: SAVE CHANGED PHOTO TO DATABASE
uploadAvatar(selectedImage);
}
}
);
@@ -71,10 +94,7 @@ public class ProfileFragment extends Fragment {
success -> {
//if a photo is taken set the image profile to it otherwise do nothing
if (success) {
//Clear the old image and set the new one
imgProfile.setImageURI(null);
imgProfile.setImageURI(photoUri);
//TODO: SAVE CHANGED PHOTO TO DATABASE
uploadAvatar(photoUri);
}
}
);
@@ -107,7 +127,6 @@ public class ProfileFragment extends Fragment {
);
}
//TODO: MAKE PROFILE VIEW DISPLAY PROFILE DATA FROM DATABASE
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
@@ -119,10 +138,13 @@ public class ProfileFragment extends Fragment {
tvProfileEmail = view.findViewById(R.id.tvProfileEmail);
tvProfilePhone = view.findViewById(R.id.tvProfilePhone);
tvProfileRole = view.findViewById(R.id.tvProfileRole);
btnChangePhoto = view.findViewById(R.id.btnChangePhoto);
btnEditEmail = view.findViewById(R.id.btnEditEmail);
btnEditPhone = view.findViewById(R.id.btnEditPhone);
btnLogout = view.findViewById(R.id.btnLogout);
Button btnChangePhoto = view.findViewById(R.id.btnChangePhoto);
Button btnEditEmail = view.findViewById(R.id.btnEditEmail);
Button btnEditPhone = view.findViewById(R.id.btnEditPhone);
Button btnLogout = view.findViewById(R.id.btnLogout);
//Load Profile Data from backend
loadProfileData();
//Set up listeners for the buttons
//Change photo button
@@ -150,7 +172,6 @@ public class ProfileFragment extends Fragment {
}
})
.show();
//TODO: UPDATE PHOTO IN DATABASE
});
//Edit email button
@@ -170,19 +191,10 @@ public class ProfileFragment extends Fragment {
.setTitle("Edit Email")
.setView(input)
.setPositiveButton("Save", (dialog, which) -> {
String newEmail = input.getText().toString();
//if the new value is a valid email then set the email to the new value
if (android.util.Patterns.EMAIL_ADDRESS.matcher(newEmail).matches()) {
tvProfileEmail.setText(newEmail);
//TODO: UPDATE THE EMAIL IN DATABASE
}
else {
//tell the user to email is invalid
new AlertDialog.Builder(requireContext())
.setTitle("Error")
.setMessage("Email is invalid")
.setPositiveButton("OK", null)
.show();
if (InputValidator.isValidEmail(input)) {
updateProfileField("email", input.getText().toString());
} else {
Toast.makeText(requireContext(), "Email is invalid", Toast.LENGTH_SHORT).show();
}
})
.setNegativeButton("Cancel", null)
@@ -210,19 +222,10 @@ public class ProfileFragment extends Fragment {
.setTitle("Edit Phone Number")
.setView(input)
.setPositiveButton("Save", (dialog, which) -> {
String newPhone = input.getText().toString();
//if the new value is format: (XXX) XXX-XXXX then set the phone to the new value
if (newPhone.matches("\\(\\d{3}\\) \\d{3}-\\d{4}")) { //TODO MAKE VALIDATION CLASS INSTEAD FOR THIS
tvProfilePhone.setText(newPhone);
//TODO: UPDATE PHONE IN DATABASE
}
else {
//tell the user to email cannot be empty
new AlertDialog.Builder(requireContext())
.setTitle("Error")
.setMessage("Phone number is invalid. Format: (XXX) XXX-XXXX")
.setPositiveButton("OK", null)
.show();
if (InputValidator.isValidPhone(input)) {
updateProfileField("phone", input.getText().toString());
} else {
Toast.makeText(requireContext(), "Phone number is invalid", Toast.LENGTH_SHORT).show();
}
})
.setNegativeButton("Cancel", null)
@@ -231,6 +234,10 @@ public class ProfileFragment extends Fragment {
//Logout button
btnLogout.setOnClickListener(v -> {
// Stop notification service before logging out so notifications stop
Intent serviceIntent = new Intent(requireContext(), ChatNotificationService.class);
requireContext().stopService(serviceIntent);
TokenManager.getInstance(requireContext()).clearLoginData(); // clear the token for next login
//get the intent to the main activity and clear the back stack so the back button won't allow the user to go back to the previous screen
Intent intent = new Intent(getActivity(), MainActivity.class);
@@ -252,4 +259,152 @@ public class ProfileFragment extends Fragment {
//launch the camera to capture the photo and save the photo to photoUri
cameraLauncher.launch(photoUri);
}
}
//Helper function to call the backend to get profile data and load it to the view
private void loadProfileData() {
AuthApi authApi = RetrofitClient.getAuthApi(requireContext());
authApi.getMe().enqueue(new Callback<UserDTO>() {
@Override
public void onResponse(Call<UserDTO> call, Response<UserDTO> response) {
//if the response is successful and the body is not null then set the user to the view
if (response.isSuccessful() && response.body() != null) {
currentUser = response.body();
//set the user data to the view
tvProfileName.setText(currentUser.getFullName());
tvProfileEmail.setText(currentUser.getEmail());
tvProfilePhone.setText(currentUser.getPhone());
tvProfileRole.setText(currentUser.getRole());
// get the avatar endpoint to load profile image and the token for authorization
String avatarUrl = RetrofitClient.BASE_URL + AuthApi.AVATAR_FILE_PATH;
String token = TokenManager.getInstance(requireContext()).getToken();
if (token != null) {
// Create GlideUrl with token to fetch the image
GlideUrl glideUrl = new GlideUrl(avatarUrl, new LazyHeaders.Builder()
.addHeader("Authorization", "Bearer " + token)
.build());
// Load image using Glide
Glide.with(ProfileFragment.this)
.load(glideUrl)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
.placeholder(R.drawable.placeholder)
.error(R.drawable.placeholder)
.into(imgProfile);
} else {
// load placeholder image if token is null
Glide.with(ProfileFragment.this)
.load(R.drawable.placeholder)
.into(imgProfile);
}
}
else {
Log.e("onResponse: ", response.message());
Toast.makeText(getContext(), "Failed to load profile: ", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<UserDTO> call, Throwable t) {
Log.e("PROFILE", "onFailure: " + t.getMessage());
Toast.makeText(getContext(), "Network error: could not load profile", Toast.LENGTH_SHORT).show();
}
});
}
//Helper function to call the backend to upload a profile image
private void uploadAvatar(Uri uri) {
try {
File file = getFileFromUri(uri);
if (file == null) return;
// Create RequestBody for file upload
RequestBody requestFile = RequestBody.create(file, MediaType.parse(requireContext().getContentResolver().getType(uri)));
MultipartBody.Part body = MultipartBody.Part.createFormData("avatar", file.getName(), requestFile);
//Call the backend to upload the avatar
AuthApi authApi = RetrofitClient.getAuthApi(requireContext());
authApi.uploadAvatar(body).enqueue(new Callback<UserDTO>() {
@Override
public void onResponse(Call<UserDTO> call, Response<UserDTO> response) {
if (response.isSuccessful() && response.body() != null) {
currentUser = response.body();
Toast.makeText(requireContext(), "Avatar updated successfully", Toast.LENGTH_SHORT).show();
// Reload image after successful upload
loadProfileData();
} else {
Toast.makeText(requireContext(), "Failed to upload avatar", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<UserDTO> call, Throwable t) {
Log.e("UPLOAD_AVATAR", "Failure: " + t.getMessage());
Toast.makeText(requireContext(), "Network error", Toast.LENGTH_SHORT).show();
}
});
} catch (Exception e) {
Log.e("UPLOAD_AVATAR", "Error: " + e.getMessage());
}
}
// Helper function to create a temporary File object from a Uri for uploading the avatar
private File getFileFromUri(Uri uri) {
try {
InputStream inputStream = requireContext().getContentResolver().openInputStream(uri);
File tempFile = new File(requireContext().getCacheDir(), "upload_avatar.jpg");
FileOutputStream outputStream = new FileOutputStream(tempFile);
byte[] buffer = new byte[1024];
int length;
while ((length = inputStream.read(buffer)) > 0) {
outputStream.write(buffer, 0, length);
}
outputStream.close();
inputStream.close();
return tempFile;
} catch (Exception e) {
Log.e("FILE_UTILS", "Error creating temp file", e);
return null;
}
}
//Helper function to update a profile field in the backend
private void updateProfileField(String fieldName, String value) {
AuthApi authApi = RetrofitClient.getAuthApi(requireContext());
Map<String, String> updates = new HashMap<>();
updates.put(fieldName, value);
authApi.updateMe(updates).enqueue(new Callback<UserDTO>() {
@Override
public void onResponse(Call<UserDTO> call, Response<UserDTO> response) {
if (response.isSuccessful() && response.body() != null) {
currentUser = response.body();
// Update the view with the new data from backend
tvProfileEmail.setText(currentUser.getEmail());
tvProfilePhone.setText(currentUser.getPhone());
Toast.makeText(requireContext(), "Profile updated successfully", Toast.LENGTH_SHORT).show();
} else {
try {
String errorJson = response.errorBody().string();
ErrorResponse errorResponse = new Gson().fromJson(errorJson, ErrorResponse.class);
String errorMessage = errorResponse.getMessage();
Toast.makeText(requireContext(), errorMessage, Toast.LENGTH_LONG).show();
} catch (Exception e) {
Log.e("UPDATE_PROFILE", "Error parsing error body", e);
Toast.makeText(requireContext(), "Failed to update profile", Toast.LENGTH_SHORT).show();
}
}
}
@Override
public void onFailure(Call<UserDTO> call, Throwable t) {
Log.e("UPDATE_PROFILE", "Failure: " + t.getMessage());
Toast.makeText(requireContext(), "Network error", Toast.LENGTH_SHORT).show();
}
});
}
}

View File

@@ -13,8 +13,11 @@ import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.Spinner;
import android.widget.Toast;
import com.example.petstoremobile.R;
@@ -43,6 +46,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
private PetApi api;
private SwipeRefreshLayout swipeRefreshLayout;
private EditText etSearch;
private Spinner spinnerStatus;
//load pet view
@Override
@@ -57,6 +61,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
setupRecyclerView(view);
setupSearch(view);
setupStatusFilter(view);
setupSwipeRefresh(view);
loadPetData();
@@ -82,24 +87,48 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
etSearch.addTextChangedListener(new TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override public void onTextChanged(CharSequence s, int start, int before, int count) {
filterPets(s.toString());
filterPets();
}
@Override public void afterTextChanged(Editable s) {}
});
}
private void filterPets(String query) {
//Setup the status filter spinner
private void setupStatusFilter(View view) {
spinnerStatus = view.findViewById(R.id.spinnerStatus);
String[] statuses = {"All Statuses", "Available", "Adopted"};
ArrayAdapter<String> adapter = new ArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_item, statuses);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinnerStatus.setAdapter(adapter);
spinnerStatus.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
filterPets();
}
@Override
public void onNothingSelected(AdapterView<?> parent) {}
});
}
// Helper function to filter pets based on search and status filter
private void filterPets() {
String query = etSearch.getText().toString().toLowerCase();
String selectedStatus = spinnerStatus.getSelectedItem().toString();
filteredList.clear();
if (query.isEmpty()) {
filteredList.addAll(petList);
} else {
String lower = query.toLowerCase();
for (PetDTO p : petList) {
if (p.getPetName().toLowerCase().contains(lower)
|| p.getPetSpecies().toLowerCase().contains(lower)
|| p.getPetBreed().toLowerCase().contains(lower)) {
filteredList.add(p);
}
for (PetDTO p : petList) {
boolean matchesSearch = query.isEmpty() ||
p.getPetName().toLowerCase().contains(query) ||
p.getPetSpecies().toLowerCase().contains(query) ||
p.getPetBreed().toLowerCase().contains(query);
boolean matchesStatus = selectedStatus.equals("All Statuses") ||
p.getPetStatus().equalsIgnoreCase(selectedStatus);
if (matchesSearch && matchesStatus) {
filteredList.add(p);
}
}
adapter.notifyDataSetChanged();
@@ -173,7 +202,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
if (response.isSuccessful() && response.body() != null) {
petList.clear();
petList.addAll(response.body().getContent());
filterPets(etSearch.getText().toString());
filterPets();
} else {
Log.e("onResponse: ", response.message());

View File

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

View File

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

View File

@@ -20,6 +20,8 @@ import com.example.petstoremobile.api.SupplierApi;
import com.example.petstoremobile.dtos.SupplierDTO;
import com.example.petstoremobile.fragments.ListFragment;
import com.example.petstoremobile.fragments.listfragments.SupplierFragment;
import com.example.petstoremobile.utils.ActivityLogger;
import com.example.petstoremobile.utils.InputValidator;
import retrofit2.Call;
import retrofit2.Callback;
@@ -58,6 +60,13 @@ public class SupplierDetailFragment extends Fragment {
//Method to Update or Add a supplier
private void saveSupplier() {
// Validates all fields using InputValidator
if (!InputValidator.isNotEmpty(etSupCompany, "Company Name")) return;
if (!InputValidator.isNotEmpty(etSupContactFirstName, "First Name")) return;
if (!InputValidator.isNotEmpty(etSupContactLastName, "Last Name")) return;
if (!InputValidator.isValidEmail(etSupEmail)) return;
if (!InputValidator.isValidPhone(etSupPhone)) return;
//get all the values from the fields
String company = etSupCompany.getText().toString().trim();
String firstName = etSupContactFirstName.getText().toString().trim();
@@ -65,12 +74,6 @@ public class SupplierDetailFragment extends Fragment {
String email = etSupEmail.getText().toString().trim();
String phone = etSupPhone.getText().toString().trim();
//check if all the fields are filled
if (company.isEmpty() || firstName.isEmpty() || lastName.isEmpty() || email.isEmpty() || phone.isEmpty()) {
Toast.makeText(getContext(), "Please fill in all fields", Toast.LENGTH_SHORT).show();
return;
}
//create a supplier object to send to the API
SupplierDTO supplierDTO = new SupplierDTO();
supplierDTO.setSupCompany(company);
@@ -89,6 +92,7 @@ public class SupplierDetailFragment extends Fragment {
@Override
public void onResponse(Call<SupplierDTO> call, Response<SupplierDTO> response) {
if (response.isSuccessful()) {
ActivityLogger.logChange(requireContext(), "Supplier", "UPDATED", supId);
Toast.makeText(getContext(), "Supplier updated successfully!", Toast.LENGTH_SHORT).show();
navigateBack();
} else {
@@ -98,6 +102,7 @@ public class SupplierDetailFragment extends Fragment {
@Override
public void onFailure(Call<SupplierDTO> call, Throwable t) {
ActivityLogger.logException(requireContext(), "SupplierDetailFragment.updateSupplier", new Exception(t));
Log.e("SupplierDetailFragment", "Error updating supplier", t);
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
@@ -108,6 +113,7 @@ public class SupplierDetailFragment extends Fragment {
@Override
public void onResponse(Call<SupplierDTO> call, Response<SupplierDTO> response) {
if (response.isSuccessful()) {
ActivityLogger.log(requireContext(), "Added new Supplier: " + company);
Toast.makeText(getContext(), "Supplier added successfully!", Toast.LENGTH_SHORT).show();
navigateBack();
} else {
@@ -117,6 +123,7 @@ public class SupplierDetailFragment extends Fragment {
@Override
public void onFailure(Call<SupplierDTO> call, Throwable t) {
ActivityLogger.logException(requireContext(), "SupplierDetailFragment.createSupplier", new Exception(t));
Log.e("SupplierDetailFragment", "Error adding supplier", t);
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
@@ -136,6 +143,7 @@ public class SupplierDetailFragment extends Fragment {
@Override
public void onResponse(Call<Void> call, Response<Void> response) {
if (response.isSuccessful()) {
ActivityLogger.logChange(requireContext(), "Supplier", "DELETED", supId);
Toast.makeText(getContext(), "Supplier deleted successfully!", Toast.LENGTH_SHORT).show();
navigateBack();
} else {
@@ -145,6 +153,7 @@ public class SupplierDetailFragment extends Fragment {
@Override
public void onFailure(Call<Void> call, Throwable t) {
ActivityLogger.logException(requireContext(), "SupplierDetailFragment.deleteSupplier", new Exception(t));
Log.e("SupplierDetailFragment", "Error deleting supplier", t);
Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
@@ -197,6 +206,11 @@ public class SupplierDetailFragment extends Fragment {
etSupContactLastName = view.findViewById(R.id.etSupContactLastName);
etSupEmail = view.findViewById(R.id.etSupEmail);
etSupPhone = view.findViewById(R.id.etSupPhone);
// Add phone number formatting (CA) and limit length to 14 characters
etSupPhone.addTextChangedListener(new android.telephony.PhoneNumberFormattingTextWatcher("CA"));
etSupPhone.setFilters(new android.text.InputFilter[]{new android.text.InputFilter.LengthFilter(14)});
btnSaveSupplier = view.findViewById(R.id.btnSaveSupplier);
btnDeleteSupplier = view.findViewById(R.id.btnDeleteSupplier);
btnBack = view.findViewById(R.id.btnBack);

View File

@@ -16,26 +16,42 @@ import androidx.core.content.FileProvider;
import androidx.fragment.app.Fragment;
import android.provider.MediaStore;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.example.petstoremobile.R;
import com.example.petstoremobile.api.PetApi;
import com.example.petstoremobile.api.RetrofitClient;
import com.example.petstoremobile.fragments.ListFragment;
import com.example.petstoremobile.fragments.listfragments.detailfragments.PetDetailFragment;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.util.Locale;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class PetProfileFragment extends Fragment {
private TextView tvPetName, tvPetSpecies, tvPetBreed, tvPetAge, tvPetPrice;
private Button btnBack, btnEditPet, btnChangePhoto;
private ImageView imgPet;
private Uri photoUri;
private int petId;
// launchers for camera and gallery
private ActivityResultLauncher<Intent> galleryLauncher;
@@ -53,8 +69,7 @@ public class PetProfileFragment extends Fragment {
result -> {
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
Uri selectedImage = result.getData().getData();
imgPet.setImageURI(selectedImage);
// TODO: SAVE CHANGED PHOTO TO DATABASE
uploadPetImage(selectedImage);
}
}
);
@@ -64,9 +79,7 @@ public class PetProfileFragment extends Fragment {
new ActivityResultContracts.TakePicture(),
success -> {
if (success) {
imgPet.setImageURI(null);
imgPet.setImageURI(photoUri);
// TODO: SAVE CHANGED PHOTO TO DATABASE
uploadPetImage(photoUri);
}
}
);
@@ -112,11 +125,15 @@ public class PetProfileFragment extends Fragment {
// Set pet details to display
if (getArguments() != null) {
petId = getArguments().getInt("petId");
tvPetName.setText(getArguments().getString("petName"));
tvPetSpecies.setText(getArguments().getString("petSpecies"));
tvPetBreed.setText(getArguments().getString("petBreed"));
tvPetAge.setText(String.format(Locale.getDefault(), "%d yr(s)", getArguments().getInt("petAge")));
tvPetPrice.setText(String.format(Locale.getDefault(), "$%.2f", getArguments().getDouble("petPrice")));
// Load pet image from backend
loadPetImage(petId);
}
//set button click listeners
@@ -169,6 +186,74 @@ public class PetProfileFragment extends Fragment {
return view;
}
// Helper function to load pet image from backend
private void loadPetImage(int petId) {
String imageUrl = RetrofitClient.BASE_URL + String.format(Locale.US, PetApi.PET_IMAGE_PATH, petId);
Glide.with(this)
.load(imageUrl)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
.placeholder(R.drawable.placeholder)
.error(R.drawable.placeholder)
.into(imgPet);
}
// Helper function to upload pet image to backend
private void uploadPetImage(Uri uri) {
try {
File file = getFileFromUri(uri);
if (file == null) return;
// Create RequestBody for file upload
RequestBody requestFile = RequestBody.create(file, MediaType.parse(requireContext().getContentResolver().getType(uri)));
MultipartBody.Part body = MultipartBody.Part.createFormData("image", file.getName(), requestFile);
// Call the backend to upload the image
PetApi petApi = RetrofitClient.getPetApi(requireContext());
petApi.uploadPetImage((long) petId, body).enqueue(new Callback<Void>() {
@Override
public void onResponse(Call<Void> call, Response<Void> response) {
if (response.isSuccessful()) {
Toast.makeText(requireContext(), "Pet photo updated successfully", Toast.LENGTH_SHORT).show();
// Reload image after successful upload
loadPetImage(petId);
} else {
Toast.makeText(requireContext(), "Failed to upload pet photo", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<Void> call, Throwable t) {
Log.e("UPLOAD_PET_IMAGE", "Failure: " + t.getMessage());
Toast.makeText(requireContext(), "Network error", Toast.LENGTH_SHORT).show();
}
});
} catch (Exception e) {
Log.e("UPLOAD_PET_IMAGE", "Error: " + e.getMessage());
}
}
// Helper function to create a temporary File object from a Uri for uploading
private File getFileFromUri(Uri uri) {
try {
InputStream inputStream = requireContext().getContentResolver().openInputStream(uri);
File tempFile = new File(requireContext().getCacheDir(), "upload_pet_image.jpg");
FileOutputStream outputStream = new FileOutputStream(tempFile);
byte[] buffer = new byte[1024];
int length;
while ((length = inputStream.read(buffer)) > 0) {
outputStream.write(buffer, 0, length);
}
outputStream.close();
inputStream.close();
return tempFile;
} catch (Exception e) {
Log.e("FILE_UTILS", "Error creating temp file", e);
return null;
}
}
private void launchCamera() {
File photoFile = new File(requireContext().getCacheDir(), "pet_photo.jpg");
photoUri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".fileprovider", photoFile);

View File

@@ -0,0 +1,226 @@
package com.example.petstoremobile.services;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.example.petstoremobile.api.ChatApi;
import com.example.petstoremobile.api.CustomerApi;
import com.example.petstoremobile.api.RetrofitClient;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.dtos.ConversationDTO;
import com.example.petstoremobile.dtos.CustomerDTO;
import com.example.petstoremobile.dtos.MessageDTO;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.utils.NotificationHelper;
import com.example.petstoremobile.websocket.StompChatManager;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
// Service to receive notifications when a new conversation is created
public class ChatNotificationService extends Service {
private static final String TAG = "ChatNotificationService";
public static Long activeConversationIdInUi = null;
private StompChatManager stompChatManager;
private final Set<Long> knownConversationIds = new HashSet<>();
private final Map<Long, Long> conversationToCustomerId = new HashMap<>();
private final Map<Long, String> customerIdToName = new HashMap<>();
private Long currentUserId;
//When the service starts, connect to the websocket
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.d(TAG, "Service started");
connectWebSocket();
return START_STICKY;
}
// helper function to connect to the websocket
private void connectWebSocket() {
//get the token and role from the shared preferences
TokenManager tm = TokenManager.getInstance(this);
String token = tm.getToken();
String role = tm.getRole();
currentUserId = tm.getUserId();
if (token != null && stompChatManager == null) {
//load customers to have names associated with customer ids
CustomerApi customerApi = RetrofitClient.getCustomerApi(this);
customerApi.getAllCustomers(0, 1000).enqueue(new Callback<PageResponse<CustomerDTO>>() {
@Override
public void onResponse(@NonNull Call<PageResponse<CustomerDTO>> call, @NonNull Response<PageResponse<CustomerDTO>> response) {
if (response.isSuccessful() && response.body() != null) {
for (CustomerDTO customer : response.body().getContent()) {
customerIdToName.put(customer.getCustomerId(), customer.getFullName());
}
}
loadConversationsAndStartStomp(token, role);
}
@Override
public void onFailure(@NonNull Call<PageResponse<CustomerDTO>> call, @NonNull Throwable t) {
Log.e(TAG, "Failed to load customers", t);
loadConversationsAndStartStomp(token, role);
}
});
}
}
private void loadConversationsAndStartStomp(String token, String role) {
// Fetch existing conversations
ChatApi chatApi = RetrofitClient.getChatApi(this);
chatApi.getAllConversations().enqueue(new Callback<List<ConversationDTO>>() {
@Override
public void onResponse(@NonNull Call<List<ConversationDTO>> call, @NonNull Response<List<ConversationDTO>> response) {
if (response.isSuccessful() && response.body() != null) {
for (ConversationDTO conversation : response.body()) {
if (conversation.getId() != null) {
knownConversationIds.add(conversation.getId());
conversationToCustomerId.put(conversation.getId(), conversation.getCustomerId());
// subscribe to existing conversations to get message notifications
if (stompChatManager != null) {
stompChatManager.subscribeToConversation(conversation.getId());
}
}
}
Log.d(TAG, "Loaded " + knownConversationIds.size() + " existing conversations");
}
startStomp(token, role);
}
@Override
public void onFailure(@NonNull Call<List<ConversationDTO>> call, @NonNull Throwable t) {
Log.e(TAG, "Failed to load existing conversations", t);
//tries to connect if loading fails
startStomp(token, role);
}
});
}
private void startStomp(String token, String role) {
if (stompChatManager != null) return;
stompChatManager = new StompChatManager(token, role);
// Listen for messages in existing conversations
stompChatManager.setMessageListener(message -> {
if (message != null && !message.getSenderId().equals(currentUserId)) {
// Check if this conversation is already active in the view
//if it is then don't make a notification for this chat
if (activeConversationIdInUi != null && activeConversationIdInUi.equals(message.getConversationId())) {
Log.d(TAG, "Disable notification for active conversation: " + message.getConversationId());
return;
}
String title = "New Message";
Long customerId = conversationToCustomerId.get(message.getConversationId());
if (customerId != null && customerIdToName.containsKey(customerId)) {
//append the customer name to the title of the notification
title = "New message from " + customerIdToName.get(customerId);
}
NotificationHelper.showNotification(
getApplicationContext(),
title,
message.getContent(),
message.getConversationId()
);
}
});
//When a conversation gets created, show a notification
stompChatManager.setConversationListener(conversation -> {
//check if the conversation exists
if (conversation != null && conversation.getId() != null) {
//check if the conversation is new
if (!knownConversationIds.contains(conversation.getId())) {
//add the conversation to the set of known conversations
knownConversationIds.add(conversation.getId());
conversationToCustomerId.put(conversation.getId(), conversation.getCustomerId());
// Subscribe to the new conversation's messages
stompChatManager.subscribeToConversation(conversation.getId());
String title = "New Support Request";
if (customerIdToName.containsKey(conversation.getCustomerId())) {
//append the customer name to the title of the notification
title = "New Support Request from " + customerIdToName.get(conversation.getCustomerId());
} else {
// Try to fetch customer name for the new request
fetchCustomerName(conversation.getCustomerId());
}
//Display a notification
NotificationHelper.showNotification(
getApplicationContext(),
title,
"A customer is requesting assistance",
conversation.getId()
);
}
}
});
// Subscribe to existing conversations if they were already loaded
for (Long id : knownConversationIds) {
stompChatManager.subscribeToConversation(id);
}
stompChatManager.setConnectionListener(new StompChatManager.ConnectionListener() {
@Override
public void onSocketOpened() {
Log.d(TAG, "WebSocket connected in service");
}
@Override
public void onSocketClosed() { Log.d(TAG, "WebSocket closed in service"); }
@Override
public void onSocketError() { Log.e(TAG, "WebSocket error in service"); }
});
stompChatManager.connect();
}
// Helper function to fetch customer name for a conversation
private void fetchCustomerName(Long customerId) {
CustomerApi customerApi = RetrofitClient.getCustomerApi(this);
customerApi.getCustomerById(customerId).enqueue(new Callback<CustomerDTO>() {
@Override
public void onResponse(@NonNull Call<CustomerDTO> call, @NonNull Response<CustomerDTO> response) {
if (response.isSuccessful() && response.body() != null) {
customerIdToName.put(customerId, response.body().getFullName());
}
}
@Override
public void onFailure(@NonNull Call<CustomerDTO> call, @NonNull Throwable t) {
Log.e(TAG, "Failed to fetch customer name", t);
}
});
}
//When the service is destroyed, disconnect from the websocket
@Override
public void onDestroy() {
if (stompChatManager != null) {
stompChatManager.disconnect();
}
super.onDestroy();
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
}

View File

@@ -61,10 +61,11 @@ public class InputValidator {
return true;
}
// Checks if the phone number is valid (digits, spaces, dashes, brackets allowed)
// Checks if the phone number is valid
public static boolean isValidPhone(EditText field) {
String phone = field.getText().toString().trim();
if (phone.isEmpty() || !phone.matches("[0-9\\-\\s\\(\\)\\+]+")) {
// Android built in phone validation pattern
if (phone.isEmpty() || !android.util.Patterns.PHONE.matcher(phone).matches()) {
field.setError("Enter a valid phone number");
field.requestFocus();
return false;
@@ -94,4 +95,3 @@ public class InputValidator {
return true;
}
}

View File

@@ -0,0 +1,56 @@
package com.example.petstoremobile.utils;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import androidx.core.app.NotificationCompat;
import com.example.petstoremobile.R;
import com.example.petstoremobile.activities.HomeActivity;
// Helper class to show notifications when called
public class NotificationHelper {
private static final String CHANNEL_ID = "chat_notifications";
private static final String CHANNEL_NAME = "Chat Notifications";
private static final String CHANNEL_DESC = "Notifications for new conversations";
private static final int NOTIFICATION_ID = 1;
// a function to show a notification
public static void showNotification(Context context, String title, String message, Long conversationId) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
//check if the device is running on Oreo or higher so we can set up a notification channel
// for these devices
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Create a notification channel
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH);
channel.setDescription(CHANNEL_DESC);
notificationManager.createNotificationChannel(channel);
}
//make the notification navigate the chat if it is clicked
Intent intent = new Intent(context, HomeActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
intent.putExtra("navigate_to", "chat");
if (conversationId != null) {
intent.putExtra("conversation_id", conversationId);
}
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
//build the notification for display
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(message)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setAutoCancel(true)
.setContentIntent(pendingIntent);
notificationManager.notify(NOTIFICATION_ID, builder.build());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

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