feat: dark mode background fixes, chat bubble long-press menu, notification dedup, biometric auth for clear

- Fix: dark mode white backgrounds via transparent windowBackground, decorView color, and explicit Surface backgrounds on IoT/Profile/MainScreen
- Fix: dark mode text colors in ProfileScreen (nickname, account info, assistant status)
- Feat: long-press chat bubble to copy message text via DropdownMenu
- Feat: notification deduplication — track notified message IDs, clear on foreground
- Feat: cancel all notifications when app enters foreground
- Feat: biometric/device-credential verification before clearing local messages

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 20:22:28 +08:00
parent 5247eef0fc
commit 9295fe8021
12 changed files with 147 additions and 21 deletions
+3
View File
@@ -88,4 +88,7 @@ dependencies {
// Core // Core
implementation(libs.core.ktx) implementation(libs.core.ktx)
// Biometric
implementation(libs.biometric)
} }
@@ -25,6 +25,8 @@ class CyreneApplication : Application() {
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activityCount = AtomicInteger(0) private val activityCount = AtomicInteger(0)
@Volatile
private var notificationHelper: NotificationHelper? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -40,6 +42,7 @@ class CyreneApplication : Application() {
override fun onActivityStarted(activity: Activity) { override fun onActivityStarted(activity: Activity) {
if (activityCount.incrementAndGet() == 1) { if (activityCount.incrementAndGet() == 1) {
RuntimeLog.general("app", "App in foreground") RuntimeLog.general("app", "App in foreground")
notificationHelper?.cancelAll()
getRepo()?.onAppForeground() getRepo()?.onAppForeground()
} }
} }
@@ -60,10 +63,11 @@ class CyreneApplication : Application() {
// Set up background notification callback once Koin is ready // Set up background notification callback once Koin is ready
initScope.launch { initScope.launch {
val notificationHelper = NotificationHelper(this@CyreneApplication) val helper = NotificationHelper(this@CyreneApplication)
notificationHelper = helper
val repo = getRepo() val repo = getRepo()
repo?.setNotificationCallback { message -> repo?.setNotificationCallback { message ->
notificationHelper.showMessageNotification(message) helper.showMessageNotification(message)
} }
} }
@@ -64,6 +64,7 @@ class ChatRepositoryImpl(
private var isAppInForeground = false private var isAppInForeground = false
private var onBackgroundNotification: ((Message) -> Unit)? = null private var onBackgroundNotification: ((Message) -> Unit)? = null
private var historyRequested = false private var historyRequested = false
private val notifiedMessageIds = mutableSetOf<String>()
// Duplicate suppression: track items from review/multi_message to skip wrapping response // Duplicate suppression: track items from review/multi_message to skip wrapping response
private val recentParsedContents = mutableListOf<String>() private val recentParsedContents = mutableListOf<String>()
@@ -79,6 +80,7 @@ class ChatRepositoryImpl(
override fun onAppForeground() { override fun onAppForeground() {
isAppInForeground = true isAppInForeground = true
notifiedMessageIds.clear()
if (!_connectionState.value) { if (!_connectionState.value) {
webSocketService.forceReconnect() webSocketService.forceReconnect()
} }
@@ -641,7 +643,9 @@ class ChatRepositoryImpl(
_incomingMessages.tryEmit(message) _incomingMessages.tryEmit(message)
if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) { if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) {
onBackgroundNotification?.invoke(message) if (notifiedMessageIds.add(id)) {
onBackgroundNotification?.invoke(message)
}
} }
} }
@@ -1,6 +1,8 @@
package top.yeij.cyrene.ui.components package top.yeij.cyrene.ui.components
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
@@ -8,13 +10,24 @@ 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.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
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.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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -42,6 +55,7 @@ fun ChatBubble(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun ChatMessageBubble( private fun ChatMessageBubble(
content: String, content: String,
@@ -49,6 +63,9 @@ private fun ChatMessageBubble(
time: String, time: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var showMenu by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -58,23 +75,45 @@ private fun ChatMessageBubble(
Column( Column(
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start, horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
) { ) {
Surface( Box {
shape = MaterialTheme.shapes.large, Surface(
color = if (isUser) shape = MaterialTheme.shapes.large,
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant,
shadowElevation = 2.dp,
modifier = Modifier.widthIn(max = 300.dp),
) {
Text(
text = content,
modifier = Modifier.padding(12.dp),
color = if (isUser) color = if (isUser)
MaterialTheme.colorScheme.onPrimary MaterialTheme.colorScheme.primary
else else
MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.surfaceVariant,
) shadowElevation = 2.dp,
modifier = Modifier
.widthIn(max = 300.dp)
.combinedClickable(
onClick = {},
onLongClick = { showMenu = true },
),
) {
Text(
text = content,
modifier = Modifier.padding(12.dp),
color = if (isUser)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("复制") },
leadingIcon = {
Icon(Icons.Default.ContentCopy, contentDescription = null)
},
onClick = {
showMenu = false
clipboardManager.setText(AnnotatedString(content))
},
)
}
} }
Text( Text(
text = time, text = time,
@@ -1,5 +1,6 @@
package top.yeij.cyrene.ui.navigation package top.yeij.cyrene.ui.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@@ -10,6 +11,7 @@ import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.DevicesOther import androidx.compose.material.icons.filled.DevicesOther
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -129,7 +131,12 @@ fun MainScreen(
} }
} }
Box(modifier = Modifier.weight(1f).fillMaxHeight()) { Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.background),
) {
when (selectedTab) { when (selectedTab) {
0 -> ChatScreen() 0 -> ChatScreen()
1 -> IoTScreen() 1 -> IoTScreen()
@@ -1,5 +1,6 @@
package top.yeij.cyrene.ui.screens.iot package top.yeij.cyrene.ui.screens.iot
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -31,6 +32,7 @@ fun IoTScreen(
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isLoading, isRefreshing = isLoading,
onRefresh = { viewModel.refreshDevices() }, onRefresh = { viewModel.refreshDevices() },
modifier = Modifier.background(MaterialTheme.colorScheme.background),
) { ) {
if (devices.isEmpty() && !isLoading) { if (devices.isEmpty() && !isLoading) {
Box( Box(
@@ -104,6 +104,7 @@ fun ProfileScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
// Profile header // Profile header
@@ -158,6 +159,7 @@ fun ProfileScreen(
text = profile.nickname.ifEmpty { profile.username }, text = profile.nickname.ifEmpty { profile.username },
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
) )
// Username // Username
if (profile.nickname.isNotBlank() && profile.nickname != profile.username) { if (profile.nickname.isNotBlank() && profile.nickname != profile.username) {
@@ -254,6 +256,7 @@ fun ProfileScreen(
Text( Text(
text = if (isDefaultAssistant) "已设为默认助手" else "未设为默认助手", text = if (isDefaultAssistant) "已设为默认助手" else "未设为默认助手",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
) )
if (!isDefaultAssistant) { if (!isDefaultAssistant) {
Text( Text(
@@ -375,6 +378,7 @@ private fun ProfileInfoCard(items: List<ProfileInfoItem>) {
Text( Text(
text = item.value, text = item.value,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@@ -1,6 +1,8 @@
package top.yeij.cyrene.ui.screens.settings package top.yeij.cyrene.ui.screens.settings
import android.widget.Toast import android.widget.Toast
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -50,6 +52,8 @@ 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.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.compose.ui.platform.LocalContext 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.KeyboardType import androidx.compose.ui.text.input.KeyboardType
@@ -394,8 +398,58 @@ fun SettingsScreen(
TextButton( TextButton(
onClick = { onClick = {
showClearDialog = false showClearDialog = false
viewModel.clearLocalMessages() val activity = context as? FragmentActivity
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show() if (activity == null) {
viewModel.clearLocalMessages()
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
return@TextButton
}
val biometricManager = BiometricManager.from(context)
val canAuth = when (biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)) {
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
}
if (!canAuth) {
viewModel.clearLocalMessages()
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
return@TextButton
}
val prompt = BiometricPrompt(
activity,
ContextCompat.getMainExecutor(context),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult,
) {
viewModel.clearLocalMessages()
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
}
override fun onAuthenticationFailed() {
Toast.makeText(context, "验证失败", Toast.LENGTH_SHORT).show()
}
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence,
) {
Toast.makeText(context, "验证取消", Toast.LENGTH_SHORT).show()
}
},
)
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle("身份验证")
.setSubtitle("需要验证身份后才能清空消息记录")
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
.build(),
)
}, },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
) { ) {
@@ -84,6 +84,7 @@ fun CyreneTheme(
val window = (view.context as Activity).window val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb() window.statusBarColor = colorScheme.background.toArgb()
window.navigationBarColor = colorScheme.background.toArgb() window.navigationBarColor = colorScheme.background.toArgb()
window.decorView.setBackgroundColor(colorScheme.background.toArgb())
WindowCompat.getInsetsController(window, view).apply { WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme isAppearanceLightNavigationBars = !darkTheme
@@ -58,6 +58,10 @@ class NotificationHelper(private val context: Context) {
notificationManager.notify(message.id.hashCode(), notification) notificationManager.notify(message.id.hashCode(), notification)
} }
fun cancelAll() {
notificationManager.cancelAll()
}
companion object { companion object {
private const val CHANNEL_ID = "cyrene_messages" private const val CHANNEL_ID = "cyrene_messages"
} }
+1
View File
@@ -3,5 +3,6 @@
<style name="Theme.Cyrene" parent="android:Theme.Material.Light.NoActionBar"> <style name="Theme.Cyrene" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowBackground">@android:color/transparent</item>
</style> </style>
</resources> </resources>
+3
View File
@@ -56,6 +56,9 @@ coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-andro
# Core # Core
core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" } core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" }
# Biometric
biometric = { group = "androidx.biometric", name = "biometric", version = "1.1.0" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }