From e72621db94b5c61b2998601c4b5cf0e21a64dc56 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 8 Sep 2026 21:29:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(sessions):=20M5=20=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E6=8A=BD=E5=B1=89=20+=20=E4=BC=9A=E8=AF=9D=E5=88=97?= =?UTF-8?q?=E8=A1=A8=EF=BC=88=E5=88=87=E6=8D=A2/=E6=96=B0=E5=BB=BA/?= =?UTF-8?q?=E5=88=A0=E9=99=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自研 MiuixDrawer(draggable+Animatable,关闭态不拦截触摸);DataStore 持久化会话快照/序号映射/当前会话;SessionsViewModel 先快照后刷新;次级会话删除二次确认,主会话禁删。 --- app/build.gradle.kts | 1 + .../top/yeij/cyrene/app/AppContainer.kt | 2 + .../kotlin/top/yeij/cyrene/app/CyreneApp.kt | 4 +- .../cyrene/core/prefs/SessionPreferences.kt | 137 +++++++++++ .../top/yeij/cyrene/ui/chat/ChatHomeScreen.kt | 55 +++++ .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 4 +- .../top/yeij/cyrene/ui/common/MiuixDrawer.kt | 221 ++++++++++++++++++ .../ui/sessions/SessionDrawerContent.kt | 194 +++++++++++++++ .../cyrene/ui/sessions/SessionsViewModel.kt | 130 +++++++++++ gradle/libs.versions.toml | 2 + 10 files changed, 747 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/prefs/SessionPreferences.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/common/MiuixDrawer.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b4c775..8312a16 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.security.crypto) + implementation(libs.androidx.datastore.preferences) debugImplementation(libs.androidx.compose.ui.tooling) 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 43aba42..2fc8de3 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.SessionPreferences import top.yeij.cyrene.data.api.ApiClientFactory import top.yeij.cyrene.data.api.SessionsApi import top.yeij.cyrene.data.repository.MessageRepository @@ -13,6 +14,7 @@ import top.yeij.cyrene.data.ws.ChatSocket class AppContainer(context: Context) { val sessionStore: SessionStore = SessionStore(context.applicationContext) + val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext) val authManager: AuthManager = AuthManager(sessionStore) private var cachedBaseUrl: String? = null diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt index 4926996..8997a26 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import top.yeij.cyrene.core.auth.SessionState -import top.yeij.cyrene.ui.chat.ChatScreen +import top.yeij.cyrene.ui.chat.ChatHomeScreen import top.yeij.cyrene.ui.common.LoadingScreen import top.yeij.cyrene.ui.login.LoginScreen @@ -20,7 +20,7 @@ fun CyreneApp(container: AppContainer) { when (state) { SessionState.Unknown -> LoadingScreen() SessionState.LoggedOut -> LoginScreen(container) - is SessionState.LoggedIn -> ChatScreen( + is SessionState.LoggedIn -> ChatHomeScreen( container = container, onLogout = { container.authManager.logout() }, ) diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/prefs/SessionPreferences.kt b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/SessionPreferences.kt new file mode 100644 index 0000000..6530f59 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/SessionPreferences.kt @@ -0,0 +1,137 @@ +package top.yeij.cyrene.core.prefs + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import top.yeij.cyrene.data.api.dto.SessionSummaryDto + +private val Context.sessionDataStore: DataStore by preferencesDataStore("cyrene_sessions") + +/** + * 会话列表的本地回退快照与显示态(§3.1)。 + * + * @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染 + * @param snapshotAt 快照时间(用于「同步于 N 分钟前」) + * @param fingerprint `baseUrl + username`;换服务器/账号后快照作废 + * @param order `sessionId -> 显示序号`(后端 title 恒空,序号本地生成) + * @param activeSessionId 上次停留的会话 + */ +data class SessionPrefs( + val snapshot: List = emptyList(), + val snapshotAt: Long = 0L, + val fingerprint: String = "", + val order: Map = emptyMap(), + val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID, +) + +class SessionPreferences(context: Context) { + + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + private val store: DataStore = context.applicationContext.sessionDataStore + + val data: Flow = store.data.map { prefs -> + SessionPrefs( + snapshot = prefs[KEY_SNAPSHOT].decodeSessions(), + snapshotAt = prefs[KEY_SNAPSHOT_AT] ?: 0L, + fingerprint = prefs[KEY_FINGERPRINT].orEmpty(), + order = prefs[KEY_ORDER].decodeOrder(), + activeSessionId = prefs[KEY_ACTIVE] ?: SessionSummaryDto.MAIN_SESSION_ID, + ) + } + + /** + * 拉取成功:整体替换快照(不做 merge,避免已删除的会话变成幽灵), + * 同时为新出现的次级会话分配显示序号、清理已消失会话的序号。 + */ + suspend fun saveSnapshot(sessions: List, fingerprint: String) { + store.edit { prefs -> + val sameAccount = prefs[KEY_FINGERPRINT].orEmpty() == fingerprint + val order = if (sameAccount) prefs[KEY_ORDER].decodeOrder().toMutableMap() else mutableMapOf() + var next = order.values.maxOrNull() ?: 0 + sessions.filterNot { it.isMain }.forEach { dto -> + if (order[dto.id] == null) order[dto.id] = ++next + } + order.keys.retainAll(sessions.map { it.id }.toSet()) + + prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, sessions) + prefs[KEY_SNAPSHOT_AT] = System.currentTimeMillis() + prefs[KEY_FINGERPRINT] = fingerprint + prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order) + } + } + + suspend fun setActive(sessionId: String) { + store.edit { prefs -> prefs[KEY_ACTIVE] = sessionId } + } + + /** 分配显示序号(当前最大值 + 1);已分配过则原样返回。 */ + suspend fun ensureOrder(sessionId: String): Int { + var assigned = 0 + store.edit { prefs -> + val order = prefs[KEY_ORDER].decodeOrder().toMutableMap() + val existing = order[sessionId] + if (existing != null) { + assigned = existing + } else { + assigned = (order.values.maxOrNull() ?: 0) + 1 + order[sessionId] = assigned + prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order) + } + } + return assigned + } + + /** 删除会话:清序号、必要时把 active 退回主会话、同步从快照里剔除。 */ + suspend fun removeSession(sessionId: String) { + store.edit { prefs -> + val order = prefs[KEY_ORDER].decodeOrder().toMutableMap() + order.remove(sessionId) + prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order) + + if (prefs[KEY_ACTIVE] == sessionId) { + prefs[KEY_ACTIVE] = SessionSummaryDto.MAIN_SESSION_ID + } + + val remaining = prefs[KEY_SNAPSHOT].decodeSessions().filterNot { it.id == sessionId } + prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, remaining) + } + } + + /** 登出 / 切换服务器时清空。 */ + suspend fun clear() { + store.edit { it.clear() } + } + + private fun String?.decodeSessions(): List = this + ?.let { runCatching { json.decodeFromString(SessionsSerializer, it) }.getOrNull() } + .orEmpty() + + private fun String?.decodeOrder(): Map = this + ?.let { runCatching { json.decodeFromString(OrderSerializer, it) }.getOrNull() } + .orEmpty() + + private companion object { + val KEY_SNAPSHOT = stringPreferencesKey("sessions_snapshot") + val KEY_SNAPSHOT_AT = longPreferencesKey("sessions_snapshot_at") + val KEY_FINGERPRINT = stringPreferencesKey("sessions_fingerprint") + val KEY_ORDER = stringPreferencesKey("sessions_order") + val KEY_ACTIVE = stringPreferencesKey("active_session_id") + + val SessionsSerializer = ListSerializer(SessionSummaryDto.serializer()) + val OrderSerializer = MapSerializer(String.serializer(), Int.serializer()) + } +} 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 new file mode 100644 index 0000000..5f69960 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt @@ -0,0 +1,55 @@ +package top.yeij.cyrene.ui.chat + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +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 + +/** 聊天页 + 侧边栏(会话切换)。 */ +@Composable +fun ChatHomeScreen( + container: AppContainer, + onLogout: () -> Unit = {}, +) { + val sessionsViewModel: SessionsViewModel = viewModel( + factory = viewModelFactory { initializer { SessionsViewModel(container) } }, + ) + val state by sessionsViewModel.state.collectAsState() + var drawerOpen 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, + ) + }, + ) +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index 47de3f3..cd7f516 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -49,11 +49,13 @@ private const val BOTTOM_TOLERANCE = 64 @Composable fun ChatScreen( container: AppContainer, + sessionId: String, onOpenDrawer: () -> Unit = {}, onLogout: () -> Unit = {}, ) { val viewModel: ChatViewModel = viewModel( - factory = viewModelFactory { initializer { ChatViewModel(container) } }, + key = sessionId, + factory = viewModelFactory { initializer { ChatViewModel(container, sessionId) } }, ) val state by viewModel.state.collectAsState() val listState = rememberLazyListState() diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/common/MiuixDrawer.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/common/MiuixDrawer.kt new file mode 100644 index 0000000..708f184 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/common/MiuixDrawer.kt @@ -0,0 +1,221 @@ +package top.yeij.cyrene.ui.common + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlin.math.ceil +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** 完全关闭时抽屉面板的位移比例(面板整体在屏幕左侧之外)。 */ +private const val ClosedFraction = 0f + +/** 完全打开时抽屉面板的位移比例。 */ +private const val OpenFraction = 1f + +/** 位移大于该值才认为抽屉「露出」,用于决定是否挂载 scrim / 拖拽手势。 */ +private const val VisibleFractionEpsilon = 0.001f + +/** 松手时位移超过面板宽度的一半即吸附到打开。 */ +private const val PositionalThreshold = 0.5f + +/** 打开 / 关闭吸附动画时长。 */ +private const val SettleAnimationMillis = 240 + +/** 触发「速度吸附」的阈值,与 AnchoredDraggableDefaults 的默认值保持一致。 */ +private val FlingVelocity = 125.dp + +/** + * 自研左滑抽屉(Miuix 组件库没有 Drawer,故自行实现)。 + * + * 设计约定: + * - 只能由汉堡按钮等外部入口打开,**不做左边缘滑出**(会和系统返回手势冲突), + * 因此关闭状态下不挂载任何拖拽 / 点击手势; + * - 抽屉露出时,在面板或遮罩上横向拖拽即可关闭; + * - 返回键优先关闭抽屉; + * - 关闭状态下遮罩与面板都不参与命中测试,避免挡住聊天页等底层内容。 + * + * 拖拽 API 选用 [Modifier.draggable] 而不是 `anchoredDraggable`: + * `AnchoredDraggableState` 的构造器 / `settle` 重载在 Compose 1.11.2 中已被标记 + * `@Deprecated`(阈值与动画需改走 `AnchoredDraggableDefaults.flingBehavior`), + * 版本间签名变动较大;而 [Modifier.draggable] 长期稳定且无实验性注解, + * 配合 [Animatable] 手动实现「位移过半 / 速度超阈值」的吸附逻辑,行为完全可控。 + * + * 纵向滚动与横向拖拽的冲突:这里**不会**给 [drawerContent] 外层套 `verticalScroll`, + * 否则调用方在内部放 `LazyColumn` 时会拿到无限高约束而崩溃。调用方若需要滚动, + * 请在 [drawerContent] 内部自行使用 `LazyColumn` 或给自定义 Column 加 `verticalScroll`。 + * 横向 `draggable` 与纵向滚动靠触摸 slop 的轴向判定自然区分:谁先超过 slop 谁接管手势, + * 因此纵向滑动列表、横向拖拽关闭可以共存。 + * + * @param open 抽屉是否打开(单向数据流,真实位移由内部 [Animatable] 维护)。 + * @param onOpenChange 请求切换开合状态;返回键、点击遮罩、拖拽吸附都会回调。 + * @param drawerWidth 抽屉面板宽度。 + * @param content 主内容,铺满整个容器,位于遮罩与抽屉之下。 + * @param drawerContent 抽屉面板内容,带 [ColumnScope],可放任意 Miuix 组件。 + */ +@Composable +fun MiuixDrawer( + open: Boolean, + onOpenChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + drawerWidth: Dp = 300.dp, + content: @Composable () -> Unit, + drawerContent: @Composable ColumnScope.() -> Unit, +) { + val density = LocalDensity.current + val drawerWidthPx = with(density) { drawerWidth.toPx() }.coerceAtLeast(1f) + val flingVelocityPx = with(density) { FlingVelocity.toPx() } + + // open 只决定动画目标值,实际位移用 Animatable 维护: + // 拖拽中的中间态、吸附动画、外部状态切换三者互不覆盖。 + val offsetFraction = remember { Animatable(if (open) OpenFraction else ClosedFraction) } + val currentOnOpenChange by rememberUpdatedState(onOpenChange) + val scope = rememberCoroutineScope() + + // derivedStateOf 只在布尔值翻转时触发重组,动画过程中不会逐帧重组。 + val drawerVisible by remember { + derivedStateOf { offsetFraction.value > VisibleFractionEpsilon } + } + // 拖拽手势的挂载条件必须包含 open:拖到完全关闭的瞬间 fraction 会先归零, + // 若这时就摘掉手势节点,onDragStopped 不会触发,外部 open 状态会失去同步。 + val interactionEnabled = open || drawerVisible + + LaunchedEffect(open) { + offsetFraction.animateTo( + targetValue = if (open) OpenFraction else ClosedFraction, + animationSpec = tween( + durationMillis = SettleAnimationMillis, + easing = FastOutSlowInEasing, + ), + ) + } + + BackHandler(enabled = open) { currentOnOpenChange(false) } + + // snapTo 是挂起函数,而 DraggableState 的回调不是挂起上下文, + // 因此放到组合作用域里启动;Animatable 的 MutatorMutex 会取消正在进行的开合动画。 + val dragState = rememberDraggableState { delta -> + scope.launch { + offsetFraction.snapTo( + (offsetFraction.value + delta / drawerWidthPx) + .coerceIn(ClosedFraction, OpenFraction), + ) + } + } + + Box(modifier = modifier.fillMaxSize()) { + content() + + // 始终组合遮罩与面板,以保留 drawerContent(如 LazyColumn)的内部状态; + // 但只有在抽屉露出(或正在被拖拽)时才挂载手势,关闭状态下整块对触摸完全透明。 + Box( + modifier = Modifier + .matchParentSize() + .then( + if (interactionEnabled) { + Modifier.draggable( + state = dragState, + orientation = Orientation.Horizontal, + onDragStopped = { velocity -> + val target = resolveTarget( + fraction = offsetFraction.value, + velocity = velocity, + flingVelocityPx = flingVelocityPx, + ) + currentOnOpenChange(target == OpenFraction) + offsetFraction.animateTo( + targetValue = target, + animationSpec = tween( + durationMillis = SettleAnimationMillis, + easing = FastOutSlowInEasing, + ), + ) + }, + ) + } else { + Modifier + }, + ), + ) { + // 遮罩:关闭时 alpha 为 0 且不挂 pointerInput,因此绝不吃掉触摸事件。 + Box( + modifier = Modifier + .matchParentSize() + // 在绘制阶段读 Animatable,避免逐帧重组。 + // windowDimming 是 Miuix 主题自带的半透明黑遮罩色(随浅色/深色主题变化), + // 比硬编码 Color.Black.copy(alpha = 0.32f) 更贴合 HyperOS 视觉; + // 若觉得偏重,可换成后者。 + .graphicsLayer { alpha = offsetFraction.value } + .background(MiuixTheme.colorScheme.windowDimming) + .then( + if (drawerVisible) { + Modifier.pointerInput(Unit) { + detectTapGestures { currentOnOpenChange(false) } + } + } else { + Modifier + }, + ), + ) + + Column( + modifier = Modifier + .width(drawerWidth) + .fillMaxHeight() + // 关闭时 x = -drawerWidthPx,面板整体移出屏幕左侧: + // 既看不见,也不会落在任何屏幕内坐标的命中范围内。 + // 用 ceil 取整,保证即使宽度是小数也至少偏移满一个面板宽度。 + .offset { + IntOffset( + x = -ceil(drawerWidthPx * (OpenFraction - offsetFraction.value)).toInt(), + y = 0, + ) + } + .background(MiuixTheme.colorScheme.surface), + ) { + drawerContent() + } + } + } +} + +/** + * 松手吸附目标:速度超过阈值按方向吸附,否则按位移是否过半判断。 + */ +private fun resolveTarget( + fraction: Float, + velocity: Float, + flingVelocityPx: Float, +): Float = when { + velocity > flingVelocityPx -> OpenFraction + velocity < -flingVelocityPx -> ClosedFraction + fraction >= PositionalThreshold -> OpenFraction + else -> ClosedFraction +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt new file mode 100644 index 0000000..235f6d6 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt @@ -0,0 +1,194 @@ +package top.yeij.cyrene.ui.sessions + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +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.unit.dp +import androidx.compose.ui.window.Dialog +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextButton +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** 侧边栏内容:会话列表 + IoT/设置占位 + 登出。 */ +@Composable +fun ColumnScope.SessionDrawerContent( + state: SessionsUiState, + onSelect: (String) -> Unit, + onCreate: () -> Unit, + onDelete: (SessionItem) -> Unit, + onOpenIot: () -> Unit, + onOpenSettings: () -> Unit, + onLogout: () -> Unit, +) { + var pendingDelete by remember { mutableStateOf(null) } + + Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp)) { + Text("Cyrene", style = MiuixTheme.textStyles.title2) + Text( + text = "与昔涟", + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("会话", style = MiuixTheme.textStyles.subtitle, modifier = Modifier.weight(1f)) + TextButton("新建", onClick = onCreate) + } + + LazyColumn(modifier = Modifier.weight(1f)) { + items(items = state.sessions, key = { it.id }) { session -> + SessionRow( + session = session, + active = session.id == state.activeSessionId, + onClick = { onSelect(session.id) }, + onDelete = { pendingDelete = session }, + ) + } + } + + if (state.fromCache && state.syncedAt > 0) { + Text( + text = "网络异常,显示 ${relativeTime(state.syncedAt)} 的结果", + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), + ) + } else if (state.loading) { + Text( + text = "同步中…", + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), + ) + } + + Spacer(Modifier.height(8.dp)) + DrawerAction("IoT(规划中)", onOpenIot) + DrawerAction("App 设置(规划中)", onOpenSettings) + DrawerAction("登出", onLogout) + Spacer(Modifier.height(16.dp)) + + pendingDelete?.let { target -> + ConfirmDialog( + title = "删除「${target.title}」?", + message = "该会话的全部消息会一并删除,且不可恢复。", + confirmText = "删除", + onConfirm = { + onDelete(target) + pendingDelete = null + }, + onDismiss = { pendingDelete = null }, + ) + } +} + +@Composable +private fun SessionRow( + session: SessionItem, + active: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = session.title, + style = MiuixTheme.textStyles.main, + color = if (active) MiuixTheme.colorScheme.primary else MiuixTheme.colorScheme.onBackground, + modifier = Modifier.weight(1f), + ) + if (!session.isMain) { + Text( + text = "删除", + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.error, + modifier = Modifier + .clickable(onClick = onDelete) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } +} + +@Composable +private fun DrawerAction(label: String, onClick: () -> Unit) { + Text( + text = label, + style = MiuixTheme.textStyles.main, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + ) +} + +@Composable +private fun ConfirmDialog( + title: String, + message: String, + confirmText: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp)) + .padding(20.dp), + ) { + Text(title, style = MiuixTheme.textStyles.title3) + Spacer(Modifier.height(8.dp)) + Text( + text = message, + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + Spacer(Modifier.height(20.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton("取消", onClick = onDismiss) + Spacer(Modifier.width(8.dp)) + TextButton(confirmText, onClick = onConfirm) + } + } + } +} + +private fun relativeTime(timestamp: Long): String { + val minutes = (System.currentTimeMillis() - timestamp) / 60_000 + return when { + minutes < 1 -> "刚刚" + minutes < 60 -> "$minutes 分钟前" + minutes < 24 * 60 -> "${minutes / 60} 小时前" + else -> "${minutes / (24 * 60)} 天前" + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt new file mode 100644 index 0000000..5fac9bd --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt @@ -0,0 +1,130 @@ +package top.yeij.cyrene.ui.sessions + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import top.yeij.cyrene.app.AppContainer +import top.yeij.cyrene.data.api.CyreneException +import top.yeij.cyrene.data.api.dto.SessionSummaryDto + +data class SessionItem( + val id: String, + val title: String, + val isMain: Boolean, +) + +data class SessionsUiState( + val sessions: List = emptyList(), + val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID, + val loading: Boolean = false, + /** 当前展示的是本地快照(服务端没拉成功)。 */ + val fromCache: Boolean = false, + val syncedAt: Long = 0L, + val error: String? = null, +) + +/** + * 会话列表(§3.1): + * 启动先用本地快照渲染,再拉服务端;成功则整体替换并回写快照,失败则保留快照并提示。 + */ +class SessionsViewModel(private val container: AppContainer) : ViewModel() { + + private val _state = MutableStateFlow(SessionsUiState()) + val state: StateFlow = _state.asStateFlow() + + private val prefs = container.sessionPreferences + + init { + viewModelScope.launch { + val cached = prefs.data.first() + _state.update { + it.copy( + sessions = cached.snapshot.map { dto -> dto.toItem(cached.order) }, + activeSessionId = cached.activeSessionId, + fromCache = cached.snapshot.isNotEmpty(), + syncedAt = cached.snapshotAt, + ) + } + refresh() + } + } + + fun refresh() { + viewModelScope.launch { + _state.update { it.copy(loading = true) } + container.sessionRepository.listSessions() + .onSuccess { list -> + prefs.saveSnapshot(list, fingerprint()) + val cached = prefs.data.first() + _state.update { + it.copy( + sessions = list.map { dto -> dto.toItem(cached.order) }, + loading = false, + fromCache = false, + syncedAt = cached.snapshotAt, + error = null, + ) + } + } + .onFailure { e -> + _state.update { + it.copy( + loading = false, + fromCache = it.sessions.isNotEmpty(), + error = e.userMessage(), + ) + } + } + } + } + + fun select(sessionId: String) { + _state.update { it.copy(activeSessionId = sessionId) } + viewModelScope.launch { prefs.setActive(sessionId) } + } + + fun createSession() { + viewModelScope.launch { + container.sessionRepository.createSession() + .onSuccess { sessionId -> + refresh() + select(sessionId) + } + .onFailure { e -> _state.update { it.copy(error = e.userMessage()) } } + } + } + + fun deleteSession(sessionId: String) { + if (sessionId == SessionSummaryDto.MAIN_SESSION_ID) return + viewModelScope.launch { + container.sessionRepository.deleteSession(sessionId) + .onSuccess { + prefs.removeSession(sessionId) + if (_state.value.activeSessionId == sessionId) { + select(SessionSummaryDto.MAIN_SESSION_ID) + } + refresh() + } + .onFailure { e -> _state.update { it.copy(error = e.userMessage()) } } + } + } + + fun dismissError() = _state.update { it.copy(error = null) } + + private fun fingerprint(): String = + "${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}" +} + +private fun SessionSummaryDto.toItem(order: Map): SessionItem = SessionItem( + id = id, + title = if (isMain) "与昔涟" else "会话 ${order[id] ?: "?"}", + isMain = isMain, +) + +private fun Throwable.userMessage(): String = + (this as? CyreneException)?.error?.userMessage ?: "操作失败,请稍后重试" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ea4143..ba6679d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ kotlinxSerialization = "1.11.0" coroutines = "1.11.0" securityCrypto = "1.1.0" junit = "4.13.2" +datastore = "1.2.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -38,6 +39,7 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } junit = { group = "junit", name = "junit", version.ref = "junit" } [plugins]