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>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<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">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=6fbe7ac" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=6fbe7ac" />
|
||||||
|
|||||||
@ -70,6 +70,9 @@ dependencies {
|
|||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
|
// JWT library
|
||||||
|
implementation(libs.java.jwt)
|
||||||
|
|
||||||
// Hilt dependencies
|
// Hilt dependencies
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
implementation(libs.ui.graphics)
|
implementation(libs.ui.graphics)
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
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.TalkScreen
|
||||||
import com.qingshuige.tangyuan.ui.screens.LoginScreen
|
import com.qingshuige.tangyuan.ui.screens.LoginScreen
|
||||||
import com.qingshuige.tangyuan.ui.screens.UserDetailScreen
|
import com.qingshuige.tangyuan.ui.screens.UserDetailScreen
|
||||||
|
import com.qingshuige.tangyuan.ui.screens.UserScreen
|
||||||
|
import com.qingshuige.tangyuan.viewmodel.UserViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -178,7 +181,8 @@ fun MainFlow(
|
|||||||
onImageClick: (Int, Int) -> Unit = { _, _ -> },
|
onImageClick: (Int, Int) -> Unit = { _, _ -> },
|
||||||
onAuthorClick: (Int) -> Unit = {},
|
onAuthorClick: (Int) -> Unit = {},
|
||||||
sharedTransitionScope: SharedTransitionScope? = null,
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
animatedContentScope: AnimatedContentScope? = null
|
animatedContentScope: AnimatedContentScope? = null,
|
||||||
|
userViewModel: UserViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val mainNavController = rememberNavController()
|
val mainNavController = rememberNavController()
|
||||||
val navBackStackEntry by mainNavController.currentBackStackEntryAsState()
|
val navBackStackEntry by mainNavController.currentBackStackEntryAsState()
|
||||||
@ -188,14 +192,40 @@ fun MainFlow(
|
|||||||
val currentScreen =
|
val currentScreen =
|
||||||
bottomBarScreens.find { it.route == currentDestination?.route } ?: Screen.Talk
|
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(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
TangyuanTopBar(
|
TangyuanTopBar(
|
||||||
currentScreen = currentScreen,
|
currentScreen = currentScreen,
|
||||||
avatarUrl = "https://dogeoss.grtsinry43.com/img/author.jpeg",
|
avatarUrl = avatarUrl,
|
||||||
pageLevel = PageLevel.PRIMARY,
|
pageLevel = PageLevel.PRIMARY,
|
||||||
onAvatarClick = onLoginClick,
|
onAvatarClick = onAvatarClick,
|
||||||
onAnnouncementClick = {/* 公告点击事件 */ },
|
onAnnouncementClick = {/* 公告点击事件 */ },
|
||||||
onPostClick = {/* 发表点击事件 */ }
|
onPostClick = {/* 发表点击事件 */ }
|
||||||
)
|
)
|
||||||
@ -228,7 +258,22 @@ fun MainFlow(
|
|||||||
}
|
}
|
||||||
composable(Screen.Topic.route) { Text(text = "侃一侃") }
|
composable(Screen.Topic.route) { Text(text = "侃一侃") }
|
||||||
composable(Screen.Message.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.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import androidx.core.content.edit
|
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) {
|
class TokenManager(context: Context? = null) {
|
||||||
private val prefs: SharedPreferences
|
private val prefs: SharedPreferences
|
||||||
|
|
||||||
@ -23,12 +37,117 @@ class TokenManager(context: Context? = null) {
|
|||||||
get() = prefs.getString("phoneNumber", null)
|
get() = prefs.getString("phoneNumber", null)
|
||||||
|
|
||||||
val password: String?
|
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?) {
|
fun setPhoneNumberAndPassword(phoneNumber: String?, password: String?) {
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
putString("phoneNumber", phoneNumber)
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.Campaign
|
import androidx.compose.material.icons.filled.Campaign
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Person
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@ -43,6 +42,8 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import com.qingshuige.tangyuan.R
|
||||||
|
import com.qingshuige.tangyuan.TangyuanApplication
|
||||||
import com.qingshuige.tangyuan.navigation.Screen
|
import com.qingshuige.tangyuan.navigation.Screen
|
||||||
|
import com.qingshuige.tangyuan.network.TokenManager
|
||||||
|
|
||||||
// 定义页面层级类型
|
// 定义页面层级类型
|
||||||
enum class PageLevel {
|
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(
|
IconButton(
|
||||||
onClick = { onAvatarClick?.invoke() },
|
onClick = { onAvatarClick?.invoke() },
|
||||||
modifier = Modifier.size(40.dp)
|
modifier = Modifier.size(40.dp)
|
||||||
) {
|
) {
|
||||||
if (avatarUrl != null) {
|
if (isLoggedIn && avatarUrl != null) {
|
||||||
|
// 已登录且有头像URL,显示用户头像
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = avatarUrl,
|
model = avatarUrl,
|
||||||
contentDescription = "头像",
|
contentDescription = "头像",
|
||||||
@ -111,16 +119,15 @@ fun TangyuanTopBar(
|
|||||||
// 处理图片加载错误
|
// 处理图片加载错误
|
||||||
error.result.throwable.printStackTrace()
|
error.result.throwable.printStackTrace()
|
||||||
},
|
},
|
||||||
fallback = painterResource(android.R.drawable.ic_menu_gallery),
|
fallback = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
error = painterResource(android.R.drawable.ic_menu_gallery)
|
error = painterResource(R.drawable.ic_launcher_foreground)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
// 未登录或没有头像URL,显示应用图标
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Person,
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
contentDescription = "头像",
|
contentDescription = if (isLoggedIn) "头像" else "应用图标",
|
||||||
modifier = Modifier
|
modifier = Modifier.size(32.dp),
|
||||||
.size(32.dp)
|
|
||||||
.clip(CircleShape),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,18 @@
|
|||||||
package com.qingshuige.tangyuan.ui.screens
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@ -26,6 +34,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@ -35,8 +44,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
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.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.qingshuige.tangyuan.R
|
import com.qingshuige.tangyuan.R
|
||||||
|
import com.qingshuige.tangyuan.model.CreateUserDto
|
||||||
import com.qingshuige.tangyuan.model.LoginDto
|
import com.qingshuige.tangyuan.model.LoginDto
|
||||||
import com.qingshuige.tangyuan.ui.components.AuroraBackground
|
import com.qingshuige.tangyuan.ui.components.AuroraBackground
|
||||||
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
|
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
|
||||||
import com.qingshuige.tangyuan.ui.theme.TangyuanShapes
|
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
|
import com.qingshuige.tangyuan.viewmodel.UserViewModel
|
||||||
|
|
||||||
|
// 登录/注册模式枚举
|
||||||
|
enum class AuthMode {
|
||||||
|
LOGIN, REGISTER
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
userViewModel: UserViewModel = hiltViewModel()
|
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 password by remember { mutableStateOf("") }
|
||||||
val loginState by userViewModel.loginState.collectAsState()
|
var confirmPassword by remember { mutableStateOf("") }
|
||||||
|
var nickname by remember { mutableStateOf("") }
|
||||||
|
|
||||||
LaunchedEffect(loginState) {
|
val loginState by userViewModel.loginState.collectAsState()
|
||||||
|
val userUiState by userViewModel.userUiState.collectAsState()
|
||||||
|
|
||||||
|
// 清除错误信息当切换模式时
|
||||||
|
LaunchedEffect(authMode) {
|
||||||
|
userViewModel.clearError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录成功后返回
|
||||||
|
LaunchedEffect(loginState.isLoggedIn) {
|
||||||
if (loginState.isLoggedIn) {
|
if (loginState.isLoggedIn) {
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AuroraBackground {
|
AuroraBackground {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxSize(),
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -80,184 +104,51 @@ fun LoginScreen(
|
|||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
// 品牌标题区域
|
// 品牌标题区域
|
||||||
Column(
|
BrandHeader()
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
// 认证卡片
|
||||||
|
AuthCard(
|
||||||
Text(
|
authMode = authMode,
|
||||||
text = "糖原社区",
|
phoneNumber = phoneNumber,
|
||||||
style = MaterialTheme.typography.displaySmall.copy(
|
onPhoneNumberChange = { phoneNumber = it },
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
password = password,
|
||||||
),
|
onPasswordChange = { password = it },
|
||||||
fontFamily = LiteraryFontFamily,
|
confirmPassword = confirmPassword,
|
||||||
fontWeight = FontWeight.Bold
|
onConfirmPasswordChange = { confirmPassword = it },
|
||||||
)
|
nickname = nickname,
|
||||||
|
onNicknameChange = { nickname = it },
|
||||||
Text(
|
onLogin = {
|
||||||
text = "假装这里有一句 slogan",
|
userViewModel.login(
|
||||||
style = MaterialTheme.typography.titleMedium.copy(
|
LoginDto(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
phoneNumber = phoneNumber,
|
||||||
letterSpacing = 2.sp
|
password = password
|
||||||
),
|
|
||||||
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()
|
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
|
},
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
onRegister = {
|
||||||
|
userViewModel.register(
|
||||||
// 登录按钮
|
CreateUserDto(
|
||||||
Button(
|
phoneNumber = phoneNumber,
|
||||||
onClick = {
|
password = password,
|
||||||
userViewModel.login(
|
nickName = nickname,
|
||||||
LoginDto(
|
avatarGuid = "8f416888-2ca4-4cda-8882-7f06a89630a2", // 默认头像
|
||||||
phoneNumber = username,
|
isoRegionName = "CN"
|
||||||
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
|
|
||||||
)
|
)
|
||||||
) {
|
)
|
||||||
if (loginState.isLoading) {
|
},
|
||||||
Row(
|
loginState = loginState,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
userUiState = userUiState
|
||||||
) {
|
)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
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.CreateUserDto
|
||||||
import com.qingshuige.tangyuan.model.LoginDto
|
import com.qingshuige.tangyuan.model.LoginDto
|
||||||
import com.qingshuige.tangyuan.model.User
|
import com.qingshuige.tangyuan.model.User
|
||||||
|
import com.qingshuige.tangyuan.network.TokenManager
|
||||||
import com.qingshuige.tangyuan.repository.UserRepository
|
import com.qingshuige.tangyuan.repository.UserRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@ -33,6 +34,8 @@ class UserViewModel @Inject constructor(
|
|||||||
private val userRepository: UserRepository
|
private val userRepository: UserRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val tokenManager = TokenManager()
|
||||||
|
|
||||||
private val _loginState = MutableStateFlow(LoginState())
|
private val _loginState = MutableStateFlow(LoginState())
|
||||||
val loginState: StateFlow<LoginState> = _loginState.asStateFlow()
|
val loginState: StateFlow<LoginState> = _loginState.asStateFlow()
|
||||||
|
|
||||||
@ -42,6 +45,96 @@ class UserViewModel @Inject constructor(
|
|||||||
private val _searchResults = MutableStateFlow<List<User>>(emptyList())
|
private val _searchResults = MutableStateFlow<List<User>>(emptyList())
|
||||||
val searchResults: StateFlow<List<User>> = _searchResults.asStateFlow()
|
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) {
|
fun login(loginDto: LoginDto) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_loginState.value = _loginState.value.copy(isLoading = true, error = null)
|
_loginState.value = _loginState.value.copy(isLoading = true, error = null)
|
||||||
@ -53,10 +146,24 @@ class UserViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.collect { result ->
|
.collect { result ->
|
||||||
|
// 登录成功,保存token
|
||||||
|
val token = result["token"]
|
||||||
|
if (token != null) {
|
||||||
|
tokenManager.token = token
|
||||||
|
}
|
||||||
|
// 登录成功,保存账号密码用于自动登录
|
||||||
|
tokenManager.setPhoneNumberAndPassword(
|
||||||
|
loginDto.phoneNumber,
|
||||||
|
loginDto.password
|
||||||
|
)
|
||||||
|
|
||||||
_loginState.value = _loginState.value.copy(
|
_loginState.value = _loginState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isLoggedIn = true,
|
isLoggedIn = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 登录成功后获取用户信息
|
||||||
|
getCurrentUserFromToken()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -72,9 +179,22 @@ class UserViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.collect { success ->
|
.collect { success ->
|
||||||
_userUiState.value = _userUiState.value.copy(
|
if (success) {
|
||||||
isLoading = false
|
// 注册成功后自动登录
|
||||||
)
|
_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() {
|
fun logout() {
|
||||||
|
// 清除所有登录信息
|
||||||
|
tokenManager.clearAll()
|
||||||
|
|
||||||
_loginState.value = LoginState()
|
_loginState.value = LoginState()
|
||||||
_userUiState.value = UserUiState()
|
_userUiState.value = UserUiState()
|
||||||
}
|
}
|
||||||
@ -137,4 +260,21 @@ class UserViewModel @Inject constructor(
|
|||||||
_loginState.value = _loginState.value.copy(error = null)
|
_loginState.value = _loginState.value.copy(error = null)
|
||||||
_userUiState.value = _userUiState.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]
|
[versions]
|
||||||
agp = "8.12.3"
|
agp = "8.12.3"
|
||||||
coilCompose = "2.7.0"
|
coilCompose = "2.7.0"
|
||||||
|
javaJwt = "4.5.0"
|
||||||
kotlin = "2.2.20"
|
kotlin = "2.2.20"
|
||||||
coreKtx = "1.17.0"
|
coreKtx = "1.17.0"
|
||||||
junit = "4.13.2"
|
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-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" }
|
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" }
|
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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user