feat: 添加昵称设置功能

- 在AppNavigation中新增NicknameSetup页面
- 更新AuthService以支持昵称更新请求
- 在AuthViewModel中添加昵称设置逻辑
- 优化LoginScreen以处理首次登录需要设置昵称的情况
- 更新ConversationListScreen以支持自动创建对话的状态管理
- 新增NicknameSetupScreen组件用于用户设置昵称
This commit is contained in:
penghanyuan
2026-01-29 19:20:38 +01:00
parent dbabce77bf
commit e06d8c5acb
8 changed files with 395 additions and 89 deletions

View File

@@ -18,6 +18,7 @@ sealed class Screen(val route: String) {
object Profile : Screen("profile")
object PersonalInfo : Screen("personal_info")
object Login : Screen("login")
object NicknameSetup : Screen("nickname_setup")
object ResetPassword : Screen("reset_password")
object AccountManagement : Screen("account_management")
object UpgradePlan : Screen("upgrade_plan")
@@ -199,6 +200,13 @@ fun AppNavigation(
popUpTo(Screen.Login.route) { inclusive = true }
}
},
onLoginSuccessNeedsNickname = {
// 登录成功但需要设置昵称,导航到昵称设置页面
navController.navigate(Screen.NicknameSetup.route) {
// 清除登录页面,避免返回
popUpTo(Screen.Login.route) { inclusive = true }
}
},
onNavigateToResetPassword = {
navController.navigate(Screen.ResetPassword.route)
},
@@ -210,6 +218,23 @@ fun AppNavigation(
}
)
}
composable(
route = Screen.NicknameSetup.route,
enterTransition = { fadeInTransition() },
exitTransition = { fadeOutTransition() },
popEnterTransition = { slideInHorizontallyFromLeft() },
popExitTransition = { slideOutHorizontallyToRight() }
) {
NicknameSetupScreen(
onSetupComplete = {
// 昵称设置完成后导航到主界面
navController.navigate(Screen.ConversationList.route) {
// 清除昵称设置页面和登录页面,避免返回
popUpTo(Screen.Login.route) { inclusive = true }
}
}
)
}
composable(
route = Screen.ResetPassword.route,
enterTransition = { slideInHorizontally() },

View File

@@ -12,6 +12,7 @@ import io.ktor.client.request.forms.*
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
@@ -526,4 +527,40 @@ class AuthService {
Result.failure(Exception("网络错误: ${e.message}", e))
}
}
/**
* 更新用户昵称
*/
suspend fun updateNickname(accessToken: String, nickname: String): Result<UserResponse> {
return try {
val response = client.put("$AUTH_BASE/me/nickname") {
contentType(ContentType.Application.Json)
header("Authorization", "Bearer $accessToken")
setBody(UpdateNicknameRequest(nickname))
}
when (response.status) {
HttpStatusCode.OK -> {
val userResponse = response.body<UserResponse>()
Result.success(userResponse)
}
HttpStatusCode.BadRequest -> {
try {
val error = response.body<ErrorResponse>()
Result.failure(Exception(error.detail))
} catch (e: Exception) {
Result.failure(Exception("更新昵称失败: ${response.status}"))
}
}
HttpStatusCode.Unauthorized -> {
Result.failure(Exception("未授权"))
}
else -> {
Result.failure(Exception("更新昵称失败: ${response.status}"))
}
}
} catch (e: Exception) {
Result.failure(Exception("网络错误: ${e.message}", e))
}
}
}

View File

@@ -136,3 +136,9 @@ data class ChangePhoneRequest(
data class MessageResponse(
val message: String
)
// 更新昵称请求
@Serializable
data class UpdateNicknameRequest(
val nickname: String
)

View File

@@ -88,6 +88,7 @@ object AppIcons {
val Phone = Icons.Default.Phone
val ExitToApp = Icons.AutoMirrored.Filled.ExitToApp
val DevicesOther = Icons.Default.DevicesOther
val PersonAdd = Icons.Default.PersonAdd
// 错误处理图标
val WifiOff = Icons.Default.WifiOff

View File

@@ -65,6 +65,9 @@ fun ConversationListScreen(
var isSelectionMode by remember { mutableStateOf(false) }
var selectedIds by remember { mutableStateOf(mutableSetOf<String>()) }
// 是否正在自动创建对话
var isAutoCreating by remember { mutableStateOf(false) }
// 刷新对话列表
LaunchedEffect(Unit) {
viewModel.refreshConversations()
@@ -72,16 +75,18 @@ fun ConversationListScreen(
val scope = rememberCoroutineScope()
// 处理新建对话
val handleCreateConversation: () -> Unit = {
scope.launch {
// 当对话列表为空时,自动创建一个对话并进入
LaunchedEffect(conversations, isLoading, isAutoCreating) {
if (!isLoading && conversations.isEmpty() && !isAutoCreating) {
isAutoCreating = true
val result = viewModel.createConversation()
result.fold(
onSuccess = { conversationId ->
onConversationClick(conversationId)
},
onFailure = { exception ->
// 错误处理可以在这里添加
// 创建失败,重置状态
isAutoCreating = false
}
)
}
@@ -175,14 +180,7 @@ fun ConversationListScreen(
)
}
} else {
// 正常模式下的操作
IconButton(onClick = handleCreateConversation) {
Icon(
imageVector = AppIcons.Add,
contentDescription = "新建对话",
tint = LightPurple
)
}
// 正常模式下不显示任何操作按钮(禁止创建多个对话)
}
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -210,12 +208,16 @@ fun ConversationListScreen(
)
}
conversations.isEmpty() -> {
// 空状态 - 提示用户创建对话
EmptyStateView(
title = "还没有对话",
message = "点击右上角「+」按钮开始您的回忆录之旅",
modifier = Modifier.fillMaxSize()
)
// 空状态 - 正在自动创建对话
if (isAutoCreating) {
LoadingIndicator()
} else {
EmptyStateView(
title = "正在初始化",
message = "正在为您准备回忆录对话...",
modifier = Modifier.fillMaxSize()
)
}
}
else -> {
LazyColumn(
@@ -243,16 +245,20 @@ fun ConversationListScreen(
onConversationClick(conversation.id)
}
},
onDelete = {
scope.launch {
viewModel.deleteConversation(conversation.id)
// 只有一个对话时禁止删除
onDelete = if (conversations.size > 1) {
{
scope.launch {
viewModel.deleteConversation(conversation.id)
}
}
},
} else null,
isSelected = selectedIds.contains(conversation.id),
isSelectionMode = isSelectionMode,
onLongClick = {
handleLongClick(conversation.id)
}
// 只有一个对话时禁止多选模式
isSelectionMode = isSelectionMode && conversations.size > 1,
onLongClick = if (conversations.size > 1) {
{ handleLongClick(conversation.id) }
} else null
)
}
}

View File

@@ -30,6 +30,7 @@ import com.huaga.life_echo.ui.components.auth.SmsCodeInput
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit,
onLoginSuccessNeedsNickname: () -> Unit = {},
onNavigateToResetPassword: (() -> Unit)? = null,
onNavigateToTerms: () -> Unit = {},
onNavigateToPrivacy: () -> Unit = {},
@@ -44,8 +45,6 @@ fun LoginScreen(
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var smsCode by remember { mutableStateOf("") }
var nickname by remember { mutableStateOf("") } // 首次登录时的昵称
var needsNickname by remember { mutableStateOf(false) } // 是否需要输入昵称
var agreedToTerms by remember { mutableStateOf(false) }
var showResultDialog by remember { mutableStateOf(false) }
@@ -55,26 +54,27 @@ fun LoginScreen(
val operationResult by viewModel.operationResult.collectAsState()
val isLoggedIn by viewModel.isLoggedIn.collectAsState()
val smsCountdown by viewModel.smsCountdown.collectAsState()
val needsNicknameSetup by viewModel.needsNicknameSetup.collectAsState()
// 显示操作结果弹窗,并检查是否需要输入昵称
// 显示操作结果弹窗
LaunchedEffect(operationResult) {
operationResult?.let {
showResultDialog = true
// 检查是否是首次登录需要昵称
if (!it.success && (it.details?.contains("首次登录") == true ||
it.details?.contains("需要设置昵称") == true)) {
needsNickname = true
}
}
}
// 登录成功后导航(延迟一下,让用户看到成功提示)
LaunchedEffect(isLoggedIn, successMessage) {
LaunchedEffect(isLoggedIn, successMessage, needsNicknameSetup) {
if (isLoggedIn && successMessage != null) {
kotlinx.coroutines.delay(1500) // 显示1.5秒成功提示后跳转
viewModel.clearSuccess()
viewModel.clearOperationResult()
onLoginSuccess()
// 根据是否需要设置昵称决定导航目标
if (needsNicknameSetup) {
onLoginSuccessNeedsNickname()
} else {
onLoginSuccess()
}
}
}
@@ -107,7 +107,7 @@ fun LoginScreen(
// 标题
Text(
text = if (needsNickname) "欢迎加入" else "欢迎",
text = "欢迎",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
@@ -116,9 +116,7 @@ fun LoginScreen(
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (needsNickname) {
"设置昵称完成注册"
} else if (!isPasswordMode) {
text = if (!isPasswordMode) {
"使用手机号验证码登录,首次登录将自动注册"
} else {
"登录您的账号以继续"
@@ -177,25 +175,6 @@ fun LoginScreen(
Spacer(modifier = Modifier.height(16.dp))
// 如果需要输入昵称(首次登录),显示昵称输入框
if (needsNickname && !isPasswordMode) {
OutlinedTextField(
value = nickname,
onValueChange = {
if (it.length <= 50) {
nickname = it
}
},
label = { Text("昵称") },
placeholder = { Text("请输入您的昵称1-50个字符") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(16.dp),
enabled = !isLoading
)
Spacer(modifier = Modifier.height(16.dp))
}
// 根据登录模式显示不同的输入框
if (isPasswordMode) {
// 密码输入框
@@ -287,14 +266,6 @@ fun LoginScreen(
modifier = Modifier.padding(bottom = 8.dp)
)
}
if (needsNickname && nickname.isNotEmpty() && nickname.trim().isEmpty()) {
Text(
text = "昵称不能为空",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 8.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
@@ -371,14 +342,9 @@ fun LoginScreen(
viewModel.login(trimmedPhone, password, agreedToTerms)
}
} else {
// 验证码登录/注册
val trimmedNickname = if (needsNickname) nickname.trim() else null
// 验证码登录/注册(无需昵称,首次登录后会跳转到昵称设置页面)
if (trimmedPhone.length == 11 && smsCode.length == 6 && agreedToTerms) {
// 如果需要昵称但未提供,不执行登录
if (needsNickname && trimmedNickname.isNullOrBlank()) {
return@Button
}
viewModel.loginWithSms(trimmedPhone, smsCode, agreedToTerms, trimmedNickname)
viewModel.loginWithSms(trimmedPhone, smsCode, agreedToTerms, null)
}
}
},
@@ -389,7 +355,7 @@ fun LoginScreen(
(if (isPasswordMode) {
password.length >= 6
} else {
smsCode.length == 6 && (!needsNickname || nickname.trim().isNotEmpty())
smsCode.length == 6
}) &&
agreedToTerms,
colors = ButtonDefaults.buttonColors(
@@ -404,7 +370,7 @@ fun LoginScreen(
)
} else {
Text(
text = if (needsNickname) "注册并登录" else "登录",
text = "登录",
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
@@ -485,22 +451,10 @@ fun LoginScreen(
onClick = {
showResultDialog = false
viewModel.clearOperationResult()
if (operationResult?.success == true) {
// 成功时延迟跳转已在LaunchedEffect中处理
needsNickname = false
nickname = ""
} else {
// 失败时如果是需要昵称的错误不清除needsNickname状态
if (!(operationResult?.details?.contains("首次登录") == true ||
operationResult?.details?.contains("需要设置昵称") == true)) {
needsNickname = false
nickname = ""
}
}
}
) {
Text(
text = if (operationResult?.success == true) "确定" else if (needsNickname) "设置昵称" else "重试",
text = if (operationResult?.success == true) "确定" else "重试",
color = if (operationResult?.success == true) LightPurple else MaterialTheme.colorScheme.error
)
}

View File

@@ -0,0 +1,219 @@
package com.huaga.life_echo.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.huaga.life_echo.ui.icons.AppIcons
import com.huaga.life_echo.ui.theme.LightPurple
import com.huaga.life_echo.ui.viewmodel.AuthViewModel
import com.huaga.life_echo.ui.viewmodel.ViewModelFactory
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NicknameSetupScreen(
onSetupComplete: () -> Unit,
viewModel: AuthViewModel = viewModel(
factory = ViewModelFactory(LocalContext.current)
)
) {
var nickname by remember { mutableStateOf("") }
var showResultDialog by remember { mutableStateOf(false) }
val isLoading by viewModel.isLoading.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
val operationResult by viewModel.operationResult.collectAsState()
// 错误消息Snackbar
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(errorMessage) {
errorMessage?.let { error ->
snackbarHostState.showSnackbar(
message = error,
duration = SnackbarDuration.Long
)
viewModel.clearError()
}
}
// 显示操作结果弹窗
LaunchedEffect(operationResult) {
operationResult?.let {
if (it.success) {
showResultDialog = true
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// 欢迎图标
Icon(
imageVector = AppIcons.PersonAdd,
contentDescription = "设置昵称",
modifier = Modifier.size(80.dp),
tint = LightPurple
)
Spacer(modifier = Modifier.height(24.dp))
// 标题
Text(
text = "欢迎加入岁月史书",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "请设置您的昵称,让我们更好地认识您",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
// 昵称输入框
OutlinedTextField(
value = nickname,
onValueChange = {
if (it.length <= 50) {
nickname = it
}
},
label = { Text("昵称") },
placeholder = { Text("请输入您的昵称1-50个字符") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(16.dp),
enabled = !isLoading
)
Spacer(modifier = Modifier.height(8.dp))
// 输入提示
if (nickname.isNotEmpty() && nickname.trim().isEmpty()) {
Text(
text = "昵称不能为空格",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 8.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
// 确认按钮
Button(
onClick = {
val trimmedNickname = nickname.trim()
if (trimmedNickname.isNotEmpty()) {
viewModel.updateNickname(trimmedNickname) {
// 成功后会显示弹窗,弹窗关闭后跳转
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
enabled = !isLoading && nickname.trim().isNotEmpty(),
colors = ButtonDefaults.buttonColors(
containerColor = LightPurple,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White
)
} else {
Text(
text = "开始使用",
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// 提示文字
Text(
text = "昵称将用于您的回忆录展示",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// 操作结果弹窗
if (showResultDialog && operationResult != null && operationResult?.success == true) {
AlertDialog(
onDismissRequest = {
showResultDialog = false
viewModel.clearOperationResult()
onSetupComplete()
},
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = AppIcons.CheckCircle,
contentDescription = "成功",
tint = Color(0xFF4CAF50),
modifier = Modifier.size(24.dp)
)
Text(
text = operationResult?.message ?: "设置成功",
fontWeight = FontWeight.Bold
)
}
},
text = {
Text(
text = operationResult?.details ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
confirmButton = {
TextButton(
onClick = {
showResultDialog = false
viewModel.clearOperationResult()
onSetupComplete()
}
) {
Text(
text = "开始探索",
color = LightPurple
)
}
},
containerColor = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium
)
}
}
}

View File

@@ -29,6 +29,10 @@ class AuthViewModel(private val context: Context) : ViewModel() {
private val _currentUser = MutableStateFlow<UserResponse?>(null)
val currentUser: StateFlow<UserResponse?> = _currentUser.asStateFlow()
// 是否需要设置昵称(首次登录用户)
private val _needsNicknameSetup = MutableStateFlow(false)
val needsNicknameSetup: StateFlow<Boolean> = _needsNicknameSetup.asStateFlow()
// 操作状态:用于显示详细的操作结果
data class OperationResult(
val success: Boolean,
@@ -230,6 +234,8 @@ class AuthViewModel(private val context: Context) : ViewModel() {
return result.fold(
onSuccess = { userResponse ->
_currentUser.value = userResponse
// 检查用户是否需要设置昵称(昵称为空表示首次登录)
_needsNicknameSetup.value = userResponse.nickname.isBlank()
// 更新TokenManager中的userId
TokenManager.saveTokens(
accessToken,
@@ -629,4 +635,56 @@ class AuthViewModel(private val context: Context) : ViewModel() {
}
}
}
/**
* 更新用户昵称
*/
fun updateNickname(nickname: String, onSuccess: () -> Unit) {
viewModelScope.launch {
_isLoading.value = true
_errorMessage.value = null
_successMessage.value = null
val accessToken = TokenManager.getAccessToken()
if (accessToken.isNullOrBlank()) {
_errorMessage.value = "未登录"
_isLoading.value = false
return@launch
}
val result = authService.updateNickname(accessToken, nickname)
result.fold(
onSuccess = { userResponse ->
_currentUser.value = userResponse
_needsNicknameSetup.value = false
_successMessage.value = "昵称设置成功"
_operationResult.value = OperationResult(
success = true,
message = "欢迎",
details = "欢迎加入岁月史书,${userResponse.nickname}"
)
onSuccess()
},
onFailure = { exception ->
val errorMsg = exception.message ?: "设置昵称失败"
_errorMessage.value = errorMsg
_operationResult.value = OperationResult(
success = false,
message = "设置失败",
details = errorMsg
)
}
)
_isLoading.value = false
}
}
/**
* 清除需要设置昵称的状态
*/
fun clearNicknameSetupState() {
_needsNicknameSetup.value = false
}
}