feat: 添加Android端认证功能

- 添加登录和注册界面
- 添加认证ViewModel和状态管理
- 添加TokenManager用于管理访问令牌和刷新令牌
- 添加AuthInterceptor用于自动添加认证头
- 添加AuthService用于调用认证API
- 添加认证相关的数据模型
This commit is contained in:
徐在坤
2026-01-18 15:57:45 +08:00
parent 347fd43b35
commit 9132447db7
8 changed files with 1084 additions and 0 deletions

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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))
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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
)

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
}
}