feat(auth): 手机号验证码登录(会话模型 + 登录页 + 导航接入)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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("去登录")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user