From 9295fe802180d8add2f79aece2984a3ab920396f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 24 May 2026 20:22:28 +0800 Subject: [PATCH] feat: dark mode background fixes, chat bubble long-press menu, notification dedup, biometric auth for clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/build.gradle.kts | 3 + .../java/top/yeij/cyrene/CyreneApplication.kt | 8 ++- .../data/repository/ChatRepositoryImpl.kt | 6 +- .../yeij/cyrene/ui/components/ChatBubble.kt | 69 +++++++++++++++---- .../top/yeij/cyrene/ui/navigation/NavGraph.kt | 9 ++- .../yeij/cyrene/ui/screens/iot/IoTScreen.kt | 2 + .../ui/screens/profile/ProfileScreen.kt | 4 ++ .../ui/screens/settings/SettingsScreen.kt | 58 +++++++++++++++- .../java/top/yeij/cyrene/ui/theme/Theme.kt | 1 + .../yeij/cyrene/util/NotificationHelper.kt | 4 ++ app/src/main/res/values/themes.xml | 1 + gradle/libs.versions.toml | 3 + 12 files changed, 147 insertions(+), 21 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6f463bf..41949fb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -88,4 +88,7 @@ dependencies { // Core implementation(libs.core.ktx) + + // Biometric + implementation(libs.biometric) } diff --git a/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt b/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt index d26e34a..2c07545 100644 --- a/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt +++ b/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt @@ -25,6 +25,8 @@ class CyreneApplication : Application() { private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val activityCount = AtomicInteger(0) + @Volatile + private var notificationHelper: NotificationHelper? = null override fun onCreate() { super.onCreate() @@ -40,6 +42,7 @@ class CyreneApplication : Application() { override fun onActivityStarted(activity: Activity) { if (activityCount.incrementAndGet() == 1) { RuntimeLog.general("app", "App in foreground") + notificationHelper?.cancelAll() getRepo()?.onAppForeground() } } @@ -60,10 +63,11 @@ class CyreneApplication : Application() { // Set up background notification callback once Koin is ready initScope.launch { - val notificationHelper = NotificationHelper(this@CyreneApplication) + val helper = NotificationHelper(this@CyreneApplication) + notificationHelper = helper val repo = getRepo() repo?.setNotificationCallback { message -> - notificationHelper.showMessageNotification(message) + helper.showMessageNotification(message) } } diff --git a/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt index 11d8b53..56efb90 100644 --- a/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt +++ b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt @@ -64,6 +64,7 @@ class ChatRepositoryImpl( private var isAppInForeground = false private var onBackgroundNotification: ((Message) -> Unit)? = null private var historyRequested = false + private val notifiedMessageIds = mutableSetOf() // Duplicate suppression: track items from review/multi_message to skip wrapping response private val recentParsedContents = mutableListOf() @@ -79,6 +80,7 @@ class ChatRepositoryImpl( override fun onAppForeground() { isAppInForeground = true + notifiedMessageIds.clear() if (!_connectionState.value) { webSocketService.forceReconnect() } @@ -641,7 +643,9 @@ class ChatRepositoryImpl( _incomingMessages.tryEmit(message) if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) { - onBackgroundNotification?.invoke(message) + if (notifiedMessageIds.add(id)) { + onBackgroundNotification?.invoke(message) + } } } diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt index 2652180..06eda26 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt @@ -1,6 +1,8 @@ package top.yeij.cyrene.ui.components +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -8,13 +10,24 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding 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.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.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.unit.dp import java.text.SimpleDateFormat @@ -42,6 +55,7 @@ fun ChatBubble( } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun ChatMessageBubble( content: String, @@ -49,6 +63,9 @@ private fun ChatMessageBubble( time: String, modifier: Modifier = Modifier, ) { + var showMenu by remember { mutableStateOf(false) } + val clipboardManager = LocalClipboardManager.current + Row( modifier = modifier .fillMaxWidth() @@ -58,23 +75,45 @@ private fun ChatMessageBubble( Column( horizontalAlignment = if (isUser) Alignment.End else Alignment.Start, ) { - Surface( - shape = MaterialTheme.shapes.large, - color = if (isUser) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.surfaceVariant, - shadowElevation = 2.dp, - modifier = Modifier.widthIn(max = 300.dp), - ) { - Text( - text = content, - modifier = Modifier.padding(12.dp), + Box { + Surface( + shape = MaterialTheme.shapes.large, color = if (isUser) - MaterialTheme.colorScheme.onPrimary + MaterialTheme.colorScheme.primary 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 = time, diff --git a/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt b/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt index 0f370fd..b9052a1 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt @@ -1,5 +1,6 @@ package top.yeij.cyrene.ui.navigation +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row 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.Person import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem 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) { 0 -> ChatScreen() 1 -> IoTScreen() diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt index 653bf36..92fed73 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt @@ -1,5 +1,6 @@ package top.yeij.cyrene.ui.screens.iot +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -31,6 +32,7 @@ fun IoTScreen( PullToRefreshBox( isRefreshing = isLoading, onRefresh = { viewModel.refreshDevices() }, + modifier = Modifier.background(MaterialTheme.colorScheme.background), ) { if (devices.isEmpty() && !isLoading) { Box( diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt index 27a8253..ad60e6e 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt @@ -104,6 +104,7 @@ fun ProfileScreen( Column( modifier = Modifier .fillMaxSize() + .background(MaterialTheme.colorScheme.background) .verticalScroll(rememberScrollState()), ) { // Profile header @@ -158,6 +159,7 @@ fun ProfileScreen( text = profile.nickname.ifEmpty { profile.username }, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, ) // Username if (profile.nickname.isNotBlank() && profile.nickname != profile.username) { @@ -254,6 +256,7 @@ fun ProfileScreen( Text( text = if (isDefaultAssistant) "已设为默认助手" else "未设为默认助手", style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, ) if (!isDefaultAssistant) { Text( @@ -375,6 +378,7 @@ private fun ProfileInfoCard(items: List) { Text( text = item.value, style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt index 9c386b3..a9436ac 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt @@ -1,6 +1,8 @@ package top.yeij.cyrene.ui.screens.settings import android.widget.Toast +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -50,6 +52,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue 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.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -394,8 +398,58 @@ fun SettingsScreen( TextButton( onClick = { showClearDialog = false - viewModel.clearLocalMessages() - Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show() + val activity = context as? FragmentActivity + 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), ) { diff --git a/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt b/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt index 9b631a7..59682c9 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt @@ -84,6 +84,7 @@ fun CyreneTheme( val window = (view.context as Activity).window window.statusBarColor = colorScheme.background.toArgb() window.navigationBarColor = colorScheme.background.toArgb() + window.decorView.setBackgroundColor(colorScheme.background.toArgb()) WindowCompat.getInsetsController(window, view).apply { isAppearanceLightStatusBars = !darkTheme isAppearanceLightNavigationBars = !darkTheme diff --git a/app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt b/app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt index aa7fda1..34d7fd2 100644 --- a/app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt +++ b/app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt @@ -58,6 +58,10 @@ class NotificationHelper(private val context: Context) { notificationManager.notify(message.id.hashCode(), notification) } + fun cancelAll() { + notificationManager.cancelAll() + } + companion object { private const val CHANNEL_ID = "cyrene_messages" } diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 59e6e36..3b01b79 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -3,5 +3,6 @@ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3ce25f7..d5db064 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,6 +56,9 @@ coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-andro # Core core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" } +# Biometric +biometric = { group = "androidx.biometric", name = "biometric", version = "1.1.0" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }