feat(settings): C 起步——App 设置页(主题/旁白/思维链默认展开/关于)

新增 AppSettings(DataStore)与 LocalAppPrefs;CyreneRoot 按偏好决定 Miuix 主题模式并下发偏好;设置页从侧边栏进入,返回回聊天。旁白开关与思维链默认展开已接入渲染层。
This commit is contained in:
AskaEth committed 2026-09-08 22:41:37 +08:00
1 parent 91117986e4
commit 61214a18fa
8 files changed
+279 -33

No files matched your search

@@ -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
@@ -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
}
@@ -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)
}
}
}
@@ -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<Preferences> by preferencesDataStore("cyrene_app")
enum class ThemeMode { System, Light, Dark }
/**
* App 级偏好(非机密)。
*
* @param themeMode 主题深浅
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
* @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<Preferences> = context.applicationContext.appDataStore
val data: Flow<AppPrefs> = 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")
}
}
@@ -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,
)
},
)
}
}
@@ -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<ContentPart>) {
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)) {
@@ -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
}
@@ -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 = "以灰斜体小字显示 <action> / <narration>",
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 -> "深色"
}