feat(auth): 手机号验证码登录(会话模型 + 登录页 + 导航接入)

This commit is contained in:
AndyChen1024
2026-09-11 13:05:10 +08:00
parent 762e3975ca
commit b14f883e3e
13 changed files with 309 additions and 34 deletions

View File

@@ -1,18 +1,134 @@
package com.mingyuan.android_client.feature.auth
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.background
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.mingyuan.android_client.core.designsystem.component.MingyuanButton
import com.mingyuan.android_client.core.designsystem.theme.Danger
import com.mingyuan.android_client.core.designsystem.theme.FaintText
import com.mingyuan.android_client.core.designsystem.theme.PageBackground
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
/** Navigation route for this feature, referenced by the app-level NavHost. */
const val AUTH_ROUTE = "auth"
private val PHONE_PATTERN = Regex("^1\\d{10}$")
@Composable
fun AuthScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "Auth")
fun AuthScreen(
onLoggedIn: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AuthViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var phone by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var privacyAgreed by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.loginSuccess.collect { onLoggedIn() }
}
val phoneValid = PHONE_PATTERN.matches(phone)
val canSendCode = phoneValid && uiState.countdownSeconds == 0 && !uiState.isSendingCode
val canSubmit = phoneValid && code.isNotBlank() && privacyAgreed && !uiState.isSubmitting
Column(
modifier = modifier
.fillMaxSize()
.background(PageBackground)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(48.dp))
Text("手机号登录", color = PrimaryText, fontSize = 22.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text("手机号仅用于账号绑定,不会公开展示", color = FaintText, fontSize = 13.sp)
Spacer(Modifier.height(32.dp))
OutlinedTextField(
value = phone,
onValueChange = { phone = it.filter(Char::isDigit).take(11) },
label = { Text("手机号") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = code,
onValueChange = { code = it.filter(Char::isDigit).take(6) },
label = { Text("验证码") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(12.dp))
MingyuanButton(
text = when {
uiState.countdownSeconds > 0 -> "${uiState.countdownSeconds}s 后重发"
uiState.isSendingCode -> "发送中"
else -> "获取验证码"
},
onClick = { viewModel.requestSmsCode(phone) },
enabled = canSendCode,
)
}
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = privacyAgreed,
onCheckedChange = { privacyAgreed = it },
)
Text(
text = "我已阅读并同意《用户协议》和《隐私政策》",
color = FaintText,
fontSize = 12.sp,
)
}
Spacer(Modifier.height(8.dp))
uiState.errorMessage?.let { message ->
Text(message, color = Danger, fontSize = 13.sp)
Spacer(Modifier.height(8.dp))
}
Spacer(Modifier.height(16.dp))
MingyuanButton(
text = if (uiState.isSubmitting) "登录中..." else "登录",
onClick = { viewModel.login(phone, code) },
enabled = canSubmit,
fullWidth = true,
)
}
}

View File

@@ -1,8 +1,74 @@
package com.mingyuan.android_client.feature.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mingyuan.android_client.core.domain.repository.LoginResult
import com.mingyuan.android_client.core.domain.repository.SessionRepository
import com.mingyuan.android_client.core.domain.repository.SmsCodeResult
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AuthUiState(
val isSendingCode: Boolean = false,
val countdownSeconds: Int = 0,
val isSubmitting: Boolean = false,
val errorMessage: String? = null,
)
@HiltViewModel
class AuthViewModel @Inject constructor() : ViewModel()
class AuthViewModel @Inject constructor(
private val sessionRepository: SessionRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
private val _loginSuccess = MutableSharedFlow<Unit>()
val loginSuccess: SharedFlow<Unit> = _loginSuccess
private var countdownJob: Job? = null
fun requestSmsCode(phone: String) {
if (_uiState.value.isSendingCode || _uiState.value.countdownSeconds > 0) return
viewModelScope.launch {
_uiState.update { it.copy(isSendingCode = true, errorMessage = null) }
when (val result = sessionRepository.requestSmsCode(phone)) {
is SmsCodeResult.Success -> startCountdown()
is SmsCodeResult.Failure -> _uiState.update { it.copy(errorMessage = result.message) }
}
_uiState.update { it.copy(isSendingCode = false) }
}
}
fun login(phone: String, code: String) {
if (_uiState.value.isSubmitting) return
viewModelScope.launch {
_uiState.update { it.copy(isSubmitting = true, errorMessage = null) }
when (val result = sessionRepository.login(phone, code)) {
is LoginResult.Success -> _loginSuccess.emit(Unit)
is LoginResult.Failure -> _uiState.update { it.copy(errorMessage = result.message) }
}
_uiState.update { it.copy(isSubmitting = false) }
}
}
private fun startCountdown() {
countdownJob?.cancel()
countdownJob = viewModelScope.launch {
for (seconds in 60 downTo 1) {
_uiState.update { it.copy(countdownSeconds = seconds) }
delay(1000)
}
_uiState.update { it.copy(countdownSeconds = 0) }
}
}
}

View File

@@ -58,7 +58,7 @@ const val CHECKIN_ROUTE = "checkin"
@Composable
fun CheckinScreen(
onNavigateToProfile: () -> Unit,
onNavigateToAuth: () -> Unit,
modifier: Modifier = Modifier,
viewModel: CheckinViewModel = hiltViewModel(),
) {
@@ -68,7 +68,7 @@ fun CheckinScreen(
LaunchedEffect(uiState) {
if (uiState is CheckinUiState.LoggedOut) {
onNavigateToProfile()
onNavigateToAuth()
}
}

View File

@@ -56,7 +56,7 @@ const val HOME_ROUTE = "home"
fun HomeScreen(
onNavigateToCheckin: () -> Unit,
onNavigateToPlan: () -> Unit,
onNavigateToProfile: () -> Unit,
onNavigateToAuth: () -> Unit,
modifier: Modifier = Modifier,
viewModel: HomeViewModel = hiltViewModel(),
) {
@@ -97,7 +97,7 @@ fun HomeScreen(
TextButton(
onClick = {
showLoginDialog = false
onNavigateToProfile()
onNavigateToAuth()
},
) {
Text("去登录")

View File

@@ -68,7 +68,7 @@ const val PLAN_ROUTE = "plan"
@Composable
fun PlanScreen(
onNavigateToPlanSubmit: (PlanType, String) -> Unit,
onNavigateToProfile: () -> Unit,
onNavigateToAuth: () -> Unit,
modifier: Modifier = Modifier,
viewModel: PlanViewModel = hiltViewModel(),
) {
@@ -77,7 +77,7 @@ fun PlanScreen(
LaunchedEffect(uiState) {
if (uiState is PlanUiState.LoggedOut) {
onNavigateToProfile()
onNavigateToAuth()
}
}

View File

@@ -55,6 +55,7 @@ const val PROFILE_ROUTE = "profile"
@Composable
fun ProfileScreen(
onNavigateToAuth: () -> Unit,
onLoggedOut: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ProfileViewModel = hiltViewModel(),
@@ -68,7 +69,7 @@ fun ProfileScreen(
when (val state = uiState) {
is ProfileUiState.Loading -> ProfileSkeleton(modifier)
is ProfileUiState.LoggedOut -> LoggedOutState(onLogin = viewModel::login, modifier = modifier)
is ProfileUiState.LoggedOut -> LoggedOutState(onLogin = onNavigateToAuth, modifier = modifier)
is ProfileUiState.Error -> ErrorState(
message = state.message,
onRetry = {},

View File

@@ -52,12 +52,6 @@ class ProfileViewModel @Inject constructor(
private val _logoutEvents = MutableSharedFlow<Unit>()
val logoutEvents: SharedFlow<Unit> = _logoutEvents
fun login() {
viewModelScope.launch {
sessionRepository.login()
}
}
fun logout() {
viewModelScope.launch {
sessionRepository.logout()