diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt index 2fc8de3..334d9cf 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -3,6 +3,7 @@ package top.yeij.cyrene.app import android.content.Context import top.yeij.cyrene.core.auth.AuthManager import top.yeij.cyrene.core.auth.SessionStore +import top.yeij.cyrene.core.prefs.AppSettings import top.yeij.cyrene.core.prefs.SessionPreferences import top.yeij.cyrene.data.api.ApiClientFactory import top.yeij.cyrene.data.api.SessionsApi @@ -15,6 +16,7 @@ class AppContainer(context: Context) { val sessionStore: SessionStore = SessionStore(context.applicationContext) val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext) + val appSettings: AppSettings = AppSettings(context.applicationContext) val authManager: AuthManager = AuthManager(sessionStore) private var cachedBaseUrl: String? = null diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/CyreneRoot.kt b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneRoot.kt new file mode 100644 index 0000000..52ea4cb --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneRoot.kt @@ -0,0 +1,31 @@ +package top.yeij.cyrene.app + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import top.yeij.cyrene.core.prefs.AppPrefs +import top.yeij.cyrene.core.prefs.LocalAppPrefs +import top.yeij.cyrene.core.prefs.ThemeMode +import top.yeij.cyrene.core.theme.CyreneTheme +import top.yukonga.miuix.kmp.theme.ColorSchemeMode + +/** + * 应用根:读 App 偏好 → 决定 Miuix 主题模式 → 通过 [LocalAppPrefs] 下发给渲染层。 + */ +@Composable +fun CyreneRoot(container: AppContainer) { + val prefs by container.appSettings.data.collectAsState(initial = AppPrefs()) + + CompositionLocalProvider(LocalAppPrefs provides prefs) { + CyreneTheme(mode = prefs.themeMode.toColorSchemeMode()) { + CyreneApp(container) + } + } +} + +private fun ThemeMode.toColorSchemeMode(): ColorSchemeMode = when (this) { + ThemeMode.System -> ColorSchemeMode.System + ThemeMode.Light -> ColorSchemeMode.Light + ThemeMode.Dark -> ColorSchemeMode.Dark +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt b/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt index 85601a0..8ff9f56 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt @@ -4,17 +4,13 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import top.yeij.cyrene.core.theme.CyreneTheme - class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) val container = (application as CyreneApplication).container setContent { - CyreneTheme { - CyreneApp(container) - } + CyreneRoot(container) } } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt new file mode 100644 index 0000000..b5caae9 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt @@ -0,0 +1,69 @@ +package top.yeij.cyrene.core.prefs + +import android.content.Context +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.appDataStore: DataStore by preferencesDataStore("cyrene_app") + +enum class ThemeMode { System, Light, Dark } + +/** + * App 级偏好(非机密)。 + * + * @param themeMode 主题深浅 + * @param showNarration 是否显示 `` / `` 旁白 + * @param thinkingExpanded 思维链卡片默认是否展开 + */ +data class AppPrefs( + val themeMode: ThemeMode = ThemeMode.System, + val showNarration: Boolean = true, + val thinkingExpanded: Boolean = true, +) + +/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */ +val LocalAppPrefs = staticCompositionLocalOf { AppPrefs() } + +class AppSettings(context: Context) { + + private val store: DataStore = context.applicationContext.appDataStore + + val data: Flow = store.data.map { prefs -> + AppPrefs( + themeMode = prefs[KEY_THEME_MODE].toThemeMode(), + showNarration = prefs[KEY_SHOW_NARRATION] ?: true, + thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true, + ) + } + + suspend fun setThemeMode(mode: ThemeMode) { + store.edit { it[KEY_THEME_MODE] = mode.name } + } + + suspend fun setShowNarration(enabled: Boolean) { + store.edit { it[KEY_SHOW_NARRATION] = enabled } + } + + suspend fun setThinkingExpanded(enabled: Boolean) { + store.edit { it[KEY_THINKING_EXPANDED] = enabled } + } + + private fun String?.toThemeMode(): ThemeMode = when (this) { + ThemeMode.Light.name -> ThemeMode.Light + ThemeMode.Dark.name -> ThemeMode.Dark + else -> ThemeMode.System + } + + private companion object { + val KEY_THEME_MODE = stringPreferencesKey("theme_mode") + val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration") + val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded") + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt index 5f69960..c58d78b 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt @@ -13,8 +13,9 @@ import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.ui.common.MiuixDrawer import top.yeij.cyrene.ui.sessions.SessionDrawerContent import top.yeij.cyrene.ui.sessions.SessionsViewModel +import top.yeij.cyrene.ui.settings.SettingsScreen -/** 聊天页 + 侧边栏(会话切换)。 */ +/** 聊天页 + 侧边栏(会话切换)+ 设置页。 */ @Composable fun ChatHomeScreen( container: AppContainer, @@ -25,31 +26,39 @@ fun ChatHomeScreen( ) val state by sessionsViewModel.state.collectAsState() var drawerOpen by remember { mutableStateOf(false) } + var showSettings by remember { mutableStateOf(false) } - MiuixDrawer( - open = drawerOpen, - onOpenChange = { drawerOpen = it }, - content = { - ChatScreen( - container = container, - sessionId = state.activeSessionId, - onOpenDrawer = { drawerOpen = true }, - onLogout = onLogout, - ) - }, - drawerContent = { - SessionDrawerContent( - state = state, - onSelect = { sessionId -> - sessionsViewModel.select(sessionId) - drawerOpen = false - }, - onCreate = sessionsViewModel::createSession, - onDelete = { sessionsViewModel.deleteSession(it.id) }, - onOpenIot = { /* P2:IoT 页面 */ }, - onOpenSettings = { /* P2:设置页面 */ }, - onLogout = onLogout, - ) - }, - ) + if (showSettings) { + SettingsScreen(container = container, onBack = { showSettings = false }) + } else { + MiuixDrawer( + open = drawerOpen, + onOpenChange = { drawerOpen = it }, + content = { + ChatScreen( + container = container, + sessionId = state.activeSessionId, + onOpenDrawer = { drawerOpen = true }, + onLogout = onLogout, + ) + }, + drawerContent = { + SessionDrawerContent( + state = state, + onSelect = { sessionId -> + sessionsViewModel.select(sessionId) + drawerOpen = false + }, + onCreate = sessionsViewModel::createSession, + onDelete = { sessionsViewModel.deleteSession(it.id) }, + onOpenIot = { /* P2:IoT 页面 */ }, + onOpenSettings = { + drawerOpen = false + showSettings = true + }, + onLogout = onLogout, + ) + }, + ) + } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt index c84976a..54b5355 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import top.yeij.cyrene.core.prefs.LocalAppPrefs import top.yeij.cyrene.data.api.dto.HistoryMessageDto import top.yeij.cyrene.domain.parse.ContentPart import top.yeij.cyrene.domain.parse.ParsedBubble @@ -131,6 +132,7 @@ private fun Bubble( /** 旁白/动作:灰斜体小字,依附在气泡上/下。 */ @Composable private fun AnnotationLine(parts: List) { + if (!LocalAppPrefs.current.showNarration) return val text = parts.joinToString(" ") { it.text }.trim() if (text.isEmpty()) return SelectionContainer(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) { diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt index dbe0e48..ec3e8f1 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import top.yeij.cyrene.core.prefs.LocalAppPrefs import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.theme.MiuixTheme @@ -35,7 +36,8 @@ fun ThinkingCard( modifier: Modifier = Modifier, ) { if (text.isBlank() && !active) return - var expanded by remember { mutableStateOf(active) } + val defaultExpanded = LocalAppPrefs.current.thinkingExpanded + var expanded by remember { mutableStateOf(active && defaultExpanded) } LaunchedEffect(active) { if (!active) expanded = false } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..f828fab --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt @@ -0,0 +1,135 @@ +package top.yeij.cyrene.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import top.yeij.cyrene.BuildConfig +import top.yeij.cyrene.app.AppContainer +import top.yeij.cyrene.core.prefs.AppPrefs +import top.yeij.cyrene.core.prefs.ThemeMode +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.SmallTopAppBar +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextButton +import top.yukonga.miuix.kmp.preference.SwitchPreference +import top.yukonga.miuix.kmp.theme.MiuixTheme + +@Composable +fun SettingsScreen( + container: AppContainer, + onBack: () -> Unit, +) { + val prefs by container.appSettings.data.collectAsState(initial = AppPrefs()) + val scope = rememberCoroutineScope() + val settings = container.appSettings + + Scaffold( + topBar = { + SmallTopAppBar( + title = "App 设置", + navigationIcon = { TextButton("返回", onClick = onBack) }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + SectionTitle("外观") + ThemeMode.entries.forEach { mode -> + ThemeOption( + label = mode.label(), + selected = prefs.themeMode == mode, + onClick = { scope.launch { settings.setThemeMode(mode) } }, + ) + } + + SectionTitle("聊天") + SwitchPreference( + title = "显示旁白", + summary = "以灰斜体小字显示 / ", + checked = prefs.showNarration, + onCheckedChange = { scope.launch { settings.setShowNarration(it) } }, + ) + SwitchPreference( + title = "思维链默认展开", + summary = "流式期间展开思维链,结束后自动折叠", + checked = prefs.thinkingExpanded, + onCheckedChange = { scope.launch { settings.setThinkingExpanded(it) } }, + ) + + SectionTitle("关于") + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("版本", style = MiuixTheme.textStyles.main, modifier = Modifier.weight(1f)) + Text( + text = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } + Spacer(Modifier.height(24.dp)) + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = MiuixTheme.textStyles.subtitle, + color = MiuixTheme.colorScheme.primary, + modifier = Modifier.padding(start = 20.dp, top = 20.dp, bottom = 8.dp), + ) +} + +@Composable +private fun ThemeOption( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MiuixTheme.textStyles.main, modifier = Modifier.weight(1f)) + if (selected) { + Text( + text = "✓", + style = MiuixTheme.textStyles.main, + color = MiuixTheme.colorScheme.primary, + ) + } + } +} + +private fun ThemeMode.label(): String = when (this) { + ThemeMode.System -> "跟随系统" + ThemeMode.Light -> "浅色" + ThemeMode.Dark -> "深色" +}