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:
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user