feat(sessions): M5 侧边栏抽屉 + 会话列表(切换/新建/删除)
自研 MiuixDrawer(draggable+Animatable,关闭态不拦截触摸);DataStore 持久化会话快照/序号映射/当前会话;SessionsViewModel 先快照后刷新;次级会话删除二次确认,主会话禁删。
This commit is contained in:
1 parent
e04995a84d
commit
e72621db94
10 files changed
+747
-3
No files matched your search
@@ -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 ?: "操作失败,请稍后重试"
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user