diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/auth/TokenManager.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/auth/TokenManager.kt new file mode 100644 index 0000000..17ff992 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/auth/TokenManager.kt @@ -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() + } + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/preferences/TokenPreferences.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/preferences/TokenPreferences.kt new file mode 100644 index 0000000..f8e20cf --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/preferences/TokenPreferences.kt @@ -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 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 = context.dataStore + + val accessToken: Flow = dataStore.data.map { preferences -> + preferences[TokenKeys.ACCESS_TOKEN] + } + + val refreshToken: Flow = dataStore.data.map { preferences -> + preferences[TokenKeys.REFRESH_TOKEN] + } + + val userId: Flow = 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) + } + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/AuthService.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/AuthService.kt new file mode 100644 index 0000000..3b235a7 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/AuthService.kt @@ -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 { + 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() + Result.success(tokenResponse) + } + HttpStatusCode.BadRequest -> { + val error = response.body() + 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 { + 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() + Result.success(tokenResponse) + } + HttpStatusCode.Unauthorized -> { + Result.failure(Exception("手机号或密码错误")) + } + HttpStatusCode.BadRequest -> { + val error = response.body() + 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 { + 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() + 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 { + 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 { + return try { + val response = client.get("$AUTH_BASE/me") { + header("Authorization", "Bearer $accessToken") + } + + when (response.status) { + HttpStatusCode.OK -> { + val userResponse = response.body() + Result.success(userResponse) + } + HttpStatusCode.Unauthorized -> { + Result.failure(Exception("未授权")) + } + else -> { + Result.failure(Exception("获取用户信息失败: ${response.status}")) + } + } + } catch (e: Exception) { + Result.failure(Exception("网络错误: ${e.message}", e)) + } + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/interceptors/AuthInterceptor.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/interceptors/AuthInterceptor.kt new file mode 100644 index 0000000..f5fcf35 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/interceptors/AuthInterceptor.kt @@ -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") + } + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/models/AuthModels.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/models/AuthModels.kt new file mode 100644 index 0000000..67a3120 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/models/AuthModels.kt @@ -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 +) diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/LoginScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/LoginScreen.kt new file mode 100644 index 0000000..8b787bf --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/LoginScreen.kt @@ -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() + } + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/RegisterScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/RegisterScreen.kt new file mode 100644 index 0000000..b71ae9e --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/RegisterScreen.kt @@ -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() + } + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/AuthViewModel.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/AuthViewModel.kt new file mode 100644 index 0000000..bc22262 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/AuthViewModel.kt @@ -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 = _isLoading.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + private val _isLoggedIn = MutableStateFlow(false) + val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() + + private val _currentUser = MutableStateFlow(null) + val currentUser: StateFlow = _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 + } +}