feat(settings): C 起步——App 设置页(主题/旁白/思维链默认展开/关于)
新增 AppSettings(DataStore)与 LocalAppPrefs;CyreneRoot 按偏好决定 Miuix 主题模式并下发偏好;设置页从侧边栏进入,返回回聊天。旁白开关与思维链默认展开已接入渲染层。
This commit is contained in:
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 android.content.Context
|
||||||
import top.yeij.cyrene.core.auth.AuthManager
|
import top.yeij.cyrene.core.auth.AuthManager
|
||||||
import top.yeij.cyrene.core.auth.SessionStore
|
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.core.prefs.SessionPreferences
|
||||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||||
import top.yeij.cyrene.data.api.SessionsApi
|
import top.yeij.cyrene.data.api.SessionsApi
|
||||||
@@ -15,6 +16,7 @@ class AppContainer(context: Context) {
|
|||||||
|
|
||||||
val sessionStore: SessionStore = SessionStore(context.applicationContext)
|
val sessionStore: SessionStore = SessionStore(context.applicationContext)
|
||||||
val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext)
|
val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext)
|
||||||
|
val appSettings: AppSettings = AppSettings(context.applicationContext)
|
||||||
val authManager: AuthManager = AuthManager(sessionStore)
|
val authManager: AuthManager = AuthManager(sessionStore)
|
||||||
|
|
||||||
private var cachedBaseUrl: String? = null
|
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.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import top.yeij.cyrene.core.theme.CyreneTheme
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val container = (application as CyreneApplication).container
|
val container = (application as CyreneApplication).container
|
||||||
setContent {
|
setContent {
|
||||||
CyreneTheme {
|
CyreneRoot(container)
|
||||||
CyreneApp(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.common.MiuixDrawer
|
||||||
import top.yeij.cyrene.ui.sessions.SessionDrawerContent
|
import top.yeij.cyrene.ui.sessions.SessionDrawerContent
|
||||||
import top.yeij.cyrene.ui.sessions.SessionsViewModel
|
import top.yeij.cyrene.ui.sessions.SessionsViewModel
|
||||||
|
import top.yeij.cyrene.ui.settings.SettingsScreen
|
||||||
|
|
||||||
/** 聊天页 + 侧边栏(会话切换)。 */
|
/** 聊天页 + 侧边栏(会话切换)+ 设置页。 */
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatHomeScreen(
|
fun ChatHomeScreen(
|
||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
@@ -25,31 +26,39 @@ fun ChatHomeScreen(
|
|||||||
)
|
)
|
||||||
val state by sessionsViewModel.state.collectAsState()
|
val state by sessionsViewModel.state.collectAsState()
|
||||||
var drawerOpen by remember { mutableStateOf(false) }
|
var drawerOpen by remember { mutableStateOf(false) }
|
||||||
|
var showSettings by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
MiuixDrawer(
|
if (showSettings) {
|
||||||
open = drawerOpen,
|
SettingsScreen(container = container, onBack = { showSettings = false })
|
||||||
onOpenChange = { drawerOpen = it },
|
} else {
|
||||||
content = {
|
MiuixDrawer(
|
||||||
ChatScreen(
|
open = drawerOpen,
|
||||||
container = container,
|
onOpenChange = { drawerOpen = it },
|
||||||
sessionId = state.activeSessionId,
|
content = {
|
||||||
onOpenDrawer = { drawerOpen = true },
|
ChatScreen(
|
||||||
onLogout = onLogout,
|
container = container,
|
||||||
)
|
sessionId = state.activeSessionId,
|
||||||
},
|
onOpenDrawer = { drawerOpen = true },
|
||||||
drawerContent = {
|
onLogout = onLogout,
|
||||||
SessionDrawerContent(
|
)
|
||||||
state = state,
|
},
|
||||||
onSelect = { sessionId ->
|
drawerContent = {
|
||||||
sessionsViewModel.select(sessionId)
|
SessionDrawerContent(
|
||||||
drawerOpen = false
|
state = state,
|
||||||
},
|
onSelect = { sessionId ->
|
||||||
onCreate = sessionsViewModel::createSession,
|
sessionsViewModel.select(sessionId)
|
||||||
onDelete = { sessionsViewModel.deleteSession(it.id) },
|
drawerOpen = false
|
||||||
onOpenIot = { /* P2:IoT 页面 */ },
|
},
|
||||||
onOpenSettings = { /* P2:设置页面 */ },
|
onCreate = sessionsViewModel::createSession,
|
||||||
onLogout = onLogout,
|
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.text.font.FontStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
||||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||||
import top.yeij.cyrene.domain.parse.ContentPart
|
import top.yeij.cyrene.domain.parse.ContentPart
|
||||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||||
@@ -131,6 +132,7 @@ private fun Bubble(
|
|||||||
/** 旁白/动作:灰斜体小字,依附在气泡上/下。 */
|
/** 旁白/动作:灰斜体小字,依附在气泡上/下。 */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AnnotationLine(parts: List<ContentPart>) {
|
private fun AnnotationLine(parts: List<ContentPart>) {
|
||||||
|
if (!LocalAppPrefs.current.showNarration) return
|
||||||
val text = parts.joinToString(" ") { it.text }.trim()
|
val text = parts.joinToString(" ") { it.text }.trim()
|
||||||
if (text.isEmpty()) return
|
if (text.isEmpty()) return
|
||||||
SelectionContainer(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) {
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
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.basic.Text
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
|
||||||
@@ -35,7 +36,8 @@ fun ThinkingCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (text.isBlank() && !active) return
|
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) {
|
LaunchedEffect(active) {
|
||||||
if (!active) expanded = false
|
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 -> "深色"
|
||||||
|
}
|
||||||
Reference in new issue
Block a user