android app
This commit is contained in:
27
saars/android-app/app/src/main/AndroidManifest.xml
Normal file
27
saars/android-app/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:name=".ChatPlatformApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@android:drawable/ic_menu_send"
|
||||
android:roundIcon="@android:drawable/ic_menu_send"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.ChatPlatform">
|
||||
<activity android:name=".presentation.login.LoginActivity" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name=".presentation.MainActivity" android:exported="false" />
|
||||
<activity android:name=".presentation.chat.ChatActivity" android:exported="false" />
|
||||
<activity android:name=".presentation.agent.AgentChatActivity" android:exported="false" />
|
||||
<service android:name=".fcm.ChatFirebaseMessagingService" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.saars.chatplatform;
|
||||
|
||||
import android.app.Application;
|
||||
|
||||
import com.saars.chatplatform.data.local.SyncHelper;
|
||||
import com.saars.chatplatform.data.remote.RetrofitClient;
|
||||
import com.saars.chatplatform.data.remote.SocketManager;
|
||||
|
||||
public class ChatPlatformApp extends Application {
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
RetrofitClient.init(this);
|
||||
SyncHelper syncHelper = new SyncHelper(this);
|
||||
SocketManager.init(syncHelper);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.saars.chatplatform.data.local;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.room.Database;
|
||||
import androidx.room.Room;
|
||||
import androidx.room.RoomDatabase;
|
||||
|
||||
import com.saars.chatplatform.data.local.dao.ConversationDao;
|
||||
import com.saars.chatplatform.data.local.dao.MessageDao;
|
||||
import com.saars.chatplatform.data.local.entity.ConversationEntity;
|
||||
import com.saars.chatplatform.data.local.entity.MessageEntity;
|
||||
|
||||
@Database(entities = {ConversationEntity.class, MessageEntity.class}, version = 1, exportSchema = false)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
private static volatile AppDatabase instance;
|
||||
|
||||
public abstract ConversationDao conversationDao();
|
||||
public abstract MessageDao messageDao();
|
||||
|
||||
public static AppDatabase getInstance(Context context) {
|
||||
if (instance == null) {
|
||||
synchronized (AppDatabase.class) {
|
||||
if (instance == null) {
|
||||
instance = Room.databaseBuilder(
|
||||
context.getApplicationContext(),
|
||||
AppDatabase.class,
|
||||
"chat_platform_db"
|
||||
).fallbackToDestructiveMigration().build();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.saars.chatplatform.data.local;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.saars.chatplatform.data.local.entity.ConversationEntity;
|
||||
import com.saars.chatplatform.data.local.entity.MessageEntity;
|
||||
import com.saars.chatplatform.data.remote.ApiService;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Saves API responses to Room for offline; loads from Room first for instant UI.
|
||||
*/
|
||||
public class SyncHelper {
|
||||
private final AppDatabase db;
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
|
||||
public SyncHelper(Context context) {
|
||||
this.db = AppDatabase.getInstance(context);
|
||||
}
|
||||
|
||||
public void saveConversations(List<ApiService.ConversationItem> items) {
|
||||
executor.execute(() -> {
|
||||
List<ConversationEntity> list = new ArrayList<>();
|
||||
for (ApiService.ConversationItem c : items) {
|
||||
ConversationEntity e = new ConversationEntity();
|
||||
e.id = c.id;
|
||||
e.title = c.title;
|
||||
e.lastMessageAt = c.last_message_at;
|
||||
e.updatedAt = System.currentTimeMillis();
|
||||
list.add(e);
|
||||
}
|
||||
db.conversationDao().insertAll(list);
|
||||
});
|
||||
}
|
||||
|
||||
public void saveMessages(String conversationId, List<ApiService.Message> items) {
|
||||
executor.execute(() -> {
|
||||
db.messageDao().deleteByConversation(conversationId);
|
||||
List<MessageEntity> list = new ArrayList<>();
|
||||
for (ApiService.Message m : items) {
|
||||
MessageEntity e = new MessageEntity();
|
||||
e.id = m.id;
|
||||
e.conversationId = m.conversation_id;
|
||||
e.senderId = m.sender_id;
|
||||
e.content = m.content;
|
||||
e.contentType = m.content_type != null ? m.content_type : "text";
|
||||
e.createdAt = m.created_at;
|
||||
e.pending = false;
|
||||
list.add(e);
|
||||
}
|
||||
db.messageDao().insertAll(list);
|
||||
});
|
||||
}
|
||||
|
||||
public void insertMessage(MessageEntity e) {
|
||||
executor.execute(() -> db.messageDao().insert(e));
|
||||
}
|
||||
|
||||
public List<ConversationEntity> getConversationsFromDb() {
|
||||
return db.conversationDao().getAll();
|
||||
}
|
||||
|
||||
public List<MessageEntity> getMessagesFromDb(String conversationId) {
|
||||
return db.messageDao().getByConversation(conversationId);
|
||||
}
|
||||
|
||||
public void saveSentMessagePending(String conversationId, String localId, String content, String senderId) {
|
||||
MessageEntity e = new MessageEntity();
|
||||
e.id = localId;
|
||||
e.conversationId = conversationId;
|
||||
e.senderId = senderId;
|
||||
e.content = content;
|
||||
e.contentType = "text";
|
||||
e.createdAt = String.valueOf(System.currentTimeMillis());
|
||||
e.pending = true;
|
||||
executor.execute(() -> db.messageDao().insert(e));
|
||||
}
|
||||
|
||||
public void markMessageSynced(String localId, String serverId, String createdAt) {
|
||||
executor.execute(() -> {
|
||||
MessageEntity e = db.messageDao().getById(localId);
|
||||
if (e != null) {
|
||||
db.messageDao().deleteById(localId);
|
||||
e.id = serverId;
|
||||
e.createdAt = createdAt;
|
||||
e.pending = false;
|
||||
db.messageDao().insert(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.saars.chatplatform.data.local;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
/**
|
||||
* Stores JWT token after login.
|
||||
*/
|
||||
public class TokenStore {
|
||||
private static final String PREFS_NAME = "chat_platform";
|
||||
private static final String KEY_ACCESS_TOKEN = "access_token";
|
||||
|
||||
private final SharedPreferences prefs;
|
||||
|
||||
public TokenStore(Context context) {
|
||||
this.prefs = context.getApplicationContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
public void setToken(String token) {
|
||||
prefs.edit().putString(KEY_ACCESS_TOKEN, token).apply();
|
||||
}
|
||||
|
||||
public String getToken() {
|
||||
return prefs.getString(KEY_ACCESS_TOKEN, null);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
prefs.edit().remove(KEY_ACCESS_TOKEN).apply();
|
||||
}
|
||||
|
||||
public boolean hasToken() {
|
||||
String t = getToken();
|
||||
return t != null && !t.isEmpty();
|
||||
}
|
||||
|
||||
/** "Bearer <token>" for Authorization header */
|
||||
public String getBearerToken() {
|
||||
String t = getToken();
|
||||
if (t == null || t.isEmpty()) return null;
|
||||
return "Bearer " + t;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.saars.chatplatform.data.local.dao;
|
||||
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Insert;
|
||||
import androidx.room.OnConflictStrategy;
|
||||
import androidx.room.Query;
|
||||
|
||||
import com.saars.chatplatform.data.local.entity.ConversationEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Dao
|
||||
public interface ConversationDao {
|
||||
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
|
||||
List<ConversationEntity> getAll();
|
||||
|
||||
@Query("SELECT * FROM conversations WHERE id = :id LIMIT 1")
|
||||
ConversationEntity getById(String id);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insertAll(List<ConversationEntity> list);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insert(ConversationEntity e);
|
||||
|
||||
@Query("DELETE FROM conversations")
|
||||
void deleteAll();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.saars.chatplatform.data.local.dao;
|
||||
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Insert;
|
||||
import androidx.room.OnConflictStrategy;
|
||||
import androidx.room.Query;
|
||||
|
||||
import com.saars.chatplatform.data.local.entity.MessageEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Dao
|
||||
public interface MessageDao {
|
||||
@Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY createdAt ASC")
|
||||
List<MessageEntity> getByConversation(String conversationId);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insert(MessageEntity m);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insertAll(List<MessageEntity> list);
|
||||
|
||||
@Query("DELETE FROM messages WHERE conversationId = :conversationId")
|
||||
void deleteByConversation(String conversationId);
|
||||
|
||||
@Query("DELETE FROM messages WHERE id = :id")
|
||||
void deleteById(String id);
|
||||
|
||||
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
|
||||
MessageEntity getById(String id);
|
||||
|
||||
@Query("DELETE FROM messages")
|
||||
void deleteAll();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.saars.chatplatform.data.local.entity;
|
||||
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.PrimaryKey;
|
||||
|
||||
@Entity(tableName = "conversations")
|
||||
public class ConversationEntity {
|
||||
@PrimaryKey
|
||||
public String id;
|
||||
public String title;
|
||||
public String lastMessageAt;
|
||||
public long updatedAt;
|
||||
|
||||
public ConversationEntity() {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.saars.chatplatform.data.local.entity;
|
||||
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.Index;
|
||||
import androidx.room.PrimaryKey;
|
||||
|
||||
@Entity(tableName = "messages", indices = {@Index("conversationId")})
|
||||
public class MessageEntity {
|
||||
@PrimaryKey
|
||||
public String id;
|
||||
public String conversationId;
|
||||
public String senderId;
|
||||
public String content;
|
||||
public String contentType;
|
||||
public String createdAt;
|
||||
/** true = saved locally, not yet confirmed by server */
|
||||
public boolean pending;
|
||||
|
||||
public MessageEntity() {}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.saars.chatplatform.data.remote;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.http.Body;
|
||||
import retrofit2.http.GET;
|
||||
import retrofit2.http.Header;
|
||||
import retrofit2.http.POST;
|
||||
import retrofit2.http.Path;
|
||||
import retrofit2.http.Query;
|
||||
|
||||
/**
|
||||
* Retrofit API interface for chat platform backend.
|
||||
*/
|
||||
public interface ApiService {
|
||||
|
||||
@POST("api/v1/auth/login")
|
||||
Call<LoginResponse> login(@Body LoginRequest request);
|
||||
|
||||
@POST("api/v1/auth/register")
|
||||
Call<LoginResponse> register(@Body RegisterRequest request);
|
||||
|
||||
/** 知你客服 Agent 代理(方式一,Token 由拦截器统一添加) */
|
||||
@POST("api/v1/agent/chat")
|
||||
Call<AgentChatResponse> agentChat(@Body AgentChatRequest body);
|
||||
|
||||
@GET("api/v1/chat/conversations")
|
||||
Call<ConversationListResponse> getConversations(
|
||||
@Header("Authorization") String token,
|
||||
@Query("skip") int skip,
|
||||
@Query("limit") int limit);
|
||||
|
||||
@POST("api/v1/chat/conversations")
|
||||
Call<ConversationItem> createConversation(
|
||||
@Header("Authorization") String token,
|
||||
@Body CreateConversationRequest body);
|
||||
|
||||
@GET("api/v1/chat/conversations/{id}/messages")
|
||||
Call<MessageListResponse> getMessages(
|
||||
@Header("Authorization") String token,
|
||||
@Path("id") String conversationId,
|
||||
@Query("before_id") String beforeId,
|
||||
@Query("limit") int limit);
|
||||
|
||||
@POST("api/v1/chat/conversations/{id}/messages")
|
||||
Call<Message> sendMessage(
|
||||
@Header("Authorization") String token,
|
||||
@Path("id") String conversationId,
|
||||
@Body SendMessageRequest body);
|
||||
|
||||
class LoginRequest {
|
||||
public String email;
|
||||
public String password;
|
||||
public LoginRequest(String email, String password) {
|
||||
this.email = email;
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
|
||||
class RegisterRequest {
|
||||
public String email;
|
||||
public String username;
|
||||
public String password;
|
||||
public RegisterRequest(String email, String username, String password) {
|
||||
this.email = email;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
|
||||
class LoginResponse {
|
||||
public String access_token;
|
||||
public String refresh_token;
|
||||
public User user;
|
||||
}
|
||||
|
||||
class User {
|
||||
public String id;
|
||||
public String email;
|
||||
public String username;
|
||||
}
|
||||
|
||||
class ConversationListResponse {
|
||||
public java.util.List<ConversationItem> items;
|
||||
}
|
||||
|
||||
class ConversationItem {
|
||||
public String id;
|
||||
public String title;
|
||||
public String last_message_at;
|
||||
}
|
||||
|
||||
class MessageListResponse {
|
||||
public java.util.List<Message> items;
|
||||
}
|
||||
|
||||
class Message {
|
||||
public String id;
|
||||
public String conversation_id;
|
||||
public String sender_id;
|
||||
public String content;
|
||||
public String content_type;
|
||||
public String created_at;
|
||||
}
|
||||
|
||||
class SendMessageRequest {
|
||||
public String content;
|
||||
public String content_type;
|
||||
public SendMessageRequest(String content) {
|
||||
this.content = content;
|
||||
this.content_type = "text";
|
||||
}
|
||||
}
|
||||
|
||||
class CreateConversationRequest {
|
||||
public String title;
|
||||
public CreateConversationRequest(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
}
|
||||
|
||||
class AgentChatRequest {
|
||||
public String message;
|
||||
public String user_id;
|
||||
public AgentChatRequest(String message, String user_id) {
|
||||
this.message = message;
|
||||
this.user_id = user_id;
|
||||
}
|
||||
}
|
||||
|
||||
class AgentChatResponse {
|
||||
public String reply;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.saars.chatplatform.data.remote;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.saars.chatplatform.BuildConfig;
|
||||
import com.saars.chatplatform.data.local.TokenStore;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.logging.HttpLoggingInterceptor;
|
||||
import retrofit2.Retrofit;
|
||||
import retrofit2.converter.gson.GsonConverterFactory;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Singleton Retrofit client with auth interceptor.
|
||||
*/
|
||||
public class RetrofitClient {
|
||||
private static volatile ApiService apiService;
|
||||
private static TokenStore tokenStore;
|
||||
|
||||
public static void init(Context context) {
|
||||
if (tokenStore == null) {
|
||||
tokenStore = new TokenStore(context);
|
||||
}
|
||||
}
|
||||
|
||||
public static TokenStore getTokenStore(Context context) {
|
||||
if (tokenStore == null) tokenStore = new TokenStore(context);
|
||||
return tokenStore;
|
||||
}
|
||||
|
||||
public static ApiService getApi() {
|
||||
if (apiService == null) {
|
||||
synchronized (RetrofitClient.class) {
|
||||
if (apiService == null) {
|
||||
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
|
||||
logging.setLevel(HttpLoggingInterceptor.Level.BODY);
|
||||
OkHttpClient.Builder builder = new OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS);
|
||||
builder.addInterceptor(chain -> {
|
||||
Request original = chain.request();
|
||||
String auth = tokenStore != null ? tokenStore.getBearerToken() : null;
|
||||
Request.Builder req = original.newBuilder();
|
||||
if (auth != null) req.header("Authorization", auth);
|
||||
req.header("Content-Type", "application/json");
|
||||
return chain.proceed(req.build());
|
||||
});
|
||||
builder.addInterceptor(logging);
|
||||
|
||||
String baseUrl = BuildConfig.API_BASE_URL;
|
||||
if (baseUrl != null && !baseUrl.endsWith("/")) baseUrl += "/";
|
||||
Retrofit retrofit = new Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(builder.build())
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build();
|
||||
apiService = retrofit.create(ApiService.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
return apiService;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.saars.chatplatform.data.remote;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.saars.chatplatform.BuildConfig;
|
||||
import com.saars.chatplatform.data.local.SyncHelper;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.net.URISyntaxException;
|
||||
|
||||
import io.socket.client.IO;
|
||||
import io.socket.client.Socket;
|
||||
import io.socket.emitter.Emitter;
|
||||
|
||||
/**
|
||||
* Socket.IO: connect with token, join conversation room, receive new_message.
|
||||
*/
|
||||
public class SocketManager {
|
||||
private static final String TAG = "SocketManager";
|
||||
private static Socket socket;
|
||||
private static String currentConversationId;
|
||||
private static NewMessageListener newMessageListener;
|
||||
private static SyncHelper syncHelper;
|
||||
|
||||
public interface NewMessageListener {
|
||||
void onNewMessage(String conversationId, String messageId, String content, String senderId, String createdAt);
|
||||
}
|
||||
|
||||
public static void init(SyncHelper helper) {
|
||||
syncHelper = helper;
|
||||
}
|
||||
|
||||
public static void connect(String token) {
|
||||
if (token == null || token.isEmpty()) return;
|
||||
if (socket != null && socket.connected()) return;
|
||||
try {
|
||||
String base = BuildConfig.API_BASE_URL;
|
||||
if (base != null && base.endsWith("/")) base = base.substring(0, base.length() - 1);
|
||||
IO.Options opts = new IO.Options();
|
||||
opts.forceNew = true;
|
||||
opts.reconnection = true;
|
||||
opts.query = "token=" + token;
|
||||
socket = IO.socket(base, opts);
|
||||
socket.on(Socket.EVENT_CONNECT, args -> Log.d(TAG, "Socket connected"));
|
||||
socket.on(Socket.EVENT_DISCONNECT, args -> Log.d(TAG, "Socket disconnected"));
|
||||
socket.on(Socket.EVENT_CONNECT_ERROR, args -> Log.e(TAG, "Socket error: " + (args.length > 0 ? args[0] : "")));
|
||||
socket.on("new_message", onNewMessage);
|
||||
socket.connect();
|
||||
} catch (URISyntaxException e) {
|
||||
Log.e(TAG, "Socket URI error", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void disconnect() {
|
||||
if (socket != null) {
|
||||
socket.disconnect();
|
||||
socket.off();
|
||||
socket = null;
|
||||
}
|
||||
currentConversationId = null;
|
||||
}
|
||||
|
||||
public static void joinConversation(String conversationId) {
|
||||
currentConversationId = conversationId;
|
||||
if (socket != null && socket.connected()) {
|
||||
JSONObject obj = new JSONObject();
|
||||
try {
|
||||
obj.put("conversation_id", conversationId);
|
||||
socket.emit("join_conversation", obj);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "join_conversation emit error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void leaveConversation() {
|
||||
if (socket != null && socket.connected() && currentConversationId != null) {
|
||||
try {
|
||||
JSONObject obj = new JSONObject();
|
||||
obj.put("conversation_id", currentConversationId);
|
||||
socket.emit("leave_conversation", obj);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "leave_conversation emit error", e);
|
||||
}
|
||||
}
|
||||
currentConversationId = null;
|
||||
}
|
||||
|
||||
public static void setNewMessageListener(NewMessageListener listener) {
|
||||
newMessageListener = listener;
|
||||
}
|
||||
|
||||
private static final Emitter.Listener onNewMessage = args -> {
|
||||
if (args.length == 0) return;
|
||||
try {
|
||||
JSONObject o = (JSONObject) args[0];
|
||||
String conversationId = o.optString("conversation_id");
|
||||
String id = o.optString("id");
|
||||
String content = o.optString("content");
|
||||
String senderId = o.optString("sender_id");
|
||||
String createdAt = o.optString("created_at");
|
||||
if (syncHelper != null) {
|
||||
com.saars.chatplatform.data.local.entity.MessageEntity e = new com.saars.chatplatform.data.local.entity.MessageEntity();
|
||||
e.id = id;
|
||||
e.conversationId = conversationId;
|
||||
e.senderId = senderId;
|
||||
e.content = content;
|
||||
e.createdAt = createdAt;
|
||||
e.pending = false;
|
||||
syncHelper.insertMessage(e);
|
||||
}
|
||||
if (newMessageListener != null) {
|
||||
newMessageListener.onNewMessage(conversationId, id, content, senderId, createdAt);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "onNewMessage parse error", e);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.saars.chatplatform.domain.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Repository interface for chat: conversations and messages (domain layer).
|
||||
*/
|
||||
public interface ChatRepository {
|
||||
|
||||
void loadConversations(LoadCallback callback);
|
||||
|
||||
void loadMessages(String conversationId, String beforeId, int limit, LoadMessagesCallback callback);
|
||||
|
||||
void sendMessage(String conversationId, String content, SendCallback callback);
|
||||
|
||||
interface LoadCallback {
|
||||
void onSuccess(List<?> items);
|
||||
void onError(Throwable t);
|
||||
}
|
||||
|
||||
interface LoadMessagesCallback {
|
||||
void onSuccess(List<?> items);
|
||||
void onError(Throwable t);
|
||||
}
|
||||
|
||||
interface SendCallback {
|
||||
void onSuccess(Object message);
|
||||
void onError(Throwable t);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.saars.chatplatform.fcm;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessagingService;
|
||||
import com.google.firebase.messaging.RemoteMessage;
|
||||
import com.saars.chatplatform.presentation.MainActivity;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* FCM: receive data/notification messages, show notification when app in background.
|
||||
*/
|
||||
public class ChatFirebaseMessagingService extends FirebaseMessagingService {
|
||||
|
||||
private static final String TAG = "ChatFCM";
|
||||
private static final String CHANNEL_ID = "chat_messages";
|
||||
|
||||
@Override
|
||||
public void onNewToken(@NonNull String token) {
|
||||
Log.d(TAG, "FCM token: " + token);
|
||||
// Optional: send token to backend POST /api/v1/users/me/fcm_token
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
|
||||
Map<String, String> data = remoteMessage.getData();
|
||||
if (data != null && !data.isEmpty()) {
|
||||
String title = data.get("title");
|
||||
String body = data.get("body");
|
||||
String conversationId = data.get("conversation_id");
|
||||
showNotification(title != null ? title : "新消息", body != null ? body : "", conversationId);
|
||||
return;
|
||||
}
|
||||
RemoteMessage.Notification notif = remoteMessage.getNotification();
|
||||
if (notif != null) {
|
||||
showNotification(notif.getTitle(), notif.getBody(), null);
|
||||
}
|
||||
}
|
||||
|
||||
private void showNotification(String title, String body, String conversationId) {
|
||||
createChannel();
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
if (conversationId != null) {
|
||||
intent.putExtra("open_conversation_id", conversationId);
|
||||
}
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
PendingIntent pi = PendingIntent.getActivity(this, 0, intent,
|
||||
PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE);
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_email)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pi);
|
||||
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
|
||||
if (nm != null) nm.notify((int) System.currentTimeMillis(), builder.build());
|
||||
}
|
||||
|
||||
private void createChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel ch = new NotificationChannel(CHANNEL_ID, "聊天消息", NotificationManager.IMPORTANCE_DEFAULT);
|
||||
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
|
||||
if (nm != null) nm.createNotificationChannel(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.saars.chatplatform.presentation;
|
||||
|
||||
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.saars.chatplatform.R;
|
||||
import com.saars.chatplatform.data.remote.ApiService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class ConversationListAdapter extends RecyclerView.Adapter<ConversationListAdapter.VH> {
|
||||
|
||||
private final List<ApiService.ConversationItem> items;
|
||||
private final OnItemClickListener listener;
|
||||
|
||||
public interface OnItemClickListener {
|
||||
void onItemClick(ApiService.ConversationItem item);
|
||||
}
|
||||
|
||||
public ConversationListAdapter(List<ApiService.ConversationItem> items, OnItemClickListener listener) {
|
||||
this.items = items;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_conversation, parent, false);
|
||||
return new VH(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull VH holder, int position) {
|
||||
ApiService.ConversationItem item = items.get(position);
|
||||
holder.tvTitle.setText(item.title != null ? item.title : "对话");
|
||||
holder.tvTime.setText(item.last_message_at != null ? item.last_message_at : "");
|
||||
holder.itemView.setOnClickListener(v -> listener.onItemClick(item));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return items.size();
|
||||
}
|
||||
|
||||
static class VH extends RecyclerView.ViewHolder {
|
||||
TextView tvTitle, tvTime;
|
||||
|
||||
VH(View itemView) {
|
||||
super(itemView);
|
||||
tvTitle = itemView.findViewById(R.id.tvTitle);
|
||||
tvTime = itemView.findViewById(R.id.tvTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.saars.chatplatform.presentation;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.saars.chatplatform.data.local.entity.ConversationEntity;
|
||||
import com.saars.chatplatform.data.local.SyncHelper;
|
||||
import com.saars.chatplatform.data.local.TokenStore;
|
||||
import com.saars.chatplatform.data.remote.ApiService;
|
||||
import com.saars.chatplatform.data.remote.RetrofitClient;
|
||||
import com.saars.chatplatform.data.remote.SocketManager;
|
||||
import com.saars.chatplatform.databinding.ActivityMainBinding;
|
||||
import com.saars.chatplatform.presentation.agent.AgentChatActivity;
|
||||
import com.saars.chatplatform.presentation.chat.ChatActivity;
|
||||
import com.saars.chatplatform.presentation.login.LoginActivity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private ActivityMainBinding binding;
|
||||
private TokenStore tokenStore;
|
||||
private SyncHelper syncHelper;
|
||||
private final List<ApiService.ConversationItem> items = new ArrayList<>();
|
||||
private ConversationListAdapter adapter;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
tokenStore = RetrofitClient.getTokenStore(this);
|
||||
if (!tokenStore.hasToken()) {
|
||||
startActivity(new Intent(this, LoginActivity.class));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
binding = ActivityMainBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
setSupportActionBar(binding.toolbar);
|
||||
|
||||
adapter = new ConversationListAdapter(items, item -> {
|
||||
Intent i = new Intent(MainActivity.this, ChatActivity.class);
|
||||
i.putExtra(ChatActivity.EXTRA_CONVERSATION_ID, item.id);
|
||||
i.putExtra(ChatActivity.EXTRA_TITLE, item.title != null ? item.title : "对话");
|
||||
startActivity(i);
|
||||
});
|
||||
binding.recycler.setLayoutManager(new LinearLayoutManager(this));
|
||||
binding.recycler.setAdapter(adapter);
|
||||
|
||||
binding.fabNew.setOnClickListener(v -> createConversation());
|
||||
binding.fabAgent.setOnClickListener(v -> startActivity(new Intent(this, AgentChatActivity.class)));
|
||||
syncHelper = new SyncHelper(this);
|
||||
loadFromDbThenApi();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
if (tokenStore.hasToken()) {
|
||||
SocketManager.connect(tokenStore.getToken());
|
||||
loadFromDbThenApi();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyConversationsToUi(List<ConversationEntity> list) {
|
||||
items.clear();
|
||||
for (ConversationEntity e : list) {
|
||||
ApiService.ConversationItem c = new ApiService.ConversationItem();
|
||||
c.id = e.id;
|
||||
c.title = e.title;
|
||||
c.last_message_at = e.lastMessageAt;
|
||||
items.add(c);
|
||||
}
|
||||
adapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
private void loadFromDbThenApi() {
|
||||
binding.progress.setVisibility(View.VISIBLE);
|
||||
new Thread(() -> {
|
||||
List<ConversationEntity> fromDb = syncHelper.getConversationsFromDb();
|
||||
runOnUiThread(() -> {
|
||||
applyConversationsToUi(fromDb);
|
||||
binding.progress.setVisibility(View.GONE);
|
||||
});
|
||||
}).start();
|
||||
RetrofitClient.getApi().getConversations(tokenStore.getBearerToken(), 0, 50)
|
||||
.enqueue(new Callback<ApiService.ConversationListResponse>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiService.ConversationListResponse> call,
|
||||
Response<ApiService.ConversationListResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().items != null) {
|
||||
syncHelper.saveConversations(response.body().items);
|
||||
new Thread(() -> {
|
||||
List<ConversationEntity> fromDb = syncHelper.getConversationsFromDb();
|
||||
runOnUiThread(() -> applyConversationsToUi(fromDb));
|
||||
}).start();
|
||||
}
|
||||
binding.progress.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<ApiService.ConversationListResponse> call, Throwable t) {
|
||||
binding.progress.setVisibility(View.GONE);
|
||||
Toast.makeText(MainActivity.this, "加载失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void createConversation() {
|
||||
binding.fabNew.setEnabled(false);
|
||||
ApiService.CreateConversationRequest req = new ApiService.CreateConversationRequest("新对话");
|
||||
RetrofitClient.getApi().createConversation(tokenStore.getBearerToken(), req)
|
||||
.enqueue(new Callback<ApiService.ConversationItem>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiService.ConversationItem> call,
|
||||
Response<ApiService.ConversationItem> response) {
|
||||
binding.fabNew.setEnabled(true);
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
items.add(0, response.body());
|
||||
adapter.notifyItemInserted(0);
|
||||
Intent i = new Intent(MainActivity.this, ChatActivity.class);
|
||||
i.putExtra(ChatActivity.EXTRA_CONVERSATION_ID, response.body().id);
|
||||
i.putExtra(ChatActivity.EXTRA_TITLE, "新对话");
|
||||
startActivity(i);
|
||||
} else {
|
||||
Toast.makeText(MainActivity.this, "创建失败", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<ApiService.ConversationItem> call, Throwable t) {
|
||||
binding.fabNew.setEnabled(true);
|
||||
Toast.makeText(MainActivity.this, "创建失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.saars.chatplatform.presentation.agent;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.saars.chatplatform.data.local.SyncHelper;
|
||||
import com.saars.chatplatform.data.local.TokenStore;
|
||||
import com.saars.chatplatform.data.remote.ApiService;
|
||||
import com.saars.chatplatform.data.remote.RetrofitClient;
|
||||
import com.saars.chatplatform.databinding.ActivityAgentChatBinding;
|
||||
import com.saars.chatplatform.presentation.chat.MessageListAdapter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
/**
|
||||
* 知你客服 Agent 对话页(方式一:通过 SAARS 后端代理调用平台执行 API)
|
||||
*/
|
||||
public class AgentChatActivity extends AppCompatActivity {
|
||||
|
||||
/** 固定会话 ID,用于 Room 存储历史 */
|
||||
public static final String AGENT_CONVERSATION_ID = "agent_知你客服";
|
||||
|
||||
private ActivityAgentChatBinding binding;
|
||||
private TokenStore tokenStore;
|
||||
private SyncHelper syncHelper;
|
||||
private final List<ApiService.Message> messages = new ArrayList<>();
|
||||
private MessageListAdapter adapter;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
binding = ActivityAgentChatBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
tokenStore = RetrofitClient.getTokenStore(this);
|
||||
syncHelper = new SyncHelper(this);
|
||||
setSupportActionBar(binding.toolbar);
|
||||
if (getSupportActionBar() != null) {
|
||||
getSupportActionBar().setTitle("知你客服");
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
}
|
||||
binding.toolbar.setNavigationOnClickListener(v -> finish());
|
||||
|
||||
adapter = new MessageListAdapter(messages);
|
||||
binding.recycler.setLayoutManager(new LinearLayoutManager(this));
|
||||
binding.recycler.setAdapter(adapter);
|
||||
|
||||
binding.btnSend.setOnClickListener(v -> sendToAgent());
|
||||
loadHistoryFromDb();
|
||||
}
|
||||
|
||||
private void loadHistoryFromDb() {
|
||||
new Thread(() -> {
|
||||
List<com.saars.chatplatform.data.local.entity.MessageEntity> fromDb =
|
||||
syncHelper.getMessagesFromDb(AGENT_CONVERSATION_ID);
|
||||
runOnUiThread(() -> {
|
||||
messages.clear();
|
||||
for (com.saars.chatplatform.data.local.entity.MessageEntity e : fromDb) {
|
||||
ApiService.Message m = new ApiService.Message();
|
||||
m.id = e.id;
|
||||
m.conversation_id = e.conversationId;
|
||||
m.sender_id = e.senderId;
|
||||
m.content = e.content;
|
||||
m.created_at = e.createdAt;
|
||||
messages.add(m);
|
||||
}
|
||||
adapter.notifyDataSetChanged();
|
||||
scrollToBottom();
|
||||
});
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void sendToAgent() {
|
||||
String content = binding.etInput.getText().toString().trim();
|
||||
if (content.isEmpty()) return;
|
||||
binding.etInput.setText("");
|
||||
binding.btnSend.setEnabled(false);
|
||||
|
||||
// user_id 不传时后端使用 JWT 的 sub 作为知你客服多轮记忆的 user_id
|
||||
ApiService.AgentChatRequest req = new ApiService.AgentChatRequest(content, null);
|
||||
RetrofitClient.getApi().agentChat(req).enqueue(new Callback<ApiService.AgentChatResponse>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiService.AgentChatResponse> call, Response<ApiService.AgentChatResponse> response) {
|
||||
binding.btnSend.setEnabled(true);
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
String reply = response.body().reply != null ? response.body().reply : "";
|
||||
appendUserMessage(content);
|
||||
appendBotMessage(reply);
|
||||
scrollToBottom();
|
||||
} else {
|
||||
Toast.makeText(AgentChatActivity.this, "请求失败: " + response.message(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<ApiService.AgentChatResponse> call, Throwable t) {
|
||||
binding.btnSend.setEnabled(true);
|
||||
Toast.makeText(AgentChatActivity.this, "请求失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void appendUserMessage(String content) {
|
||||
ApiService.Message m = new ApiService.Message();
|
||||
m.id = "local_u_" + System.currentTimeMillis();
|
||||
m.conversation_id = AGENT_CONVERSATION_ID;
|
||||
m.sender_id = "user";
|
||||
m.content = content;
|
||||
m.created_at = String.valueOf(System.currentTimeMillis());
|
||||
messages.add(m);
|
||||
adapter.notifyItemInserted(messages.size() - 1);
|
||||
saveMessageToRoom(m);
|
||||
}
|
||||
|
||||
private void appendBotMessage(String content) {
|
||||
ApiService.Message m = new ApiService.Message();
|
||||
m.id = "local_b_" + System.currentTimeMillis();
|
||||
m.conversation_id = AGENT_CONVERSATION_ID;
|
||||
m.sender_id = "agent";
|
||||
m.content = content;
|
||||
m.created_at = String.valueOf(System.currentTimeMillis());
|
||||
messages.add(m);
|
||||
adapter.notifyItemInserted(messages.size() - 1);
|
||||
com.saars.chatplatform.data.local.entity.MessageEntity e = new com.saars.chatplatform.data.local.entity.MessageEntity();
|
||||
e.id = m.id;
|
||||
e.conversationId = m.conversation_id;
|
||||
e.senderId = m.sender_id;
|
||||
e.content = m.content;
|
||||
e.createdAt = m.created_at;
|
||||
e.pending = false;
|
||||
syncHelper.insertMessage(e);
|
||||
}
|
||||
|
||||
private void saveMessageToRoom(ApiService.Message m) {
|
||||
com.saars.chatplatform.data.local.entity.MessageEntity e = new com.saars.chatplatform.data.local.entity.MessageEntity();
|
||||
e.id = m.id;
|
||||
e.conversationId = m.conversation_id;
|
||||
e.senderId = m.sender_id;
|
||||
e.content = m.content;
|
||||
e.createdAt = m.created_at;
|
||||
e.pending = false;
|
||||
syncHelper.insertMessage(e);
|
||||
}
|
||||
|
||||
private void scrollToBottom() {
|
||||
if (messages.isEmpty()) return;
|
||||
binding.recycler.smoothScrollToPosition(messages.size() - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.saars.chatplatform.presentation.chat;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.saars.chatplatform.data.local.SyncHelper;
|
||||
import com.saars.chatplatform.data.local.TokenStore;
|
||||
import com.saars.chatplatform.data.local.entity.MessageEntity;
|
||||
import com.saars.chatplatform.data.remote.ApiService;
|
||||
import com.saars.chatplatform.data.remote.RetrofitClient;
|
||||
import com.saars.chatplatform.data.remote.SocketManager;
|
||||
import com.saars.chatplatform.databinding.ActivityChatBinding;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class ChatActivity extends AppCompatActivity {
|
||||
|
||||
public static final String EXTRA_CONVERSATION_ID = "conversation_id";
|
||||
public static final String EXTRA_TITLE = "title";
|
||||
|
||||
private ActivityChatBinding binding;
|
||||
private TokenStore tokenStore;
|
||||
private SyncHelper syncHelper;
|
||||
private String conversationId;
|
||||
private final List<ApiService.Message> messages = new ArrayList<>();
|
||||
private MessageListAdapter adapter;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
binding = ActivityChatBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
conversationId = getIntent().getStringExtra(EXTRA_CONVERSATION_ID);
|
||||
String title = getIntent().getStringExtra(EXTRA_TITLE);
|
||||
if (conversationId == null) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
tokenStore = RetrofitClient.getTokenStore(this);
|
||||
syncHelper = new SyncHelper(this);
|
||||
setSupportActionBar(binding.toolbar);
|
||||
if (getSupportActionBar() != null) {
|
||||
getSupportActionBar().setTitle(title != null ? title : "对话");
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
}
|
||||
binding.toolbar.setNavigationOnClickListener(v -> finish());
|
||||
|
||||
adapter = new MessageListAdapter(messages);
|
||||
binding.recycler.setLayoutManager(new LinearLayoutManager(this));
|
||||
binding.recycler.setAdapter(adapter);
|
||||
|
||||
binding.btnSend.setOnClickListener(v -> sendMessage());
|
||||
loadFromDbThenApi();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
SocketManager.joinConversation(conversationId);
|
||||
SocketManager.setNewMessageListener((convId, msgId, content, senderId, createdAt) -> {
|
||||
if (!convId.equals(conversationId)) return;
|
||||
runOnUiThread(() -> {
|
||||
ApiService.Message m = new ApiService.Message();
|
||||
m.id = msgId;
|
||||
m.conversation_id = convId;
|
||||
m.sender_id = senderId;
|
||||
m.content = content;
|
||||
m.created_at = createdAt;
|
||||
messages.add(m);
|
||||
adapter.notifyItemInserted(messages.size() - 1);
|
||||
scrollToBottom();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
SocketManager.leaveConversation();
|
||||
SocketManager.setNewMessageListener(null);
|
||||
}
|
||||
|
||||
private void applyMessagesToUi(List<MessageEntity> list) {
|
||||
messages.clear();
|
||||
for (MessageEntity e : list) {
|
||||
ApiService.Message m = new ApiService.Message();
|
||||
m.id = e.id;
|
||||
m.conversation_id = e.conversationId;
|
||||
m.sender_id = e.senderId;
|
||||
m.content = e.content;
|
||||
m.created_at = e.createdAt;
|
||||
messages.add(m);
|
||||
}
|
||||
adapter.notifyDataSetChanged();
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
private void loadFromDbThenApi() {
|
||||
new Thread(() -> {
|
||||
List<MessageEntity> fromDb = syncHelper.getMessagesFromDb(conversationId);
|
||||
runOnUiThread(() -> applyMessagesToUi(fromDb));
|
||||
}).start();
|
||||
RetrofitClient.getApi().getMessages(tokenStore.getBearerToken(), conversationId, null, 50)
|
||||
.enqueue(new Callback<ApiService.MessageListResponse>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiService.MessageListResponse> call,
|
||||
Response<ApiService.MessageListResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().items != null) {
|
||||
syncHelper.saveMessages(conversationId, response.body().items);
|
||||
new Thread(() -> {
|
||||
List<MessageEntity> fromDb = syncHelper.getMessagesFromDb(conversationId);
|
||||
runOnUiThread(() -> applyMessagesToUi(fromDb));
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<ApiService.MessageListResponse> call, Throwable t) {
|
||||
Toast.makeText(ChatActivity.this, "加载消息失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void sendMessage() {
|
||||
String content = binding.etInput.getText().toString().trim();
|
||||
if (content.isEmpty()) return;
|
||||
binding.etInput.setText("");
|
||||
binding.btnSend.setEnabled(false);
|
||||
|
||||
ApiService.SendMessageRequest req = new ApiService.SendMessageRequest(content);
|
||||
RetrofitClient.getApi().sendMessage(tokenStore.getBearerToken(), conversationId, req)
|
||||
.enqueue(new Callback<ApiService.Message>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiService.Message> call, Response<ApiService.Message> response) {
|
||||
binding.btnSend.setEnabled(true);
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
ApiService.Message msg = response.body();
|
||||
messages.add(msg);
|
||||
adapter.notifyItemInserted(messages.size() - 1);
|
||||
scrollToBottom();
|
||||
MessageEntity e = new MessageEntity();
|
||||
e.id = msg.id;
|
||||
e.conversationId = msg.conversation_id;
|
||||
e.senderId = msg.sender_id;
|
||||
e.content = msg.content;
|
||||
e.contentType = msg.content_type != null ? msg.content_type : "text";
|
||||
e.createdAt = msg.created_at;
|
||||
e.pending = false;
|
||||
syncHelper.insertMessage(e);
|
||||
} else {
|
||||
Toast.makeText(ChatActivity.this, "发送失败: " + response.message(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<ApiService.Message> call, Throwable t) {
|
||||
binding.btnSend.setEnabled(true);
|
||||
Toast.makeText(ChatActivity.this, "发送失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void scrollToBottom() {
|
||||
if (messages.isEmpty()) return;
|
||||
binding.recycler.smoothScrollToPosition(messages.size() - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.saars.chatplatform.presentation.chat;
|
||||
|
||||
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.saars.chatplatform.R;
|
||||
import com.saars.chatplatform.data.remote.ApiService;
|
||||
import java.util.List;
|
||||
|
||||
public class MessageListAdapter extends RecyclerView.Adapter<MessageListAdapter.VH> {
|
||||
private final List<ApiService.Message> items;
|
||||
|
||||
public MessageListAdapter(List<ApiService.Message> items) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_message, parent, false);
|
||||
return new VH(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull VH holder, int position) {
|
||||
ApiService.Message m = items.get(position);
|
||||
holder.tvMessage.setText(m.content != null ? m.content : "");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return items.size();
|
||||
}
|
||||
|
||||
static class VH extends RecyclerView.ViewHolder {
|
||||
TextView tvMessage;
|
||||
VH(View itemView) {
|
||||
super(itemView);
|
||||
tvMessage = itemView.findViewById(R.id.tvMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.saars.chatplatform.presentation.login;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.saars.chatplatform.data.local.TokenStore;
|
||||
import com.saars.chatplatform.data.remote.ApiService;
|
||||
import com.saars.chatplatform.data.remote.RetrofitClient;
|
||||
import com.saars.chatplatform.databinding.ActivityLoginBinding;
|
||||
import com.saars.chatplatform.presentation.MainActivity;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class LoginActivity extends AppCompatActivity {
|
||||
|
||||
private ActivityLoginBinding binding;
|
||||
private TokenStore tokenStore;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
binding = ActivityLoginBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
tokenStore = RetrofitClient.getTokenStore(this);
|
||||
|
||||
binding.btnLogin.setOnClickListener(v -> doLogin());
|
||||
binding.btnRegister.setOnClickListener(v -> doRegister());
|
||||
}
|
||||
|
||||
private void doLogin() {
|
||||
String email = binding.etEmail.getText().toString().trim();
|
||||
String password = binding.etPassword.getText().toString();
|
||||
if (email.isEmpty() || password.isEmpty()) {
|
||||
Toast.makeText(this, "请输入邮箱和密码", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
binding.btnLogin.setEnabled(false);
|
||||
ApiService.LoginRequest req = new ApiService.LoginRequest(email, password);
|
||||
RetrofitClient.getApi().login(req).enqueue(new Callback<ApiService.LoginResponse>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiService.LoginResponse> call, Response<ApiService.LoginResponse> response) {
|
||||
binding.btnLogin.setEnabled(true);
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
tokenStore.setToken(response.body().access_token);
|
||||
com.saars.chatplatform.data.remote.SocketManager.connect(response.body().access_token);
|
||||
startActivity(new Intent(LoginActivity.this, MainActivity.class));
|
||||
finish();
|
||||
} else {
|
||||
Toast.makeText(LoginActivity.this, "登录失败: " + (response.message()), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<ApiService.LoginResponse> call, Throwable t) {
|
||||
binding.btnLogin.setEnabled(true);
|
||||
Toast.makeText(LoginActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void doRegister() {
|
||||
String email = binding.etEmail.getText().toString().trim();
|
||||
String username = binding.etUsername.getText().toString().trim();
|
||||
String password = binding.etPassword.getText().toString();
|
||||
if (email.isEmpty() || username.isEmpty() || password.isEmpty()) {
|
||||
Toast.makeText(this, "请输入邮箱、用户名和密码", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
binding.btnRegister.setEnabled(false);
|
||||
ApiService.RegisterRequest req = new ApiService.RegisterRequest(email, username, password);
|
||||
RetrofitClient.getApi().register(req).enqueue(new Callback<ApiService.LoginResponse>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiService.LoginResponse> call, Response<ApiService.LoginResponse> response) {
|
||||
binding.btnRegister.setEnabled(true);
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
tokenStore.setToken(response.body().access_token);
|
||||
com.saars.chatplatform.data.remote.SocketManager.connect(response.body().access_token);
|
||||
Toast.makeText(LoginActivity.this, "注册成功", Toast.LENGTH_SHORT).show();
|
||||
startActivity(new Intent(LoginActivity.this, MainActivity.class));
|
||||
finish();
|
||||
} else {
|
||||
Toast.makeText(LoginActivity.this, "注册失败: " + response.message(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<ApiService.LoginResponse> call, Throwable t) {
|
||||
binding.btnRegister.setEnabled(true);
|
||||
Toast.makeText(LoginActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:navigationIcon="@android:drawable/ic_menu_revert"
|
||||
app:title="知你客服" />
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:padding="8dp" />
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
<EditText
|
||||
android:id="@+id/etInput"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="输入消息,与知你客服对话"
|
||||
android:inputType="text"
|
||||
android:minHeight="48dp" />
|
||||
<Button
|
||||
android:id="@+id/btnSend"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="发送"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
39
saars/android-app/app/src/main/res/layout/activity_chat.xml
Normal file
39
saars/android-app/app/src/main/res/layout/activity_chat.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:navigationIcon="@android:drawable/ic_menu_revert"
|
||||
app:title="对话" />
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:padding="8dp" />
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
<EditText
|
||||
android:id="@+id/etInput"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="输入消息"
|
||||
android:inputType="text"
|
||||
android:minHeight="48dp" />
|
||||
<Button
|
||||
android:id="@+id/btnSend"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="发送"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
77
saars/android-app/app/src/main/res/layout/activity_login.xml
Normal file
77
saars/android-app/app/src/main/res/layout/activity_login.xml
Normal file
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="聊天平台"
|
||||
android:textSize="24sp"
|
||||
android:layout_marginTop="48dp"
|
||||
android:layout_marginBottom="32dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="邮箱"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etEmail"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textEmailAddress" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="用户名(注册时填)"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_marginTop="16dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etUsername"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="密码"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_marginTop="16dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etPassword"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnLogin"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="登录"
|
||||
android:layout_marginTop="24dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnRegister"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="注册"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
54
saars/android-app/app/src/main/res/layout/activity_main.xml
Normal file
54
saars/android-app/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:title="会话列表" />
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false" />
|
||||
</FrameLayout>
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fabAgent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="88dp"
|
||||
android:contentDescription="知你客服"
|
||||
app:srcCompat="@android:drawable/ic_menu_send" />
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fabNew"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_margin="16dp"
|
||||
android:contentDescription="新对话"
|
||||
app:srcCompat="@android:drawable/ic_input_add" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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="16dp"
|
||||
android:background="?attr/selectableItemBackground">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTime"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
android:layout_marginTop="4dp" />
|
||||
</LinearLayout>
|
||||
13
saars/android-app/app/src/main/res/layout/item_message.xml
Normal file
13
saars/android-app/app/src/main/res/layout/item_message.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp">
|
||||
<TextView
|
||||
android:id="@+id/tvMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxWidth="280dp"
|
||||
android:padding="12dp"
|
||||
android:textSize="15sp" />
|
||||
</FrameLayout>
|
||||
4
saars/android-app/app/src/main/res/values/colors.xml
Normal file
4
saars/android-app/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primary">#6750A4</color>
|
||||
</resources>
|
||||
4
saars/android-app/app/src/main/res/values/strings.xml
Normal file
4
saars/android-app/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Chat Platform</string>
|
||||
</resources>
|
||||
6
saars/android-app/app/src/main/res/values/themes.xml
Normal file
6
saars/android-app/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.ChatPlatform" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user