feat: 添加Android端认证功能
- 添加登录和注册界面 - 添加认证ViewModel和状态管理 - 添加TokenManager用于管理访问令牌和刷新令牌 - 添加AuthInterceptor用于自动添加认证头 - 添加AuthService用于调用认证API - 添加认证相关的数据模型
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package com.huaga.life_echo.data.auth
|
||||
|
||||
import android.content.Context
|
||||
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 com.huaga.life_echo.data.preferences.TokenPreferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* 令牌管理器单例
|
||||
* 负责管理访问令牌和刷新令牌的存储与访问
|
||||
*/
|
||||
object TokenManager {
|
||||
private var tokenPreferences: TokenPreferences? = null
|
||||
private val _isLoggedIn = mutableStateOf(false)
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
var isLoggedIn: Boolean
|
||||
get() = _isLoggedIn.value
|
||||
private set(value) { _isLoggedIn.value = value }
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (tokenPreferences == null) {
|
||||
tokenPreferences = TokenPreferences(context)
|
||||
}
|
||||
// 检查是否已登录
|
||||
checkLoginStatus(context)
|
||||
}
|
||||
|
||||
private fun checkLoginStatus(context: Context) {
|
||||
if (tokenPreferences == null) {
|
||||
tokenPreferences = TokenPreferences(context)
|
||||
}
|
||||
scope.launch {
|
||||
val accessToken = tokenPreferences?.getAccessToken()
|
||||
_isLoggedIn.value = !accessToken.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveTokens(accessToken: String, refreshToken: String, userId: String) {
|
||||
tokenPreferences?.saveTokens(accessToken, refreshToken, userId)
|
||||
_isLoggedIn.value = true
|
||||
}
|
||||
|
||||
suspend fun getAccessToken(): String? {
|
||||
return tokenPreferences?.getAccessToken()
|
||||
}
|
||||
|
||||
suspend fun getRefreshToken(): String? {
|
||||
return tokenPreferences?.getRefreshToken()
|
||||
}
|
||||
|
||||
suspend fun getUserId(): String? {
|
||||
return tokenPreferences?.getUserId()
|
||||
}
|
||||
|
||||
suspend fun clearTokens() {
|
||||
tokenPreferences?.clearTokens()
|
||||
_isLoggedIn.value = false
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberIsLoggedIn(): Boolean {
|
||||
return remember { _isLoggedIn }.value
|
||||
}
|
||||
|
||||
// 同步方法(用于非协程环境)
|
||||
fun getAccessTokenSync(): String? {
|
||||
return runBlocking {
|
||||
tokenPreferences?.getAccessToken()
|
||||
}
|
||||
}
|
||||
|
||||
fun getRefreshTokenSync(): String? {
|
||||
return runBlocking {
|
||||
tokenPreferences?.getRefreshToken()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.huaga.life_echo.data.preferences
|
||||
|
||||
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.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "token_preferences")
|
||||
|
||||
object TokenKeys {
|
||||
val ACCESS_TOKEN = stringPreferencesKey("access_token")
|
||||
val REFRESH_TOKEN = stringPreferencesKey("refresh_token")
|
||||
val USER_ID = stringPreferencesKey("user_id")
|
||||
}
|
||||
|
||||
class TokenPreferences(private val context: Context) {
|
||||
private val dataStore: DataStore<Preferences> = context.dataStore
|
||||
|
||||
val accessToken: Flow<String?> = dataStore.data.map { preferences ->
|
||||
preferences[TokenKeys.ACCESS_TOKEN]
|
||||
}
|
||||
|
||||
val refreshToken: Flow<String?> = dataStore.data.map { preferences ->
|
||||
preferences[TokenKeys.REFRESH_TOKEN]
|
||||
}
|
||||
|
||||
val userId: Flow<String?> = dataStore.data.map { preferences ->
|
||||
preferences[TokenKeys.USER_ID]
|
||||
}
|
||||
|
||||
suspend fun saveTokens(accessToken: String, refreshToken: String, userId: String) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[TokenKeys.ACCESS_TOKEN] = accessToken
|
||||
preferences[TokenKeys.REFRESH_TOKEN] = refreshToken
|
||||
preferences[TokenKeys.USER_ID] = userId
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAccessToken(): String? {
|
||||
return dataStore.data.map { it[TokenKeys.ACCESS_TOKEN] }.first()
|
||||
}
|
||||
|
||||
suspend fun getRefreshToken(): String? {
|
||||
return dataStore.data.map { it[TokenKeys.REFRESH_TOKEN] }.first()
|
||||
}
|
||||
|
||||
suspend fun getUserId(): String? {
|
||||
return dataStore.data.map { it[TokenKeys.USER_ID] }.first()
|
||||
}
|
||||
|
||||
suspend fun clearTokens() {
|
||||
dataStore.edit { preferences ->
|
||||
preferences.remove(TokenKeys.ACCESS_TOKEN)
|
||||
preferences.remove(TokenKeys.REFRESH_TOKEN)
|
||||
preferences.remove(TokenKeys.USER_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.huaga.life_echo.network
|
||||
|
||||
import com.huaga.life_echo.config.AppConfig
|
||||
import com.huaga.life_echo.network.models.ErrorResponse
|
||||
import com.huaga.life_echo.network.models.LoginRequest
|
||||
import com.huaga.life_echo.network.models.RefreshTokenRequest
|
||||
import com.huaga.life_echo.network.models.RegisterRequest
|
||||
import com.huaga.life_echo.network.models.TokenResponse
|
||||
import com.huaga.life_echo.network.models.UserResponse
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.android.Android
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* 认证服务
|
||||
* 处理用户注册、登录、刷新令牌、登出等操作
|
||||
*/
|
||||
class AuthService {
|
||||
private val client = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = false
|
||||
})
|
||||
}
|
||||
install(Logging) {
|
||||
level = LogLevel.INFO
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BASE_URL = AppConfig.BASE_URL
|
||||
private const val AUTH_BASE = "$BASE_URL/api/auth"
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
suspend fun register(
|
||||
phone: String,
|
||||
password: String,
|
||||
nickname: String,
|
||||
email: String? = null
|
||||
): Result<TokenResponse> {
|
||||
return try {
|
||||
val response = client.post("$AUTH_BASE/register") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(RegisterRequest(phone, password, nickname, email))
|
||||
}
|
||||
|
||||
when (response.status) {
|
||||
HttpStatusCode.Created -> {
|
||||
val tokenResponse = response.body<TokenResponse>()
|
||||
Result.success(tokenResponse)
|
||||
}
|
||||
HttpStatusCode.BadRequest -> {
|
||||
val error = response.body<ErrorResponse>()
|
||||
Result.failure(Exception(error.detail))
|
||||
}
|
||||
else -> {
|
||||
Result.failure(Exception("注册失败: ${response.status}"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("网络错误: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
suspend fun login(phone: String, password: String): Result<TokenResponse> {
|
||||
return try {
|
||||
val response = client.post("$AUTH_BASE/login") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(LoginRequest(phone, password))
|
||||
}
|
||||
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK -> {
|
||||
val tokenResponse = response.body<TokenResponse>()
|
||||
Result.success(tokenResponse)
|
||||
}
|
||||
HttpStatusCode.Unauthorized -> {
|
||||
Result.failure(Exception("手机号或密码错误"))
|
||||
}
|
||||
HttpStatusCode.BadRequest -> {
|
||||
val error = response.body<ErrorResponse>()
|
||||
Result.failure(Exception(error.detail))
|
||||
}
|
||||
else -> {
|
||||
Result.failure(Exception("登录失败: ${response.status}"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("网络错误: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
*/
|
||||
suspend fun refreshToken(refreshToken: String): Result<TokenResponse> {
|
||||
return try {
|
||||
val response = client.post("$AUTH_BASE/refresh") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(RefreshTokenRequest(refreshToken))
|
||||
}
|
||||
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK -> {
|
||||
val tokenResponse = response.body<TokenResponse>()
|
||||
Result.success(tokenResponse)
|
||||
}
|
||||
HttpStatusCode.Unauthorized -> {
|
||||
Result.failure(Exception("刷新令牌无效或已过期"))
|
||||
}
|
||||
else -> {
|
||||
Result.failure(Exception("刷新令牌失败: ${response.status}"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("网络错误: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
suspend fun logout(accessToken: String, refreshToken: String): Result<Unit> {
|
||||
return try {
|
||||
val response = client.post("$AUTH_BASE/logout") {
|
||||
contentType(ContentType.Application.Json)
|
||||
header("Authorization", "Bearer $accessToken")
|
||||
setBody(RefreshTokenRequest(refreshToken))
|
||||
}
|
||||
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK -> {
|
||||
Result.success(Unit)
|
||||
}
|
||||
HttpStatusCode.Unauthorized -> {
|
||||
Result.failure(Exception("未授权"))
|
||||
}
|
||||
else -> {
|
||||
Result.failure(Exception("登出失败: ${response.status}"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("网络错误: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
suspend fun getCurrentUser(accessToken: String): Result<UserResponse> {
|
||||
return try {
|
||||
val response = client.get("$AUTH_BASE/me") {
|
||||
header("Authorization", "Bearer $accessToken")
|
||||
}
|
||||
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK -> {
|
||||
val userResponse = response.body<UserResponse>()
|
||||
Result.success(userResponse)
|
||||
}
|
||||
HttpStatusCode.Unauthorized -> {
|
||||
Result.failure(Exception("未授权"))
|
||||
}
|
||||
else -> {
|
||||
Result.failure(Exception("获取用户信息失败: ${response.status}"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("网络错误: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.huaga.life_echo.network.interceptors
|
||||
|
||||
import com.huaga.life_echo.data.auth.TokenManager
|
||||
import com.huaga.life_echo.network.AuthService
|
||||
import io.ktor.client.plugins.api.createClientPlugin
|
||||
import io.ktor.http.HttpHeaders
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* 认证拦截器配置
|
||||
*/
|
||||
data class AuthInterceptorConfig(
|
||||
var tokenManager: TokenManager? = null,
|
||||
var authService: AuthService? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 认证拦截器插件
|
||||
* 自动在请求头添加Authorization
|
||||
* 注意:401响应处理需要在调用方实现,因为Ktor的响应管道中无法直接重试请求
|
||||
*/
|
||||
val AuthInterceptorPlugin = createClientPlugin("AuthInterceptor", ::AuthInterceptorConfig) {
|
||||
// 在外部作用域捕获配置
|
||||
val config = pluginConfig
|
||||
|
||||
onRequest { request, _ ->
|
||||
// 获取访问令牌
|
||||
val accessToken = runBlocking {
|
||||
config.tokenManager?.getAccessToken()
|
||||
}
|
||||
|
||||
// 如果存在访问令牌,添加到请求头
|
||||
if (!accessToken.isNullOrBlank()) {
|
||||
request.headers.append(HttpHeaders.Authorization, "Bearer $accessToken")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.huaga.life_echo.network.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 认证相关的数据模型
|
||||
*/
|
||||
|
||||
// 注册请求
|
||||
@Serializable
|
||||
data class RegisterRequest(
|
||||
val phone: String,
|
||||
val password: String,
|
||||
val nickname: String,
|
||||
val email: String? = null
|
||||
)
|
||||
|
||||
// 登录请求
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val phone: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
// 刷新令牌请求
|
||||
@Serializable
|
||||
data class RefreshTokenRequest(
|
||||
val refresh_token: String
|
||||
)
|
||||
|
||||
// 令牌响应
|
||||
@Serializable
|
||||
data class TokenResponse(
|
||||
val access_token: String,
|
||||
val refresh_token: String,
|
||||
val token_type: String = "bearer"
|
||||
)
|
||||
|
||||
// 用户信息响应
|
||||
@Serializable
|
||||
data class UserResponse(
|
||||
val id: String,
|
||||
val phone: String,
|
||||
val email: String?,
|
||||
val nickname: String,
|
||||
val avatar_url: String?,
|
||||
val subscription_type: String,
|
||||
val created_at: String
|
||||
)
|
||||
|
||||
// 错误响应
|
||||
@Serializable
|
||||
data class ErrorResponse(
|
||||
val detail: String
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.huaga.life_echo.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.huaga.life_echo.ui.icons.AppIcons
|
||||
import com.huaga.life_echo.ui.theme.LightPurple
|
||||
import com.huaga.life_echo.ui.viewmodel.AuthViewModel
|
||||
import com.huaga.life_echo.ui.viewmodel.ViewModelFactory
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onLoginSuccess: () -> Unit,
|
||||
onNavigateToRegister: () -> Unit,
|
||||
viewModel: AuthViewModel = viewModel(
|
||||
factory = ViewModelFactory(LocalContext.current)
|
||||
)
|
||||
) {
|
||||
var phone by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
||||
val isLoggedIn by viewModel.isLoggedIn.collectAsState()
|
||||
|
||||
// 登录成功后导航
|
||||
LaunchedEffect(isLoggedIn) {
|
||||
if (isLoggedIn) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
// 显示错误消息
|
||||
LaunchedEffect(errorMessage) {
|
||||
if (errorMessage != null) {
|
||||
// 错误消息会在Snackbar中显示
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
) {
|
||||
// 顶部栏
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "登录",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = LightPurple
|
||||
)
|
||||
)
|
||||
|
||||
// 内容区域
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
text = "欢迎回来",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "登录您的账号以继续",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
// 手机号输入框
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = {
|
||||
if (it.length <= 11) {
|
||||
phone = it.filter { char -> char.isDigit() }
|
||||
}
|
||||
},
|
||||
label = { Text("手机号") },
|
||||
placeholder = { Text("请输入11位手机号") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 密码输入框
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("密码") },
|
||||
placeholder = { Text("请输入密码") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
|
||||
contentDescription = if (passwordVisible) "隐藏密码" else "显示密码"
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// 登录按钮
|
||||
Button(
|
||||
onClick = {
|
||||
if (phone.length == 11 && password.length >= 6) {
|
||||
viewModel.login(phone, password)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
enabled = !isLoading && phone.length == 11 && password.length >= 6,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = LightPurple
|
||||
)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "登录",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 注册链接
|
||||
TextButton(onClick = onNavigateToRegister) {
|
||||
Text(
|
||||
text = "还没有账号?立即注册",
|
||||
color = LightPurple,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 错误消息Snackbar
|
||||
errorMessage?.let { error ->
|
||||
LaunchedEffect(error) {
|
||||
// 这里可以显示Snackbar,暂时先清除错误
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.huaga.life_echo.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.huaga.life_echo.ui.icons.AppIcons
|
||||
import com.huaga.life_echo.ui.theme.LightPurple
|
||||
import com.huaga.life_echo.ui.viewmodel.AuthViewModel
|
||||
import com.huaga.life_echo.ui.viewmodel.ViewModelFactory
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
onRegisterSuccess: () -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: AuthViewModel = viewModel(
|
||||
factory = ViewModelFactory(LocalContext.current)
|
||||
)
|
||||
) {
|
||||
var phone by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var nickname by remember { mutableStateOf("") }
|
||||
var email by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var confirmPasswordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
||||
val isLoggedIn by viewModel.isLoggedIn.collectAsState()
|
||||
|
||||
// 注册成功后导航
|
||||
LaunchedEffect(isLoggedIn) {
|
||||
if (isLoggedIn) {
|
||||
onRegisterSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
// 表单验证
|
||||
val isFormValid = phone.length == 11 &&
|
||||
password.length >= 6 &&
|
||||
password == confirmPassword &&
|
||||
nickname.isNotBlank()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
) {
|
||||
// 顶部栏
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "注册",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
imageVector = AppIcons.ArrowBack,
|
||||
contentDescription = "返回",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = LightPurple
|
||||
)
|
||||
)
|
||||
|
||||
// 内容区域
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
text = "创建账号",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "填写以下信息完成注册",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
item {
|
||||
// 手机号输入框
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = {
|
||||
if (it.length <= 11) {
|
||||
phone = it.filter { char -> char.isDigit() }
|
||||
}
|
||||
},
|
||||
label = { Text("手机号") },
|
||||
placeholder = { Text("请输入11位手机号") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
enabled = !isLoading
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
// 密码输入框
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("密码") },
|
||||
placeholder = { Text("至少6位字符") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
|
||||
contentDescription = if (passwordVisible) "隐藏密码" else "显示密码"
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = !isLoading
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
// 确认密码输入框
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
label = { Text("确认密码") },
|
||||
placeholder = { Text("请再次输入密码") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (confirmPasswordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
|
||||
contentDescription = if (confirmPasswordVisible) "隐藏密码" else "显示密码"
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = !isLoading,
|
||||
isError = confirmPassword.isNotBlank() && password != confirmPassword,
|
||||
supportingText = {
|
||||
if (confirmPassword.isNotBlank() && password != confirmPassword) {
|
||||
Text("密码不一致")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
// 昵称输入框
|
||||
OutlinedTextField(
|
||||
value = nickname,
|
||||
onValueChange = { nickname = it },
|
||||
label = { Text("昵称") },
|
||||
placeholder = { Text("请输入昵称") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = !isLoading
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
// 邮箱输入框(可选)
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("邮箱(可选)") },
|
||||
placeholder = { Text("请输入邮箱地址") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
enabled = !isLoading
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 注册按钮
|
||||
Button(
|
||||
onClick = {
|
||||
if (isFormValid) {
|
||||
viewModel.register(
|
||||
phone = phone,
|
||||
password = password,
|
||||
nickname = nickname,
|
||||
email = if (email.isNotBlank()) email else null
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
enabled = !isLoading && isFormValid,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = LightPurple
|
||||
)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "注册",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 错误消息Snackbar
|
||||
errorMessage?.let { error ->
|
||||
LaunchedEffect(error) {
|
||||
// 这里可以显示Snackbar,暂时先清除错误
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.huaga.life_echo.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.huaga.life_echo.data.auth.TokenManager
|
||||
import com.huaga.life_echo.network.AuthService
|
||||
import com.huaga.life_echo.network.models.UserResponse
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AuthViewModel(private val context: Context) : ViewModel() {
|
||||
private val authService = AuthService()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
private val _isLoggedIn = MutableStateFlow(false)
|
||||
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
||||
|
||||
private val _currentUser = MutableStateFlow<UserResponse?>(null)
|
||||
val currentUser: StateFlow<UserResponse?> = _currentUser.asStateFlow()
|
||||
|
||||
init {
|
||||
TokenManager.initialize(context)
|
||||
checkAuthStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查认证状态
|
||||
*/
|
||||
fun checkAuthStatus() {
|
||||
viewModelScope.launch {
|
||||
val accessToken = TokenManager.getAccessToken()
|
||||
_isLoggedIn.value = !accessToken.isNullOrBlank()
|
||||
|
||||
// 如果已登录,获取用户信息
|
||||
if (_isLoggedIn.value) {
|
||||
accessToken?.let { token ->
|
||||
loadUserInfo(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
fun login(phone: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
_errorMessage.value = null
|
||||
|
||||
val result = authService.login(phone, password)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { tokenResponse ->
|
||||
// 从token中解析user_id(需要解析JWT)
|
||||
// 暂时使用空字符串,后续可以从token中解析
|
||||
val userId = "" // TODO: 从JWT token中解析user_id
|
||||
|
||||
// 保存令牌
|
||||
TokenManager.saveTokens(
|
||||
tokenResponse.access_token,
|
||||
tokenResponse.refresh_token,
|
||||
userId
|
||||
)
|
||||
|
||||
_isLoggedIn.value = true
|
||||
|
||||
// 获取用户信息
|
||||
loadUserInfo(tokenResponse.access_token)
|
||||
},
|
||||
onFailure = { exception ->
|
||||
_errorMessage.value = exception.message ?: "登录失败"
|
||||
_isLoggedIn.value = false
|
||||
}
|
||||
)
|
||||
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
fun register(phone: String, password: String, nickname: String, email: String? = null) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
_errorMessage.value = null
|
||||
|
||||
val result = authService.register(phone, password, nickname, email)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { tokenResponse ->
|
||||
// 从token中解析user_id(需要解析JWT)
|
||||
// 暂时使用空字符串,后续可以从token中解析
|
||||
val userId = "" // TODO: 从JWT token中解析user_id
|
||||
|
||||
// 保存令牌
|
||||
TokenManager.saveTokens(
|
||||
tokenResponse.access_token,
|
||||
tokenResponse.refresh_token,
|
||||
userId
|
||||
)
|
||||
|
||||
_isLoggedIn.value = true
|
||||
|
||||
// 获取用户信息
|
||||
loadUserInfo(tokenResponse.access_token)
|
||||
},
|
||||
onFailure = { exception ->
|
||||
_errorMessage.value = exception.message ?: "注册失败"
|
||||
_isLoggedIn.value = false
|
||||
}
|
||||
)
|
||||
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
_errorMessage.value = null
|
||||
|
||||
val accessToken = TokenManager.getAccessToken()
|
||||
val refreshToken = TokenManager.getRefreshToken()
|
||||
|
||||
if (accessToken != null && refreshToken != null) {
|
||||
val result = authService.logout(accessToken, refreshToken)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
// 清除本地令牌
|
||||
TokenManager.clearTokens()
|
||||
_isLoggedIn.value = false
|
||||
_currentUser.value = null
|
||||
},
|
||||
onFailure = { exception ->
|
||||
// 即使登出失败,也清除本地令牌
|
||||
TokenManager.clearTokens()
|
||||
_isLoggedIn.value = false
|
||||
_currentUser.value = null
|
||||
_errorMessage.value = exception.message ?: "登出失败"
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// 没有令牌,直接清除
|
||||
TokenManager.clearTokens()
|
||||
_isLoggedIn.value = false
|
||||
_currentUser.value = null
|
||||
}
|
||||
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载用户信息
|
||||
*/
|
||||
private suspend fun loadUserInfo(accessToken: String) {
|
||||
val result = authService.getCurrentUser(accessToken)
|
||||
result.fold(
|
||||
onSuccess = { userResponse ->
|
||||
_currentUser.value = userResponse
|
||||
// 更新TokenManager中的userId
|
||||
TokenManager.saveTokens(
|
||||
accessToken,
|
||||
TokenManager.getRefreshToken() ?: "",
|
||||
userResponse.id
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
// 获取用户信息失败,不影响登录状态
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除错误消息
|
||||
*/
|
||||
fun clearError() {
|
||||
_errorMessage.value = null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user