feat: Implement user profile screen and enhance authentication
This commit introduces a dedicated user profile screen (`UserScreen`) and significantly enhances the authentication system by adding registration, auto-login, and robust form validation.
**Key Changes:**
* **feat(UserScreen):**
* Added a new `UserScreen` to display the logged-in user's profile.
* Displays user details including avatar, nickname, ID, bio, location, and email in a styled card.
* Includes a menu section for actions like "Post Management," "Settings," and "About."
* Shows a "not logged in" state to prompt users to log in.
* **feat(Auth):**
* **Registration:** Implemented a registration flow alongside the login flow on the `LoginScreen`. Users can now switch between Login and Register modes.
* **Auto-Login:** The app now automatically logs in the user on startup if a valid token or saved credentials exist, improving user experience.
* **Token Management:** Enhanced `TokenManager` to handle JWT parsing to extract `userId`, check token validity (expiration), and securely store credentials. Added `java-jwt` dependency for this.
* **Logout:** Improved the logout function to clear all stored tokens and user credentials from `SharedPreferences`.
* **feat(Validation):**
* Introduced `ValidationUtils` to provide real-time validation for phone number, password, and nickname fields on the `LoginScreen`.
* Input fields now display specific error messages to guide the user (e.g., "Password must contain a letter," "Invalid phone number format").
* **refactor(LoginScreen):**
* Redesigned the `LoginScreen` to support both login and registration (`AuthMode`) with animated transitions between modes and form fields.
* Form submission buttons are dynamically enabled based on validation results.
* After successful registration, the user is now automatically logged in.
* **refactor(UserViewModel):**
* Integrated auto-login logic (`checkAutoLogin`) that runs on initialization.
* Refactored `login` and `register` flows to handle token storage and fetch user profile data immediately after authentication.
* Added `isLoggedIn()` to provide a reliable check of the current authentication state.
* **refactor(TopBar):**
* The top bar avatar now correctly reflects the user's login state. It displays the user's avatar when logged in and a generic app icon when logged out.
* Clicking the avatar/icon now navigates to the `UserScreen` if logged in, or the `LoginScreen` if not.
This commit is contained in:
parent
0a0491ca1b
commit
46588259dd
2
.idea/deploymentTargetSelector.xml
generated
2
.idea/deploymentTargetSelector.xml
generated
@ -4,7 +4,7 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2025-10-06T04:37:55.083829Z">
|
||||
<DropdownSelection timestamp="2025-10-06T14:55:34.827294Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=6fbe7ac" />
|
||||
|
||||
@ -70,6 +70,9 @@ dependencies {
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
// JWT library
|
||||
implementation(libs.java.jwt)
|
||||
|
||||
// Hilt dependencies
|
||||
implementation(libs.hilt.android)
|
||||
implementation(libs.ui.graphics)
|
||||
|
||||
@ -8,6 +8,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
@ -24,6 +25,8 @@ import com.qingshuige.tangyuan.ui.screens.ImageDetailScreen
|
||||
import com.qingshuige.tangyuan.ui.screens.TalkScreen
|
||||
import com.qingshuige.tangyuan.ui.screens.LoginScreen
|
||||
import com.qingshuige.tangyuan.ui.screens.UserDetailScreen
|
||||
import com.qingshuige.tangyuan.ui.screens.UserScreen
|
||||
import com.qingshuige.tangyuan.viewmodel.UserViewModel
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
@ -178,7 +181,8 @@ fun MainFlow(
|
||||
onImageClick: (Int, Int) -> Unit = { _, _ -> },
|
||||
onAuthorClick: (Int) -> Unit = {},
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
animatedContentScope: AnimatedContentScope? = null,
|
||||
userViewModel: UserViewModel = hiltViewModel()
|
||||
) {
|
||||
val mainNavController = rememberNavController()
|
||||
val navBackStackEntry by mainNavController.currentBackStackEntryAsState()
|
||||
@ -188,14 +192,40 @@ fun MainFlow(
|
||||
val currentScreen =
|
||||
bottomBarScreens.find { it.route == currentDestination?.route } ?: Screen.Talk
|
||||
|
||||
// 观察登录状态和用户信息
|
||||
val loginState by userViewModel.loginState.collectAsState()
|
||||
val userUiState by userViewModel.userUiState.collectAsState()
|
||||
|
||||
// 获取头像URL - 当用户状态变化时重新计算
|
||||
val avatarUrl = remember(loginState.user, userUiState.currentUser) {
|
||||
userViewModel.getCurrentUserAvatarUrl()
|
||||
}
|
||||
|
||||
// 头像点击处理逻辑
|
||||
val onAvatarClick = {
|
||||
if (userViewModel.isLoggedIn()) {
|
||||
// 已登录,跳转到"我的"页面
|
||||
mainNavController.navigate(Screen.User.route) {
|
||||
popUpTo(mainNavController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
} else {
|
||||
// 未登录,跳转到登录页面
|
||||
onLoginClick()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TangyuanTopBar(
|
||||
currentScreen = currentScreen,
|
||||
avatarUrl = "https://dogeoss.grtsinry43.com/img/author.jpeg",
|
||||
avatarUrl = avatarUrl,
|
||||
pageLevel = PageLevel.PRIMARY,
|
||||
onAvatarClick = onLoginClick,
|
||||
onAvatarClick = onAvatarClick,
|
||||
onAnnouncementClick = {/* 公告点击事件 */ },
|
||||
onPostClick = {/* 发表点击事件 */ }
|
||||
)
|
||||
@ -228,7 +258,22 @@ fun MainFlow(
|
||||
}
|
||||
composable(Screen.Topic.route) { Text(text = "侃一侃") }
|
||||
composable(Screen.Message.route) { Text(text = "消息") }
|
||||
composable(Screen.User.route) { Text(text = "我的") }
|
||||
composable(Screen.User.route) {
|
||||
UserScreen(
|
||||
onEditProfile = {
|
||||
// TODO: 导航到编辑个人资料页面
|
||||
},
|
||||
onPostManagement = {
|
||||
// TODO: 导航到帖子管理页面
|
||||
},
|
||||
onSettings = {
|
||||
// TODO: 导航到设置页面
|
||||
},
|
||||
onAbout = {
|
||||
// TODO: 导航到关于页面
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,21 @@ package com.qingshuige.tangyuan.network
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import android.util.Base64
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.exceptions.JWTDecodeException
|
||||
|
||||
/**
|
||||
* TokenManager - 负责管理用户认证信息
|
||||
*
|
||||
* 安全性说明:
|
||||
* 1. 使用SharedPreferences存储在应用私有目录,其他应用无法直接访问
|
||||
* 2. 密码使用简单的Base64编码(非加密,仅混淆)
|
||||
* 3. 对于生产环境,建议:
|
||||
* - 使用Android Keystore进行真正的加密
|
||||
* - 实现生物识别验证
|
||||
* - 使用refresh token机制减少密码存储
|
||||
*/
|
||||
class TokenManager(context: Context? = null) {
|
||||
private val prefs: SharedPreferences
|
||||
|
||||
@ -23,12 +37,117 @@ class TokenManager(context: Context? = null) {
|
||||
get() = prefs.getString("phoneNumber", null)
|
||||
|
||||
val password: String?
|
||||
get() = prefs.getString("password", null)
|
||||
get() {
|
||||
val encodedPassword = prefs.getString("password", null)
|
||||
return encodedPassword?.let { decodePassword(it) }
|
||||
}
|
||||
|
||||
fun setPhoneNumberAndPassword(phoneNumber: String?, password: String?) {
|
||||
prefs.edit {
|
||||
putString("phoneNumber", phoneNumber)
|
||||
putString("password", password)
|
||||
// 对密码进行简单编码存储
|
||||
putString("password", password?.let { encodePassword(it) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有存储的认证信息
|
||||
*/
|
||||
fun clearAll() {
|
||||
prefs.edit {
|
||||
remove("JwtToken")
|
||||
remove("phoneNumber")
|
||||
remove("password")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有保存的登录凭据
|
||||
*/
|
||||
fun hasCredentials(): Boolean {
|
||||
return phoneNumber != null && password != null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从JWT token中解析用户ID
|
||||
*/
|
||||
fun getUserIdFromToken(): Int? {
|
||||
return token?.let { jwtToken ->
|
||||
try {
|
||||
val decodedJWT = JWT.decode(jwtToken)
|
||||
println("DEBUG: 解析到的JWT: $decodedJWT")
|
||||
// 从JWT的name claim中获取用户ID
|
||||
decodedJWT.getClaim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name")
|
||||
.asString()?.toIntOrNull()
|
||||
} catch (e: JWTDecodeException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查token是否已过期
|
||||
*/
|
||||
fun isTokenExpired(): Boolean {
|
||||
return token?.let { jwtToken ->
|
||||
try {
|
||||
val decodedJWT = JWT.decode(jwtToken)
|
||||
val expiresAt = decodedJWT.expiresAt
|
||||
expiresAt?.before(java.util.Date()) == true
|
||||
} catch (e: JWTDecodeException) {
|
||||
true // 如果无法解析,认为已过期
|
||||
}
|
||||
} ?: true // 如果没有token,认为已过期
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查token是否有效(存在且未过期)
|
||||
*/
|
||||
fun isTokenValid(): Boolean {
|
||||
return token != null && !isTokenExpired()
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的密码编码(Base64,仅用于混淆)
|
||||
* 注意:这不是真正的加密,只是为了避免明文存储
|
||||
*/
|
||||
private fun encodePassword(password: String): String {
|
||||
return Base64.encodeToString(password.toByteArray(), Base64.DEFAULT)
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码解码
|
||||
*/
|
||||
private fun decodePassword(encodedPassword: String): String {
|
||||
return String(Base64.decode(encodedPassword, Base64.DEFAULT))
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 用于生产环境的密码加密建议
|
||||
*
|
||||
* 更安全的实现应该:
|
||||
* 1. 使用Android Keystore生成和存储密钥
|
||||
* 2. 使用AES加密密码
|
||||
* 3. 结合生物识别验证
|
||||
* 4. 实现Token刷新机制,减少对密码的依赖
|
||||
*/
|
||||
const val SECURITY_NOTE = """
|
||||
当前实现使用SharedPreferences + Base64编码存储密码。
|
||||
|
||||
安全性评估:
|
||||
- ✅ 存储在应用私有目录,其他应用无法访问
|
||||
- ✅ Base64编码避免明文存储
|
||||
- ✅ JWT解析获取用户信息
|
||||
- ✅ Token过期检查
|
||||
- ⚠️ Root设备可能被读取
|
||||
- ⚠️ Base64不是真正的加密
|
||||
|
||||
生产环境建议:
|
||||
- 使用Android Keystore进行真正加密
|
||||
- 实现refresh token机制
|
||||
- 添加生物识别验证
|
||||
- 考虑不存储密码,仅依赖token
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,7 +34,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Campaign
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@ -43,6 +42,8 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
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.draw.clip
|
||||
@ -55,7 +56,10 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.qingshuige.tangyuan.R
|
||||
import com.qingshuige.tangyuan.TangyuanApplication
|
||||
import com.qingshuige.tangyuan.navigation.Screen
|
||||
import com.qingshuige.tangyuan.network.TokenManager
|
||||
|
||||
// 定义页面层级类型
|
||||
enum class PageLevel {
|
||||
@ -93,13 +97,17 @@ fun TangyuanTopBar(
|
||||
)
|
||||
}
|
||||
}
|
||||
// 一级页面且不是我的页面显示头像
|
||||
pageLevel == PageLevel.PRIMARY && currentScreen != Screen.User -> {
|
||||
// 一级页面且不是我的页面显示头像或应用图标
|
||||
pageLevel == PageLevel.PRIMARY -> {
|
||||
val tokenManager = TokenManager()
|
||||
val isLoggedIn = tokenManager.token != null
|
||||
|
||||
IconButton(
|
||||
onClick = { onAvatarClick?.invoke() },
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
if (avatarUrl != null) {
|
||||
if (isLoggedIn && avatarUrl != null) {
|
||||
// 已登录且有头像URL,显示用户头像
|
||||
AsyncImage(
|
||||
model = avatarUrl,
|
||||
contentDescription = "头像",
|
||||
@ -111,16 +119,15 @@ fun TangyuanTopBar(
|
||||
// 处理图片加载错误
|
||||
error.result.throwable.printStackTrace()
|
||||
},
|
||||
fallback = painterResource(android.R.drawable.ic_menu_gallery),
|
||||
error = painterResource(android.R.drawable.ic_menu_gallery)
|
||||
fallback = painterResource(R.drawable.ic_launcher_foreground),
|
||||
error = painterResource(R.drawable.ic_launcher_foreground)
|
||||
)
|
||||
} else {
|
||||
// 未登录或没有头像URL,显示应用图标
|
||||
Icon(
|
||||
Icons.Filled.Person,
|
||||
contentDescription = "头像",
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape),
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = if (isLoggedIn) "头像" else "应用图标",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,18 @@
|
||||
package com.qingshuige.tangyuan.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -12,10 +24,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
@ -26,6 +34,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@ -35,8 +44,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
@ -46,31 +53,48 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.qingshuige.tangyuan.R
|
||||
import com.qingshuige.tangyuan.model.CreateUserDto
|
||||
import com.qingshuige.tangyuan.model.LoginDto
|
||||
import com.qingshuige.tangyuan.ui.components.AuroraBackground
|
||||
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanShapes
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanTypography
|
||||
import com.qingshuige.tangyuan.utils.ValidationUtils
|
||||
import com.qingshuige.tangyuan.viewmodel.UserViewModel
|
||||
|
||||
// 登录/注册模式枚举
|
||||
enum class AuthMode {
|
||||
LOGIN, REGISTER
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
navController: NavController,
|
||||
userViewModel: UserViewModel = hiltViewModel()
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var authMode by remember { mutableStateOf(AuthMode.LOGIN) }
|
||||
var phoneNumber by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var nickname by remember { mutableStateOf("") }
|
||||
|
||||
val loginState by userViewModel.loginState.collectAsState()
|
||||
val userUiState by userViewModel.userUiState.collectAsState()
|
||||
|
||||
LaunchedEffect(loginState) {
|
||||
// 清除错误信息当切换模式时
|
||||
LaunchedEffect(authMode) {
|
||||
userViewModel.clearError()
|
||||
}
|
||||
|
||||
// 登录成功后返回
|
||||
LaunchedEffect(loginState.isLoggedIn) {
|
||||
if (loginState.isLoggedIn) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
AuroraBackground {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@ -80,184 +104,51 @@ fun LoginScreen(
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// 品牌标题区域
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(bottom = 48.dp)
|
||||
) {
|
||||
// 装饰性的圆形背景
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_launcher_foreground),
|
||||
contentDescription = "糖原社区Logo",
|
||||
modifier = Modifier.size(96.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
BrandHeader()
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
text = "糖原社区",
|
||||
style = MaterialTheme.typography.displaySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "假装这里有一句 slogan",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing = 2.sp
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 登录卡片
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = TangyuanShapes.CulturalCard,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(
|
||||
defaultElevation = 8.dp
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
|
||||
// 用户名输入框
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = {
|
||||
Text(
|
||||
"手机号",
|
||||
fontFamily = LiteraryFontFamily
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.tertiary,
|
||||
focusedLabelColor = MaterialTheme.colorScheme.tertiary,
|
||||
cursorColor = MaterialTheme.colorScheme.tertiary
|
||||
),
|
||||
isError = loginState.error != null,
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// 密码输入框
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = {
|
||||
Text(
|
||||
"密码",
|
||||
fontFamily = LiteraryFontFamily
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.tertiary,
|
||||
focusedLabelColor = MaterialTheme.colorScheme.tertiary,
|
||||
cursorColor = MaterialTheme.colorScheme.tertiary
|
||||
),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
isError = loginState.error != null,
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// 错误提示
|
||||
loginState.error?.let { error ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
// 认证卡片
|
||||
AuthCard(
|
||||
authMode = authMode,
|
||||
phoneNumber = phoneNumber,
|
||||
onPhoneNumberChange = { phoneNumber = it },
|
||||
password = password,
|
||||
onPasswordChange = { password = it },
|
||||
confirmPassword = confirmPassword,
|
||||
onConfirmPasswordChange = { confirmPassword = it },
|
||||
nickname = nickname,
|
||||
onNicknameChange = { nickname = it },
|
||||
onLogin = {
|
||||
userViewModel.login(
|
||||
LoginDto(
|
||||
phoneNumber = phoneNumber,
|
||||
password = password
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 登录按钮
|
||||
Button(
|
||||
onClick = {
|
||||
userViewModel.login(
|
||||
LoginDto(
|
||||
phoneNumber = username,
|
||||
password = password,
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
enabled = !loginState.isLoading && username.isNotBlank() && password.isNotBlank(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiary
|
||||
),
|
||||
elevation = ButtonDefaults.buttonElevation(
|
||||
defaultElevation = 4.dp,
|
||||
pressedElevation = 8.dp
|
||||
)
|
||||
},
|
||||
onRegister = {
|
||||
userViewModel.register(
|
||||
CreateUserDto(
|
||||
phoneNumber = phoneNumber,
|
||||
password = password,
|
||||
nickName = nickname,
|
||||
avatarGuid = "8f416888-2ca4-4cda-8882-7f06a89630a2", // 默认头像
|
||||
isoRegionName = "CN"
|
||||
)
|
||||
) {
|
||||
if (loginState.isLoading) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = MaterialTheme.colorScheme.onTertiary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
"登录中...",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontFamily = LiteraryFontFamily
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"进入社区",
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
fontSize = 16.sp,
|
||||
letterSpacing = 1.sp
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
loginState = loginState,
|
||||
userUiState = userUiState
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 切换模式按钮
|
||||
AuthModeSwitch(
|
||||
authMode = authMode,
|
||||
onModeChange = { authMode = it }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 底部装饰文案
|
||||
Text(
|
||||
@ -273,3 +164,403 @@ fun LoginScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrandHeader() {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
) {
|
||||
// Logo
|
||||
Box(
|
||||
modifier = Modifier.size(80.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_launcher_foreground),
|
||||
contentDescription = "糖原社区Logo",
|
||||
modifier = Modifier.size(96.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
text = "糖原社区",
|
||||
style = MaterialTheme.typography.displaySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "假装这里有一句 slogan",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing = 2.sp
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthCard(
|
||||
authMode: AuthMode,
|
||||
phoneNumber: String,
|
||||
onPhoneNumberChange: (String) -> Unit,
|
||||
password: String,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
confirmPassword: String,
|
||||
onConfirmPasswordChange: (String) -> Unit,
|
||||
nickname: String,
|
||||
onNicknameChange: (String) -> Unit,
|
||||
onLogin: () -> Unit,
|
||||
onRegister: () -> Unit,
|
||||
loginState: com.qingshuige.tangyuan.viewmodel.LoginState,
|
||||
userUiState: com.qingshuige.tangyuan.viewmodel.UserUiState
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = TangyuanShapes.CulturalCard,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(
|
||||
defaultElevation = 8.dp
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// 动态标题
|
||||
AnimatedContent(
|
||||
targetState = authMode,
|
||||
transitionSpec = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { if (targetState == AuthMode.REGISTER) it else -it },
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
) togetherWith slideOutHorizontally(
|
||||
targetOffsetX = { if (targetState == AuthMode.REGISTER) -it else it },
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
)
|
||||
},
|
||||
label = "auth_title"
|
||||
) { mode ->
|
||||
Text(
|
||||
text = if (mode == AuthMode.LOGIN) "欢迎回来" else "加入社区",
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 手机号输入框
|
||||
val phoneError = if (phoneNumber.isNotEmpty()) ValidationUtils.getPhoneNumberError(phoneNumber) else null
|
||||
OutlinedTextField(
|
||||
value = phoneNumber,
|
||||
onValueChange = onPhoneNumberChange,
|
||||
label = {
|
||||
Text(
|
||||
"手机号",
|
||||
fontFamily = LiteraryFontFamily
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.tertiary,
|
||||
focusedLabelColor = MaterialTheme.colorScheme.tertiary,
|
||||
cursorColor = MaterialTheme.colorScheme.tertiary
|
||||
),
|
||||
isError = phoneError != null || loginState.error != null || userUiState.error != null,
|
||||
singleLine = true,
|
||||
supportingText = phoneError?.let {
|
||||
{ Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily
|
||||
) }
|
||||
}
|
||||
)
|
||||
|
||||
// 密码输入框
|
||||
val passwordError = if (password.isNotEmpty()) ValidationUtils.getPasswordError(password) else null
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = onPasswordChange,
|
||||
label = {
|
||||
Text(
|
||||
"密码",
|
||||
fontFamily = LiteraryFontFamily
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.tertiary,
|
||||
focusedLabelColor = MaterialTheme.colorScheme.tertiary,
|
||||
cursorColor = MaterialTheme.colorScheme.tertiary
|
||||
),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
isError = passwordError != null || loginState.error != null || userUiState.error != null,
|
||||
singleLine = true,
|
||||
supportingText = passwordError?.let {
|
||||
{ Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily
|
||||
) }
|
||||
}
|
||||
)
|
||||
|
||||
// 注册专用字段
|
||||
AnimatedVisibility(
|
||||
visible = authMode == AuthMode.REGISTER,
|
||||
enter = slideInVertically(
|
||||
initialOffsetY = { -it },
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
) + fadeIn(),
|
||||
exit = slideOutVertically(
|
||||
targetOffsetY = { -it },
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
) + fadeOut()
|
||||
) {
|
||||
Column {
|
||||
// 确认密码
|
||||
val confirmPasswordError = if (confirmPassword.isNotEmpty())
|
||||
ValidationUtils.getConfirmPasswordError(password, confirmPassword) else null
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = onConfirmPasswordChange,
|
||||
label = {
|
||||
Text(
|
||||
"确认密码",
|
||||
fontFamily = LiteraryFontFamily
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.tertiary,
|
||||
focusedLabelColor = MaterialTheme.colorScheme.tertiary,
|
||||
cursorColor = MaterialTheme.colorScheme.tertiary
|
||||
),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
isError = confirmPasswordError != null,
|
||||
singleLine = true,
|
||||
supportingText = confirmPasswordError?.let {
|
||||
{ Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily
|
||||
) }
|
||||
}
|
||||
)
|
||||
|
||||
// 昵称
|
||||
val nicknameError = if (nickname.isNotEmpty())
|
||||
ValidationUtils.getNicknameError(nickname) else null
|
||||
OutlinedTextField(
|
||||
value = nickname,
|
||||
onValueChange = onNicknameChange,
|
||||
label = {
|
||||
Text(
|
||||
"昵称",
|
||||
fontFamily = LiteraryFontFamily
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.tertiary,
|
||||
focusedLabelColor = MaterialTheme.colorScheme.tertiary,
|
||||
cursorColor = MaterialTheme.colorScheme.tertiary
|
||||
),
|
||||
isError = nicknameError != null || userUiState.error != null,
|
||||
singleLine = true,
|
||||
supportingText = nicknameError?.let {
|
||||
{ Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily
|
||||
) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 错误提示
|
||||
val errorMessage = loginState.error ?: userUiState.error
|
||||
errorMessage?.let { error ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 主要操作按钮
|
||||
val isLoading = loginState.isLoading || userUiState.isLoading
|
||||
|
||||
val isFormValid = when (authMode) {
|
||||
AuthMode.LOGIN -> {
|
||||
phoneNumber.isNotBlank() && password.isNotBlank() &&
|
||||
ValidationUtils.getPhoneNumberError(phoneNumber) == null &&
|
||||
ValidationUtils.getPasswordError(password) == null
|
||||
}
|
||||
AuthMode.REGISTER -> {
|
||||
phoneNumber.isNotBlank() && password.isNotBlank() &&
|
||||
confirmPassword.isNotBlank() && nickname.isNotBlank() &&
|
||||
ValidationUtils.getPhoneNumberError(phoneNumber) == null &&
|
||||
ValidationUtils.getPasswordError(password) == null &&
|
||||
ValidationUtils.getConfirmPasswordError(password, confirmPassword) == null &&
|
||||
ValidationUtils.getNicknameError(nickname) == null
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (authMode == AuthMode.LOGIN) {
|
||||
onLogin()
|
||||
} else {
|
||||
onRegister()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
enabled = !isLoading && isFormValid,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiary
|
||||
),
|
||||
elevation = ButtonDefaults.buttonElevation(
|
||||
defaultElevation = 4.dp,
|
||||
pressedElevation = 8.dp
|
||||
)
|
||||
) {
|
||||
if (isLoading) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = MaterialTheme.colorScheme.onTertiary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (authMode == AuthMode.LOGIN) "登录中..." else "注册中...",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontFamily = LiteraryFontFamily
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AnimatedContent(
|
||||
targetState = authMode,
|
||||
transitionSpec = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { if (targetState == AuthMode.REGISTER) it else -it },
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
) togetherWith slideOutHorizontally(
|
||||
targetOffsetX = { if (targetState == AuthMode.REGISTER) -it else it },
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
)
|
||||
},
|
||||
label = "button_text"
|
||||
) { mode ->
|
||||
Text(
|
||||
text = if (mode == AuthMode.LOGIN) "进入社区" else "加入社区",
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
fontSize = 16.sp,
|
||||
letterSpacing = 1.sp
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthModeSwitch(
|
||||
authMode: AuthMode,
|
||||
onModeChange: (AuthMode) -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (authMode == AuthMode.LOGIN) "还没有账号?" else "已有账号?",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
onModeChange(
|
||||
if (authMode == AuthMode.LOGIN) AuthMode.REGISTER else AuthMode.LOGIN
|
||||
)
|
||||
}
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = authMode,
|
||||
transitionSpec = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { if (targetState == AuthMode.REGISTER) it else -it },
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
) togetherWith slideOutHorizontally(
|
||||
targetOffsetX = { if (targetState == AuthMode.REGISTER) -it else it },
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
)
|
||||
},
|
||||
label = "switch_text"
|
||||
) { mode ->
|
||||
Text(
|
||||
text = if (mode == AuthMode.LOGIN) "立即注册" else "去登录",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,540 @@
|
||||
package com.qingshuige.tangyuan.ui.screens
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.PostAdd
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.outlined.Email
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.qingshuige.tangyuan.R
|
||||
import com.qingshuige.tangyuan.TangyuanApplication
|
||||
import com.qingshuige.tangyuan.model.User
|
||||
import com.qingshuige.tangyuan.ui.theme.EnglishFontFamily
|
||||
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanGeneralFontFamily
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanShapes
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanTheme
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanTypography
|
||||
import com.qingshuige.tangyuan.utils.withPanguSpacing
|
||||
import com.qingshuige.tangyuan.viewmodel.UserViewModel
|
||||
|
||||
@Composable
|
||||
fun UserScreen(
|
||||
onEditProfile: () -> Unit = {},
|
||||
onPostManagement: () -> Unit = {},
|
||||
onSettings: () -> Unit = {},
|
||||
onAbout: () -> Unit = {},
|
||||
userViewModel: UserViewModel = hiltViewModel()
|
||||
) {
|
||||
val loginState by userViewModel.loginState.collectAsState()
|
||||
val userUiState by userViewModel.userUiState.collectAsState()
|
||||
|
||||
// 当前用户信息
|
||||
val currentUser = loginState.user ?: userUiState.currentUser
|
||||
val isLoggedIn = userViewModel.isLoggedIn()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
if (isLoggedIn && currentUser != null) {
|
||||
// 用户信息卡片
|
||||
UserInfoCard(
|
||||
user = currentUser,
|
||||
onEditClick = onEditProfile
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 菜单选项
|
||||
MenuSection(
|
||||
onPostManagement = onPostManagement,
|
||||
onSettings = onSettings,
|
||||
onAbout = onAbout
|
||||
)
|
||||
} else {
|
||||
// 未登录状态
|
||||
NotLoggedInContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserInfoCard(
|
||||
user: User,
|
||||
onEditClick: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// 头像和编辑按钮
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = "${TangyuanApplication.instance.bizDomain}images/${user.avatarGuid}.jpg",
|
||||
contentDescription = "用户头像",
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.clip(CircleShape)
|
||||
.border(
|
||||
width = 3.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = CircleShape
|
||||
),
|
||||
contentScale = ContentScale.Crop,
|
||||
error = painterResource(R.drawable.ic_launcher_foreground),
|
||||
fallback = painterResource(R.drawable.ic_launcher_foreground)
|
||||
)
|
||||
|
||||
// 编辑按钮
|
||||
IconButton(
|
||||
onClick = onEditClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = CircleShape
|
||||
)
|
||||
.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = "编辑",
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 昵称 - 一行
|
||||
Text(
|
||||
text = user.nickName.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.titleLarge.copy(
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 用户ID - 一行
|
||||
Text(
|
||||
text = "ID: ${user.userId}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = EnglishFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 手机号 - 一行
|
||||
if (user.phoneNumber.isNotEmpty()) {
|
||||
Text(
|
||||
text = "手机号: ${user.phoneNumber.replaceRange(3, 7, "****")}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = EnglishFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// 地区和邮箱胶囊 - 一行
|
||||
AnimatedVisibility(
|
||||
visible = user.isoRegionName.isNotBlank() || user.email.isNotBlank(),
|
||||
enter = slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
) + fadeIn(
|
||||
animationSpec = tween(400, delayMillis = 100)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 地区信息胶囊
|
||||
if (user.isoRegionName.isNotBlank()) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier.wrapContentWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.LocationOn,
|
||||
contentDescription = "地区",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = user.isoRegionName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// 邮箱信息胶囊
|
||||
if (user.email.isNotBlank()) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Email,
|
||||
contentDescription = "邮箱",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = user.email,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 用户签名
|
||||
if (user.bio.isNotBlank()) {
|
||||
Text(
|
||||
text = user.bio.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 22.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun UserInfoCardPreview() {
|
||||
TangyuanTheme {
|
||||
Surface {
|
||||
UserInfoCard(
|
||||
user = User(
|
||||
userId = 123456,
|
||||
nickName = "示例用户",
|
||||
phoneNumber = "13800138000",
|
||||
isoRegionName = "中国 北京",
|
||||
email = "example@email.com",
|
||||
bio = "这是一个示例用户的个人简介,展示了如何在用户信息卡片中显示多行文本。",
|
||||
avatarGuid = "default_avatar"
|
||||
),
|
||||
onEditClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserStats(user: User) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
StatItem(
|
||||
label = "帖子",
|
||||
value = "0" // TODO: 从用户数据中获取
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
StatItem(
|
||||
label = "关注",
|
||||
value = "0" // TODO: 从用户数据中获取
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
StatItem(
|
||||
label = "粉丝",
|
||||
value = "0" // TODO: 从用户数据中获取
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatItem(
|
||||
label: String,
|
||||
value: String
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
style = TangyuanTypography.numberMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerticalDivider() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(40.dp)
|
||||
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuSection(
|
||||
onPostManagement: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
onAbout: () -> Unit
|
||||
) {
|
||||
Text(
|
||||
text = "功能菜单",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
MenuItem(
|
||||
icon = Icons.Default.PostAdd,
|
||||
title = "帖子管理",
|
||||
subtitle = "管理我的帖子和草稿",
|
||||
onClick = onPostManagement
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
|
||||
)
|
||||
|
||||
MenuItem(
|
||||
icon = Icons.Default.Settings,
|
||||
title = "设置",
|
||||
subtitle = "个性化设置和隐私选项",
|
||||
onClick = onSettings
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
|
||||
)
|
||||
|
||||
MenuItem(
|
||||
icon = Icons.Default.Info,
|
||||
title = "关于",
|
||||
subtitle = "版本信息和帮助",
|
||||
onClick = onAbout,
|
||||
showDivider = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuItem(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
showDivider: Boolean = true
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowRight,
|
||||
contentDescription = "进入",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotLoggedInContent() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = "糖原社区",
|
||||
modifier = Modifier.size(120.dp),
|
||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "欢迎来到糖原社区",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "登录后查看个人信息",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
package com.qingshuige.tangyuan.utils
|
||||
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* 表单验证工具类
|
||||
*/
|
||||
object ValidationUtils {
|
||||
|
||||
/**
|
||||
* 验证手机号格式
|
||||
* 支持中国大陆手机号格式:11位数字,以1开头
|
||||
*/
|
||||
fun isValidPhoneNumber(phoneNumber: String): Boolean {
|
||||
if (phoneNumber.isBlank()) return false
|
||||
|
||||
// 中国大陆手机号正则表达式
|
||||
val phonePattern = Pattern.compile("^1[3-9]\\d{9}$")
|
||||
return phonePattern.matcher(phoneNumber).matches()
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码强度
|
||||
* 要求:至少6位,包含字母和数字
|
||||
*/
|
||||
fun isValidPassword(password: String): Boolean {
|
||||
if (password.length < 6) return false
|
||||
|
||||
// 检查是否包含字母和数字
|
||||
val hasLetter = password.any { it.isLetter() }
|
||||
val hasDigit = password.any { it.isDigit() }
|
||||
|
||||
return hasLetter && hasDigit
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证昵称格式
|
||||
* 要求:1-20个字符,不能包含特殊字符
|
||||
*/
|
||||
fun isValidNickname(nickname: String): Boolean {
|
||||
if (nickname.isBlank() || nickname.length > 20) return false
|
||||
|
||||
// 允许中文、英文、数字、下划线
|
||||
val nicknamePattern = Pattern.compile("^[\\u4e00-\\u9fa5a-zA-Z0-9_]+$")
|
||||
return nicknamePattern.matcher(nickname).matches()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取手机号验证错误信息
|
||||
*/
|
||||
fun getPhoneNumberError(phoneNumber: String): String? {
|
||||
return when {
|
||||
phoneNumber.isBlank() -> "请输入手机号"
|
||||
!isValidPhoneNumber(phoneNumber) -> "请输入正确的手机号格式"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取密码验证错误信息
|
||||
*/
|
||||
fun getPasswordError(password: String): String? {
|
||||
return when {
|
||||
password.isBlank() -> "请输入密码"
|
||||
password.length < 6 -> "密码至少需要6位"
|
||||
!password.any { it.isLetter() } -> "密码必须包含字母"
|
||||
!password.any { it.isDigit() } -> "密码必须包含数字"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取昵称验证错误信息
|
||||
*/
|
||||
fun getNicknameError(nickname: String): String? {
|
||||
return when {
|
||||
nickname.isBlank() -> "请输入昵称"
|
||||
nickname.length > 20 -> "昵称不能超过20个字符"
|
||||
!isValidNickname(nickname) -> "昵称只能包含中文、英文、数字和下划线"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取确认密码验证错误信息
|
||||
*/
|
||||
fun getConfirmPasswordError(password: String, confirmPassword: String): String? {
|
||||
return when {
|
||||
confirmPassword.isBlank() -> "请确认密码"
|
||||
password != confirmPassword -> "两次输入的密码不一致"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.qingshuige.tangyuan.model.CreateUserDto
|
||||
import com.qingshuige.tangyuan.model.LoginDto
|
||||
import com.qingshuige.tangyuan.model.User
|
||||
import com.qingshuige.tangyuan.network.TokenManager
|
||||
import com.qingshuige.tangyuan.repository.UserRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@ -33,6 +34,8 @@ class UserViewModel @Inject constructor(
|
||||
private val userRepository: UserRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val tokenManager = TokenManager()
|
||||
|
||||
private val _loginState = MutableStateFlow(LoginState())
|
||||
val loginState: StateFlow<LoginState> = _loginState.asStateFlow()
|
||||
|
||||
@ -42,6 +45,96 @@ class UserViewModel @Inject constructor(
|
||||
private val _searchResults = MutableStateFlow<List<User>>(emptyList())
|
||||
val searchResults: StateFlow<List<User>> = _searchResults.asStateFlow()
|
||||
|
||||
init {
|
||||
// 启动时尝试自动登录
|
||||
checkAutoLogin()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以自动登录
|
||||
*/
|
||||
private fun checkAutoLogin() {
|
||||
viewModelScope.launch {
|
||||
val token = tokenManager.token
|
||||
val phoneNumber = tokenManager.phoneNumber
|
||||
val password = tokenManager.password
|
||||
|
||||
if (tokenManager.isTokenValid()) {
|
||||
// Token有效,设置登录状态并获取用户信息
|
||||
_loginState.value = _loginState.value.copy(isLoggedIn = true)
|
||||
getCurrentUserFromToken()
|
||||
} else if (phoneNumber != null && password != null) {
|
||||
// Token无效但有保存的账号密码,尝试自动登录
|
||||
autoLogin(phoneNumber, password)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动登录
|
||||
*/
|
||||
private fun autoLogin(phoneNumber: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_loginState.value = _loginState.value.copy(isLoading = true, error = null)
|
||||
val loginDto = LoginDto(phoneNumber = phoneNumber, password = password)
|
||||
|
||||
userRepository.login(loginDto)
|
||||
.catch { e ->
|
||||
// 自动登录失败,清除保存的凭据
|
||||
tokenManager.clearAll()
|
||||
_loginState.value = _loginState.value.copy(
|
||||
isLoading = false,
|
||||
error = null // 自动登录失败不显示错误
|
||||
)
|
||||
}
|
||||
.collect { result ->
|
||||
// 自动登录成功,保存新token
|
||||
val newToken = result["token"]
|
||||
if (newToken != null) {
|
||||
tokenManager.token = newToken
|
||||
}
|
||||
_loginState.value = _loginState.value.copy(
|
||||
isLoading = false,
|
||||
isLoggedIn = true,
|
||||
)
|
||||
// 自动登录成功后获取用户信息
|
||||
getCurrentUserFromToken()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从token中获取用户ID并加载用户信息
|
||||
*/
|
||||
private fun getCurrentUserFromToken() {
|
||||
viewModelScope.launch {
|
||||
val userId = tokenManager.getUserIdFromToken()
|
||||
println("DEBUG: 从token中获取的用户ID: $userId")
|
||||
if (userId != null) {
|
||||
// 获取用户信息
|
||||
userRepository.getUserById(userId)
|
||||
.catch { e ->
|
||||
println("DEBUG: 获取用户信息失败: ${e.message}")
|
||||
// 获取用户信息失败
|
||||
_userUiState.value = _userUiState.value.copy(
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
.collect { user ->
|
||||
println("DEBUG: 获取到用户信息: ${user.nickName}, 头像: ${user.avatarGuid}")
|
||||
// 更新userUiState
|
||||
_userUiState.value = _userUiState.value.copy(
|
||||
currentUser = user
|
||||
)
|
||||
// 同时更新loginState中的用户信息
|
||||
_loginState.value = _loginState.value.copy(user = user)
|
||||
}
|
||||
} else {
|
||||
println("DEBUG: 无法从token中解析用户ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun login(loginDto: LoginDto) {
|
||||
viewModelScope.launch {
|
||||
_loginState.value = _loginState.value.copy(isLoading = true, error = null)
|
||||
@ -53,10 +146,24 @@ class UserViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
.collect { result ->
|
||||
// 登录成功,保存token
|
||||
val token = result["token"]
|
||||
if (token != null) {
|
||||
tokenManager.token = token
|
||||
}
|
||||
// 登录成功,保存账号密码用于自动登录
|
||||
tokenManager.setPhoneNumberAndPassword(
|
||||
loginDto.phoneNumber,
|
||||
loginDto.password
|
||||
)
|
||||
|
||||
_loginState.value = _loginState.value.copy(
|
||||
isLoading = false,
|
||||
isLoggedIn = true,
|
||||
)
|
||||
|
||||
// 登录成功后获取用户信息
|
||||
getCurrentUserFromToken()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -72,9 +179,22 @@ class UserViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
.collect { success ->
|
||||
_userUiState.value = _userUiState.value.copy(
|
||||
isLoading = false
|
||||
)
|
||||
if (success) {
|
||||
// 注册成功后自动登录
|
||||
_userUiState.value = _userUiState.value.copy(isLoading = false)
|
||||
|
||||
// 自动登录
|
||||
val loginDto = LoginDto(
|
||||
phoneNumber = createUserDto.phoneNumber,
|
||||
password = createUserDto.password
|
||||
)
|
||||
login(loginDto)
|
||||
} else {
|
||||
_userUiState.value = _userUiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "注册失败,请重试"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -129,6 +249,9 @@ class UserViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
// 清除所有登录信息
|
||||
tokenManager.clearAll()
|
||||
|
||||
_loginState.value = LoginState()
|
||||
_userUiState.value = UserUiState()
|
||||
}
|
||||
@ -137,4 +260,21 @@ class UserViewModel @Inject constructor(
|
||||
_loginState.value = _loginState.value.copy(error = null)
|
||||
_userUiState.value = _userUiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户头像URL
|
||||
*/
|
||||
fun getCurrentUserAvatarUrl(): String? {
|
||||
val user = _loginState.value.user ?: _userUiState.value.currentUser
|
||||
return user?.let {
|
||||
"${com.qingshuige.tangyuan.TangyuanApplication.instance.bizDomain}images/${it.avatarGuid}.jpg"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
fun isLoggedIn(): Boolean {
|
||||
return tokenManager.isTokenValid() && _loginState.value.isLoggedIn
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
[versions]
|
||||
agp = "8.12.3"
|
||||
coilCompose = "2.7.0"
|
||||
javaJwt = "4.5.0"
|
||||
kotlin = "2.2.20"
|
||||
coreKtx = "1.17.0"
|
||||
junit = "4.13.2"
|
||||
@ -27,6 +28,7 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref =
|
||||
androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "materialIconsCore" }
|
||||
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsCore" }
|
||||
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
|
||||
java-jwt = { module = "com.auth0:java-jwt", version.ref = "javaJwt" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user