feat: markdown/code message renderers, collapsible non-chat content, dark mode fixes, and data persistence
- Add MarkdownBubble (headings, bold/italic, code blocks, lists, quotes, links) - Add CodeBubble with dark background + language header - Add CollapsibleBubble wrapper for long non-chat content with fold/expand button - Update WSReviewMessage DTO: add type and metadata fields for review messages - Fix message dedup: apply removeWrappingDuplicates before DB insert instead of on return value - Fix dark mode: explicit text colors on StatusIndicator, icon tints, dynamicColor=false - Add enter-to-send toggle and typing indicator style (bubble/text) in settings - Overlay: transparent window background, pill-shaped semi-transparent input field - Remove PullToRefreshBox (conflicted with reverseLayout scroll), use refresh button - Add auto-refresh when connection transitions offline→online - Fix session ID fallback for DB message loading after APK update Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,6 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"cyrene.db",
|
"cyrene.db",
|
||||||
)
|
)
|
||||||
.fallbackToDestructiveMigration()
|
|
||||||
.build()
|
.build()
|
||||||
.also { INSTANCE = it }
|
.also { INSTANCE = it }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class PreferencesDataStore(private val context: Context) {
|
|||||||
private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
|
private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
|
||||||
private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context")
|
private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context")
|
||||||
private val KEY_TYPING_INDICATOR_STYLE = stringPreferencesKey("typing_indicator_style")
|
private val KEY_TYPING_INDICATOR_STYLE = stringPreferencesKey("typing_indicator_style")
|
||||||
|
private val KEY_ENTER_TO_SEND = booleanPreferencesKey("enter_to_send")
|
||||||
}
|
}
|
||||||
|
|
||||||
val typingIndicatorStyle: Flow<String> = context.dataStore.data.map { it[KEY_TYPING_INDICATOR_STYLE] ?: "bubble" }
|
val typingIndicatorStyle: Flow<String> = context.dataStore.data.map { it[KEY_TYPING_INDICATOR_STYLE] ?: "bubble" }
|
||||||
@@ -42,6 +43,12 @@ class PreferencesDataStore(private val context: Context) {
|
|||||||
context.dataStore.edit { it[KEY_TYPING_INDICATOR_STYLE] = style }
|
context.dataStore.edit { it[KEY_TYPING_INDICATOR_STYLE] = style }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val enterToSend: Flow<Boolean> = context.dataStore.data.map { it[KEY_ENTER_TO_SEND] ?: false }
|
||||||
|
|
||||||
|
suspend fun saveEnterToSend(enabled: Boolean) {
|
||||||
|
context.dataStore.edit { it[KEY_ENTER_TO_SEND] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
|
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
|
||||||
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
|
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
|
||||||
val baseUrl: Flow<String?> = context.dataStore.data.map { it[KEY_BASE_URL] }
|
val baseUrl: Flow<String?> = context.dataStore.data.map { it[KEY_BASE_URL] }
|
||||||
|
|||||||
@@ -55,11 +55,18 @@ data class WSServerMessage(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class WSReviewMessage(
|
data class WSReviewMessage(
|
||||||
@SerializedName("role") val role: String?,
|
@SerializedName("type") val type: String? = null,
|
||||||
@SerializedName("text") val text: String?,
|
@SerializedName("role") val role: String? = null,
|
||||||
@SerializedName("content") val content: String?,
|
@SerializedName("text") val text: String? = null,
|
||||||
@SerializedName("msg_type") val msgType: String?,
|
@SerializedName("content") val content: String? = null,
|
||||||
|
@SerializedName("msg_type") val msgType: String? = null,
|
||||||
@SerializedName("delay_ms") val delayMs: Long? = 0,
|
@SerializedName("delay_ms") val delayMs: Long? = 0,
|
||||||
|
@SerializedName("metadata") val metadata: WSReviewMetadata? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WSReviewMetadata(
|
||||||
|
@SerializedName("language") val language: String? = null,
|
||||||
|
@SerializedName("url") val url: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class WSHistoryMessage(
|
data class WSHistoryMessage(
|
||||||
|
|||||||
@@ -308,20 +308,7 @@ class ChatRepositoryImpl(
|
|||||||
?.toLongOrNull() ?: 0L
|
?.toLongOrNull() ?: 0L
|
||||||
val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
|
val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
|
||||||
ensureConversation(sessionId)
|
ensureConversation(sessionId)
|
||||||
filteredDtos.forEach { dto ->
|
val messages = filteredDtos.map { dto ->
|
||||||
messageDao.upsert(
|
|
||||||
MessageEntity(
|
|
||||||
id = "db_${dto.id}",
|
|
||||||
conversationId = sessionId,
|
|
||||||
role = dto.role,
|
|
||||||
content = dto.content,
|
|
||||||
msgType = dto.msgType ?: "chat",
|
|
||||||
timestamp = dto.createdAt,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
RuntimeLog.http("loadMessages", "HTTP loaded ${filteredDtos.size} messages for session=$sessionId")
|
|
||||||
filteredDtos.map { dto ->
|
|
||||||
Message(
|
Message(
|
||||||
id = "db_${dto.id}",
|
id = "db_${dto.id}",
|
||||||
conversationId = sessionId,
|
conversationId = sessionId,
|
||||||
@@ -330,7 +317,22 @@ class ChatRepositoryImpl(
|
|||||||
msgType = dto.msgType ?: "chat",
|
msgType = dto.msgType ?: "chat",
|
||||||
timestamp = dto.createdAt,
|
timestamp = dto.createdAt,
|
||||||
)
|
)
|
||||||
}.removeWrappingDuplicates()
|
}
|
||||||
|
val deduped = messages.removeWrappingDuplicates()
|
||||||
|
deduped.forEach { msg ->
|
||||||
|
messageDao.upsert(
|
||||||
|
MessageEntity(
|
||||||
|
id = msg.id,
|
||||||
|
conversationId = msg.conversationId,
|
||||||
|
role = msg.role,
|
||||||
|
content = msg.content,
|
||||||
|
msgType = msg.msgType,
|
||||||
|
timestamp = msg.timestamp,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RuntimeLog.http("loadMessages", "HTTP loaded ${deduped.size} messages (${messages.size} before dedup) for session=$sessionId")
|
||||||
|
deduped
|
||||||
} else {
|
} else {
|
||||||
RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
|
RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
|
||||||
requestHistoryViaWs(sessionId)
|
requestHistoryViaWs(sessionId)
|
||||||
@@ -479,12 +481,16 @@ class ChatRepositoryImpl(
|
|||||||
"review" -> {
|
"review" -> {
|
||||||
recentParsedContents.clear()
|
recentParsedContents.clear()
|
||||||
wsMsg.reviewMessages?.forEach { review ->
|
wsMsg.reviewMessages?.forEach { review ->
|
||||||
val text = review.content ?: review.text ?: return@forEach
|
val rawText = review.content ?: review.text ?: return@forEach
|
||||||
val role = review.role ?: "assistant"
|
val role = review.role ?: "assistant"
|
||||||
val rvMsgType = review.msgType ?: "action"
|
val rvMsgType = review.type ?: review.msgType ?: "action"
|
||||||
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
|
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
|
||||||
recentParsedContents.add(text)
|
// Encode code language metadata into content for the renderer
|
||||||
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = text, msgType = rvMsgType, isStreaming = false)
|
val content = if (rvMsgType == "code" && review.metadata?.language != null) {
|
||||||
|
"[lang:${review.metadata.language}]\n$rawText"
|
||||||
|
} else rawText
|
||||||
|
recentParsedContents.add(rawText)
|
||||||
|
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = content, msgType = rvMsgType, isStreaming = false)
|
||||||
}
|
}
|
||||||
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
||||||
// Clean up wrapping response that arrived before this review
|
// Clean up wrapping response that arrived before this review
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ val appModule = module {
|
|||||||
factory { GetConversationsUseCase(get()) }
|
factory { GetConversationsUseCase(get()) }
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
viewModel { ChatViewModel(get(), get()) }
|
viewModel { ChatViewModel(get(), get(), get()) }
|
||||||
viewModel { IoTViewModel(get()) }
|
viewModel { IoTViewModel(get()) }
|
||||||
viewModel { OverlayViewModel(get(), get(), get()) }
|
viewModel { OverlayViewModel(get(), get(), get()) }
|
||||||
viewModel { ProfileViewModel(get(), get(), get()) }
|
viewModel { ProfileViewModel(get(), get(), get()) }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.savedstate.SavedStateRegistry
|
|||||||
import androidx.savedstate.SavedStateRegistryController
|
import androidx.savedstate.SavedStateRegistryController
|
||||||
import androidx.savedstate.SavedStateRegistryOwner
|
import androidx.savedstate.SavedStateRegistryOwner
|
||||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||||
|
import android.content.res.Configuration
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.koin.core.context.GlobalContext
|
import org.koin.core.context.GlobalContext
|
||||||
@@ -66,6 +67,21 @@ class CyreneVoiceInteractionSession(context: Context) :
|
|||||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||||
val vm = overlayViewModel
|
val vm = overlayViewModel
|
||||||
val session = this@CyreneVoiceInteractionSession
|
val session = this@CyreneVoiceInteractionSession
|
||||||
|
|
||||||
|
val darkTheme = runBlocking {
|
||||||
|
val prefs = GlobalContext.get().get<PreferencesDataStore>()
|
||||||
|
val mode = prefs.themeMode.firstOrNull()
|
||||||
|
when (mode) {
|
||||||
|
"light" -> false
|
||||||
|
"dark" -> true
|
||||||
|
else -> {
|
||||||
|
val nightMode = session.context.resources.configuration.uiMode and
|
||||||
|
Configuration.UI_MODE_NIGHT_MASK
|
||||||
|
nightMode == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ComposeView(context).apply {
|
return ComposeView(context).apply {
|
||||||
// Configure window as soon as view is attached — before system overrides flags
|
// Configure window as soon as view is attached — before system overrides flags
|
||||||
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
|
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
|
||||||
@@ -77,7 +93,7 @@ class CyreneVoiceInteractionSession(context: Context) :
|
|||||||
setViewTreeLifecycleOwner(session)
|
setViewTreeLifecycleOwner(session)
|
||||||
setViewTreeSavedStateRegistryOwner(session)
|
setViewTreeSavedStateRegistryOwner(session)
|
||||||
setContent {
|
setContent {
|
||||||
CyreneTheme {
|
CyreneTheme(darkTheme = darkTheme) {
|
||||||
if (vm != null) {
|
if (vm != null) {
|
||||||
OverlayContent(
|
OverlayContent(
|
||||||
onDismiss = { finish() },
|
onDismiss = { finish() },
|
||||||
|
|||||||
@@ -9,15 +9,21 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
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.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -28,12 +34,434 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
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.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
// --- Markdown block model ---
|
||||||
|
|
||||||
|
private sealed class MdBlock {
|
||||||
|
data class Heading(val level: Int, val text: String) : MdBlock()
|
||||||
|
data class Paragraph(val text: String) : MdBlock()
|
||||||
|
data class CodeBlock(val language: String?, val code: String) : MdBlock()
|
||||||
|
data class ListItem(val ordered: Boolean, val index: Int, val text: String) : MdBlock()
|
||||||
|
data class Quote(val text: String) : MdBlock()
|
||||||
|
class ThematicBreak : MdBlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseMarkdownBlocks(text: String): List<MdBlock> {
|
||||||
|
val lines = text.lines()
|
||||||
|
val blocks = mutableListOf<MdBlock>()
|
||||||
|
var i = 0
|
||||||
|
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = lines[i]
|
||||||
|
val trimmed = line.trimStart()
|
||||||
|
|
||||||
|
when {
|
||||||
|
// Fenced code block
|
||||||
|
trimmed.startsWith("```") -> {
|
||||||
|
val lang = trimmed.removePrefix("```").trim().ifBlank { null }
|
||||||
|
val codeLines = mutableListOf<String>()
|
||||||
|
i++
|
||||||
|
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
|
||||||
|
codeLines.add(lines[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (i < lines.size) i++ // skip closing ```
|
||||||
|
blocks.add(MdBlock.CodeBlock(lang, codeLines.joinToString("\n")))
|
||||||
|
}
|
||||||
|
// Heading
|
||||||
|
trimmed.startsWith("#") -> {
|
||||||
|
val match = Regex("^(#{1,6})\\s+(.+)$").find(trimmed)
|
||||||
|
if (match != null) {
|
||||||
|
val level = match.groupValues[1].length
|
||||||
|
blocks.add(MdBlock.Heading(level, match.groupValues[2]))
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Thematic break
|
||||||
|
trimmed.matches(Regex("^[-*_]{3,}$")) -> {
|
||||||
|
blocks.add(MdBlock.ThematicBreak())
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Blockquote
|
||||||
|
line.startsWith(">") || trimmed.startsWith(">") -> {
|
||||||
|
val quoteLines = mutableListOf<String>()
|
||||||
|
while (i < lines.size) {
|
||||||
|
val cur = lines[i]
|
||||||
|
if (cur.trimStart().startsWith(">")) {
|
||||||
|
quoteLines.add(cur.trimStart().removePrefix(">").trimStart())
|
||||||
|
i++
|
||||||
|
} else if (cur.isBlank()) {
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (quoteLines.isNotEmpty()) {
|
||||||
|
blocks.add(MdBlock.Quote(quoteLines.joinToString("\n")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unordered list
|
||||||
|
trimmed.matches(Regex("^[-*+]\\s+.*$")) -> {
|
||||||
|
while (i < lines.size && lines[i].trimStart().matches(Regex("^[-*+]\\s+.*$"))) {
|
||||||
|
val itemText = lines[i].trimStart().replaceFirst(Regex("^[-*+]\\s+"), "")
|
||||||
|
blocks.add(MdBlock.ListItem(false, blocks.size + 1, itemText))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ordered list
|
||||||
|
trimmed.matches(Regex("^\\d+\\.\\s+.*$")) -> {
|
||||||
|
var idx = 1
|
||||||
|
while (i < lines.size && lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*$"))) {
|
||||||
|
val itemText = lines[i].trimStart().replaceFirst(Regex("^\\d+\\.\\s+"), "")
|
||||||
|
blocks.add(MdBlock.ListItem(true, idx, itemText))
|
||||||
|
idx++
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Blank line — skip
|
||||||
|
line.isBlank() -> { i++ }
|
||||||
|
// Paragraph
|
||||||
|
else -> {
|
||||||
|
val paraLines = mutableListOf<String>()
|
||||||
|
while (i < lines.size &&
|
||||||
|
lines[i].isNotBlank() &&
|
||||||
|
!lines[i].trimStart().startsWith("```") &&
|
||||||
|
!lines[i].trimStart().startsWith("#") &&
|
||||||
|
!lines[i].trimStart().matches(Regex("^[-*_]{3,}$")) &&
|
||||||
|
!lines[i].trimStart().startsWith(">") &&
|
||||||
|
!lines[i].trimStart().matches(Regex("^[-*+]\\s+.*$")) &&
|
||||||
|
!lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*$"))
|
||||||
|
) {
|
||||||
|
paraLines.add(lines[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (paraLines.isNotEmpty()) {
|
||||||
|
blocks.add(MdBlock.Paragraph(paraLines.joinToString(" ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun renderInlineMarkdown(text: String): AnnotatedString {
|
||||||
|
return buildAnnotatedString {
|
||||||
|
var remaining = text
|
||||||
|
while (remaining.isNotEmpty()) {
|
||||||
|
// Bold + Italic ***
|
||||||
|
val boldItalic = Regex("""\*\*\*(.+?)\*\*\*""").find(remaining)
|
||||||
|
// Bold **
|
||||||
|
val bold = Regex("""\*\*(.+?)\*\*""").find(remaining)
|
||||||
|
// Italic *
|
||||||
|
val italic = Regex("""(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)""").find(remaining)
|
||||||
|
// Inline code `
|
||||||
|
val code = Regex("""`([^`]+)`""").find(remaining)
|
||||||
|
// Link [text](url)
|
||||||
|
val link = Regex("""\[([^\]]+)\]\(([^)]+)\)""").find(remaining)
|
||||||
|
|
||||||
|
val matches = listOfNotNull(
|
||||||
|
boldItalic?.let { "bi" to it },
|
||||||
|
bold?.let { "b" to it },
|
||||||
|
italic?.let { "i" to it },
|
||||||
|
code?.let { "c" to it },
|
||||||
|
link?.let { "l" to it },
|
||||||
|
).sortedBy { it.second.range.first }
|
||||||
|
|
||||||
|
if (matches.isEmpty()) {
|
||||||
|
append(remaining)
|
||||||
|
remaining = ""
|
||||||
|
} else {
|
||||||
|
val (kind, match) = matches.first()
|
||||||
|
// Append text before the match
|
||||||
|
if (match.range.first > 0) {
|
||||||
|
append(remaining.substring(0, match.range.first))
|
||||||
|
}
|
||||||
|
when (kind) {
|
||||||
|
"bi" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
|
||||||
|
append(match.groupValues[1])
|
||||||
|
}
|
||||||
|
"b" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||||
|
append(match.groupValues[1])
|
||||||
|
}
|
||||||
|
"i" -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||||
|
append(match.groupValues[1])
|
||||||
|
}
|
||||||
|
"c" -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = Color.Gray.copy(alpha = 0.2f))) {
|
||||||
|
append(match.groupValues[1])
|
||||||
|
}
|
||||||
|
"l" -> {
|
||||||
|
val label = match.groupValues[1]
|
||||||
|
val url = match.groupValues[2]
|
||||||
|
pushStringAnnotation("url", url)
|
||||||
|
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline)) {
|
||||||
|
append(label)
|
||||||
|
}
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remaining = remaining.substring(match.range.last + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Markdown bubble ---
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MarkdownBubble(content: String, modifier: Modifier = Modifier) {
|
||||||
|
val blocks = remember(content) { parseMarkdownBlocks(content) }
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f),
|
||||||
|
shadowElevation = 1.dp,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
blocks.forEach { block ->
|
||||||
|
when (block) {
|
||||||
|
is MdBlock.Heading -> {
|
||||||
|
val fontSize = when (block.level) {
|
||||||
|
1 -> 22.sp
|
||||||
|
2 -> 19.sp
|
||||||
|
3 -> 17.sp
|
||||||
|
4 -> 15.sp
|
||||||
|
5 -> 14.sp
|
||||||
|
else -> 13.sp
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = renderInlineMarkdown(block.text),
|
||||||
|
fontSize = fontSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = if (block.level <= 2) 6.dp else 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is MdBlock.Paragraph -> {
|
||||||
|
if (block.text.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = renderInlineMarkdown(block.text),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MdBlock.CodeBlock -> {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = Color(0xFF1E1E1E),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
if (block.language != null) {
|
||||||
|
Text(
|
||||||
|
text = block.language,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(Color(0xFF333333))
|
||||||
|
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
color = Color(0xFFCCCCCC),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = block.code,
|
||||||
|
modifier = Modifier.padding(10.dp),
|
||||||
|
color = Color(0xFFD4D4D4),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MdBlock.ListItem -> {
|
||||||
|
val prefix = if (block.ordered) "${block.index}. " else "• "
|
||||||
|
Row(modifier = Modifier.padding(start = 8.dp, top = 2.dp)) {
|
||||||
|
Text(
|
||||||
|
text = prefix,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = renderInlineMarkdown(block.text),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MdBlock.Quote -> {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
) {
|
||||||
|
Row {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.5f))
|
||||||
|
.weight(0.012f)
|
||||||
|
) {}
|
||||||
|
Text(
|
||||||
|
text = renderInlineMarkdown(block.text),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(8.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MdBlock.ThematicBreak -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 6.dp)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
|
||||||
|
shape = MaterialTheme.shapes.extraSmall,
|
||||||
|
)
|
||||||
|
.weight(1f)
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Code bubble (standalone code block message) ---
|
||||||
|
|
||||||
|
private val codeDarkBg = Color(0xFF1E1E1E)
|
||||||
|
private val codeSurface = Color(0xFF333333)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CodeBubble(content: String, modifier: Modifier = Modifier) {
|
||||||
|
val (language, code) = remember(content) {
|
||||||
|
if (content.startsWith("[lang:")) {
|
||||||
|
val endBracket = content.indexOf("]\n")
|
||||||
|
if (endBracket > 0) {
|
||||||
|
content.substring(6, endBracket) to content.substring(endBracket + 2)
|
||||||
|
} else "Code" to content
|
||||||
|
} else "Code" to content
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = codeDarkBg,
|
||||||
|
shadowElevation = 2.dp,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
// Language header
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(codeSurface, MaterialTheme.shapes.medium)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = language,
|
||||||
|
color = Color(0xFFAAAAAA),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Code content
|
||||||
|
Text(
|
||||||
|
text = code,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
color = Color(0xFFD4D4D4),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Collapsible wrapper for non-chat content ---
|
||||||
|
|
||||||
|
private const val COLLAPSE_THRESHOLD = 300
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CollapsibleBubble(
|
||||||
|
content: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
bubble: @Composable (String, Modifier) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val lines = content.lines()
|
||||||
|
val isLong = content.length > COLLAPSE_THRESHOLD || lines.size > 8
|
||||||
|
|
||||||
|
if (!isLong) {
|
||||||
|
bubble(content, modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
if (expanded) {
|
||||||
|
bubble(content, Modifier)
|
||||||
|
} else {
|
||||||
|
val truncated = lines.take(5).joinToString("\n").let {
|
||||||
|
if (it.length >= content.length) it else it + "\n…"
|
||||||
|
}
|
||||||
|
bubble(truncated, Modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = { expanded = !expanded },
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 4.dp)
|
||||||
|
.size(32.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||||
|
contentDescription = if (expanded) "折叠" else "展开",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main ChatBubble dispatcher ---
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatBubble(
|
fun ChatBubble(
|
||||||
content: String,
|
content: String,
|
||||||
@@ -48,13 +476,25 @@ fun ChatBubble(
|
|||||||
when (msgType) {
|
when (msgType) {
|
||||||
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier)
|
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier)
|
||||||
"action" -> ActionMessage(content, modifier)
|
"action" -> ActionMessage(content, modifier)
|
||||||
"thinking" -> ThinkingBubble(content, modifier)
|
"markdown" -> CollapsibleBubble(content, modifier) { text, mod ->
|
||||||
"tool_progress" -> ToolProgressBubble(content, modifier)
|
MarkdownBubble(text, mod)
|
||||||
|
}
|
||||||
|
"code" -> CollapsibleBubble(content, modifier) { text, mod ->
|
||||||
|
CodeBubble(text, mod)
|
||||||
|
}
|
||||||
|
"thinking" -> CollapsibleBubble(content, modifier) { text, mod ->
|
||||||
|
ThinkingBubble(text, mod)
|
||||||
|
}
|
||||||
|
"tool_progress" -> CollapsibleBubble(content, modifier) { text, mod ->
|
||||||
|
ToolProgressBubble(text, mod)
|
||||||
|
}
|
||||||
"system_info" -> SystemInfoBubble(content, modifier)
|
"system_info" -> SystemInfoBubble(content, modifier)
|
||||||
else -> ChatMessageBubble(content, isUser, formattedTime, modifier)
|
else -> ChatMessageBubble(content, isUser, formattedTime, modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Chat message bubble ---
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatMessageBubble(
|
private fun ChatMessageBubble(
|
||||||
@@ -125,6 +565,8 @@ private fun ChatMessageBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Action message ---
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
||||||
Row(
|
Row(
|
||||||
@@ -136,7 +578,7 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
|||||||
Text(
|
Text(
|
||||||
text = content,
|
text = content,
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
fontStyle = FontStyle.Italic,
|
||||||
),
|
),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Start,
|
textAlign = TextAlign.Start,
|
||||||
@@ -144,6 +586,8 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Thinking bubble ---
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
|
private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
|
||||||
Box(
|
Box(
|
||||||
@@ -164,6 +608,8 @@ private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Tool progress bubble ---
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
|
private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
|
||||||
Row(
|
Row(
|
||||||
@@ -186,6 +632,8 @@ private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- System info bubble ---
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
|
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -49,15 +49,15 @@ fun StatusIndicator(
|
|||||||
modifier = Modifier.size(8.dp),
|
modifier = Modifier.size(8.dp),
|
||||||
tint = Color(0xFF4CAF50),
|
tint = Color(0xFF4CAF50),
|
||||||
)
|
)
|
||||||
Text("昔涟", style = MaterialTheme.typography.labelLarge)
|
Text("昔涟", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
CyreneStatus.THINKING -> {
|
CyreneStatus.THINKING -> {
|
||||||
PulsingDot(Color(0xFFFFA726))
|
PulsingDot(Color(0xFFFFA726))
|
||||||
Text("思考中…", style = MaterialTheme.typography.labelLarge)
|
Text("思考中…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
CyreneStatus.SPEAKING -> {
|
CyreneStatus.SPEAKING -> {
|
||||||
PulsingDot(Color(0xFF42A5F5))
|
PulsingDot(Color(0xFF42A5F5))
|
||||||
Text("正在说话…", style = MaterialTheme.typography.labelLarge)
|
Text("正在说话…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
CyreneStatus.OFFLINE -> {
|
CyreneStatus.OFFLINE -> {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -66,7 +66,7 @@ fun StatusIndicator(
|
|||||||
modifier = Modifier.size(8.dp),
|
modifier = Modifier.size(8.dp),
|
||||||
tint = Color(0xFF9E9E9E),
|
tint = Color(0xFF9E9E9E),
|
||||||
)
|
)
|
||||||
Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge)
|
Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.offset
|
|||||||
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.layout.width
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
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
|
||||||
@@ -57,6 +59,7 @@ import androidx.compose.ui.platform.LocalConfiguration
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -112,6 +115,7 @@ fun OverlayContent(
|
|||||||
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
||||||
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
||||||
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
|
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
|
||||||
|
val enterToSend by settingsViewModel.enterToSend.collectAsState()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val isProcessing = state == OverlayState.PROCESSING
|
val isProcessing = state == OverlayState.PROCESSING
|
||||||
val recordSec = recordDurationMs / 1000f
|
val recordSec = recordDurationMs / 1000f
|
||||||
@@ -191,6 +195,7 @@ fun OverlayContent(
|
|||||||
isLocked = isLocked,
|
isLocked = isLocked,
|
||||||
typingDots = typingDots.value,
|
typingDots = typingDots.value,
|
||||||
typingIndicatorStyle = typingIndicatorStyle,
|
typingIndicatorStyle = typingIndicatorStyle,
|
||||||
|
enterToSend = enterToSend,
|
||||||
animIndex = animIndex,
|
animIndex = animIndex,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
onNavigateToMain = onNavigateToMain,
|
onNavigateToMain = onNavigateToMain,
|
||||||
@@ -209,6 +214,7 @@ fun OverlayContent(
|
|||||||
isLocked = isLocked,
|
isLocked = isLocked,
|
||||||
typingDots = typingDots.value,
|
typingDots = typingDots.value,
|
||||||
typingIndicatorStyle = typingIndicatorStyle,
|
typingIndicatorStyle = typingIndicatorStyle,
|
||||||
|
enterToSend = enterToSend,
|
||||||
animIndex = animIndex,
|
animIndex = animIndex,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
onNavigateToMain = onNavigateToMain,
|
onNavigateToMain = onNavigateToMain,
|
||||||
@@ -233,6 +239,7 @@ private fun PortraitContent(
|
|||||||
isLocked: Boolean,
|
isLocked: Boolean,
|
||||||
typingDots: String,
|
typingDots: String,
|
||||||
typingIndicatorStyle: String,
|
typingIndicatorStyle: String,
|
||||||
|
enterToSend: Boolean,
|
||||||
animIndex: Map<String, Int>,
|
animIndex: Map<String, Int>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onNavigateToMain: () -> Unit,
|
onNavigateToMain: () -> Unit,
|
||||||
@@ -286,6 +293,7 @@ private fun PortraitContent(
|
|||||||
isLocked = isLocked,
|
isLocked = isLocked,
|
||||||
typingDots = typingDots,
|
typingDots = typingDots,
|
||||||
typingIndicatorStyle = typingIndicatorStyle,
|
typingIndicatorStyle = typingIndicatorStyle,
|
||||||
|
enterToSend = enterToSend,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,6 +311,7 @@ private fun LandscapeContent(
|
|||||||
isLocked: Boolean,
|
isLocked: Boolean,
|
||||||
typingDots: String,
|
typingDots: String,
|
||||||
typingIndicatorStyle: String,
|
typingIndicatorStyle: String,
|
||||||
|
enterToSend: Boolean,
|
||||||
animIndex: Map<String, Int>,
|
animIndex: Map<String, Int>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onNavigateToMain: () -> Unit,
|
onNavigateToMain: () -> Unit,
|
||||||
@@ -366,6 +375,7 @@ private fun LandscapeContent(
|
|||||||
isLocked = isLocked,
|
isLocked = isLocked,
|
||||||
typingDots = typingDots,
|
typingDots = typingDots,
|
||||||
typingIndicatorStyle = typingIndicatorStyle,
|
typingIndicatorStyle = typingIndicatorStyle,
|
||||||
|
enterToSend = enterToSend,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,6 +418,7 @@ private fun InputArea(
|
|||||||
isLocked: Boolean = false,
|
isLocked: Boolean = false,
|
||||||
typingDots: String = "",
|
typingDots: String = "",
|
||||||
typingIndicatorStyle: String = "bubble",
|
typingIndicatorStyle: String = "bubble",
|
||||||
|
enterToSend: Boolean = false,
|
||||||
) {
|
) {
|
||||||
// Gesture tracking state — local to InputArea
|
// Gesture tracking state — local to InputArea
|
||||||
var isDragging by remember { mutableStateOf(false) }
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
@@ -417,17 +428,11 @@ private fun InputArea(
|
|||||||
val inLockZone = isDragging && dragOffsetX > 60f
|
val inLockZone = isDragging && dragOffsetX > 60f
|
||||||
val isProcessing = state == OverlayState.PROCESSING
|
val isProcessing = state == OverlayState.PROCESSING
|
||||||
|
|
||||||
Surface(
|
Column(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
.fillMaxWidth()
|
||||||
shadowElevation = 8.dp,
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
|
|
||||||
) {
|
) {
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(12.dp),
|
|
||||||
) {
|
|
||||||
// "昔涟正在输入..." indicator (text mode only)
|
// "昔涟正在输入..." indicator (text mode only)
|
||||||
if (isProcessing && typingDots.isNotEmpty() && typingIndicatorStyle == "text") {
|
if (isProcessing && typingDots.isNotEmpty() && typingIndicatorStyle == "text") {
|
||||||
Text(
|
Text(
|
||||||
@@ -524,7 +529,23 @@ private fun InputArea(
|
|||||||
placeholder = { Text("输入消息...") },
|
placeholder = { Text("输入消息...") },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
maxLines = 3,
|
maxLines = 3,
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.35f),
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
|
||||||
|
),
|
||||||
|
keyboardOptions = if (enterToSend) {
|
||||||
|
KeyboardOptions(imeAction = ImeAction.Done)
|
||||||
|
} else {
|
||||||
|
KeyboardOptions.Default
|
||||||
|
},
|
||||||
|
keyboardActions = if (enterToSend) {
|
||||||
|
KeyboardActions(
|
||||||
|
onDone = { if (inputText.isNotBlank()) viewModel.sendText() },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
KeyboardActions.Default
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
@@ -610,6 +631,5 @@ private fun InputArea(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,19 +23,20 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
|||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.KeyboardVoice
|
import androidx.compose.material.icons.filled.KeyboardVoice
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
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.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|
||||||
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
|
||||||
@@ -51,6 +52,7 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -94,7 +96,6 @@ private fun AnimatedChatBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
viewModel: ChatViewModel = koinViewModel(),
|
viewModel: ChatViewModel = koinViewModel(),
|
||||||
@@ -109,6 +110,7 @@ fun ChatScreen(
|
|||||||
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
||||||
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
||||||
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
|
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
|
||||||
|
val enterToSend by settingsViewModel.enterToSend.collectAsState()
|
||||||
|
|
||||||
// reverseLayout: index 0 = newest (visual bottom), index N-1 = oldest (visual top)
|
// reverseLayout: index 0 = newest (visual bottom), index N-1 = oldest (visual top)
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
@@ -173,7 +175,7 @@ fun ChatScreen(
|
|||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.imePadding(),
|
.imePadding(),
|
||||||
) {
|
) {
|
||||||
// Top status bar
|
// Top status bar with refresh button
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -181,14 +183,28 @@ fun ChatScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
StatusIndicator(status = status)
|
StatusIndicator(status = status)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.refreshMessages() },
|
||||||
|
enabled = !isRefreshing,
|
||||||
|
) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Refresh,
|
||||||
|
contentDescription = "刷新",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Messages area (fills remaining space, shrinks with IME)
|
// Messages area (fills remaining space, shrinks with IME)
|
||||||
PullToRefreshBox(
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
isRefreshing = isRefreshing,
|
|
||||||
onRefresh = { viewModel.refreshMessages() },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
if (messages.isEmpty() && !isStreaming) {
|
if (messages.isEmpty() && !isStreaming) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -324,6 +340,18 @@ fun ChatScreen(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
keyboardOptions = if (enterToSend) {
|
||||||
|
KeyboardOptions(imeAction = ImeAction.Done)
|
||||||
|
} else {
|
||||||
|
KeyboardOptions.Default
|
||||||
|
},
|
||||||
|
keyboardActions = if (enterToSend) {
|
||||||
|
KeyboardActions(
|
||||||
|
onDone = { if (inputText.isNotBlank()) viewModel.sendMessage() },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
KeyboardActions.Default
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -380,7 +408,11 @@ fun ChatScreen(
|
|||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.Send,
|
||||||
|
contentDescription = "发送",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import androidx.compose.foundation.text.KeyboardOptions
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.DarkMode
|
import androidx.compose.material.icons.filled.DarkMode
|
||||||
import androidx.compose.material.icons.filled.DeleteForever
|
import androidx.compose.material.icons.filled.DeleteForever
|
||||||
@@ -80,6 +81,7 @@ fun SettingsScreen(
|
|||||||
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
|
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
|
||||||
val autoScreenContext by viewModel.autoScreenContext.collectAsState()
|
val autoScreenContext by viewModel.autoScreenContext.collectAsState()
|
||||||
val typingIndicatorStyle by viewModel.typingIndicatorStyle.collectAsState()
|
val typingIndicatorStyle by viewModel.typingIndicatorStyle.collectAsState()
|
||||||
|
val enterToSend by viewModel.enterToSend.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -221,6 +223,19 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("回车键发送") },
|
||||||
|
supportingContent = { Text(if (enterToSend) "回车直接发送消息" else "回车换行") },
|
||||||
|
leadingContent = { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) },
|
||||||
|
trailingContent = {
|
||||||
|
androidx.compose.material3.Switch(
|
||||||
|
checked = enterToSend,
|
||||||
|
onCheckedChange = { viewModel.saveEnterToSend(it) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { viewModel.saveEnterToSend(!enterToSend) },
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ private val DarkColorScheme = darkColorScheme(
|
|||||||
@Composable
|
@Composable
|
||||||
fun CyreneTheme(
|
fun CyreneTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = false,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
import top.yeij.cyrene.domain.model.Conversation
|
import top.yeij.cyrene.domain.model.Conversation
|
||||||
import top.yeij.cyrene.domain.model.Message
|
import top.yeij.cyrene.domain.model.Message
|
||||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
@@ -24,27 +26,10 @@ private fun List<Message>.deduplicate(): List<Message> {
|
|||||||
return filter { seen.add(it.id) }
|
return filter { seen.add(it.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
|
|
||||||
if (size < 3) return this
|
|
||||||
val toRemove = mutableSetOf<String>()
|
|
||||||
for (msg in this) {
|
|
||||||
val containedCount = count { other ->
|
|
||||||
other.id != msg.id &&
|
|
||||||
other.content.isNotBlank() &&
|
|
||||||
other.content.length < msg.content.length &&
|
|
||||||
msg.content.contains(other.content) &&
|
|
||||||
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
|
|
||||||
}
|
|
||||||
if (containedCount >= 2) {
|
|
||||||
toRemove.add(msg.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChatViewModel(
|
class ChatViewModel(
|
||||||
private val chatRepository: ChatRepository,
|
private val chatRepository: ChatRepository,
|
||||||
private val voiceRecorder: VoiceRecorder,
|
private val voiceRecorder: VoiceRecorder,
|
||||||
|
private val preferencesDataStore: PreferencesDataStore,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val isConnected: StateFlow<Boolean> = chatRepository.connectionState
|
val isConnected: StateFlow<Boolean> = chatRepository.connectionState
|
||||||
@@ -93,7 +78,14 @@ class ChatViewModel(
|
|||||||
chatRepository.ensureConnected()
|
chatRepository.ensureConnected()
|
||||||
chatRepository.loadMessagesFromServer(sessionId)
|
chatRepository.loadMessagesFromServer(sessionId)
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
loadMessagesFromDb(currentSessionId ?: return@launch)
|
// Fall back to persisted sessionId if initializeSession failed
|
||||||
|
val sid = currentSessionId
|
||||||
|
?: preferencesDataStore.currentSessionId.firstOrNull()
|
||||||
|
if (sid != null) {
|
||||||
|
currentSessionId = sid
|
||||||
|
chatRepository.currentSessionId = sid
|
||||||
|
loadMessagesFromDb(sid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe incoming live messages — insert at correct descending position
|
// Observe incoming live messages — insert at correct descending position
|
||||||
@@ -144,6 +136,27 @@ class ChatViewModel(
|
|||||||
if (streaming) _isSending.value = false
|
if (streaming) _isSending.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-refresh when connection transitions from offline to online
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOffline = false
|
||||||
|
chatRepository.connectionState.collect { connected ->
|
||||||
|
if (connected && wasOffline && currentSessionId != null) {
|
||||||
|
try {
|
||||||
|
val lastCleared = preferencesDataStore.lastClearedTimestamp
|
||||||
|
.firstOrNull()?.toLongOrNull() ?: 0L
|
||||||
|
val latestMessages = chatRepository.loadMessagesFromServer(currentSessionId!!)
|
||||||
|
val latestServerTs = latestMessages.maxOfOrNull { it.timestamp } ?: 0L
|
||||||
|
if (latestServerTs > lastCleared) {
|
||||||
|
// Newer messages exist on server — DB upsert already
|
||||||
|
// triggered the Room Flow; force a scroll to newest
|
||||||
|
RuntimeLog.chat("auto-refresh", "Reconnected, loaded ${latestMessages.size} messages")
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
wasOffline = !connected
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Voice recording (WeChat-style gesture) ---
|
// --- Voice recording (WeChat-style gesture) ---
|
||||||
@@ -183,7 +196,6 @@ class ChatViewModel(
|
|||||||
(db + live).values
|
(db + live).values
|
||||||
.sortedByDescending { it.timestamp }
|
.sortedByDescending { it.timestamp }
|
||||||
.deduplicate()
|
.deduplicate()
|
||||||
.removeWrappingDuplicates()
|
|
||||||
}
|
}
|
||||||
val idx = _messageAnimIndex.value.toMutableMap()
|
val idx = _messageAnimIndex.value.toMutableMap()
|
||||||
messages.forEach { m ->
|
messages.forEach { m ->
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ class SettingsViewModel(
|
|||||||
private val _autoScreenContext = MutableStateFlow(false)
|
private val _autoScreenContext = MutableStateFlow(false)
|
||||||
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
|
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
|
||||||
|
|
||||||
|
private val _enterToSend = MutableStateFlow(false)
|
||||||
|
val enterToSend: StateFlow<Boolean> = _enterToSend.asStateFlow()
|
||||||
|
|
||||||
private val _typingIndicatorStyle = MutableStateFlow("bubble")
|
private val _typingIndicatorStyle = MutableStateFlow("bubble")
|
||||||
val typingIndicatorStyle: StateFlow<String> = _typingIndicatorStyle.asStateFlow()
|
val typingIndicatorStyle: StateFlow<String> = _typingIndicatorStyle.asStateFlow()
|
||||||
|
|
||||||
@@ -69,6 +72,11 @@ class SettingsViewModel(
|
|||||||
_typingIndicatorStyle.value = value
|
_typingIndicatorStyle.value = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
|
preferencesDataStore.enterToSend.collect { value ->
|
||||||
|
_enterToSend.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
combine(
|
combine(
|
||||||
preferencesDataStore.baseUrl,
|
preferencesDataStore.baseUrl,
|
||||||
@@ -178,6 +186,11 @@ class SettingsViewModel(
|
|||||||
scope.launch { preferencesDataStore.saveTypingIndicatorStyle(style) }
|
scope.launch { preferencesDataStore.saveTypingIndicatorStyle(style) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveEnterToSend(enabled: Boolean) {
|
||||||
|
_enterToSend.value = enabled
|
||||||
|
scope.launch { preferencesDataStore.saveEnterToSend(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
fun clearLocalMessages() {
|
fun clearLocalMessages() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
chatRepository.clearLocalMessages()
|
chatRepository.clearLocalMessages()
|
||||||
|
|||||||
Reference in New Issue
Block a user