Merge pull request #30 from RecentRunner/WorkingOnProfileAndPushNotification

Working on profile and push notification
This commit is contained in:
2026-03-25 09:18:48 -06:00
committed by GitHub
15 changed files with 413 additions and 60 deletions

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>

View File

@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

6
android/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

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"

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);
bottomNav = findViewById(R.id.bottom_navigation);
// Load ListFragment by default only if this is a fresh start
//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,9 +70,49 @@ public class HomeActivity extends AppCompatActivity {
}
return false;
});
// Start the notification service and request for notification permission
startNotificationService();
requestNotificationPermission();
}
//helper function to load a fragment
// 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 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"))) {
loadFragment(new 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()

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;
@@ -90,11 +91,11 @@ public class MainActivity extends AppCompatActivity {
);
//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 +107,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);

View File

@@ -1,17 +1,29 @@
package com.example.petstoremobile.api.auth;
import com.example.petstoremobile.dtos.AuthDTO;
import com.example.petstoremobile.dtos.UserDTO;
import java.util.Map;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.GET;
import retrofit2.http.POST;
import retrofit2.http.PUT;
//Api for logging in and getting current user
public interface AuthApi {
//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);
}

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

@@ -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,34 @@ import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
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.util.HashMap;
import java.util.Map;
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;
@@ -107,7 +122,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 +133,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
@@ -170,19 +187,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 +218,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 +230,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 +255,72 @@ 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());
//TODO: LOAD PHOTO FROM DATABASE
}
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 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

@@ -0,0 +1,88 @@
package com.example.petstoremobile.services;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.util.Log;
import androidx.annotation.Nullable;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.dtos.ConversationDTO;
import com.example.petstoremobile.utils.NotificationHelper;
import com.example.petstoremobile.websocket.StompChatManager;
import java.util.HashSet;
import java.util.Set;
// Service to receive notifications when a new conversation is created
public class ChatNotificationService extends Service {
private static final String TAG = "ChatNotificationService";
private StompChatManager stompChatManager;
private final Set<Long> knownConversationIds = new HashSet<>();
//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();
if (token != null && stompChatManager == null) {
stompChatManager = new StompChatManager(token, role);
//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());
NotificationHelper.showNotification(
getApplicationContext(),
"Customer Support",
"A customer request for assistance"
);
}
}
});
stompChatManager.setConnectionListener(new StompChatManager.ConnectionListener() {
// when the websocket is connected, set isFirstLoad to false after a delay
@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();
}
}
//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,53 @@
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) {
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");
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

@@ -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"/>