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.serialization.json)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
|
||||||
|
|||||||
@@ -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.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
|
||||||
import top.yeij.cyrene.data.repository.MessageRepository
|
import top.yeij.cyrene.data.repository.MessageRepository
|
||||||
@@ -13,6 +14,7 @@ import top.yeij.cyrene.data.ws.ChatSocket
|
|||||||
class AppContainer(context: Context) {
|
class AppContainer(context: Context) {
|
||||||
|
|
||||||
val sessionStore: SessionStore = SessionStore(context.applicationContext)
|
val sessionStore: SessionStore = SessionStore(context.applicationContext)
|
||||||
|
val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext)
|
||||||
val authManager: AuthManager = AuthManager(sessionStore)
|
val authManager: AuthManager = AuthManager(sessionStore)
|
||||||
|
|
||||||
private var cachedBaseUrl: String? = null
|
private var cachedBaseUrl: String? = null
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import top.yeij.cyrene.core.auth.SessionState
|
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.common.LoadingScreen
|
||||||
import top.yeij.cyrene.ui.login.LoginScreen
|
import top.yeij.cyrene.ui.login.LoginScreen
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ fun CyreneApp(container: AppContainer) {
|
|||||||
when (state) {
|
when (state) {
|
||||||
SessionState.Unknown -> LoadingScreen()
|
SessionState.Unknown -> LoadingScreen()
|
||||||
SessionState.LoggedOut -> LoginScreen(container)
|
SessionState.LoggedOut -> LoginScreen(container)
|
||||||
is SessionState.LoggedIn -> ChatScreen(
|
is SessionState.LoggedIn -> ChatHomeScreen(
|
||||||
container = container,
|
container = container,
|
||||||
onLogout = { container.authManager.logout() },
|
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
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
|
sessionId: String,
|
||||||
onOpenDrawer: () -> Unit = {},
|
onOpenDrawer: () -> Unit = {},
|
||||||
onLogout: () -> Unit = {},
|
onLogout: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val viewModel: ChatViewModel = viewModel(
|
val viewModel: ChatViewModel = viewModel(
|
||||||
factory = viewModelFactory { initializer { ChatViewModel(container) } },
|
key = sessionId,
|
||||||
|
factory = viewModelFactory { initializer { ChatViewModel(container, sessionId) } },
|
||||||
)
|
)
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val listState = rememberLazyListState()
|
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"
|
coroutines = "1.11.0"
|
||||||
securityCrypto = "1.1.0"
|
securityCrypto = "1.1.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
|
datastore = "1.2.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-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" }
|
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-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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
Reference in new issue
Block a user