Compare commits

...
10 Commits
37 changed files with 1912 additions and 509 deletions
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+123
View File
@@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-06-22T13:08:49.001161300Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=3bde5b08" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection>
<targets>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=BNU5T17801000820" />
</handle>
</Target>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=3bde5b08" />
</handle>
</Target>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=53633685" />
</handle>
</Target>
</targets>
</DialogSelection>
</SelectionState>
</selectionStates>
</component>
</project>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="API" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
<ColumnSorterState>
<option name="column" value="Type" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+65
View File
@@ -0,0 +1,65 @@
# Rainnya
Android AI 聊天客户端,通过 WebSocket 连接 AstrBot 服务端,支持流式回复、会话管理、本地持久化。
## 特性
- **WebSocket 实时通信** — 全双工流式传输,逐字显示 AI 回复
- **会话管理** — 多会话切换、重命名、删除
- **本地持久化** — Room 数据库,App 杀死重启后自动恢复
- **Material3 动态取色** — Android 12+ 自动适配主题色
- **Markdown 渲染** — AI 回复中的粗体、代码块、链接等
- **连接状态指示** — 实时显示 WebSocket 连接状态
- **消息复制、时间戳、长文本折叠** — 便捷的消息交互
## 技术栈
- **语言**: Kotlin 2.2
- **UI**: Jetpack Compose + Material3
- **WebSocket**: OkHttp 4.12
- **数据库**: Room 2.7 (KSP)
- **构建**: Gradle 9.4 + AGP 9.2
- **最低支持**: API 28 (Android 9)
## 架构
```
app/
├── data/
│ ├── local/ # Room 数据库 (AppDatabase, ChatDao)
│ ├── model/ # 数据模型 (ChatMessage, ChatSession)
│ ├── repository/ # ChatRepository — 状态管理 + 业务逻辑
│ ├── settings/ # AppSettings — SharedPreferences
│ └── websocket/ # AstrBotWsClient — OkHttp WebSocket
├── ui/
│ ├── chat/ # 聊天页面 (ChatScreen, ChatViewModel)
│ ├── components/ # 可复用组件 (MessageBubble, ChatInputBar, MarkdownText)
│ ├── sessions/ # 会话列表
│ ├── settings/ # 设置页面
│ ├── navigation/ # 底部导航
│ ├── theme/ # 主题
│ └── util/ # 工具 (KeyboardHeightUtil)
└── MainActivity.kt
```
## 配置
在「设置」页面填写:
| 配置项 | 说明 |
|--------|------|
| 服务器地址 | AstrBot HTTP 地址 (如 `http://192.168.1.100:6185`) |
| API Key | AstrBot 的 API 密钥 |
| 用户名 | 显示名称,自动添加 `app_` 前缀 |
## 构建
```bash
git clone https://github.com/yourname/rainnya.git
cd rainnya
./gradlew assembleDebug
```
## 协议
MIT
+5
View File
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.com.google.devtools.ksp)
}
android {
@@ -51,6 +52,10 @@ dependencies {
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.square.okhttp)
implementation(libs.square.gson)
implementation(libs.coil.compose)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+4
View File
@@ -0,0 +1,4 @@
storeFile=rainnya.jks
storePassword=rainnya123
keyAlias=rainnya
keyPassword=rainnya123
@@ -0,0 +1,30 @@
package com.rainnya.chat.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.rainnya.chat.data.model.ChatMessage
import com.rainnya.chat.data.model.ChatSession
@Database(entities = [ChatSession::class, ChatMessage::class], version = 2, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun chatDao(): ChatDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"rainnya_db"
).fallbackToDestructiveMigration(false).build().also { INSTANCE = it }
}
}
}
}
@@ -0,0 +1,38 @@
package com.rainnya.chat.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.rainnya.chat.data.model.ChatMessage
import com.rainnya.chat.data.model.ChatSession
@Dao
interface ChatDao {
@Query("SELECT * FROM chat_sessions ORDER BY updatedAt DESC")
suspend fun getAllSessions(): List<ChatSession>
@Query("SELECT * FROM chat_sessions ORDER BY updatedAt DESC LIMIT 1")
suspend fun getLatestSession(): ChatSession?
@Query("SELECT * FROM chat_messages WHERE sessionId = :sessionId ORDER BY timestamp ASC")
suspend fun getMessagesForSession(sessionId: String): List<ChatMessage>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSession(session: ChatSession)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessages(messages: List<ChatMessage>)
@Query("DELETE FROM chat_messages WHERE sessionId = :sessionId")
suspend fun deleteMessagesForSession(sessionId: String)
@Query("DELETE FROM chat_sessions WHERE sessionId = :sessionId")
suspend fun deleteSession(sessionId: String)
@Query("UPDATE chat_sessions SET displayName = :newName WHERE sessionId = :sessionId")
suspend fun renameSession(sessionId: String, newName: String)
@Query("UPDATE chat_sessions SET updatedAt = :updatedAt WHERE sessionId = :sessionId")
suspend fun updateSessionTimestamp(sessionId: String, updatedAt: Long)
}
@@ -0,0 +1,12 @@
package com.rainnya.chat.data.local
import androidx.room.TypeConverter
import com.rainnya.chat.data.model.MessageRole
class Converters {
@TypeConverter
fun fromMessageRole(role: MessageRole): String = role.name
@TypeConverter
fun toMessageRole(value: String): MessageRole = MessageRole.valueOf(value)
}
@@ -1,12 +1,18 @@
package com.rainnya.chat.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "chat_messages")
data class ChatMessage(
val id: String,
@PrimaryKey val id: String,
val content: String,
val role: MessageRole,
val timestamp: Long = System.currentTimeMillis(),
val sessionId: String = "",
val streaming: Boolean = false,
val attachmentId: String? = null,
val imageUrl: String? = null,
)
enum class MessageRole { USER, ASSISTANT, SYSTEM }
@@ -1,8 +1,12 @@
package com.rainnya.chat.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "chat_sessions")
data class ChatSession(
val sessionId: String,
@PrimaryKey val sessionId: String,
val displayName: String = "",
val createdAt: String = "",
val updatedAt: String = "",
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
)
@@ -16,4 +16,15 @@ data class WsIncomingMessage(
val message_id: String? = null,
val streaming: Boolean? = null,
val code: String? = null,
val attachment_id: String? = null,
val url: String? = null,
)
data class WsMessageSegment(
val type: String,
val text: String? = null,
val attachment_id: String? = null,
val url: String? = null,
val filename: String? = null,
val mime_type: String? = null,
)
@@ -1,17 +1,27 @@
package com.rainnya.chat.data.repository
import android.content.Context
import android.net.Uri
import android.util.Log
import com.google.gson.Gson
import com.rainnya.chat.data.local.AppDatabase
import com.rainnya.chat.data.model.ChatMessage
import com.rainnya.chat.data.model.MessageRole
import com.rainnya.chat.data.model.ChatSession
import com.rainnya.chat.data.model.WsMessageSegment
import com.rainnya.chat.data.settings.AppSettings
import com.rainnya.chat.data.upload.ImageUploader
import com.rainnya.chat.data.websocket.AstrBotWsClient
import com.rainnya.chat.data.websocket.WsEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -21,14 +31,20 @@ import java.util.concurrent.TimeUnit
private const val TAG = "RainnyaRepo"
class ChatRepository(
private val scope: CoroutineScope,
private val context: Context,
private val settings: AppSettings,
private val wsClient: AstrBotWsClient = AstrBotWsClient(Gson()),
private val imageUploader: ImageUploader = ImageUploader(),
) {
private val httpClient = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
private val db = AppDatabase.getInstance(context)
private val dao = db.chatDao()
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages
@@ -38,11 +54,43 @@ class ChatRepository(
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState
private val _uploadProgress = MutableStateFlow(0f)
val uploadProgress: StateFlow<Float> = _uploadProgress
private val _uploading = MutableStateFlow(false)
val uploading: StateFlow<Boolean> = _uploading
private var currentSessionId: String? = null
private val sessionMessages = mutableMapOf<String, MutableList<ChatMessage>>()
private var streamTimeoutJob: Job? = null
val wsEvents: Flow<WsEvent> = wsClient.events
init {
scope.launch(Dispatchers.IO) {
loadFromDatabase()
}
}
private suspend fun loadFromDatabase() {
try {
val sessions = dao.getAllSessions()
_sessions.value = sessions
for (session in sessions) {
val msgs = dao.getMessagesForSession(session.sessionId)
sessionMessages[session.sessionId] = msgs.toMutableList()
}
val latest = sessions.maxByOrNull { it.updatedAt }
if (latest != null) {
currentSessionId = latest.sessionId
_messages.value = sessionMessages[latest.sessionId]?.toList() ?: emptyList()
Log.d(TAG, "Loaded latest session: ${latest.sessionId}")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load from database", e)
}
}
fun connect() {
if (!settings.isConfigured) {
Log.w(TAG, "Cannot connect: settings not configured")
@@ -64,33 +112,92 @@ class ChatRepository(
_connectionState.value = ConnectionState.DISCONNECTED
}
fun sendMessage(text: String) {
fun sendMessage(text: String, attachmentId: String? = null, imageUrl: String? = null) {
if (_connectionState.value != ConnectionState.CONNECTED) {
Log.w(TAG, "Cannot send: not connected")
return
}
ensureSessionExists()
val messageId = UUID.randomUUID().toString()
Log.d(TAG, "Sending message id=$messageId text=$text")
val userMsg = ChatMessage(
val userMsgBuilder = ChatMessage(
id = "user_$messageId",
content = text,
role = MessageRole.USER,
sessionId = currentSessionId ?: "",
imageUrl = imageUrl,
)
val list = _messages.value.toMutableList()
list.add(userMsg)
list.add(userMsgBuilder)
list.add(
ChatMessage(
id = "pending_$messageId",
content = "",
role = MessageRole.ASSISTANT,
sessionId = currentSessionId ?: "",
streaming = true,
)
)
_messages.value = list
saveCurrentMessages()
val sendSessionId = if (currentSessionId?.startsWith("local_") == true) null else currentSessionId
val messagePayload: Any = if (attachmentId != null) {
val segments = mutableListOf<WsMessageSegment>()
if (text.isNotBlank()) {
segments.add(WsMessageSegment(type = "plain", text = text))
}
segments.add(WsMessageSegment(
type = "image",
attachment_id = attachmentId,
url = "${settings.httpBaseUrl}/api/v1/file?attachment_id=$attachmentId",
))
segments
} else {
text
}
wsClient.sendMessage(
text = text,
message = messagePayload,
username = settings.taggedUsername,
sessionId = currentSessionId,
sessionId = sendSessionId,
messageId = messageId,
)
}
fun sendMessageWithImage(text: String, imageUri: Uri) {
if (_connectionState.value != ConnectionState.CONNECTED) {
Log.w(TAG, "Cannot send: not connected")
return
}
_uploading.value = true
_uploadProgress.value = 0f
scope.launch(Dispatchers.IO) {
try {
val attachmentId = imageUploader.upload(
context = context,
baseUrl = settings.httpBaseUrl,
apiKey = settings.apiKey,
uri = imageUri,
onProgress = { progress ->
_uploadProgress.value = progress
},
)
_uploadProgress.value = 1f
_uploading.value = false
sendMessage(text = text, attachmentId = attachmentId, imageUrl = imageUri.toString())
} catch (e: Exception) {
Log.e(TAG, "Image upload failed", e)
_uploading.value = false
_uploadProgress.value = 0f
}
}
}
fun handleWsEvent(event: WsEvent) {
try {
when (event) {
@@ -101,10 +208,12 @@ class ChatRepository(
is WsEvent.Disconnected -> {
Log.d(TAG, "Event: Disconnected")
_connectionState.value = ConnectionState.DISCONNECTED
markPendingStreamAsFailed()
}
is WsEvent.Error -> {
Log.e(TAG, "Event: Error ${event.error}")
_connectionState.value = ConnectionState.ERROR
markPendingStreamAsFailed()
}
is WsEvent.MessageReceived -> handleMessage(event)
}
@@ -121,13 +230,41 @@ class ChatRepository(
when (msg.type) {
"session_id" -> {
msg.session_id?.let { sid ->
val prevId = currentSessionId
currentSessionId = sid
val existing = _sessions.value.any { it.sessionId == sid }
if (!existing) {
_sessions.value = _sessions.value + ChatSession(
val prevSession = prevId?.let { id ->
_sessions.value.find { it.sessionId == id }
}
val displayName = prevSession?.displayName
?: "会话 ${_sessions.value.size + 1}"
val createdAt = prevSession?.createdAt
?: System.currentTimeMillis()
val session = ChatSession(
sessionId = sid,
displayName = "会话 ${_sessions.value.size + 1}",
displayName = displayName,
createdAt = createdAt,
)
_sessions.value = _sessions.value.map {
if (it.sessionId == prevId) session else it
}
sessionMessages.remove(prevId)
sessionMessages[sid] = _messages.value.toMutableList()
runBlocking(Dispatchers.IO) {
try {
if (prevId != null) {
dao.deleteMessagesForSession(prevId)
dao.deleteSession(prevId)
}
dao.insertSession(session)
val msgs = _messages.value
dao.deleteMessagesForSession(sid)
dao.insertMessages(msgs)
} catch (e: Exception) {
Log.e(TAG, "Failed to migrate session", e)
}
}
}
}
}
@@ -135,6 +272,13 @@ class ChatRepository(
val text = msg.data?.toString() ?: ""
if (text.isEmpty()) return
val isStreaming = msg.streaming ?: true
val trimmed = text.trimStart()
if (trimmed.startsWith("{") && trimmed.contains("chatcmpl-tool-")) {
if (isStreaming) resetStreamTimeout()
return
}
val list = _messages.value.toMutableList()
val lastAssistant = list.indexOfLast { it.role == MessageRole.ASSISTANT }
@@ -143,6 +287,7 @@ class ChatRepository(
content = list[lastAssistant].content + text
)
_messages.value = list
resetStreamTimeout()
} else {
list.add(
ChatMessage(
@@ -156,7 +301,27 @@ class ChatRepository(
_messages.value = list
}
}
"image" -> {
val list = _messages.value.toMutableList()
val attachmentId = msg.attachment_id
val imageUrl = msg.url
list.add(
ChatMessage(
id = UUID.randomUUID().toString(),
content = "",
role = MessageRole.ASSISTANT,
sessionId = currentSessionId ?: "",
streaming = false,
attachmentId = attachmentId,
imageUrl = imageUrl,
)
)
_messages.value = list
saveCurrentMessages()
}
"end" -> {
streamTimeoutJob?.cancel()
val list = _messages.value.toMutableList()
val lastAssistant = list.indexOfLast { it.role == MessageRole.ASSISTANT }
if (lastAssistant >= 0) {
@@ -188,9 +353,62 @@ class ChatRepository(
}
}
private fun resetStreamTimeout() {
streamTimeoutJob?.cancel()
streamTimeoutJob = scope.launch {
delay(30_000L)
val list = _messages.value.toMutableList()
val lastAssistant = list.indexOfLast { it.role == MessageRole.ASSISTANT }
if (lastAssistant >= 0 && list[lastAssistant].streaming) {
list[lastAssistant] = list[lastAssistant].copy(streaming = false)
_messages.value = list
saveCurrentMessages()
Log.d(TAG, "Stream timed out after 30s, ended streaming")
}
}
}
private fun saveCurrentMessages() {
val sid = currentSessionId ?: return
sessionMessages[sid] = _messages.value.toMutableList()
runBlocking(Dispatchers.IO) {
try {
dao.deleteMessagesForSession(sid)
dao.insertMessages(_messages.value)
dao.updateSessionTimestamp(sid, System.currentTimeMillis())
} catch (e: Exception) {
Log.e(TAG, "Failed to save messages to database", e)
}
}
updateLocalSessionTimestamp(sid)
}
private fun markPendingStreamAsFailed() {
streamTimeoutJob?.cancel()
val list = _messages.value.toMutableList()
var changed = false
for (i in list.indices) {
if (list[i].role == MessageRole.ASSISTANT && list[i].streaming) {
val msg = list[i]
val newContent = if (msg.content.isEmpty()) {
"消息传输失败,请重试"
} else {
"${msg.content}\n\n—— 消息传输失败,请重试"
}
list[i] = msg.copy(content = newContent, streaming = false)
changed = true
}
}
if (changed) {
_messages.value = list
saveCurrentMessages()
}
}
private fun updateLocalSessionTimestamp(sessionId: String) {
_sessions.value = _sessions.value.map {
if (it.sessionId == sessionId) it.copy(updatedAt = System.currentTimeMillis()) else it
}
}
fun switchSession(sessionId: String) {
@@ -200,10 +418,28 @@ class ChatRepository(
_messages.value = sessionMessages[sessionId]?.toList() ?: emptyList()
}
private fun ensureSessionExists() {
if (currentSessionId == null) {
Log.d(TAG, "No active session, auto-creating one")
newSession()
}
}
fun newSession() {
Log.d(TAG, "New session")
saveCurrentMessages()
currentSessionId = null
val placeholderId = "local_${UUID.randomUUID().toString().take(8)}"
val emptySession = ChatSession(sessionId = placeholderId, displayName = "新会话")
_sessions.value = _sessions.value + emptySession
sessionMessages[placeholderId] = mutableListOf()
runBlocking(Dispatchers.IO) {
try {
dao.insertSession(emptySession)
} catch (e: Exception) {
Log.e(TAG, "Failed to save new session", e)
}
}
currentSessionId = placeholderId
_messages.value = emptyList()
}
@@ -217,6 +453,13 @@ class ChatRepository(
_sessions.value = _sessions.value.map {
if (it.sessionId == sessionId) it.copy(displayName = newName) else it
}
runBlocking(Dispatchers.IO) {
try {
dao.renameSession(sessionId, newName)
} catch (e: Exception) {
Log.e(TAG, "Failed to rename session in database", e)
}
}
}
fun deleteSession(sessionId: String) {
@@ -227,6 +470,14 @@ class ChatRepository(
currentSessionId = null
_messages.value = emptyList()
}
runBlocking(Dispatchers.IO) {
try {
dao.deleteMessagesForSession(sessionId)
dao.deleteSession(sessionId)
} catch (e: Exception) {
Log.e(TAG, "Failed to delete session from database", e)
}
}
}
suspend fun testConnection(): String = withContext(Dispatchers.IO) {
@@ -76,6 +76,8 @@ class AstrBotWsClient(
message_id = json.get("message_id")?.asString,
streaming = json.get("streaming")?.asBoolean,
code = json.get("code")?.asString,
attachment_id = json.get("attachment_id")?.asString,
url = json.get("url")?.asString,
)
scope.launch { _events.send(WsEvent.MessageReceived(msg)) }
} catch (e: Exception) {
@@ -96,13 +98,13 @@ class AstrBotWsClient(
}
fun sendMessage(
text: String,
message: Any,
username: String,
sessionId: String? = null,
messageId: String = UUID.randomUUID().toString(),
) {
val msg = WsOutgoingMessage(
message = text,
message = message,
username = username,
session_id = sessionId,
message_id = messageId,
@@ -1,19 +1,26 @@
package com.rainnya.chat.ui.chat
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -23,34 +30,59 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Chat
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material.icons.rounded.Stars
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rainnya.chat.data.repository.ConnectionState
import com.rainnya.chat.ui.components.ChatInputBar
import com.rainnya.chat.ui.components.MessageBubble
import com.rainnya.chat.ui.theme.RainnyaTheme
import com.rainnya.chat.ui.util.rememberImeHeightPx
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val MAX_VISIBLE = 200
private const val LOAD_MORE_STEP = 100
private val suggestions = listOf(
"介绍一下你自己",
"今天有什么新闻?",
"帮我写一首诗",
"什么是机器学习?",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -60,37 +92,62 @@ fun ChatScreen(
) {
val state by viewModel.uiState.collectAsState()
val listState = rememberLazyListState()
val snackbar = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val navBarDp = scaffoldPadding.calculateBottomPadding()
val density = LocalDensity.current
val imeHeightPx = WindowInsets.ime.getBottom(density)
val imeHeightPx = rememberImeHeightPx()
val bottomDp = if (imeHeightPx > 0) {
with(density) { imeHeightPx.toDp() }
} else {
navBarDp
}
val lastMsg = state.messages.lastOrNull()
val streamingContentLen = lastMsg?.let { if (it.streaming) it.content.length else null }
LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) {
listState.animateScrollToItem(state.messages.size - 1)
}
}
LaunchedEffect(streamingContentLen) {
if (streamingContentLen != null && state.messages.isNotEmpty()) {
listState.animateScrollToItem(state.messages.size - 1)
}
}
val currentSession = viewModel.repository.currentSession()
val totalMessages = state.messages.size
var visibleCount by remember(totalMessages) { mutableIntStateOf(MAX_VISIBLE) }
val canLoadMore = totalMessages > MAX_VISIBLE && visibleCount < totalMessages
val displayMessages = if (canLoadMore || totalMessages > MAX_VISIBLE) {
state.messages.takeLast(visibleCount.coerceAtMost(totalMessages))
} else {
state.messages
}
val isAtBottom by remember {
derivedStateOf {
val info = listState.layoutInfo
if (info.totalItemsCount == 0) return@derivedStateOf true
val lastVisible = info.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf true
lastVisible.index >= info.totalItemsCount - 2 &&
lastVisible.offset + lastVisible.size <= info.viewportEndOffset + 1
}
}
LaunchedEffect(totalMessages, currentSession?.sessionId) {
if (displayMessages.isNotEmpty()) {
listState.animateScrollToItem(displayMessages.size - 1)
}
}
LaunchedEffect(isAtBottom) {
if (!isAtBottom) return@LaunchedEffect
snapshotFlow {
state.messages.lastOrNull()?.let { if (it.streaming) it.content.length else null }
}.collect { len ->
if (len != null && displayMessages.isNotEmpty()) {
listState.scrollToItem(displayMessages.size - 1)
}
}
}
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
onResult = { uri: Uri? -> uri?.let { viewModel.addImage(it) } }
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = bottomDp)
modifier = Modifier.fillMaxSize().padding(bottom = bottomDp),
) {
TopAppBar(
title = {
@@ -101,13 +158,12 @@ fun ChatScreen(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
currentSession?.let { s ->
if (state.isStreaming) {
Text(
text = s.sessionId.take(8),
text = "AI 正在回复…",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -117,134 +173,240 @@ fun ChatScreen(
titleContentColor = MaterialTheme.colorScheme.onSurface,
),
actions = {
ConnectionStatusChip(
ConnectionDot(
state = state.connectionState,
onReconnect = { viewModel.reconnect() },
)
Spacer(Modifier.width(4.dp))
IconButton(onClick = { viewModel.newSession() }) {
Icon(Icons.Rounded.Add, contentDescription = "新会话")
}
},
)
if (state.messages.isEmpty()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Rounded.Chat,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
)
Spacer(Modifier.height(16.dp))
Text(
text = "发送消息开始对话",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
} else {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
state = listState,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(state.messages, key = { it.id }) { message ->
MessageBubble(message = message)
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
if (state.messages.isEmpty()) {
EmptyChat(onSuggestion = { viewModel.sendMessage(it) })
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
verticalArrangement = Arrangement.spacedBy(2.dp),
contentPadding = PaddingValues(vertical = 8.dp),
) {
if (canLoadMore) {
item(key = "__load_more__") {
LoadMoreHeader(
totalCount = totalMessages,
onClick = {
visibleCount =
(visibleCount + LOAD_MORE_STEP).coerceAtMost(totalMessages)
},
)
}
}
items(displayMessages, key = { it.id }) { message ->
MessageBubble(
message = message,
onCopy = { text ->
copyToClipboard(context, text)
scope.launch {
snackbar.showSnackbar(
message = "已复制",
duration = SnackbarDuration.Short,
)
}
},
)
}
item(key = "__bottom_spacer__") {
Spacer(Modifier.height(4.dp))
}
}
}
SnackbarHost(
hostState = snackbar,
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 8.dp),
)
}
ChatInputBar(
text = state.inputText,
onTextChange = viewModel::updateInputText,
onSend = { viewModel.sendMessage(state.inputText) },
onPickImage = {
imagePickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
pendingImageUri = state.pendingImageUri,
onRemoveImage = viewModel::removeImage,
uploading = state.uploading,
uploadProgress = state.uploadProgress,
connectionState = state.connectionState,
isStreaming = state.isStreaming,
)
}
}
@Composable
private fun ConnectionStatusChip(
state: ConnectionState,
onReconnect: () -> Unit,
) {
val infiniteTransition = rememberInfiniteTransition(label = "spin")
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "rotation",
)
private fun copyToClipboard(context: Context, text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Rainnya", text))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 4.dp),
@Composable
private fun LoadMoreHeader(totalCount: Int, onClick: () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
when (state) {
ConnectionState.CONNECTED -> {
Text(
text = "已连接",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
ConnectionState.CONNECTING -> {
CircularProgressIndicator(
modifier = Modifier
.size(14.dp)
.rotate(rotation),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(4.dp))
Text(
text = "连接中…",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
ConnectionState.ERROR -> {
Text(
text = "连接失败",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(end = 8.dp),
)
IconButton(onClick = onReconnect, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Rounded.Refresh,
contentDescription = "重连",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
}
}
ConnectionState.DISCONNECTED -> {
Text(
text = "未连接",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 8.dp),
)
IconButton(onClick = onReconnect, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Rounded.Refresh,
contentDescription = "重连",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 32.dp),
) {
Box(
modifier = Modifier
.weight(1f)
.height(1.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
Text(
text = "查看更多历史消息(共${totalCount}条)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 12.dp),
)
Box(
modifier = Modifier
.weight(1f)
.height(1.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
}
}
@Composable
private fun EmptyChat(onSuggestion: (String) -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Rounded.Stars,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
)
Spacer(Modifier.height(16.dp))
Text(
text = "有什么可以帮助你的?",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(24.dp))
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
suggestions.chunked(2).forEach { pair ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
pair.forEach { text ->
SuggestionCard(
text = text,
onClick = { onSuggestion(text) },
modifier = Modifier.weight(1f),
)
}
if (pair.size == 1) {
Spacer(Modifier.weight(1f))
}
}
}
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
private fun SuggestionCard(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.6f))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
private fun ConnectionDot(
state: ConnectionState,
onReconnect: () -> Unit,
) {
val dotColor = when (state) {
ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primary
ConnectionState.CONNECTING -> MaterialTheme.colorScheme.tertiary
ConnectionState.ERROR -> MaterialTheme.colorScheme.error
ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
}
val alpha by if (state == ConnectionState.CONNECTING) {
rememberInfiniteTransition(label = "pulse").animateFloat(
initialValue = 0.4f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse),
label = "pulseAlpha",
)
} else {
remember { mutableStateOf(1f) }
}
if (state == ConnectionState.ERROR || state == ConnectionState.DISCONNECTED) {
IconButton(onClick = onReconnect, modifier = Modifier.size(32.dp)) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.alpha(alpha)
.background(dotColor),
)
}
} else {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.alpha(alpha)
.background(dotColor),
)
}
}
@Composable
fun ChatScreenPreview() {
RainnyaTheme {
@@ -1,6 +1,7 @@
package com.rainnya.chat.ui.chat
import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -18,11 +19,17 @@ data class ChatUiState(
val messages: List<com.rainnya.chat.data.model.ChatMessage> = emptyList(),
val connectionState: ConnectionState = ConnectionState.DISCONNECTED,
val inputText: String = "",
)
val pendingImageUri: Uri? = null,
val uploading: Boolean = false,
val uploadProgress: Float = 0f,
) {
val isStreaming: Boolean
get() = messages.any { it.streaming }
}
class ChatViewModel(application: Application) : AndroidViewModel(application), LifecycleEventObserver {
private val settings = AppSettings(application)
val repository = ChatRepository(settings)
val repository = ChatRepository(viewModelScope, application, settings)
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState
@@ -38,6 +45,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
_uiState.value = _uiState.value.copy(connectionState = state)
}
}
viewModelScope.launch {
repository.uploading.collect { uploading ->
_uiState.value = _uiState.value.copy(uploading = uploading)
}
}
viewModelScope.launch {
repository.uploadProgress.collect { progress ->
_uiState.value = _uiState.value.copy(uploadProgress = progress)
}
}
viewModelScope.launch {
repository.wsEvents.collect { event ->
repository.handleWsEvent(event)
@@ -58,15 +75,29 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
}
fun sendMessage(text: String) {
if (text.isBlank()) return
repository.sendMessage(text.trim())
_uiState.value = _uiState.value.copy(inputText = "")
if (text.isBlank() && _uiState.value.pendingImageUri == null) return
val imageUri = _uiState.value.pendingImageUri
if (imageUri != null) {
repository.sendMessageWithImage(text.trim(), imageUri)
_uiState.value = _uiState.value.copy(inputText = "", pendingImageUri = null)
} else {
repository.sendMessage(text.trim())
_uiState.value = _uiState.value.copy(inputText = "")
}
}
fun updateInputText(text: String) {
_uiState.value = _uiState.value.copy(inputText = text)
}
fun addImage(uri: Uri) {
_uiState.value = _uiState.value.copy(pendingImageUri = uri)
}
fun removeImage() {
_uiState.value = _uiState.value.copy(pendingImageUri = null)
}
fun newSession() {
repository.newSession()
}
@@ -1,20 +1,53 @@
package com.rainnya.chat.ui.components
import android.net.Uri
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.rainnya.chat.data.repository.ConnectionState
import com.rainnya.chat.ui.theme.RainnyaTheme
@@ -23,64 +56,211 @@ fun ChatInputBar(
text: String,
onTextChange: (String) -> Unit,
onSend: () -> Unit,
onPickImage: () -> Unit,
pendingImageUri: Uri? = null,
onRemoveImage: () -> Unit = {},
uploading: Boolean = false,
uploadProgress: Float = 0f,
connectionState: ConnectionState = ConnectionState.CONNECTED,
isStreaming: Boolean = false,
modifier: Modifier = Modifier,
) {
val enabled = connectionState == ConnectionState.CONNECTED
val placeholderText = when (connectionState) {
ConnectionState.DISCONNECTED -> "未连接,请在设置中配置"
ConnectionState.CONNECTING -> "连接中…"
ConnectionState.ERROR -> "连接失败"
ConnectionState.CONNECTED -> "输入消息"
val inputEnabled = connectionState == ConnectionState.CONNECTED
val canSend = inputEnabled && (text.isNotBlank() || pendingImageUri != null) && !isStreaming && !uploading
val placeholderText = when {
connectionState == ConnectionState.DISCONNECTED -> "连接,请在设置中配置"
connectionState == ConnectionState.CONNECTING -> "连接中"
connectionState == ConnectionState.ERROR -> "连接失败,点击重连"
else -> "发消息给 Rainnya…"
}
OutlinedTextField(
value = text,
onValueChange = onTextChange,
enabled = enabled,
placeholder = { Text(placeholderText) },
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
shape = MaterialTheme.shapes.extraLarge,
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
),
trailingIcon = {
var isFocused by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxWidth()) {
if (pendingImageUri != null) {
ImagePreviewThumbnail(
uri = pendingImageUri,
onRemove = onRemoveImage,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
.clip(RoundedCornerShape(28.dp))
.background(
if (inputEnabled || isFocused) {
MaterialTheme.colorScheme.surfaceContainerHigh
} else {
MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f)
},
)
.heightIn(min = 52.dp)
.padding(start = 6.dp, end = 6.dp, top = 2.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onSend,
enabled = enabled && text.isNotBlank(),
modifier = Modifier.size(48.dp),
onClick = onPickImage,
enabled = inputEnabled && !uploading,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Filled.Send,
contentDescription = "发送",
tint = if (enabled && text.isNotBlank())
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.size(24.dp),
imageVector = Icons.Outlined.Image,
contentDescription = "选择图片",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
},
singleLine = true,
)
BasicTextField(
value = text,
onValueChange = { if (inputEnabled) onTextChange(it) },
enabled = inputEnabled,
modifier = Modifier
.weight(1f)
.onFocusChanged { isFocused = it.isFocused },
textStyle = TextStyle(
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyLarge.fontSize * 1.15f,
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.15f,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
singleLine = false,
maxLines = 5,
decorationBox = { innerTextField ->
Box(
modifier = Modifier.heightIn(min = 28.dp),
contentAlignment = Alignment.CenterStart,
) {
if (text.isEmpty()) {
Text(
text = placeholderText,
style = MaterialTheme.typography.bodyLarge.copy(
fontSize = MaterialTheme.typography.bodyLarge.fontSize * 1.15f,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = if (inputEnabled) 0.5f else 0.35f,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
innerTextField()
}
},
)
if (uploading) {
Box(
modifier = Modifier
.padding(start = 8.dp)
.size(40.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
progress = { uploadProgress },
modifier = Modifier.size(28.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
} else {
AnimatedVisibility(
visible = canSend,
enter = scaleIn(animationSpec = tween(200)) + fadeIn(tween(150)),
exit = scaleOut(animationSpec = tween(150)) + fadeOut(tween(100)),
) {
Box(
modifier = Modifier
.padding(start = 8.dp)
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.then(
if (canSend) {
Modifier.clickable { onSend() }
} else {
Modifier
}
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "发送",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
}
@Composable
private fun ImagePreviewThumbnail(
uri: Uri,
onRemove: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 36.dp, vertical = 4.dp),
) {
Box(
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp)),
) {
AsyncImage(
model = uri,
contentDescription = "待发送图片",
modifier = Modifier.fillMaxWidth().height(64.dp),
contentScale = ContentScale.Crop,
)
}
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f))
.clickable(onClick = onRemove),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "移除图片",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(14.dp),
)
}
}
}
@Preview(showBackground = true)
@Composable
fun ChatInputBarConnectedPreview() {
RainnyaTheme {
ChatInputBar(text = "hello", onTextChange = {}, onSend = {}, connectionState = ConnectionState.CONNECTED)
ChatInputBar(
text = "hello", onTextChange = {}, onSend = {}, onPickImage = {},
connectionState = ConnectionState.CONNECTED,
)
}
}
@Preview(showBackground = true)
@Composable
fun ChatInputBarDisconnectedPreview() {
fun ChatInputBarStreamingPreview() {
RainnyaTheme {
ChatInputBar(text = "", onTextChange = {}, onSend = {}, connectionState = ConnectionState.DISCONNECTED)
ChatInputBar(
text = "", onTextChange = {}, onSend = {}, onPickImage = {},
connectionState = ConnectionState.CONNECTED, isStreaming = true,
)
}
}
@@ -0,0 +1,204 @@
package com.rainnya.chat.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.util.regex.Pattern
private val CODE_BLOCK_REGEX = Pattern.compile(
"```(\\w*)\\s*\\r?\\n(.*?)```", Pattern.DOTALL
)
private val BOLD_REGEX = Pattern.compile("\\*\\*(.+?)\\*\\*")
private val ITALIC_REGEX = Pattern.compile("(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)")
private val INLINE_CODE_REGEX = Pattern.compile("`(.+?)`")
private val LINK_REGEX = Pattern.compile("\\[([^\\]]+)\\]\\(([^)]+)\\)")
private sealed class MdSegment {
data class Code(val language: String, val code: String) : MdSegment()
data class Text(val content: String) : MdSegment()
}
@Composable
fun MarkdownText(
text: String,
style: TextStyle = MaterialTheme.typography.bodyLarge,
color: Color = MaterialTheme.colorScheme.onSurface,
modifier: Modifier = Modifier,
linkColor: Color = MaterialTheme.colorScheme.primary,
) {
val segments = remember(text) { parseMarkdownSegments(text) }
Column(modifier = modifier) {
for (segment in segments) {
when (segment) {
is MdSegment.Code -> CodeBlock(segment.code)
is MdSegment.Text -> InlineMarkdown(
text = segment.content,
style = style,
color = color,
linkColor = linkColor,
)
}
}
}
}
@Composable
private fun CodeBlock(code: String, modifier: Modifier = Modifier) {
val bg = MaterialTheme.colorScheme.surfaceContainerHigh
Text(
text = code,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(bg)
.padding(12.dp),
lineHeight = 20.sp,
)
}
@Composable
private fun InlineMarkdown(
text: String,
style: TextStyle,
color: Color,
linkColor: Color,
) {
val codeColor = MaterialTheme.colorScheme.primary
val annotated = remember(text, style, color, linkColor, codeColor) {
parseInlineMarkdown(text, style, color, linkColor, codeColor)
}
Text(text = annotated, style = style)
}
private fun parseMarkdownSegments(text: String): List<MdSegment> {
val segments = mutableListOf<MdSegment>()
val matcher = CODE_BLOCK_REGEX.matcher(text)
var lastEnd = 0
while (matcher.find()) {
if (matcher.start() > lastEnd) {
segments.add(MdSegment.Text(text.substring(lastEnd, matcher.start())))
}
val lang = matcher.group(1)!!.trim()
val code = matcher.group(2)!!.trimEnd()
segments.add(MdSegment.Code(lang, code))
lastEnd = matcher.end()
}
if (lastEnd < text.length) {
segments.add(MdSegment.Text(text.substring(lastEnd)))
}
return segments
}
private fun parseInlineMarkdown(
text: String,
baseStyle: TextStyle,
baseColor: Color,
linkColor: Color,
codeColor: Color,
): AnnotatedString = buildAnnotatedString {
var remaining = text
val fontSizeSp = baseStyle.fontSize.value
val monospaceSize = if (fontSizeSp > 0f) (fontSizeSp * 0.9f).sp else 14.sp
while (remaining.isNotEmpty()) {
val boldMatcher = BOLD_REGEX.matcher(remaining)
val italicMatcher = ITALIC_REGEX.matcher(remaining)
val codeMatcher = INLINE_CODE_REGEX.matcher(remaining)
val linkMatcher = LINK_REGEX.matcher(remaining)
val boldStart = if (boldMatcher.find()) boldMatcher.start() else Int.MAX_VALUE
val italicStart = if (italicMatcher.find()) italicMatcher.start() else Int.MAX_VALUE
val codeStart = if (codeMatcher.find()) codeMatcher.start() else Int.MAX_VALUE
val linkStart = if (linkMatcher.find()) linkMatcher.start() else Int.MAX_VALUE
val earliest = minOf(boldStart, italicStart, codeStart, linkStart)
if (earliest == Int.MAX_VALUE) {
append(remaining)
break
}
if (earliest > 0) {
append(remaining.substring(0, earliest))
remaining = remaining.substring(earliest)
continue
}
when (earliest) {
boldStart -> {
val m = BOLD_REGEX.matcher(remaining)
m.find()
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = baseColor)) {
append(m.group(1)!!)
}
remaining = remaining.substring(m.end())
}
italicStart -> {
val m = ITALIC_REGEX.matcher(remaining)
m.find()
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = baseColor)) {
append(m.group(1)!!)
}
remaining = remaining.substring(m.end())
}
codeStart -> {
val m = INLINE_CODE_REGEX.matcher(remaining)
m.find()
withStyle(SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = monospaceSize,
color = codeColor,
)) {
append(m.group(1)!!)
}
remaining = remaining.substring(m.end())
}
linkStart -> {
val m = LINK_REGEX.matcher(remaining)
m.find()
val linkText = m.group(1)
val linkUrl = m.group(2) ?: ""
withStyle(SpanStyle(
color = linkColor,
textDecoration = TextDecoration.Underline,
)) {
append(linkText ?: linkUrl)
}
remaining = remaining.substring(m.end())
}
}
}
if (length > 0) {
addStyle(SpanStyle(color = baseColor), 0, length)
}
}
@@ -6,113 +6,329 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.rainnya.chat.data.model.ChatMessage
import com.rainnya.chat.data.model.MessageRole
import com.rainnya.chat.ui.theme.RainnyaTheme
import kotlinx.coroutines.delay
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private const val CHAR_DELAY_MS = 8L
private const val COLLAPSE_THRESHOLD = 300
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(message: ChatMessage, modifier: Modifier = Modifier) {
fun MessageBubble(
message: ChatMessage,
onCopy: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
val isUser = message.role == MessageRole.USER
val isSystem = message.role == MessageRole.SYSTEM
if (isSystem) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = message.content,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
if (message.role == MessageRole.SYSTEM) {
SystemMessageBubble(message = message, modifier = modifier)
return
}
val bubbleColor = if (isUser) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
val textColor = if (isUser) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
.padding(horizontal = 8.dp, vertical = 3.dp),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom,
) {
val bgColor = if (isUser)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceContainerHighest
val textColor = if (isUser)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onSurface
val shape = if (isUser)
RoundedCornerShape(16.dp, 4.dp, 16.dp, 16.dp)
else
RoundedCornerShape(4.dp, 16.dp, 16.dp, 16.dp)
val cursorAlpha = if (message.streaming) {
val transition = rememberInfiniteTransition(label = "cursor")
val alpha by transition.animateFloat(
initialValue = 0.4f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(500, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "cursorAlpha",
)
alpha
} else {
0f
if (!isUser) {
Aavatar(text = "AI", modifier = Modifier.padding(end = 6.dp, bottom = 2.dp))
}
Box(
modifier = Modifier
.widthIn(max = 280.dp)
.clip(shape)
.background(bgColor)
.padding(horizontal = 16.dp, vertical = 10.dp),
Column(
modifier = Modifier.widthIn(max = 320.dp),
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
) {
Text(
text = if (message.streaming) {
buildAnnotatedString {
append(message.content)
withStyle(SpanStyle(color = textColor.copy(alpha = cursorAlpha))) {
append("")
}
}
} else {
AnnotatedString(message.content)
},
val bubbleShape = if (isUser) {
RoundedCornerShape(18.dp, 4.dp, 18.dp, 18.dp)
} else {
RoundedCornerShape(4.dp, 18.dp, 18.dp, 18.dp)
}
Column(
modifier = Modifier
.clip(bubbleShape)
.background(bubbleColor)
.combinedClickable(
onClick = { },
onLongClick = { onCopy(message.content) },
)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
if (message.imageUrl != null) {
AsyncImage(
model = message.imageUrl,
contentDescription = "图片",
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.heightIn(max = 240.dp),
contentScale = ContentScale.FillWidth,
)
Spacer(Modifier.height(6.dp))
}
BubbleContent(
message = message,
textColor = textColor,
)
val timeStr = timestampString(message.timestamp)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = timeStr,
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = 0.4f),
)
}
}
}
if (isUser) {
Aavatar(text = "", modifier = Modifier.padding(start = 6.dp, bottom = 2.dp))
}
}
}
@Composable
private fun BubbleContent(
message: ChatMessage,
textColor: Color,
) {
val isStreaming = message.streaming
if (isStreaming && message.content.isEmpty()) {
PulseLoadingBar(textColor = textColor)
return
}
var expanded by remember(message.id) { mutableStateOf(false) }
val shouldCollapse = !isStreaming && message.content.length > COLLAPSE_THRESHOLD
val displayText = if (shouldCollapse && !expanded) {
message.content.take(COLLAPSE_THRESHOLD)
} else {
message.content
}
if (displayText.isNotBlank()) {
if (isStreaming) {
StreamingText(
fullContent = displayText,
textColor = textColor,
messageId = message.id,
)
} else {
MarkdownText(
text = displayText,
color = textColor,
style = MaterialTheme.typography.bodyLarge,
)
}
}
if (shouldCollapse) {
TextButton(
onClick = { expanded = !expanded },
modifier = Modifier.padding(top = 2.dp),
) {
Text(
text = if (expanded) "收起" else "显示全部 (${message.content.length}字)",
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Medium,
),
color = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun StreamingText(
fullContent: String,
textColor: Color,
messageId: String,
) {
var visibleLength by remember(messageId) { mutableIntStateOf(0) }
LaunchedEffect(fullContent, messageId) {
while (visibleLength < fullContent.length) {
visibleLength++
delay(CHAR_DELAY_MS)
}
}
val cursorAlpha by rememberInfiniteTransition(label = "cursor").animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(500, easing = LinearEasing), RepeatMode.Reverse),
label = "cursorAlpha",
)
val end = visibleLength.coerceAtMost(fullContent.length)
Text(
text = buildAnnotatedString {
append(fullContent.substring(0, end))
withStyle(SpanStyle(color = textColor.copy(alpha = cursorAlpha))) {
append("")
}
},
color = textColor,
style = MaterialTheme.typography.bodyLarge,
)
}
@Composable
private fun PulseLoadingBar(textColor: Color) {
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "pulseAlpha",
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Box(
modifier = Modifier
.weight(1f)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(
Brush.horizontalGradient(
colors = listOf(
textColor.copy(alpha = alpha * 0.3f),
textColor.copy(alpha = alpha * 0.8f),
textColor.copy(alpha = alpha * 0.3f),
),
),
),
)
}
}
@Composable
private fun Aavatar(text: String, modifier: Modifier = Modifier) {
val bg = if (text == "") {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.secondaryContainer
}
val fg = if (text == "") {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSecondaryContainer
}
Box(
modifier = modifier
.size(28.dp)
.clip(CircleShape)
.background(bg),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
),
color = fg,
)
}
}
@Composable
private fun SystemMessageBubble(message: ChatMessage, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.Center,
) {
Text(
text = message.content,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
private fun timestampString(timestamp: Long): String {
val now = System.currentTimeMillis()
val sdf = if (now - timestamp < 86400000L) {
SimpleDateFormat("HH:mm", Locale.getDefault())
} else {
SimpleDateFormat("MM/dd HH:mm", Locale.getDefault())
}
return sdf.format(Date(timestamp))
}
@Preview(showBackground = true)
@@ -121,10 +337,8 @@ fun MessageBubbleUserPreview() {
RainnyaTheme {
MessageBubble(
message = ChatMessage(
id = "1",
content = "你好,今天天气怎么样?",
role = MessageRole.USER,
)
id = "1", content = "你好,今天天气怎么样?", role = MessageRole.USER,
),
)
}
}
@@ -136,9 +350,9 @@ fun MessageBubbleAssistantPreview() {
MessageBubble(
message = ChatMessage(
id = "2",
content = "今天天气很好,适合出门散步。",
content = "今天天气很好,适合出门散步。可以试试去公园走走。",
role = MessageRole.ASSISTANT,
)
),
)
}
}
@@ -2,10 +2,10 @@ package com.rainnya.chat.ui.navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.automirrored.outlined.Chat
import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Chat
import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
@@ -34,7 +34,7 @@ data class BottomNavItem(
)
private val navItems = listOf(
BottomNavItem("聊天", Icons.Filled.Chat, Icons.Outlined.Chat),
BottomNavItem("聊天", Icons.AutoMirrored.Filled.Chat, Icons.AutoMirrored.Outlined.Chat),
BottomNavItem("会话", Icons.Filled.Forum, Icons.Outlined.Forum),
BottomNavItem("设置", Icons.Filled.Settings, Icons.Outlined.Settings),
)
@@ -75,12 +75,12 @@ fun AppNavigation(settings: AppSettings) {
chatViewModel.switchSession(sessionId)
selectedIndex = 0
},
modifier = Modifier.padding(padding),
modifier = Modifier.padding(bottom = padding.calculateBottomPadding()),
)
2 -> SettingsScreen(
settings = settings,
repository = repository,
modifier = Modifier.padding(padding),
modifier = Modifier.padding(bottom = padding.calculateBottomPadding()),
)
}
}
@@ -3,6 +3,7 @@ package com.rainnya.chat.ui.sessions
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -15,7 +16,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Chat
import androidx.compose.material.icons.automirrored.rounded.Chat
import androidx.compose.material.icons.rounded.ChatBubble
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
@@ -34,15 +35,21 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.rainnya.chat.data.repository.ChatRepository
import com.rainnya.chat.data.settings.AppSettings
import com.rainnya.chat.ui.theme.RainnyaTheme
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
@@ -58,7 +65,12 @@ fun SessionsScreen(
Column(modifier = modifier.fillMaxSize()) {
TopAppBar(
title = { Text("会话") },
title = {
Text(
"会话",
style = MaterialTheme.typography.headlineMedium,
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
@@ -94,62 +106,69 @@ fun SessionsScreen(
} else {
LazyColumn(modifier = Modifier.weight(1f)) {
items(sessions, key = { it.sessionId }) { session ->
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onSessionClick(session.sessionId) },
onLongClick = {
menuSessionId = session.sessionId
Box(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onSessionClick(session.sessionId) },
onLongClick = {
menuSessionId = session.sessionId
renameText = session.displayName
},
)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.Chat,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = session.displayName.ifEmpty { session.sessionId.take(8) },
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = session.sessionId.take(8),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = formatTime(session.updatedAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
DropdownMenu(
expanded = menuSessionId == session.sessionId,
onDismissRequest = { menuSessionId = null },
offset = DpOffset(48.dp, 0.dp),
) {
DropdownMenuItem(
text = { Text("重命名") },
onClick = {
menuSessionId = null
renameSessionId = session.sessionId
renameText = session.displayName
},
)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Rounded.Chat,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = session.displayName.ifEmpty { session.sessionId.take(8) },
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = session.sessionId.take(8),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
DropdownMenuItem(
text = { Text("删除", color = MaterialTheme.colorScheme.error) },
onClick = {
menuSessionId = null
repository.deleteSession(session.sessionId)
},
)
}
}
DropdownMenu(
expanded = menuSessionId == session.sessionId,
onDismissRequest = { menuSessionId = null },
) {
DropdownMenuItem(
text = { Text("重命名") },
onClick = {
menuSessionId = null
renameSessionId = session.sessionId
renameText = session.displayName
},
)
DropdownMenuItem(
text = { Text("删除", color = MaterialTheme.colorScheme.error) },
onClick = {
menuSessionId = null
repository.deleteSession(session.sessionId)
},
)
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f),
@@ -190,12 +209,27 @@ fun SessionsScreen(
}
}
private fun formatTime(epochMillis: Long): String {
val now = System.currentTimeMillis()
val diff = now - epochMillis
val sdf = if (diff < 86400000L) {
SimpleDateFormat("HH:mm", Locale.getDefault())
} else if (diff < 604800000L) {
SimpleDateFormat("EEE HH:mm", Locale.getDefault())
} else {
SimpleDateFormat("MM/dd HH:mm", Locale.getDefault())
}
return sdf.format(Date(epochMillis))
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun SessionsScreenPreview() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
RainnyaTheme {
SessionsScreen(
repository = ChatRepository(AppSettings(androidx.compose.ui.platform.LocalContext.current)),
repository = ChatRepository(scope, context, AppSettings(context)),
onSessionClick = {},
)
}
@@ -28,12 +28,14 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
@@ -56,10 +58,16 @@ fun SettingsScreen(
var testResult by remember { mutableStateOf<String?>(null) }
var testing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
Column(modifier = modifier.fillMaxSize()) {
TopAppBar(
title = { Text("设置") },
title = {
Text(
"设置",
style = MaterialTheme.typography.headlineMedium,
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
@@ -143,7 +151,7 @@ fun SettingsScreen(
onClick = {
testing = true
testResult = null
val repo = repository ?: ChatRepository(settings)
val repo = repository ?: ChatRepository(scope, context, settings)
scope.launch {
testResult = repo.testConnection()
testing = false
@@ -0,0 +1,50 @@
package com.rainnya.chat.ui.util
import android.graphics.Rect
import android.os.Build
import android.view.ViewTreeObserver
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
/**
* Returns the current IME (keyboard) height in pixels.
*
* - API 30+ : `WindowInsets.ime` — native, reactive
* - API 21-29: `ViewTreeObserver` + `getWindowVisibleDisplayFrame` with dynamic baseline
*/
@Composable
fun rememberImeHeightPx(): Int {
val density = LocalDensity.current
val view = LocalView.current
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return WindowInsets.ime.getBottom(density)
}
var imeHeight by remember { mutableIntStateOf(0) }
var baselinePx by remember { mutableIntStateOf(-1) }
DisposableEffect(view) {
val listener = ViewTreeObserver.OnGlobalLayoutListener {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
val spaceBelow = view.rootView.height - rect.bottom
if (baselinePx < 0) baselinePx = spaceBelow
val keyboard = (spaceBelow - baselinePx).coerceAtLeast(0)
imeHeight = keyboard
if (keyboard == 0) baselinePx = spaceBelow
}
view.viewTreeObserver.addOnGlobalLayoutListener(listener)
onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(listener) }
}
return imeHeight
}
@@ -5,166 +5,6 @@
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:fillColor="#1A1B2E"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -1,30 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
android:viewportWidth="1024"
android:viewportHeight="1024">
<group
android:translateX="179"
android:translateY="179"
android:scaleX="0.65"
android:scaleY="0.65">
<path
android:fillColor="#FFFFFF"
android:pathData="M602.24 246.72a17.28 17.28 0 0 0-11.84-16.32l-42.88-14.4A90.56 90.56 0 0 1 490.24 160l-14.4-42.88a17.28 17.28 0 0 0-32 0L428.8 160a90.56 90.56 0 0 1-57.28 57.28l-42.88 14.4a17.28 17.28 0 0 0 0 32l42.88 14.4a90.56 90.56 0 0 1 57.28 57.28l14.4 42.88a17.28 17.28 0 0 0 32 0l14.4-42.88a90.56 90.56 0 0 1 57.28-57.28l42.88-14.4a17.28 17.28 0 0 0 12.48-16.96z m301.12 221.76l-48.32-16a101.44 101.44 0 0 1-64-64l-16-48.32a19.2 19.2 0 0 0-36.8 0l-16 48.32a101.44 101.44 0 0 1-64 64l-48.32 16a19.2 19.2 0 0 0 0 36.8l48.32 16a101.44 101.44 0 0 1 64 64l16 48.32a19.2 19.2 0 0 0 36.8 0l16-48.32a101.44 101.44 0 0 1 64-64l48.32-16a19.2 19.2 0 0 0 0-36.8z m-376.64 195.52l-64-20.8a131.84 131.84 0 0 1-83.52-83.52l-20.8-64a25.28 25.28 0 0 0-47.68 0l-20.8 64a131.84 131.84 0 0 1-82.24 83.52l-64 20.8a25.28 25.28 0 0 0 0 47.68l64 20.8a131.84 131.84 0 0 1 83.52 83.84l20.8 64a25.28 25.28 0 0 0 47.68 0l20.8-64a131.84 131.84 0 0 1 83.52-83.52l64-20.8a25.28 25.28 0 0 0 0-47.68z" />
</group>
</vector>
+1
View File
@@ -2,4 +2,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.com.google.devtools.ksp) apply false
}
+2
View File
@@ -7,6 +7,7 @@
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
@@ -15,5 +16,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# phase entirely if nothing that affects the build configuration (such as build scripts)
# has changed. Additionally, Gradle applies performance optimizations to task execution.
org.gradle.configuration-cache=true
android.disallowKotlinSourceSets=false
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
+8
View File
@@ -12,6 +12,9 @@ okhttp = "4.12.0"
gson = "2.11.0"
navigationCompose = "2.8.5"
lifecycleViewmodelCompose = "2.6.1"
room = "2.7.1"
ksp = "2.2.10-2.0.2"
coil = "3.1.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -33,8 +36,13 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material"
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
square-okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
square-gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
com-google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+1 -3
View File
@@ -14,9 +14,7 @@ pluginManagement {
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
// foojay-resolver-convention removed; uses JAVA_HOME or org.gradle.java.home
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {