feat(sessions): M5 侧边栏抽屉 + 会话列表(切换/新建/删除)

自研 MiuixDrawer(draggable+Animatable,关闭态不拦截触摸);DataStore 持久化会话快照/序号映射/当前会话;SessionsViewModel 先快照后刷新;次级会话删除二次确认,主会话禁删。
This commit is contained in:
AskaEth committed 2026-09-08 21:29:24 +08:00
1 parent e04995a84d
commit e72621db94
10 files changed
+747 -3

No files matched your search

+1
View File
@@ -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)
@@ -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
@@ -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() },
)
@@ -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<Preferences> 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<SessionSummaryDto> = emptyList(),
val snapshotAt: Long = 0L,
val fingerprint: String = "",
val order: Map<String, Int> = emptyMap(),
val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
)
class SessionPreferences(context: Context) {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
private val store: DataStore<Preferences> = context.applicationContext.sessionDataStore
val data: Flow<SessionPrefs> = 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<SessionSummaryDto>, 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<SessionSummaryDto> = this
?.let { runCatching { json.decodeFromString(SessionsSerializer, it) }.getOrNull() }
.orEmpty()
private fun String?.decodeOrder(): Map<String, Int> = 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())
}
}
@@ -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,
)
},
)
}
@@ -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()
@@ -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
}
@@ -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<SessionItem?>(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)} 天前"
}
}
@@ -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<SessionItem> = 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<SessionsUiState> = _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<String, Int>): SessionItem = SessionItem(
id = id,
title = if (isMain) "与昔涟" else "会话 ${order[id] ?: "?"}",
isMain = isMain,
)
private fun Throwable.userMessage(): String =
(this as? CyreneException)?.error?.userMessage ?: "操作失败,请稍后重试"
+2
View File
@@ -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]