Compare commits
10
Commits
e445fba430
...
ff30c3aa92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff30c3aa92 | ||
|
|
ef19aca527 | ||
|
|
3b5adb8549 | ||
|
|
3b752946a4 | ||
|
|
2cc922c6c2 | ||
|
|
4019970c17 | ||
|
|
76814b2e33 | ||
|
|
28ea051080 | ||
|
|
6b36a57f89 | ||
|
|
c756e0a029 |
Generated
+6
@@ -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>
|
||||
Generated
+123
@@ -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>
|
||||
Generated
+5
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+36
@@ -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>
|
||||
Generated
+21
@@ -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>
|
||||
Generated
+18
@@ -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
@@ -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>
|
||||
Generated
+10
@@ -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>
|
||||
Generated
+17
@@ -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
@@ -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>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user