diff --git a/app-android/app/src/main/java/com/huaga/life_echo/navigation/AppNavigation.kt b/app-android/app/src/main/java/com/huaga/life_echo/navigation/AppNavigation.kt index 7c4fba3..499c706 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/navigation/AppNavigation.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/navigation/AppNavigation.kt @@ -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() }, diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/AuthService.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/AuthService.kt index 27ee7dc..1aea1da 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/network/AuthService.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/AuthService.kt @@ -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 { + 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() + Result.success(userResponse) + } + HttpStatusCode.BadRequest -> { + try { + val error = response.body() + 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)) + } + } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/models/AuthModels.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/models/AuthModels.kt index 6eb91c3..aecaddb 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/network/models/AuthModels.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/models/AuthModels.kt @@ -136,3 +136,9 @@ data class ChangePhoneRequest( data class MessageResponse( val message: String ) + +// 更新昵称请求 +@Serializable +data class UpdateNicknameRequest( + val nickname: String +) diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/icons/AppIcons.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/icons/AppIcons.kt index c7093c0..fc6f2cc 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/icons/AppIcons.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/icons/AppIcons.kt @@ -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 diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ConversationListScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ConversationListScreen.kt index 259f72b..b97828d 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ConversationListScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ConversationListScreen.kt @@ -65,6 +65,9 @@ fun ConversationListScreen( var isSelectionMode by remember { mutableStateOf(false) } var selectedIds by remember { mutableStateOf(mutableSetOf()) } + // 是否正在自动创建对话 + 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 ) } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/LoginScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/LoginScreen.kt index 4c2c88a..e40a9da 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/LoginScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/LoginScreen.kt @@ -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 ) } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/NicknameSetupScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/NicknameSetupScreen.kt new file mode 100644 index 0000000..62190d5 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/NicknameSetupScreen.kt @@ -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 + ) + } + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/AuthViewModel.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/AuthViewModel.kt index 69631ba..fa6cc8a 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/AuthViewModel.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/AuthViewModel.kt @@ -29,6 +29,10 @@ class AuthViewModel(private val context: Context) : ViewModel() { private val _currentUser = MutableStateFlow(null) val currentUser: StateFlow = _currentUser.asStateFlow() + // 是否需要设置昵称(首次登录用户) + private val _needsNicknameSetup = MutableStateFlow(false) + val needsNicknameSetup: StateFlow = _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 + } }