Added chat implementation with websockets

This commit is contained in:
Alex
2026-03-15 17:41:42 -06:00
parent 7b8b75cd82
commit 4e3261e987
29 changed files with 1005 additions and 169 deletions

View File

@@ -37,13 +37,11 @@ dependencies {
implementation(libs.activity) implementation(libs.activity)
implementation(libs.constraintlayout) implementation(libs.constraintlayout)
implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0") implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.9.1") implementation("com.squareup.okhttp3:logging-interceptor:4.9.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.android.material:material:1.11.0") implementation("com.google.android.material:material:1.11.0")
implementation("androidx.viewpager2:viewpager2:1.1.0") implementation("androidx.viewpager2:viewpager2:1.1.0")
@@ -53,6 +51,11 @@ dependencies {
implementation("androidx.camera:camera-view:1.4.0") implementation("androidx.camera:camera-view:1.4.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation(libs.swiperefreshlayout) implementation(libs.swiperefreshlayout)
implementation("com.github.NaikSoftware:StompProtocolAndroid:1.6.6")
implementation("io.reactivex.rxjava2:rxjava:2.2.21")
implementation("io.reactivex.rxjava2:rxandroid:2.1.1")
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.espresso.core)

View File

@@ -1,7 +1,7 @@
package com.example.petstoremobile; package com.example.petstoremobile;
import android.app.Application; import android.app.Application;
import com.example.petstoremobile.api.Auth.TokenManager; import com.example.petstoremobile.api.auth.TokenManager;
public class PetStoreApplication extends Application { public class PetStoreApplication extends Application {
@Override @Override

View File

@@ -15,8 +15,8 @@ import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsCompat;
import com.example.petstoremobile.R; import com.example.petstoremobile.R;
import com.example.petstoremobile.api.Auth.AuthApi; import com.example.petstoremobile.api.auth.AuthApi;
import com.example.petstoremobile.api.Auth.TokenManager; import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.api.RetrofitClient; import com.example.petstoremobile.api.RetrofitClient;
import com.example.petstoremobile.dtos.AuthDTO; import com.example.petstoremobile.dtos.AuthDTO;
@@ -64,7 +64,7 @@ public class MainActivity extends AppCompatActivity {
//Set click listener for login button //Set click listener for login button
btnLogin.setOnClickListener(v -> { btnLogin.setOnClickListener(v -> {
//Get user name and password from text fields //Get username and password from text fields
String username = etUser.getText().toString(); String username = etUser.getText().toString();
String password = etPassword.getText().toString(); String password = etPassword.getText().toString();
@@ -88,11 +88,33 @@ public class MainActivity extends AppCompatActivity {
response.body().getUsername(), response.body().getUsername(),
response.body().getRole() response.body().getRole()
); );
//go to home activity after login
Intent intent = new Intent(MainActivity.this, HomeActivity.class); //fetch user id from api then login to home activity
startActivity(intent); RetrofitClient.getAuthApi(MainActivity.this).getCurrentUser()
Toast.makeText(MainActivity.this, "Login successful", Toast.LENGTH_SHORT).show(); .enqueue(new Callback<AuthDTO.UserResponse>() {
finish(); @Override
public void onResponse(Call<AuthDTO.UserResponse> call,
Response<AuthDTO.UserResponse> response) {
if (response.isSuccessful() && response.body() != null) {
TokenManager.getInstance(MainActivity.this)
.saveUserId(response.body().getId());
}
Toast.makeText(MainActivity.this, "Login successful", Toast.LENGTH_SHORT).show();
startActivity(new Intent(MainActivity.this, HomeActivity.class));
finish();
}
@Override
public void onFailure(Call<AuthDTO.UserResponse> call,
Throwable t) {
Log.e("MainActivity", "Failed to fetch userId", t);
Toast.makeText(MainActivity.this, "Login successful", Toast.LENGTH_SHORT).show();
startActivity(new Intent(MainActivity.this, HomeActivity.class));
finish();
}
});
} else { } else {
Toast.makeText(MainActivity.this, "Login failed", Toast.LENGTH_SHORT).show(); Toast.makeText(MainActivity.this, "Login failed", Toast.LENGTH_SHORT).show();
tvLoginStatus.setText("Login failed"); tvLoginStatus.setText("Login failed");

View File

@@ -0,0 +1,78 @@
package com.example.petstoremobile.adapters;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.models.Message;
import java.util.List;
public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private static final int TYPE_SENT = 1;
private static final int TYPE_RECEIVED = 2;
private final List<Message> messages;
private Long currentUserId;
public MessageAdapter(List<Message> messages, Long currentUserId) {
this.messages = messages;
this.currentUserId = currentUserId;
}
public void setCurrentUserId(Long id) {
this.currentUserId = id;
notifyDataSetChanged();
}
@Override
public int getItemViewType(int position) {
Message m = messages.get(position);
if (currentUserId != null && currentUserId.equals(m.getSenderId())) {
return TYPE_SENT;
}
return TYPE_RECEIVED;
}
@NonNull @Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inf = LayoutInflater.from(parent.getContext());
if (viewType == TYPE_SENT) {
View v = inf.inflate(R.layout.item_message_sent, parent, false);
return new SentHolder(v);
} else {
View v = inf.inflate(R.layout.item_message_received, parent, false);
return new ReceivedHolder(v);
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
Message m = messages.get(position);
if (holder instanceof SentHolder) ((SentHolder) holder).bind(m);
if (holder instanceof ReceivedHolder) ((ReceivedHolder) holder).bind(m);
}
@Override public int getItemCount() { return messages.size(); }
static class SentHolder extends RecyclerView.ViewHolder {
TextView tvMessage;
SentHolder(View v) {
super(v);
tvMessage = v.findViewById(R.id.tvMessageContent); // updated
}
void bind(Message m) { tvMessage.setText(m.getContent()); }
}
static class ReceivedHolder extends RecyclerView.ViewHolder {
TextView tvMessage;
ReceivedHolder(View v) {
super(v);
tvMessage = v.findViewById(R.id.tvMessageContent); // updated
}
void bind(Message m) { tvMessage.setText(m.getContent()); }
}
}

View File

@@ -11,17 +11,13 @@ import retrofit2.http.GET;
import retrofit2.http.POST; import retrofit2.http.POST;
import retrofit2.http.Path; import retrofit2.http.Path;
//api calls to get conversations
public interface ChatApi { public interface ChatApi {
@GET("v1/chat/conversations") @GET("api/v1/chat/conversations")
Call<List<ConversationDTO>> getAllConversations(); Call<List<ConversationDTO>> getAllConversations();
@GET("v1/chat/conversations/{conversationId}") @GET("api/v1/chat/conversations/{conversationId}")
Call<ConversationDTO> getConversationById(@Path("conversationId") Long conversationId); Call<ConversationDTO> getConversationById(@Path("conversationId") Long conversationId);
@GET("v1/chat/conversations/{conversationId}/messages")
Call<List<MessageDTO>> getMessages(@Path("conversationId") Long conversationId);
@POST("v1/chat/conversations/{conversationId}/messages")
Call<MessageDTO> sendMessage(@Path("conversationId") Long conversationId, @Body MessageDTO message);
} }

View File

@@ -10,11 +10,12 @@ import retrofit2.http.GET;
import retrofit2.http.Path; import retrofit2.http.Path;
import retrofit2.http.Query; import retrofit2.http.Query;
//api calls to get customers
public interface CustomerApi { public interface CustomerApi {
@GET("v1/customers") @GET("api/v1/customers")
Call<PageResponse<CustomerDTO>> getAllCustomers(@Query("page") int page, @Query("size") int size); Call<PageResponse<CustomerDTO>> getAllCustomers(@Query("page") int page, @Query("size") int size);
@GET("v1/customers/{customerId}") @GET("api/v1/customers/{customerId}")
Call<CustomerDTO> getCustomerById(@Path("customerId") Long customerId); Call<CustomerDTO> getCustomerById(@Path("customerId") Long customerId);
} }

View File

@@ -0,0 +1,20 @@
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.MessageDTO;
import com.example.petstoremobile.dtos.SendMessageRequest;
import java.util.List;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.GET;
import retrofit2.http.POST;
import retrofit2.http.Path;
//api calls to get and send messages
public interface MessageApi {
@GET("api/v1/chat/conversations/{id}/messages")
Call<List<MessageDTO>> getMessages(@Path("id") Long conversationId);
@POST("api/v1/chat/conversations/{id}/messages")
Call<MessageDTO> sendMessage(@Path("id") Long conversationId, @Body SendMessageRequest request);
}

View File

@@ -12,28 +12,29 @@ import retrofit2.http.PUT;
import retrofit2.http.Path; import retrofit2.http.Path;
import retrofit2.http.Query; import retrofit2.http.Query;
//api calls to CRUD pets
public interface PetApi { public interface PetApi {
// Get all pets // Get all pets
@GET("v1/pets") @GET("api/v1/pets")
Call<PageResponse<PetDTO>> getAllPets( Call<PageResponse<PetDTO>> getAllPets(
@Query("page") int page, @Query("page") int page,
@Query("size") int size @Query("size") int size
); );
// Get pet by id // Get pet by id
@GET("v1/pets/{id}") @GET("api/v1/pets/{id}")
Call<PetDTO> getPetById(@Path("id") Long id); Call<PetDTO> getPetById(@Path("id") Long id);
// Create pet // Create pet
@POST("v1/pets") @POST("api/v1/pets")
Call<PetDTO> createPet(@Body PetDTO pet); Call<PetDTO> createPet(@Body PetDTO pet);
// Update pet // Update pet
@PUT("v1/pets/{id}") @PUT("api/v1/pets/{id}")
Call<PetDTO> updatePet(@Path("id") Long id, @Body PetDTO pet); Call<PetDTO> updatePet(@Path("id") Long id, @Body PetDTO pet);
// Delete pet // Delete pet
@DELETE("v1/pets/{id}") @DELETE("api/v1/pets/{id}")
Call<Void> deletePet(@Path("id") Long id); Call<Void> deletePet(@Path("id") Long id);
} }

View File

@@ -2,18 +2,19 @@ package com.example.petstoremobile.api;
import android.content.Context; import android.content.Context;
import com.example.petstoremobile.api.Auth.AuthApi; import com.example.petstoremobile.api.auth.AuthApi;
import com.example.petstoremobile.api.Auth.AuthInterceptor; import com.example.petstoremobile.api.auth.AuthInterceptor;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
import okhttp3.logging.HttpLoggingInterceptor; import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Retrofit; import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory; import retrofit2.converter.gson.GsonConverterFactory;
//Retrofit client Used for API calls
public class RetrofitClient { public class RetrofitClient {
//base URL //base URL
// public static final String BASE_URL = "http://10.0.2.2:8080/api/"; //for emulator testing 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/api/"; //for hardware testing change to computer ip if using hardware to test // public static final String BASE_URL = "http://10.0.0.200:8080/"; //for hardware testing
private static Retrofit retrofit = null; private static Retrofit retrofit = null;
@@ -62,4 +63,8 @@ public class RetrofitClient {
return getClient(context).create(CustomerApi.class); return getClient(context).create(CustomerApi.class);
} }
public static MessageApi getMessageApi(Context context) {
return getClient(context).create(MessageApi.class);
}
} }

View File

@@ -12,27 +12,28 @@ import retrofit2.http.PUT;
import retrofit2.http.Path; import retrofit2.http.Path;
import retrofit2.http.Query; import retrofit2.http.Query;
//api calls to CRUD services
public interface ServiceApi { public interface ServiceApi {
// Get all services // Get all services
@GET("v1/services") @GET("api/v1/services")
Call<PageResponse<ServiceDTO>> getAllServices( Call<PageResponse<ServiceDTO>> getAllServices(
@Query("page") int page, @Query("page") int page,
@Query("size") int size @Query("size") int size
); );
// Get service by id // Get service by id
@GET("v1/services/{id}") @GET("api/v1/services/{id}")
Call<ServiceDTO> getServiceById(@Path("id") Long id); Call<ServiceDTO> getServiceById(@Path("id") Long id);
// Create service // Create service
@POST("v1/services") @POST("api/v1/services")
Call<ServiceDTO> createService(@Body ServiceDTO service); Call<ServiceDTO> createService(@Body ServiceDTO service);
// Update service // Update service
@PUT("v1/services/{id}") @PUT("api/v1/services/{id}")
Call<ServiceDTO> updateService(@Path("id") Long id, @Body ServiceDTO service); Call<ServiceDTO> updateService(@Path("id") Long id, @Body ServiceDTO service);
// Delete service // Delete service
@DELETE("v1/services/{id}") @DELETE("api/v1/services/{id}")
Call<Void> deleteService(@Path("id") Long id); Call<Void> deleteService(@Path("id") Long id);
} }

View File

@@ -12,27 +12,28 @@ import retrofit2.http.PUT;
import retrofit2.http.Path; import retrofit2.http.Path;
import retrofit2.http.Query; import retrofit2.http.Query;
//api calls to CRUD suppliers
public interface SupplierApi { public interface SupplierApi {
// Get all suppliers // Get all suppliers
@GET("v1/suppliers") @GET("api/v1/suppliers")
Call<PageResponse<SupplierDTO>> getAllSuppliers( Call<PageResponse<SupplierDTO>> getAllSuppliers(
@Query("page") int page, @Query("page") int page,
@Query("size") int size @Query("size") int size
); );
// Get supplier by id // Get supplier by id
@GET("v1/suppliers/{id}") @GET("api/v1/suppliers/{id}")
Call<SupplierDTO> getSupplierById(@Path("id") Long id); Call<SupplierDTO> getSupplierById(@Path("id") Long id);
// Create supplier // Create supplier
@POST("v1/suppliers") @POST("api/v1/suppliers")
Call<SupplierDTO> createSupplier(@Body SupplierDTO supplier); Call<SupplierDTO> createSupplier(@Body SupplierDTO supplier);
// Update supplier // Update supplier
@PUT("v1/suppliers/{id}") @PUT("api/v1/suppliers/{id}")
Call<SupplierDTO> updateSupplier(@Path("id") Long id, @Body SupplierDTO supplier); Call<SupplierDTO> updateSupplier(@Path("id") Long id, @Body SupplierDTO supplier);
// Delete supplier // Delete supplier
@DELETE("v1/suppliers/{id}") @DELETE("api/v1/suppliers/{id}")
Call<Void> deleteSupplier(@Path("id") Long id); Call<Void> deleteSupplier(@Path("id") Long id);
} }

View File

@@ -1,13 +1,17 @@
package com.example.petstoremobile.api.Auth; package com.example.petstoremobile.api.auth;
import com.example.petstoremobile.dtos.AuthDTO; import com.example.petstoremobile.dtos.AuthDTO;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.http.Body; import retrofit2.http.Body;
import retrofit2.http.GET;
import retrofit2.http.POST; import retrofit2.http.POST;
//Api for logging in //Api for logging in and getting current user
public interface AuthApi { public interface AuthApi {
@POST("v1/auth/login") @POST("api/v1/auth/login")
Call<AuthDTO.LoginResponse> login(@Body AuthDTO.LoginRequest loginRequest); Call<AuthDTO.LoginResponse> login(@Body AuthDTO.LoginRequest loginRequest);
@GET("api/v1/auth/me")
Call<AuthDTO.UserResponse> getCurrentUser();
} }

View File

@@ -1,4 +1,4 @@
package com.example.petstoremobile.api.Auth; package com.example.petstoremobile.api.auth;
import android.content.Context; import android.content.Context;

View File

@@ -1,4 +1,4 @@
package com.example.petstoremobile.api.Auth; package com.example.petstoremobile.api.auth;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
@@ -9,6 +9,7 @@ public class TokenManager {
private static final String USERNAME_KEY = "username"; private static final String USERNAME_KEY = "username";
private static final String ROLE_KEY = "role"; private static final String ROLE_KEY = "role";
private static final String PREFS_NAME = "auth_prefs"; private static final String PREFS_NAME = "auth_prefs";
private static final String USER_ID_KEY = "user_id";
private static TokenManager instance; private static TokenManager instance;
private SharedPreferences prefs; private SharedPreferences prefs;
@@ -46,6 +47,15 @@ public class TokenManager {
return prefs.getString(ROLE_KEY, null); return prefs.getString(ROLE_KEY, null);
} }
public Long getUserId() {
long id = prefs.getLong(USER_ID_KEY, -1L);
return id == -1L ? null : id;
}
public void saveUserId(Long userId) {
prefs.edit().putLong(USER_ID_KEY, userId).apply();
}
//Check if logged in //Check if logged in
public boolean isLoggedIn() { public boolean isLoggedIn() {
return getToken() != null; return getToken() != null;

View File

@@ -25,4 +25,25 @@ public class AuthDTO {
public String getUsername() { return username; } public String getUsername() { return username; }
public String getRole() { return role; } public String getRole() { return role; }
} }
//Used to get logged in profile
public static class UserResponse {
private Long id;
private String username;
private String email;
private String fullName;
private String avatarUrl;
private String role;
private Long storeId;
private String storeName;
public Long getId() { return id; }
public String getUsername() { return username; }
public String getEmail() { return email; }
public String getFullName() { return fullName; }
public String getAvatarUrl() { return avatarUrl; }
public String getRole() { return role; }
public Long getStoreId() { return storeId; }
public String getStoreName() { return storeName; }
}
} }

View File

@@ -1,46 +1,44 @@
package com.example.petstoremobile.dtos; package com.example.petstoremobile.dtos;
import com.google.gson.annotations.SerializedName;
public class MessageDTO { public class MessageDTO {
private String id;
@SerializedName("id")
private Long id;
@SerializedName("conversationId")
private Long conversationId;
@SerializedName("senderId")
private Long senderId;
@SerializedName("content")
private String content; private String content;
private String senderId;
private long timestamp; @SerializedName("timestamp")
private String timestamp;
@SerializedName("isRead")
private Boolean isRead;
public MessageDTO() {} public MessageDTO() {}
public MessageDTO(String content) { public Long getId() { return id; }
this.content = content; public void setId(Long id) { this.id = id; }
}
public String getId() { public Long getConversationId() { return conversationId; }
return id; public void setConversationId(Long conversationId) { this.conversationId = conversationId; }
}
public void setId(String id) { public Long getSenderId() { return senderId; }
this.id = id; public void setSenderId(Long senderId) { this.senderId = senderId; }
}
public String getContent() { public String getContent() { return content; }
return content; public void setContent(String content) { this.content = content; }
}
public void setContent(String content) { public String getTimestamp() { return timestamp; }
this.content = content; public void setTimestamp(String timestamp) { this.timestamp = timestamp; }
}
public String getSenderId() { public Boolean getIsRead() { return isRead; }
return senderId; public void setIsRead(Boolean isRead) { this.isRead = isRead; }
}
public void setSenderId(String senderId) {
this.senderId = senderId;
}
public long getTimestamp() {
return timestamp;
}
public void setTimestamp(long timestamp) {
this.timestamp = timestamp;
}
} }

View File

@@ -0,0 +1,13 @@
package com.example.petstoremobile.dtos;
public class SendMessageRequest {
private String content;
public SendMessageRequest(String content) {
this.content = content;
}
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
}

View File

@@ -1,157 +1,407 @@
package com.example.petstoremobile.fragments; package com.example.petstoremobile.fragments;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log;
import android.view.*;
import android.widget.*;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.GravityCompat; import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout; import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import com.example.petstoremobile.R; import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.ChatAdapter; import com.example.petstoremobile.adapters.ChatAdapter;
import com.example.petstoremobile.adapters.MessageAdapter;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.api.ChatApi; import com.example.petstoremobile.api.ChatApi;
import com.example.petstoremobile.api.CustomerApi; import com.example.petstoremobile.api.CustomerApi;
import com.example.petstoremobile.api.MessageApi;
import com.example.petstoremobile.api.RetrofitClient; import com.example.petstoremobile.api.RetrofitClient;
import com.example.petstoremobile.dtos.ConversationDTO; import com.example.petstoremobile.dtos.ConversationDTO;
import com.example.petstoremobile.dtos.CustomerDTO; import com.example.petstoremobile.dtos.CustomerDTO;
import com.example.petstoremobile.dtos.MessageDTO;
import com.example.petstoremobile.dtos.PageResponse; import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.SendMessageRequest;
import com.example.petstoremobile.models.Chat; import com.example.petstoremobile.models.Chat;
import com.example.petstoremobile.models.Message;
import java.util.ArrayList; import com.example.petstoremobile.websocket.StompChatManager;
import java.util.HashMap; import java.util.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import retrofit2.*;
import retrofit2.Call; public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickListener, StompChatManager.MessageListener,
import retrofit2.Callback; StompChatManager.ConversationListener, StompChatManager.ConnectionListener {
import retrofit2.Response;
private static final String TAG = "ChatFragment";
public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickListener { // View
private LinearLayout chatContainer;
private EditText etMessage;
private ScrollView scrollView;
private Button btnSend;
private ImageButton hamburger;
private DrawerLayout drawerLayout; private DrawerLayout drawerLayout;
private RecyclerView rvChatList; private RecyclerView rvChatList, rvMessages;
private EditText etMessage;
private Button btnSend;
// Adapters
private ChatAdapter chatAdapter; private ChatAdapter chatAdapter;
private List<Chat> chatList = new ArrayList<>(); private MessageAdapter messageAdapter;
// Data
private final List<Chat> chatList = new ArrayList<>();
private final List<Message> messageList = new ArrayList<>();
private final Map<Long, String> customerNames = new HashMap<>();
// APIs
private ChatApi chatApi; private ChatApi chatApi;
private CustomerApi customerApi; private CustomerApi customerApi;
private Map<Long, String> customerNames = new HashMap<>(); private MessageApi messageApi;
// chat
private Long currentUserId;
private Long activeConversationId;
private StompChatManager stompChatManager;
@Override @Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, public View onCreateView(@NonNull LayoutInflater inflater,
Bundle savedInstanceState) { ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_chat, container, false); View view = inflater.inflate(R.layout.fragment_chat, container, false);
chatApi = RetrofitClient.getChatApi(requireContext()); chatApi = RetrofitClient.getChatApi(requireContext());
customerApi = RetrofitClient.getCustomerApi(requireContext()); customerApi = RetrofitClient.getCustomerApi(requireContext());
messageApi = RetrofitClient.getMessageApi(requireContext());
drawerLayout = view.findViewById(R.id.chatDrawerLayout); drawerLayout = view.findViewById(R.id.chatDrawerLayout);
hamburger = view.findViewById(R.id.btnHamburger);
rvChatList = view.findViewById(R.id.rvChatList); rvChatList = view.findViewById(R.id.rvChatList);
rvMessages = view.findViewById(R.id.rvMessages);
etMessage = view.findViewById(R.id.etMessage);
btnSend = view.findViewById(R.id.btnSend);
setupRecyclerView(); ImageButton hamburger = view.findViewById(R.id.btnHamburger);
hamburger.setOnClickListener(v -> drawerLayout.openDrawer(GravityCompat.START));
btnSend.setOnClickListener(v -> sendMessage());
setupRecyclerViews();
loadInitialData(); loadInitialData();
// Open the local chat drawer when hamburger is clicked
hamburger.setOnClickListener(v -> {
if (drawerLayout != null) {
drawerLayout.openDrawer(GravityCompat.START);
}
});
return view; return view;
} }
private void setupRecyclerView() { private void setupRecyclerViews() {
// Set up Drawer menu to select conversation
chatAdapter = new ChatAdapter(chatList, this); chatAdapter = new ChatAdapter(chatList, this);
rvChatList.setLayoutManager(new LinearLayoutManager(getContext())); rvChatList.setLayoutManager(new LinearLayoutManager(getContext()));
rvChatList.setAdapter(chatAdapter); rvChatList.setAdapter(chatAdapter);
// set up RecyclerView for selected chat to show messages
messageAdapter = new MessageAdapter(messageList, null);
LinearLayoutManager lm = new LinearLayoutManager(getContext());
lm.setStackFromEnd(true);
rvMessages.setLayoutManager(lm);
rvMessages.setAdapter(messageAdapter);
setConversationActive(false);
} }
//Helper function to load token and user id then connect to websocket
private void loadInitialData() { private void loadInitialData() {
// Fetch all customers (first page, large size to get many) TokenManager tm = TokenManager.getInstance(requireContext());
String token = tm.getToken();
currentUserId = tm.getUserId();
String role = tm.getRole();
messageAdapter.setCurrentUserId(currentUserId);
// if token exist then connect to websocket
if (token != null) {
stompChatManager = new StompChatManager(token, role);
stompChatManager.setMessageListener(this);
stompChatManager.setConversationListener(this);
stompChatManager.setConnectionListener(this);
stompChatManager.connect();
} else {
Log.e(TAG, "No token found");
}
loadCustomers();
}
//Helper function to load customer names for it to be displayed on drawer menu
private void loadCustomers() {
customerApi.getAllCustomers(0, 100).enqueue(new Callback<PageResponse<CustomerDTO>>() { customerApi.getAllCustomers(0, 100).enqueue(new Callback<PageResponse<CustomerDTO>>() {
@Override @Override
public void onResponse(Call<PageResponse<CustomerDTO>> call, Response<PageResponse<CustomerDTO>> response) { public void onResponse(@NonNull Call<PageResponse<CustomerDTO>> call,
@NonNull Response<PageResponse<CustomerDTO>> response) {
if (response.isSuccessful() && response.body() != null) { if (response.isSuccessful() && response.body() != null) {
for (CustomerDTO customer : response.body().getContent()) { for (CustomerDTO c : response.body().getContent()) {
customerNames.put(customer.getCustomerId(), customer.getFullName()); customerNames.put(c.getCustomerId(), c.getFullName());
} }
} }
// Then load conversations
loadConversations(); loadConversations();
} }
@Override @Override
public void onFailure(Call<PageResponse<CustomerDTO>> call, Throwable t) { public void onFailure(@NonNull Call<PageResponse<CustomerDTO>> call,
Log.e("ChatFragment", "Error loading customers", t); @NonNull Throwable t) {
loadConversations(); // Try loading conversations anyway loadConversations();
} }
}); });
} }
//helper function to load conversations entities to display with customer names in drawer menu
private void loadConversations() { private void loadConversations() {
chatApi.getAllConversations().enqueue(new Callback<List<ConversationDTO>>() { chatApi.getAllConversations().enqueue(new Callback<List<ConversationDTO>>() {
@Override @Override
public void onResponse(Call<List<ConversationDTO>> call, Response<List<ConversationDTO>> response) { public void onResponse(@NonNull Call<List<ConversationDTO>> call,
@NonNull Response<List<ConversationDTO>> response) {
if (response.isSuccessful() && response.body() != null) { if (response.isSuccessful() && response.body() != null) {
chatList.clear(); chatList.clear();
List<Chat> loadedChats = response.body().stream() List<Chat> loaded = response.body().stream()
.map(dto -> { .map(dto -> {
String name = customerNames.getOrDefault(dto.getCustomerId(), "Customer #" + dto.getCustomerId()); String name = customerNames.getOrDefault(
return new Chat( dto.getCustomerId(), "Customer #" + dto.getCustomerId());
String.valueOf(dto.getId()), return new Chat(String.valueOf(dto.getId()),
name, name, dto.getLastMessage(),
dto.getLastMessage()); dto.getCustomerId(), dto.getStaffId());
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());
chatList.addAll(loadedChats); chatList.addAll(loaded);
chatAdapter.notifyDataSetChanged(); chatAdapter.notifyDataSetChanged();
} else { if (activeConversationId == null) {
Log.e("ChatFragment", "Failed to load conversations: " + response.message()); messageList.clear();
messageAdapter.notifyDataSetChanged();
setConversationActive(false);
}
} }
} }
@Override @Override
public void onFailure(Call<List<ConversationDTO>> call, Throwable t) { public void onFailure(@NonNull Call<List<ConversationDTO>> call,
Log.e("ChatFragment", "Error loading conversations", t); @NonNull Throwable t) {
Toast.makeText(getContext(), "Error loading chats", Toast.LENGTH_SHORT).show(); Log.e(TAG, "Error loading conversations", t);
} }
}); });
} }
// Called when user taps a chat in the drawer
// Loads messages for that chat selected
@Override @Override
public void onChatClick(Chat chat) { public void onChatClick(Chat chat) {
// Handle chat selection activeConversationId = Long.parseLong(chat.getChatId());
Toast.makeText(getContext(), "Selected chat: " + chat.getCustomerName(), Toast.LENGTH_SHORT).show(); setConversationActive(true);
drawerLayout.closeDrawer(GravityCompat.START);
// Close drawer after selection
if (drawerLayout != null) { if (stompChatManager != null) {
drawerLayout.closeDrawer(GravityCompat.START); stompChatManager.subscribeToConversation(activeConversationId);
} }
// TODO: Load actual messages for the selected chat loadMessageHistory(activeConversationId);
} }
}
//helper function to load messages for selected chat
private void loadMessageHistory(Long conversationId) {
messageApi.getMessages(conversationId).enqueue(new Callback<List<MessageDTO>>() {
@Override
public void onResponse(@NonNull Call<List<MessageDTO>> call,
@NonNull Response<List<MessageDTO>> response) {
if (response.isSuccessful() && response.body() != null) {
messageList.clear();
for (MessageDTO dto : response.body()) {
messageList.add(dtoToModel(dto));
}
messageAdapter.notifyDataSetChanged();
scrollToBottom();
}
}
@Override
public void onFailure(@NonNull Call<List<MessageDTO>> call,
@NonNull Throwable t) {
Log.e(TAG, "Error loading messages", t);
}
});
}
//Helper function to send a message to the chat
private void sendMessage() {
//check if a chat is selected
if (activeConversationId == null) return;
//get the message from text field
String text = etMessage.getText().toString().trim();
if (text.isEmpty()) return;
//clear text field after sending
etMessage.setText("");
//calls api to send the message
messageApi.sendMessage(activeConversationId, new SendMessageRequest(text))
.enqueue(new Callback<MessageDTO>() {
@Override
public void onResponse(@NonNull Call<MessageDTO> call,
@NonNull Response<MessageDTO> response) {
if (response.isSuccessful() && response.body() != null) {
messageList.add(dtoToModel(response.body()));
messageAdapter.notifyItemInserted(messageList.size() - 1);
scrollToBottom();
loadConversations();
}
}
@Override
public void onFailure(@NonNull Call<MessageDTO> call,
@NonNull Throwable t) {
Log.e(TAG, "Send failed", t);
}
});
}
// When a message is received updates the chat preview
@Override
public void onMessageReceived(MessageDTO dto) {
//if there is no active selected conversation or the message received is for another chat, then just update the preview of last message
if (activeConversationId == null || !activeConversationId.equals(dto.getConversationId())) {
updateConversationPreview(dto.getConversationId(), dto.getContent());
return;
}
updateConversationPreview(dto.getConversationId(), dto.getContent());
if (currentUserId != null && currentUserId.equals(dto.getSenderId())) return;
//else add the message to the active chat if it's not from the current user
messageList.add(dtoToModel(dto));
messageAdapter.notifyItemInserted(messageList.size() - 1);
scrollToBottom();
}
// When a new conversation is added, updates the chat preview
@Override
public void onConversationUpdated(ConversationDTO dto) {
boolean updated = false;
String name = customerNames.getOrDefault(
dto.getCustomerId(), "Customer #" + dto.getCustomerId());
for (int i = 0; i < chatList.size(); i++) {
Chat existing = chatList.get(i);
if (existing.getChatId().equals(String.valueOf(dto.getId()))) {
chatList.set(i, new Chat(
String.valueOf(dto.getId()),
name,
dto.getLastMessage(),
dto.getCustomerId(),
dto.getStaffId()
));
chatAdapter.notifyItemChanged(i);
updated = true;
break;
}
}
if (!updated) {
chatList.add(0, new Chat(
String.valueOf(dto.getId()),
name,
dto.getLastMessage(),
dto.getCustomerId(),
dto.getStaffId()
));
chatAdapter.notifyItemInserted(0);
}
if (activeConversationId != null && activeConversationId.equals(dto.getId())) {
setConversationActive(true);
}
}
@Override
public void onSocketOpened() {
if (!isAdded()) {
return;
}
loadConversations();
if (activeConversationId != null) {
loadMessageHistory(activeConversationId);
}
}
@Override
public void onSocketClosed() {
if (!isAdded()) {
return;
}
loadConversations();
}
@Override
public void onSocketError() {
if (!isAdded()) {
return;
}
loadConversations();
if (activeConversationId != null) {
loadMessageHistory(activeConversationId);
}
}
// Helper function to convert DTO to message
private Message dtoToModel(MessageDTO dto) {
Message m = new Message();
m.setId(dto.getId());
m.setConversationId(dto.getConversationId());
m.setSenderId(dto.getSenderId());
m.setContent(dto.getContent());
m.setTimestamp(dto.getTimestamp());
m.setIsRead(dto.getIsRead());
return m;
}
//Helper function to scroll to bottom of the chat
private void scrollToBottom() {
if (!messageList.isEmpty()) {
rvMessages.post(() ->
rvMessages.smoothScrollToPosition(messageList.size() - 1));
}
}
// Helper function to update the chat preview last message
private void updateConversationPreview(Long conversationId, String lastMessage) {
if (conversationId == null) {
return;
}
for (int i = 0; i < chatList.size(); i++) {
Chat existing = chatList.get(i);
if (existing.getChatId().equals(String.valueOf(conversationId))) {
Chat updated = new Chat(
existing.getChatId(),
existing.getCustomerName(),
lastMessage,
existing.getCustomerId(),
existing.getStaffId()
);
chatList.set(i, updated);
chatAdapter.notifyItemChanged(i);
return;
}
}
}
//Helper function to enable or disable the send button when there is no active chat
private void setConversationActive(boolean active) {
btnSend.setEnabled(active);
etMessage.setEnabled(active);
if (!active) {
activeConversationId = null;
if (stompChatManager != null) {
stompChatManager.clearConversationSubscription();
}
messageList.clear();
messageAdapter.notifyDataSetChanged();
etMessage.setText("");
etMessage.setHint("Select a chat to start messaging");
} else {
etMessage.setHint("Type a message...");
}
}
// When fragment is destroyed, disconnect from websocket
@Override
public void onDestroyView() {
super.onDestroyView();
if (stompChatManager != null) stompChatManager.disconnect();
}
}

View File

@@ -26,7 +26,7 @@ import android.widget.TextView;
import com.example.petstoremobile.R; import com.example.petstoremobile.R;
import com.example.petstoremobile.activities.MainActivity; import com.example.petstoremobile.activities.MainActivity;
import com.example.petstoremobile.api.Auth.TokenManager; import com.example.petstoremobile.api.auth.TokenManager;
import java.io.File; import java.io.File;
@@ -150,6 +150,7 @@ public class ProfileFragment extends Fragment {
} }
}) })
.show(); .show();
//TODO: UPDATE PHOTO IN DATABASE
}); });
//Edit email button //Edit email button

View File

@@ -4,11 +4,15 @@ public class Chat {
private String chatId; private String chatId;
private String customerName; private String customerName;
private String lastMessage; private String lastMessage;
private Long customerId;
private Long staffId;
public Chat(String chatId, String customerName, String lastMessage) { public Chat(String chatId, String customerName, String lastMessage, Long customerId, Long staffId) {
this.chatId = chatId; this.chatId = chatId;
this.customerName = customerName; this.customerName = customerName;
this.lastMessage = lastMessage; this.lastMessage = lastMessage;
this.customerId = customerId;
this.staffId = staffId;
} }
public String getChatId() { public String getChatId() {
@@ -22,4 +26,12 @@ public class Chat {
public String getLastMessage() { public String getLastMessage() {
return lastMessage; return lastMessage;
} }
public Long getCustomerId() {
return customerId;
}
public Long getStaffId() {
return staffId;
}
} }

View File

@@ -0,0 +1,36 @@
package com.example.petstoremobile.models;
public class Message {
private Long id;
private Long conversationId;
private Long senderId;
private String content;
private String timestamp;
private Boolean isRead;
public Message() {}
public Message(Long conversationId, Long senderId, String content) {
this.conversationId = conversationId;
this.senderId = senderId;
this.content = content;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getConversationId() { return conversationId; }
public void setConversationId(Long conversationId) { this.conversationId = conversationId; }
public Long getSenderId() { return senderId; }
public void setSenderId(Long senderId) { this.senderId = senderId; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getTimestamp() { return timestamp; }
public void setTimestamp(String timestamp) { this.timestamp = timestamp; }
public Boolean getIsRead() { return isRead; }
public void setIsRead(Boolean isRead) { this.isRead = isRead; }
}

View File

@@ -0,0 +1,295 @@
package com.example.petstoremobile.websocket;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.example.petstoremobile.api.RetrofitClient;
import com.example.petstoremobile.dtos.ConversationDTO;
import com.example.petstoremobile.dtos.MessageDTO;
import com.google.gson.Gson;
import java.util.HashMap;
import java.util.Collections;
import java.util.Locale;
import java.util.Map;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
import ua.naiksoftware.stomp.Stomp;
import ua.naiksoftware.stomp.StompClient;
import ua.naiksoftware.stomp.dto.StompHeader;
//Used to handle the websocket connection for the chat
public class StompChatManager {
private static final String TAG = "StompChatManager";
//Interface for when a message is received
public interface MessageListener {
void onMessageReceived(MessageDTO message);
}
//Interface for when a conversation is created or updated
public interface ConversationListener {
void onConversationUpdated(ConversationDTO conversation);
}
//Interface for when the websocket connection is opened, closed, or has an error
public interface ConnectionListener {
void onSocketOpened();
void onSocketClosed();
void onSocketError();
}
private StompClient stompClient;
private final CompositeDisposable compositeDisposable = new CompositeDisposable();
private Disposable topicDisposable;
private Disposable conversationsDisposable;
private Disposable userConversationsDisposable;
private Disposable errorQueueDisposable;
private final Gson gson = new Gson();
private final Handler reconnectHandler = new Handler(Looper.getMainLooper());
private MessageListener messageListener;
private ConversationListener conversationListener;
private ConnectionListener connectionListener;
private final String authToken;
private final String role;
private boolean isConnected;
private boolean isConnecting;
private boolean manualDisconnect;
private Long pendingConversationId;
public StompChatManager(String authToken, String role) {
this.authToken = authToken;
this.role = role == null ? "" : role.trim().toUpperCase(Locale.ROOT);
}
public void setMessageListener(MessageListener listener) {
this.messageListener = listener;
}
public void setConversationListener(ConversationListener listener) {
this.conversationListener = listener;
}
public void setConnectionListener(ConnectionListener listener) {
this.connectionListener = listener;
}
// Set up a stomp connection
public void connect() {
if (authToken == null || authToken.isBlank()) {
Log.e(TAG, "Cannot connect websocket without token");
return;
}
if (isConnected || isConnecting) {
return;
}
manualDisconnect = false;
isConnecting = true;
reconnectHandler.removeCallbacksAndMessages(null);
String webSocketUrl = buildWebSocketUrl();
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + authToken);
stompClient = Stomp.over(Stomp.ConnectionProvider.OKHTTP, webSocketUrl, headers);
compositeDisposable.add(
stompClient.lifecycle()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(event -> {
switch (event.getType()) {
case OPENED:
isConnected = true;
isConnecting = false;
Log.d(TAG, "WebSocket opened");
if (connectionListener != null) {
connectionListener.onSocketOpened();
}
subscribeToErrorQueue();
subscribeToConversationFeeds();
if (pendingConversationId != null) {
subscribeToTopic(pendingConversationId);
}
break;
case CLOSED:
isConnected = false;
isConnecting = false;
Log.d(TAG, "WebSocket closed");
if (connectionListener != null) {
connectionListener.onSocketClosed();
}
scheduleReconnect();
break;
case ERROR:
isConnected = false;
isConnecting = false;
Log.e(TAG, "WebSocket error: " + event.getException());
if (connectionListener != null) {
connectionListener.onSocketError();
}
scheduleReconnect();
break;
}
})
);
stompClient.connect(Collections.singletonList(
new StompHeader("Authorization", "Bearer " + authToken)
));
}
// Subscribes to updates for a specific conversation
public void subscribeToConversation(Long conversationId) {
pendingConversationId = conversationId;
if (!isConnected || stompClient == null) {
Log.d(TAG, "Delaying subscription until socket opens for conversation " + conversationId);
connect();
return;
}
subscribeToTopic(conversationId);
}
// Clears the current conversation subscription
public void clearConversationSubscription() {
pendingConversationId = null;
if (topicDisposable != null && !topicDisposable.isDisposed()) {
topicDisposable.dispose();
topicDisposable = null;
}
}
//helper function to subscribe to a specific conversation topic
private void subscribeToTopic(Long conversationId) {
if (topicDisposable != null && !topicDisposable.isDisposed()) {
topicDisposable.dispose();
}
String topic = "/topic/chat/conversations/" + conversationId;
Log.d(TAG, "Subscribing to topic " + topic);
topicDisposable = stompClient.topic(topic)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
stompMessage -> {
MessageDTO message = gson.fromJson(
stompMessage.getPayload(), MessageDTO.class);
if (messageListener != null) {
messageListener.onMessageReceived(message);
}
},
throwable -> Log.e(TAG, "Topic error", throwable)
);
compositeDisposable.add(topicDisposable);
}
// Listens for conversation updates and refresh the chat list
private void subscribeToConversationFeeds() {
if (conversationsDisposable != null && !conversationsDisposable.isDisposed()) {
conversationsDisposable.dispose();
}
if (userConversationsDisposable != null && !userConversationsDisposable.isDisposed()) {
userConversationsDisposable.dispose();
}
userConversationsDisposable = stompClient.topic("/user/queue/chat/conversations")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
stompMessage -> {
Log.d(TAG, "Queue conversation update: " + stompMessage.getPayload());
ConversationDTO conversation = gson.fromJson(
stompMessage.getPayload(), ConversationDTO.class);
if (conversationListener != null) {
conversationListener.onConversationUpdated(conversation);
}
},
throwable -> Log.e(TAG, "Conversation queue error", throwable)
);
compositeDisposable.add(userConversationsDisposable);
if (isCustomer()) {
return;
}
conversationsDisposable = stompClient.topic("/topic/chat/conversations")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
stompMessage -> {
Log.d(TAG, "Broadcast conversation update: " + stompMessage.getPayload());
ConversationDTO conversation = gson.fromJson(
stompMessage.getPayload(), ConversationDTO.class);
if (conversationListener != null) {
conversationListener.onConversationUpdated(conversation);
}
},
throwable -> Log.e(TAG, "Conversation topic error", throwable)
);
compositeDisposable.add(conversationsDisposable);
}
// Log any error from stomp
private void subscribeToErrorQueue() {
if (errorQueueDisposable != null && !errorQueueDisposable.isDisposed()) {
errorQueueDisposable.dispose();
}
errorQueueDisposable = stompClient.topic("/user/queue/chat/errors")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
stompMessage -> Log.e(TAG, "WebSocket queue error payload: " + stompMessage.getPayload()),
throwable -> Log.e(TAG, "WebSocket error queue subscription failed", throwable)
);
compositeDisposable.add(errorQueueDisposable);
}
// Disconnects the stomp connection
public void disconnect() {
manualDisconnect = true;
isConnected = false;
isConnecting = false;
pendingConversationId = null;
reconnectHandler.removeCallbacksAndMessages(null);
compositeDisposable.clear();
if (stompClient != null) {
stompClient.disconnect();
}
}
// Make the URL for the websocket connection
private String buildWebSocketUrl() {
String baseUrl = RetrofitClient.BASE_URL.endsWith("/")
? RetrofitClient.BASE_URL.substring(0, RetrofitClient.BASE_URL.length() - 1)
: RetrofitClient.BASE_URL;
if (baseUrl.startsWith("https://")) {
return "wss://" + baseUrl.substring("https://".length()) + "/ws/chat";
}
if (baseUrl.startsWith("http://")) {
return "ws://" + baseUrl.substring("http://".length()) + "/ws/chat";
}
return baseUrl + "/ws/chat";
}
// Helper to check if the current user is a customer
private boolean isCustomer() {
return "CUSTOMER".equals(role);
}
// if connection drops, try to reconnect after 1 second
private void scheduleReconnect() {
if (manualDisconnect) {
return;
}
reconnectHandler.removeCallbacksAndMessages(null);
reconnectHandler.postDelayed(this::connect, 1000);
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E0E0E0" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp"
android:bottomRightRadius="16dp"
android:bottomLeftRadius="2dp" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E74C3C" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp"
android:bottomRightRadius="2dp"
android:bottomLeftRadius="16dp" />
</shape>

View File

@@ -37,21 +37,13 @@
</LinearLayout> </LinearLayout>
<ScrollView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/scrollView" android:id="@+id/rvMessages"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:padding="8dp"> android:padding="8dp"
android:clipToPadding="false" />
<LinearLayout
android:id="@+id/chatContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp"/>
</ScrollView>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp"
android:gravity="start">
<TextView
android:id="@+id/tvMessageContent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_message_received"
android:padding="12dp"
android:text="Received message"
android:textColor="@color/text_dark"
android:maxWidth="300dp" />
</LinearLayout>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp"
android:gravity="end">
<TextView
android:id="@+id/tvMessageContent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_message_sent"
android:padding="12dp"
android:text="Sent message"
android:textColor="@color/white"
android:maxWidth="300dp" />
</LinearLayout>

View File

@@ -4,8 +4,28 @@
<item name="colorPrimary">@color/primary_dark</item> <item name="colorPrimary">@color/primary_dark</item>
<item name="colorPrimaryVariant">@color/primary_medium</item> <item name="colorPrimaryVariant">@color/primary_medium</item>
<item name="colorAccent">@color/accent_coral</item> <item name="colorAccent">@color/accent_coral</item>
<item name="android:windowBackground">@color/background_grey</item> <item name="android:windowBackground">@color/primary_dark</item>
<item name="editTextStyle">@style/Widget.App.EditText</item> <item name="editTextStyle">@style/Widget.App.EditText</item>
<item name="spinnerStyle">@style/Widget.App.Spinner</item> <item name="spinnerStyle">@style/Widget.App.Spinner</item>
<item name="materialAlertDialogTheme">@style/ThemeOverlay.App.MaterialAlertDialog</item>
<item name="alertDialogTheme">@style/Theme.App.AlertDialog</item>
</style>
<style name="ThemeOverlay.App.MaterialAlertDialog" parent="ThemeOverlay.Material3.MaterialAlertDialog">
<item name="colorPrimary">@color/white</item>
<item name="colorOnSurface">@color/white</item>
<item name="buttonBarPositiveButtonStyle">@style/Widget.App.Button.TextButton</item>
<item name="buttonBarNegativeButtonStyle">@style/Widget.App.Button.TextButton</item>
</style>
<style name="Theme.App.AlertDialog" parent="Theme.AppCompat.Dialog.Alert">
<item name="colorAccent">@color/white</item>
<item name="android:textColorPrimary">@color/white</item>
<item name="buttonBarPositiveButtonStyle">@style/Widget.App.Button.TextButton</item>
<item name="buttonBarNegativeButtonStyle">@style/Widget.App.Button.TextButton</item>
</style>
<style name="Widget.App.Button.TextButton" parent="Widget.Material3.Button.TextButton">
<item name="android:textColor">@color/white</item>
</style> </style>
</resources> </resources>

View File

@@ -19,10 +19,10 @@ dependencyResolutionManagement {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven { url = uri("https://jitpack.io") }
} }
} }
rootProject.name = "PetStoreMobile" rootProject.name = "PetStoreMobile"
include(":app") include(":app")