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:
grtsinry43 2025-10-07 00:06:37 +08:00
parent 0a0491ca1b
commit 46588259dd
Signed by: grtsinry43
GPG Key ID: F3305FB3A978C934
10 changed files with 1448 additions and 207 deletions

View File

@ -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" />

View File

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

View File

@ -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: 导航到关于页面
}
)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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