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 {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.com.google.devtools.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -51,6 +52,10 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.material.icons.extended)
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
implementation(libs.square.okhttp)
|
implementation(libs.square.okhttp)
|
||||||
implementation(libs.square.gson)
|
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)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
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
|
package com.rainnya.chat.data.model
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "chat_messages")
|
||||||
data class ChatMessage(
|
data class ChatMessage(
|
||||||
val id: String,
|
@PrimaryKey val id: String,
|
||||||
val content: String,
|
val content: String,
|
||||||
val role: MessageRole,
|
val role: MessageRole,
|
||||||
val timestamp: Long = System.currentTimeMillis(),
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
val sessionId: String = "",
|
val sessionId: String = "",
|
||||||
val streaming: Boolean = false,
|
val streaming: Boolean = false,
|
||||||
|
val attachmentId: String? = null,
|
||||||
|
val imageUrl: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class MessageRole { USER, ASSISTANT, SYSTEM }
|
enum class MessageRole { USER, ASSISTANT, SYSTEM }
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package com.rainnya.chat.data.model
|
package com.rainnya.chat.data.model
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "chat_sessions")
|
||||||
data class ChatSession(
|
data class ChatSession(
|
||||||
val sessionId: String,
|
@PrimaryKey val sessionId: String,
|
||||||
val displayName: String = "",
|
val displayName: String = "",
|
||||||
val createdAt: String = "",
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
val updatedAt: String = "",
|
val updatedAt: Long = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,4 +16,15 @@ data class WsIncomingMessage(
|
|||||||
val message_id: String? = null,
|
val message_id: String? = null,
|
||||||
val streaming: Boolean? = null,
|
val streaming: Boolean? = null,
|
||||||
val code: String? = 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
|
package com.rainnya.chat.data.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.google.gson.Gson
|
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.ChatMessage
|
||||||
import com.rainnya.chat.data.model.MessageRole
|
import com.rainnya.chat.data.model.MessageRole
|
||||||
import com.rainnya.chat.data.model.ChatSession
|
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.settings.AppSettings
|
||||||
|
import com.rainnya.chat.data.upload.ImageUploader
|
||||||
import com.rainnya.chat.data.websocket.AstrBotWsClient
|
import com.rainnya.chat.data.websocket.AstrBotWsClient
|
||||||
import com.rainnya.chat.data.websocket.WsEvent
|
import com.rainnya.chat.data.websocket.WsEvent
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -21,14 +31,20 @@ import java.util.concurrent.TimeUnit
|
|||||||
private const val TAG = "RainnyaRepo"
|
private const val TAG = "RainnyaRepo"
|
||||||
|
|
||||||
class ChatRepository(
|
class ChatRepository(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val context: Context,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
private val wsClient: AstrBotWsClient = AstrBotWsClient(Gson()),
|
private val wsClient: AstrBotWsClient = AstrBotWsClient(Gson()),
|
||||||
|
private val imageUploader: ImageUploader = ImageUploader(),
|
||||||
) {
|
) {
|
||||||
private val httpClient = OkHttpClient.Builder()
|
private val httpClient = OkHttpClient.Builder()
|
||||||
.connectTimeout(5, TimeUnit.SECONDS)
|
.connectTimeout(5, TimeUnit.SECONDS)
|
||||||
.readTimeout(5, TimeUnit.SECONDS)
|
.readTimeout(5, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
private val db = AppDatabase.getInstance(context)
|
||||||
|
private val dao = db.chatDao()
|
||||||
|
|
||||||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||||
val messages: StateFlow<List<ChatMessage>> = _messages
|
val messages: StateFlow<List<ChatMessage>> = _messages
|
||||||
|
|
||||||
@@ -38,11 +54,43 @@ class ChatRepository(
|
|||||||
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
|
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||||
val connectionState: StateFlow<ConnectionState> = _connectionState
|
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 var currentSessionId: String? = null
|
||||||
private val sessionMessages = mutableMapOf<String, MutableList<ChatMessage>>()
|
private val sessionMessages = mutableMapOf<String, MutableList<ChatMessage>>()
|
||||||
|
private var streamTimeoutJob: Job? = null
|
||||||
|
|
||||||
val wsEvents: Flow<WsEvent> = wsClient.events
|
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() {
|
fun connect() {
|
||||||
if (!settings.isConfigured) {
|
if (!settings.isConfigured) {
|
||||||
Log.w(TAG, "Cannot connect: settings not configured")
|
Log.w(TAG, "Cannot connect: settings not configured")
|
||||||
@@ -64,33 +112,92 @@ class ChatRepository(
|
|||||||
_connectionState.value = ConnectionState.DISCONNECTED
|
_connectionState.value = ConnectionState.DISCONNECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage(text: String) {
|
fun sendMessage(text: String, attachmentId: String? = null, imageUrl: String? = null) {
|
||||||
if (_connectionState.value != ConnectionState.CONNECTED) {
|
if (_connectionState.value != ConnectionState.CONNECTED) {
|
||||||
Log.w(TAG, "Cannot send: not connected")
|
Log.w(TAG, "Cannot send: not connected")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ensureSessionExists()
|
||||||
val messageId = UUID.randomUUID().toString()
|
val messageId = UUID.randomUUID().toString()
|
||||||
Log.d(TAG, "Sending message id=$messageId text=$text")
|
Log.d(TAG, "Sending message id=$messageId text=$text")
|
||||||
|
|
||||||
val userMsg = ChatMessage(
|
val userMsgBuilder = ChatMessage(
|
||||||
id = "user_$messageId",
|
id = "user_$messageId",
|
||||||
content = text,
|
content = text,
|
||||||
role = MessageRole.USER,
|
role = MessageRole.USER,
|
||||||
sessionId = currentSessionId ?: "",
|
sessionId = currentSessionId ?: "",
|
||||||
|
imageUrl = imageUrl,
|
||||||
)
|
)
|
||||||
|
|
||||||
val list = _messages.value.toMutableList()
|
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
|
_messages.value = list
|
||||||
saveCurrentMessages()
|
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(
|
wsClient.sendMessage(
|
||||||
text = text,
|
message = messagePayload,
|
||||||
username = settings.taggedUsername,
|
username = settings.taggedUsername,
|
||||||
sessionId = currentSessionId,
|
sessionId = sendSessionId,
|
||||||
messageId = messageId,
|
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) {
|
fun handleWsEvent(event: WsEvent) {
|
||||||
try {
|
try {
|
||||||
when (event) {
|
when (event) {
|
||||||
@@ -101,10 +208,12 @@ class ChatRepository(
|
|||||||
is WsEvent.Disconnected -> {
|
is WsEvent.Disconnected -> {
|
||||||
Log.d(TAG, "Event: Disconnected")
|
Log.d(TAG, "Event: Disconnected")
|
||||||
_connectionState.value = ConnectionState.DISCONNECTED
|
_connectionState.value = ConnectionState.DISCONNECTED
|
||||||
|
markPendingStreamAsFailed()
|
||||||
}
|
}
|
||||||
is WsEvent.Error -> {
|
is WsEvent.Error -> {
|
||||||
Log.e(TAG, "Event: Error ${event.error}")
|
Log.e(TAG, "Event: Error ${event.error}")
|
||||||
_connectionState.value = ConnectionState.ERROR
|
_connectionState.value = ConnectionState.ERROR
|
||||||
|
markPendingStreamAsFailed()
|
||||||
}
|
}
|
||||||
is WsEvent.MessageReceived -> handleMessage(event)
|
is WsEvent.MessageReceived -> handleMessage(event)
|
||||||
}
|
}
|
||||||
@@ -121,13 +230,41 @@ class ChatRepository(
|
|||||||
when (msg.type) {
|
when (msg.type) {
|
||||||
"session_id" -> {
|
"session_id" -> {
|
||||||
msg.session_id?.let { sid ->
|
msg.session_id?.let { sid ->
|
||||||
|
val prevId = currentSessionId
|
||||||
currentSessionId = sid
|
currentSessionId = sid
|
||||||
val existing = _sessions.value.any { it.sessionId == sid }
|
val existing = _sessions.value.any { it.sessionId == sid }
|
||||||
if (!existing) {
|
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,
|
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() ?: ""
|
val text = msg.data?.toString() ?: ""
|
||||||
if (text.isEmpty()) return
|
if (text.isEmpty()) return
|
||||||
val isStreaming = msg.streaming ?: true
|
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 list = _messages.value.toMutableList()
|
||||||
val lastAssistant = list.indexOfLast { it.role == MessageRole.ASSISTANT }
|
val lastAssistant = list.indexOfLast { it.role == MessageRole.ASSISTANT }
|
||||||
|
|
||||||
@@ -143,6 +287,7 @@ class ChatRepository(
|
|||||||
content = list[lastAssistant].content + text
|
content = list[lastAssistant].content + text
|
||||||
)
|
)
|
||||||
_messages.value = list
|
_messages.value = list
|
||||||
|
resetStreamTimeout()
|
||||||
} else {
|
} else {
|
||||||
list.add(
|
list.add(
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
@@ -156,7 +301,27 @@ class ChatRepository(
|
|||||||
_messages.value = list
|
_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" -> {
|
"end" -> {
|
||||||
|
streamTimeoutJob?.cancel()
|
||||||
val list = _messages.value.toMutableList()
|
val list = _messages.value.toMutableList()
|
||||||
val lastAssistant = list.indexOfLast { it.role == MessageRole.ASSISTANT }
|
val lastAssistant = list.indexOfLast { it.role == MessageRole.ASSISTANT }
|
||||||
if (lastAssistant >= 0) {
|
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() {
|
private fun saveCurrentMessages() {
|
||||||
val sid = currentSessionId ?: return
|
val sid = currentSessionId ?: return
|
||||||
sessionMessages[sid] = _messages.value.toMutableList()
|
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) {
|
fun switchSession(sessionId: String) {
|
||||||
@@ -200,10 +418,28 @@ class ChatRepository(
|
|||||||
_messages.value = sessionMessages[sessionId]?.toList() ?: emptyList()
|
_messages.value = sessionMessages[sessionId]?.toList() ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ensureSessionExists() {
|
||||||
|
if (currentSessionId == null) {
|
||||||
|
Log.d(TAG, "No active session, auto-creating one")
|
||||||
|
newSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun newSession() {
|
fun newSession() {
|
||||||
Log.d(TAG, "New session")
|
Log.d(TAG, "New session")
|
||||||
saveCurrentMessages()
|
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()
|
_messages.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +453,13 @@ class ChatRepository(
|
|||||||
_sessions.value = _sessions.value.map {
|
_sessions.value = _sessions.value.map {
|
||||||
if (it.sessionId == sessionId) it.copy(displayName = newName) else it
|
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) {
|
fun deleteSession(sessionId: String) {
|
||||||
@@ -227,6 +470,14 @@ class ChatRepository(
|
|||||||
currentSessionId = null
|
currentSessionId = null
|
||||||
_messages.value = emptyList()
|
_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) {
|
suspend fun testConnection(): String = withContext(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ class AstrBotWsClient(
|
|||||||
message_id = json.get("message_id")?.asString,
|
message_id = json.get("message_id")?.asString,
|
||||||
streaming = json.get("streaming")?.asBoolean,
|
streaming = json.get("streaming")?.asBoolean,
|
||||||
code = json.get("code")?.asString,
|
code = json.get("code")?.asString,
|
||||||
|
attachment_id = json.get("attachment_id")?.asString,
|
||||||
|
url = json.get("url")?.asString,
|
||||||
)
|
)
|
||||||
scope.launch { _events.send(WsEvent.MessageReceived(msg)) }
|
scope.launch { _events.send(WsEvent.MessageReceived(msg)) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -96,13 +98,13 @@ class AstrBotWsClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage(
|
fun sendMessage(
|
||||||
text: String,
|
message: Any,
|
||||||
username: String,
|
username: String,
|
||||||
sessionId: String? = null,
|
sessionId: String? = null,
|
||||||
messageId: String = UUID.randomUUID().toString(),
|
messageId: String = UUID.randomUUID().toString(),
|
||||||
) {
|
) {
|
||||||
val msg = WsOutgoingMessage(
|
val msg = WsOutgoingMessage(
|
||||||
message = text,
|
message = message,
|
||||||
username = username,
|
username = username,
|
||||||
session_id = sessionId,
|
session_id = sessionId,
|
||||||
message_id = messageId,
|
message_id = messageId,
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
package com.rainnya.chat.ui.chat
|
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.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
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.Icons
|
||||||
import androidx.compose.material.icons.rounded.Add
|
import androidx.compose.material.icons.rounded.Add
|
||||||
import androidx.compose.material.icons.rounded.Chat
|
import androidx.compose.material.icons.rounded.Stars
|
||||||
import androidx.compose.material.icons.rounded.Refresh
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.rainnya.chat.data.repository.ConnectionState
|
import com.rainnya.chat.data.repository.ConnectionState
|
||||||
import com.rainnya.chat.ui.components.ChatInputBar
|
import com.rainnya.chat.ui.components.ChatInputBar
|
||||||
import com.rainnya.chat.ui.components.MessageBubble
|
import com.rainnya.chat.ui.components.MessageBubble
|
||||||
import com.rainnya.chat.ui.theme.RainnyaTheme
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -60,37 +92,62 @@ fun ChatScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
val navBarDp = scaffoldPadding.calculateBottomPadding()
|
val navBarDp = scaffoldPadding.calculateBottomPadding()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val imeHeightPx = rememberImeHeightPx()
|
||||||
val imeHeightPx = WindowInsets.ime.getBottom(density)
|
|
||||||
val bottomDp = if (imeHeightPx > 0) {
|
val bottomDp = if (imeHeightPx > 0) {
|
||||||
with(density) { imeHeightPx.toDp() }
|
with(density) { imeHeightPx.toDp() }
|
||||||
} else {
|
} else {
|
||||||
navBarDp
|
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 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize().padding(bottom = bottomDp),
|
||||||
.fillMaxSize()
|
|
||||||
.padding(bottom = bottomDp)
|
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
@@ -101,13 +158,12 @@ fun ChatScreen(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
currentSession?.let { s ->
|
if (state.isStreaming) {
|
||||||
Text(
|
Text(
|
||||||
text = s.sessionId.take(8),
|
text = "AI 正在回复…",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,134 +173,240 @@ fun ChatScreen(
|
|||||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
actions = {
|
actions = {
|
||||||
ConnectionStatusChip(
|
ConnectionDot(
|
||||||
state = state.connectionState,
|
state = state.connectionState,
|
||||||
onReconnect = { viewModel.reconnect() },
|
onReconnect = { viewModel.reconnect() },
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
IconButton(onClick = { viewModel.newSession() }) {
|
IconButton(onClick = { viewModel.newSession() }) {
|
||||||
Icon(Icons.Rounded.Add, contentDescription = "新会话")
|
Icon(Icons.Rounded.Add, contentDescription = "新会话")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
if (state.messages.isEmpty()) {
|
if (state.messages.isEmpty()) {
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
EmptyChat(onSuggestion = { viewModel.sendMessage(it) })
|
||||||
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 {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
state = listState,
|
state = listState,
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
items(state.messages, key = { it.id }) { message ->
|
if (canLoadMore) {
|
||||||
MessageBubble(message = message)
|
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(
|
ChatInputBar(
|
||||||
text = state.inputText,
|
text = state.inputText,
|
||||||
onTextChange = viewModel::updateInputText,
|
onTextChange = viewModel::updateInputText,
|
||||||
onSend = { viewModel.sendMessage(state.inputText) },
|
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,
|
connectionState = state.connectionState,
|
||||||
|
isStreaming = state.isStreaming,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyToClipboard(context: Context, text: String) {
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("Rainnya", text))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadMoreHeader(totalCount: Int, onClick: () -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
@Composable
|
||||||
private fun ConnectionStatusChip(
|
private fun ConnectionDot(
|
||||||
state: ConnectionState,
|
state: ConnectionState,
|
||||||
onReconnect: () -> Unit,
|
onReconnect: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "spin")
|
val dotColor = when (state) {
|
||||||
val rotation by infiniteTransition.animateFloat(
|
ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primary
|
||||||
initialValue = 0f,
|
ConnectionState.CONNECTING -> MaterialTheme.colorScheme.tertiary
|
||||||
targetValue = 360f,
|
ConnectionState.ERROR -> MaterialTheme.colorScheme.error
|
||||||
animationSpec = infiniteRepeatable(
|
ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
animation = tween(1000, easing = LinearEasing),
|
|
||||||
repeatMode = RepeatMode.Restart,
|
|
||||||
),
|
|
||||||
label = "rotation",
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(end = 4.dp),
|
|
||||||
) {
|
|
||||||
when (state) {
|
|
||||||
ConnectionState.CONNECTED -> {
|
|
||||||
Text(
|
|
||||||
text = "已连接",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
ConnectionState.CONNECTING -> {
|
|
||||||
CircularProgressIndicator(
|
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
|
modifier = Modifier
|
||||||
.size(14.dp)
|
.size(10.dp)
|
||||||
.rotate(rotation),
|
.clip(CircleShape)
|
||||||
strokeWidth = 2.dp,
|
.alpha(alpha)
|
||||||
color = MaterialTheme.colorScheme.primary,
|
.background(dotColor),
|
||||||
)
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "连接中…",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ConnectionState.ERROR -> {
|
} else {
|
||||||
Text(
|
Box(
|
||||||
text = "连接失败",
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.labelSmall,
|
.size(10.dp)
|
||||||
color = MaterialTheme.colorScheme.error,
|
.clip(CircleShape)
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
.alpha(alpha)
|
||||||
|
.background(dotColor),
|
||||||
)
|
)
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true, showSystemUi = true)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreenPreview() {
|
fun ChatScreenPreview() {
|
||||||
RainnyaTheme {
|
RainnyaTheme {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.rainnya.chat.ui.chat
|
package com.rainnya.chat.ui.chat
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.net.Uri
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
@@ -18,11 +19,17 @@ data class ChatUiState(
|
|||||||
val messages: List<com.rainnya.chat.data.model.ChatMessage> = emptyList(),
|
val messages: List<com.rainnya.chat.data.model.ChatMessage> = emptyList(),
|
||||||
val connectionState: ConnectionState = ConnectionState.DISCONNECTED,
|
val connectionState: ConnectionState = ConnectionState.DISCONNECTED,
|
||||||
val inputText: String = "",
|
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 {
|
class ChatViewModel(application: Application) : AndroidViewModel(application), LifecycleEventObserver {
|
||||||
private val settings = AppSettings(application)
|
private val settings = AppSettings(application)
|
||||||
val repository = ChatRepository(settings)
|
val repository = ChatRepository(viewModelScope, application, settings)
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ChatUiState())
|
private val _uiState = MutableStateFlow(ChatUiState())
|
||||||
val uiState: StateFlow<ChatUiState> = _uiState
|
val uiState: StateFlow<ChatUiState> = _uiState
|
||||||
@@ -38,6 +45,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
|
|||||||
_uiState.value = _uiState.value.copy(connectionState = state)
|
_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 {
|
viewModelScope.launch {
|
||||||
repository.wsEvents.collect { event ->
|
repository.wsEvents.collect { event ->
|
||||||
repository.handleWsEvent(event)
|
repository.handleWsEvent(event)
|
||||||
@@ -58,15 +75,29 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage(text: String) {
|
fun sendMessage(text: String) {
|
||||||
if (text.isBlank()) return
|
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())
|
repository.sendMessage(text.trim())
|
||||||
_uiState.value = _uiState.value.copy(inputText = "")
|
_uiState.value = _uiState.value.copy(inputText = "")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateInputText(text: String) {
|
fun updateInputText(text: String) {
|
||||||
_uiState.value = _uiState.value.copy(inputText = text)
|
_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() {
|
fun newSession() {
|
||||||
repository.newSession()
|
repository.newSession()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,53 @@
|
|||||||
package com.rainnya.chat.ui.components
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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.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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
import com.rainnya.chat.data.repository.ConnectionState
|
import com.rainnya.chat.data.repository.ConnectionState
|
||||||
import com.rainnya.chat.ui.theme.RainnyaTheme
|
import com.rainnya.chat.ui.theme.RainnyaTheme
|
||||||
|
|
||||||
@@ -23,64 +56,211 @@ fun ChatInputBar(
|
|||||||
text: String,
|
text: String,
|
||||||
onTextChange: (String) -> Unit,
|
onTextChange: (String) -> Unit,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
|
onPickImage: () -> Unit,
|
||||||
|
pendingImageUri: Uri? = null,
|
||||||
|
onRemoveImage: () -> Unit = {},
|
||||||
|
uploading: Boolean = false,
|
||||||
|
uploadProgress: Float = 0f,
|
||||||
connectionState: ConnectionState = ConnectionState.CONNECTED,
|
connectionState: ConnectionState = ConnectionState.CONNECTED,
|
||||||
|
isStreaming: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val enabled = connectionState == ConnectionState.CONNECTED
|
val inputEnabled = connectionState == ConnectionState.CONNECTED
|
||||||
val placeholderText = when (connectionState) {
|
val canSend = inputEnabled && (text.isNotBlank() || pendingImageUri != null) && !isStreaming && !uploading
|
||||||
ConnectionState.DISCONNECTED -> "未连接,请在设置中配置"
|
|
||||||
ConnectionState.CONNECTING -> "连接中…"
|
val placeholderText = when {
|
||||||
ConnectionState.ERROR -> "连接失败"
|
connectionState == ConnectionState.DISCONNECTED -> "未连接,请在设置中配置"
|
||||||
ConnectionState.CONNECTED -> "输入消息…"
|
connectionState == ConnectionState.CONNECTING -> "连接中…"
|
||||||
|
connectionState == ConnectionState.ERROR -> "连接失败,点击重连"
|
||||||
|
else -> "发消息给 Rainnya…"
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
value = text,
|
|
||||||
onValueChange = onTextChange,
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
enabled = enabled,
|
if (pendingImageUri != null) {
|
||||||
placeholder = { Text(placeholderText) },
|
ImagePreviewThumbnail(
|
||||||
modifier = modifier
|
uri = pendingImageUri,
|
||||||
.fillMaxWidth()
|
onRemove = onRemoveImage,
|
||||||
.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 = {
|
|
||||||
IconButton(
|
|
||||||
onClick = onSend,
|
|
||||||
enabled = enabled && text.isNotBlank(),
|
|
||||||
modifier = Modifier.size(48.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),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
},
|
},
|
||||||
singleLine = true,
|
|
||||||
)
|
)
|
||||||
|
.heightIn(min = 52.dp)
|
||||||
|
.padding(start = 6.dp, end = 6.dp, top = 2.dp, bottom = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onPickImage,
|
||||||
|
enabled = inputEnabled && !uploading,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Image,
|
||||||
|
contentDescription = "选择图片",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInputBarConnectedPreview() {
|
fun ChatInputBarConnectedPreview() {
|
||||||
RainnyaTheme {
|
RainnyaTheme {
|
||||||
ChatInputBar(text = "hello", onTextChange = {}, onSend = {}, connectionState = ConnectionState.CONNECTED)
|
ChatInputBar(
|
||||||
|
text = "hello", onTextChange = {}, onSend = {}, onPickImage = {},
|
||||||
|
connectionState = ConnectionState.CONNECTED,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInputBarDisconnectedPreview() {
|
fun ChatInputBarStreamingPreview() {
|
||||||
RainnyaTheme {
|
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,125 +6,339 @@ import androidx.compose.animation.core.animateFloat
|
|||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
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.ChatMessage
|
||||||
import com.rainnya.chat.data.model.MessageRole
|
import com.rainnya.chat.data.model.MessageRole
|
||||||
import com.rainnya.chat.ui.theme.RainnyaTheme
|
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
|
@Composable
|
||||||
fun MessageBubble(message: ChatMessage, modifier: Modifier = Modifier) {
|
fun MessageBubble(
|
||||||
val isUser = message.role == MessageRole.USER
|
message: ChatMessage,
|
||||||
val isSystem = message.role == MessageRole.SYSTEM
|
onCopy: (String) -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
if (isSystem) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
) {
|
||||||
Text(
|
val isUser = message.role == MessageRole.USER
|
||||||
text = message.content,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
if (message.role == MessageRole.SYSTEM) {
|
||||||
color = MaterialTheme.colorScheme.error,
|
SystemMessageBubble(message = message, modifier = modifier)
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val bubbleColor = if (isUser) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
}
|
||||||
|
val textColor = if (isUser) {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
) {
|
) {
|
||||||
val bgColor = if (isUser)
|
if (!isUser) {
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
Aavatar(text = "AI", modifier = Modifier.padding(end = 6.dp, bottom = 2.dp))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Column(
|
||||||
|
modifier = Modifier.widthIn(max = 320.dp),
|
||||||
|
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
|
||||||
|
) {
|
||||||
|
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
|
modifier = Modifier
|
||||||
.widthIn(max = 280.dp)
|
.clip(bubbleShape)
|
||||||
.clip(shape)
|
.background(bubbleColor)
|
||||||
.background(bgColor)
|
.combinedClickable(
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
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(
|
||||||
text = if (message.streaming) {
|
text = timeStr,
|
||||||
buildAnnotatedString {
|
style = MaterialTheme.typography.labelSmall,
|
||||||
append(message.content)
|
color = textColor.copy(alpha = 0.4f),
|
||||||
withStyle(SpanStyle(color = textColor.copy(alpha = cursorAlpha))) {
|
)
|
||||||
append("▌")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
AnnotatedString(message.content)
|
message.content
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayText.isNotBlank()) {
|
||||||
|
if (isStreaming) {
|
||||||
|
StreamingText(
|
||||||
|
fullContent = displayText,
|
||||||
|
textColor = textColor,
|
||||||
|
messageId = message.id,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
MarkdownText(
|
||||||
|
text = displayText,
|
||||||
|
color = textColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
color = textColor,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
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)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageBubbleUserPreview() {
|
fun MessageBubbleUserPreview() {
|
||||||
RainnyaTheme {
|
RainnyaTheme {
|
||||||
MessageBubble(
|
MessageBubble(
|
||||||
message = ChatMessage(
|
message = ChatMessage(
|
||||||
id = "1",
|
id = "1", content = "你好,今天天气怎么样?", role = MessageRole.USER,
|
||||||
content = "你好,今天天气怎么样?",
|
),
|
||||||
role = MessageRole.USER,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,9 +350,9 @@ fun MessageBubbleAssistantPreview() {
|
|||||||
MessageBubble(
|
MessageBubble(
|
||||||
message = ChatMessage(
|
message = ChatMessage(
|
||||||
id = "2",
|
id = "2",
|
||||||
content = "今天天气很好,适合出门散步。",
|
content = "今天天气很好,适合出门散步。可以试试去公园走走。",
|
||||||
role = MessageRole.ASSISTANT,
|
role = MessageRole.ASSISTANT,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package com.rainnya.chat.ui.navigation
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
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.Forum
|
||||||
import androidx.compose.material.icons.filled.Settings
|
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.Forum
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -34,7 +34,7 @@ data class BottomNavItem(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val navItems = listOf(
|
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.Forum, Icons.Outlined.Forum),
|
||||||
BottomNavItem("设置", Icons.Filled.Settings, Icons.Outlined.Settings),
|
BottomNavItem("设置", Icons.Filled.Settings, Icons.Outlined.Settings),
|
||||||
)
|
)
|
||||||
@@ -75,12 +75,12 @@ fun AppNavigation(settings: AppSettings) {
|
|||||||
chatViewModel.switchSession(sessionId)
|
chatViewModel.switchSession(sessionId)
|
||||||
selectedIndex = 0
|
selectedIndex = 0
|
||||||
},
|
},
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(bottom = padding.calculateBottomPadding()),
|
||||||
)
|
)
|
||||||
2 -> SettingsScreen(
|
2 -> SettingsScreen(
|
||||||
settings = settings,
|
settings = settings,
|
||||||
repository = repository,
|
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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
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.material.icons.rounded.ChatBubble
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
@@ -34,15 +35,21 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.rainnya.chat.data.repository.ChatRepository
|
import com.rainnya.chat.data.repository.ChatRepository
|
||||||
import com.rainnya.chat.data.settings.AppSettings
|
import com.rainnya.chat.data.settings.AppSettings
|
||||||
import com.rainnya.chat.ui.theme.RainnyaTheme
|
import com.rainnya.chat.ui.theme.RainnyaTheme
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -58,7 +65,12 @@ fun SessionsScreen(
|
|||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("会话") },
|
title = {
|
||||||
|
Text(
|
||||||
|
"会话",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
@@ -94,6 +106,7 @@ fun SessionsScreen(
|
|||||||
} else {
|
} else {
|
||||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||||
items(sessions, key = { it.sessionId }) { session ->
|
items(sessions, key = { it.sessionId }) { session ->
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -108,7 +121,7 @@ fun SessionsScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Chat,
|
imageVector = Icons.AutoMirrored.Rounded.Chat,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
@@ -126,12 +139,18 @@ fun SessionsScreen(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
text = formatTime(session.updatedAt),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = menuSessionId == session.sessionId,
|
expanded = menuSessionId == session.sessionId,
|
||||||
onDismissRequest = { menuSessionId = null },
|
onDismissRequest = { menuSessionId = null },
|
||||||
|
offset = DpOffset(48.dp, 0.dp),
|
||||||
) {
|
) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("重命名") },
|
text = { Text("重命名") },
|
||||||
@@ -149,7 +168,7 @@ fun SessionsScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f),
|
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)
|
@Preview(showBackground = true, showSystemUi = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionsScreenPreview() {
|
fun SessionsScreenPreview() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
RainnyaTheme {
|
RainnyaTheme {
|
||||||
SessionsScreen(
|
SessionsScreen(
|
||||||
repository = ChatRepository(AppSettings(androidx.compose.ui.platform.LocalContext.current)),
|
repository = ChatRepository(scope, context, AppSettings(context)),
|
||||||
onSessionClick = {},
|
onSessionClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -56,10 +58,16 @@ fun SettingsScreen(
|
|||||||
var testResult by remember { mutableStateOf<String?>(null) }
|
var testResult by remember { mutableStateOf<String?>(null) }
|
||||||
var testing by remember { mutableStateOf(false) }
|
var testing by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("设置") },
|
title = {
|
||||||
|
Text(
|
||||||
|
"设置",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
@@ -143,7 +151,7 @@ fun SettingsScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
testing = true
|
testing = true
|
||||||
testResult = null
|
testResult = null
|
||||||
val repo = repository ?: ChatRepository(settings)
|
val repo = repository ?: ChatRepository(scope, context, settings)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
testResult = repo.testConnection()
|
testResult = repo.testConnection()
|
||||||
testing = false
|
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:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#3DDC84"
|
android:fillColor="#1A1B2E"
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
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>
|
</vector>
|
||||||
|
|||||||
@@ -1,30 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="1024"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="1024">
|
||||||
<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">
|
<group
|
||||||
<aapt:attr name="android:fillColor">
|
android:translateX="179"
|
||||||
<gradient
|
android:translateY="179"
|
||||||
android:endX="85.84757"
|
android:scaleX="0.65"
|
||||||
android:endY="92.4963"
|
android:scaleY="0.65">
|
||||||
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
|
<path
|
||||||
android:fillColor="#FFFFFF"
|
android:fillColor="#FFFFFF"
|
||||||
android:fillType="nonZero"
|
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" />
|
||||||
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"
|
</group>
|
||||||
android:strokeWidth="1"
|
|
||||||
android:strokeColor="#00000000" />
|
|
||||||
</vector>
|
</vector>
|
||||||
@@ -2,4 +2,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.compose) 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.
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
# The setting is particularly useful for tweaking memory settings.
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
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.
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
# This option should only be used with decoupled projects. For more details, visit
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
# 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)
|
# phase entirely if nothing that affects the build configuration (such as build scripts)
|
||||||
# has changed. Additionally, Gradle applies performance optimizations to task execution.
|
# has changed. Additionally, Gradle applies performance optimizations to task execution.
|
||||||
org.gradle.configuration-cache=true
|
org.gradle.configuration-cache=true
|
||||||
|
android.disallowKotlinSourceSets=false
|
||||||
# Kotlin code style for this project: "official" or "obsolete":
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
@@ -12,6 +12,9 @@ okhttp = "4.12.0"
|
|||||||
gson = "2.11.0"
|
gson = "2.11.0"
|
||||||
navigationCompose = "2.8.5"
|
navigationCompose = "2.8.5"
|
||||||
lifecycleViewmodelCompose = "2.6.1"
|
lifecycleViewmodelCompose = "2.6.1"
|
||||||
|
room = "2.7.1"
|
||||||
|
ksp = "2.2.10-2.0.2"
|
||||||
|
coil = "3.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
square-okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
square-okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
square-gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
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()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
plugins {
|
// foojay-resolver-convention removed; uses JAVA_HOME or org.gradle.java.home
|
||||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
|
||||||
}
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
|
|||||||
Reference in New Issue
Block a user