android app

This commit is contained in:
rjb
2026-03-07 09:01:00 +08:00
parent 9d3198f6bc
commit 717cd2a1ac
63 changed files with 3067 additions and 0 deletions

View 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>

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
});
}
}

View File

@@ -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 &lt;token&gt;" for Authorization header */
public String getBearerToken() {
String t = getToken();
if (t == null || t.isEmpty()) return null;
return "Bearer " + t;
}
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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() {}
}

View File

@@ -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() {}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
};
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
});
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
});
}
}

View 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>

View 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>

View 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>

View 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>

View File

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

View 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>

View File

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

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Chat Platform</string>
</resources>

View 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>