feat: 初始化 Android 客户端(M0 骨架 + M1 登录 + M2 基础模块)

M0:Gradle 9.7.1 / AGP 9.2.1 / Kotlin 2.4 / Miuix 0.9.3,minSdk 33,compileSdk 37。
M1:登录页(服务器地址可配、服务端/本地错误分流、EncryptedSharedPreferences、自动登录与静默重登、401 登出)。
M2 基础模块:消息内容解析器(<p>/<action>/<narration> + 流式增量)、WebSocket 客户端、REST 数据层与分页去重。
45 个单元测试全绿,真机验证通过(登录页 + 启动图标)。
devdocs/ 收录设计文档、前后端协同事项与细化方案。
This commit is contained in:
AskaEth committed 2026-09-08 20:40:58 +08:00
commit 84de51c7b7
67 files changed
+3960

No files matched your search

@@ -0,0 +1,25 @@
package top.yeij.cyrene.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun LoadingScreen() {
Scaffold { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
Text("加载中…", style = MiuixTheme.textStyles.main)
}
}
}
@@ -0,0 +1,52 @@
package top.yeij.cyrene.ui.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Sidebar
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun HomeScreen(onLogout: () -> Unit = {}) {
Scaffold(
topBar = {
SmallTopAppBar(
title = "Cyrene",
navigationIcon = {
IconButton(onClick = { /* M5:打开侧边栏 */ }) {
Icon(imageVector = MiuixIcons.Basic.Sidebar, contentDescription = "菜单")
}
},
actions = {
TextButton("登出", onClick = onLogout)
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = "与昔涟", style = MiuixTheme.textStyles.title2)
Text(
text = "会话列表 · 规划中",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerVariant,
)
}
}
}
@@ -0,0 +1,143 @@
package top.yeij.cyrene.ui.login
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import top.yeij.cyrene.app.AppContainer
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun LoginScreen(container: AppContainer) {
val viewModel: LoginViewModel = viewModel(
factory = viewModelFactory {
initializer { LoginViewModel(container.authManager, container.sessionStore) }
},
)
val state by viewModel.state.collectAsState()
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(72.dp))
Text("Cyrene", style = MiuixTheme.textStyles.title1)
Spacer(Modifier.height(4.dp))
Text(
text = "登录后与昔涟聊天",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
Spacer(Modifier.height(32.dp))
Text(
text = if (state.serverExpanded) "服务器地址(点击收起)" else "服务器地址(点击展开)",
style = MiuixTheme.textStyles.subtitle,
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.toggleServerExpanded() }
.padding(vertical = 8.dp),
)
if (state.serverExpanded) {
TextField(
value = state.baseUrl,
onValueChange = viewModel::onBaseUrlChange,
label = "http://192.168.1.10:8080",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
}
TextField(
value = state.username,
onValueChange = viewModel::onUsernameChange,
label = "用户名",
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
TextField(
value = state.password,
onValueChange = viewModel::onPasswordChange,
label = "密码",
singleLine = true,
visualTransformation = if (state.passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { viewModel.submit() }),
trailingIcon = {
IconButton(onClick = viewModel::togglePasswordVisible) {
Text(
text = if (state.passwordVisible) "隐藏" else "显示",
style = MiuixTheme.textStyles.footnote1,
)
}
},
modifier = Modifier.fillMaxWidth(),
)
if (state.error != null) {
Spacer(Modifier.height(12.dp))
Text(
text = state.error.orEmpty(),
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(28.dp))
Button(
onClick = viewModel::submit,
enabled = !state.loading,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.loading) "登录中…" else "登录")
}
Spacer(Modifier.height(32.dp))
}
}
}
@@ -0,0 +1,73 @@
package top.yeij.cyrene.ui.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import top.yeij.cyrene.core.auth.AuthManager
import top.yeij.cyrene.core.auth.SessionStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class LoginUiState(
val baseUrl: String = "",
val username: String = "",
val password: String = "",
val passwordVisible: Boolean = false,
val serverExpanded: Boolean = false,
val loading: Boolean = false,
val error: String? = null,
)
class LoginViewModel(
private val authManager: AuthManager,
sessionStore: SessionStore,
) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state.asStateFlow()
init {
val stored = sessionStore.load()
_state.update {
it.copy(
baseUrl = stored.baseUrl,
username = stored.username.orEmpty(),
serverExpanded = stored.baseUrl.isBlank(),
)
}
}
fun onBaseUrlChange(value: String) = _state.update { it.copy(baseUrl = value, error = null) }
fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) }
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
fun togglePasswordVisible() = _state.update { it.copy(passwordVisible = !it.passwordVisible) }
fun toggleServerExpanded() = _state.update { it.copy(serverExpanded = !it.serverExpanded) }
fun submit() {
val snapshot = _state.value
if (snapshot.loading) return
val validationError = when {
snapshot.baseUrl.isBlank() -> "请填写服务器地址"
snapshot.username.isBlank() -> "请填写用户名"
snapshot.password.isBlank() -> "请填写密码"
else -> null
}
if (validationError != null) {
_state.update { it.copy(error = validationError, serverExpanded = true) }
return
}
_state.update { it.copy(loading = true, error = null) }
viewModelScope.launch {
val error = authManager.login(snapshot.baseUrl, snapshot.username.trim(), snapshot.password)
_state.update { it.copy(loading = false, error = error?.userMessage) }
}
}
}