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

@@ -21,6 +21,8 @@ import androidx.navigation.navArgument
import com.mingyuan.android_client.core.designsystem.theme.TabActive
import com.mingyuan.android_client.core.designsystem.theme.TabBackground
import com.mingyuan.android_client.core.designsystem.theme.TabInactive
import com.mingyuan.android_client.feature.auth.AUTH_ROUTE
import com.mingyuan.android_client.feature.auth.AuthScreen
import com.mingyuan.android_client.feature.checkin.CHECKIN_ROUTE
import com.mingyuan.android_client.feature.checkin.CheckinScreen
import com.mingyuan.android_client.feature.home.HOME_ROUTE
@@ -74,18 +76,18 @@ fun AppNavHost() {
HomeScreen(
onNavigateToCheckin = { navigateToTab(CHECKIN_ROUTE) },
onNavigateToPlan = { navigateToTab(PLAN_ROUTE) },
onNavigateToProfile = { navigateToTab(PROFILE_ROUTE) },
onNavigateToAuth = { navController.navigate(AUTH_ROUTE) },
)
}
composable(CHECKIN_ROUTE) {
CheckinScreen(onNavigateToProfile = { navigateToTab(PROFILE_ROUTE) })
CheckinScreen(onNavigateToAuth = { navController.navigate(AUTH_ROUTE) })
}
composable(PLAN_ROUTE) {
PlanScreen(
onNavigateToPlanSubmit = { type, periodKey ->
navController.navigate(planSubmitRoute(type, periodKey))
},
onNavigateToProfile = { navigateToTab(PROFILE_ROUTE) },
onNavigateToAuth = { navController.navigate(AUTH_ROUTE) },
)
}
composable(
@@ -100,8 +102,14 @@ fun AppNavHost() {
) {
PlanSubmitScreen(onBack = { navController.popBackStack() })
}
composable(AUTH_ROUTE) {
AuthScreen(onLoggedIn = { navController.popBackStack() })
}
composable(PROFILE_ROUTE) {
ProfileScreen(onLoggedOut = { navigateToTab(HOME_ROUTE) })
ProfileScreen(
onNavigateToAuth = { navController.navigate(AUTH_ROUTE) },
onLoggedOut = { navigateToTab(HOME_ROUTE) },
)
}
}
}

View File

@@ -1,5 +1,10 @@
package com.mingyuan.android_client.core.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import com.mingyuan.android_client.core.data.repository.FakeCheckinRepository
import com.mingyuan.android_client.core.data.repository.FakeHomeRepository
import com.mingyuan.android_client.core.data.repository.FakePlanRepository
@@ -12,7 +17,9 @@ import com.mingyuan.android_client.core.domain.repository.ProfileRepository
import com.mingyuan.android_client.core.domain.repository.SessionRepository
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@@ -39,4 +46,11 @@ abstract class DataModule {
@Binds
@Singleton
abstract fun bindSessionRepository(impl: FakeSessionRepository): SessionRepository
companion object {
@Provides
@Singleton
fun provideSessionDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("session") }
}
}

View File

@@ -1,25 +1,71 @@
package com.mingyuan.android_client.core.data.repository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
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 com.mingyuan.android_client.core.model.Session
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* Local-mock session implementation. It simulates the "phone + SMS code" login flow and persists
* the token via DataStore so the login survives process restarts (mirroring the mini-program's
* `wx.getStorageSync('token')`). Swap this for a network-backed implementation when the backend
* endpoints are ready.
*
* Mock credentials: phone `13812341234`, code `123456`.
*/
@Singleton
class FakeSessionRepository @Inject constructor() : SessionRepository {
class FakeSessionRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : SessionRepository {
private val _isLoggedIn = MutableStateFlow(true)
override val isLoggedIn: Flow<Boolean> = _isLoggedIn
private companion object {
val TOKEN_KEY = stringPreferencesKey("token")
const val MOCK_SMS_CODE = "123456"
val PHONE_PATTERN = Regex("^1\\d{10}$")
// Mirrors the backend's "phone must be pre-provisioned" constraint.
val PHONE_WHITELIST = setOf("13812341234")
}
override suspend fun login() {
delay(300)
_isLoggedIn.value = true
private var pendingCode: String? = null
private var pendingPhone: String? = null
override val session: Flow<Session?> = dataStore.data
.map { prefs -> prefs[TOKEN_KEY]?.let(::Session) }
override suspend fun requestSmsCode(phone: String): SmsCodeResult {
if (!PHONE_PATTERN.matches(phone)) {
return SmsCodeResult.Failure("请输入正确的手机号")
}
delay(500)
pendingPhone = phone
pendingCode = MOCK_SMS_CODE
return SmsCodeResult.Success
}
override suspend fun login(phone: String, code: String): LoginResult {
if (phone != pendingPhone || code != pendingCode) {
return LoginResult.Failure("验证码错误")
}
if (phone !in PHONE_WHITELIST) {
return LoginResult.Failure("手机号未匹配,无法登录")
}
delay(500)
dataStore.edit { it[TOKEN_KEY] = "mock-token-$phone" }
pendingPhone = null
pendingCode = null
return LoginResult.Success
}
override suspend fun logout() {
delay(300)
_isLoggedIn.value = false
dataStore.edit { it.remove(TOKEN_KEY) }
}
}

View File

@@ -8,5 +8,5 @@ android {
dependencies {
implementation(project(":core:model"))
implementation(libs.androidx.datastore.preferences)
api(libs.androidx.datastore.preferences)
}

View File

@@ -1,11 +1,30 @@
package com.mingyuan.android_client.core.domain.repository
import com.mingyuan.android_client.core.model.Session
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
sealed interface SmsCodeResult {
data object Success : SmsCodeResult
data class Failure(val message: String) : SmsCodeResult
}
sealed interface LoginResult {
data object Success : LoginResult
data class Failure(val message: String) : LoginResult
}
interface SessionRepository {
val isLoggedIn: Flow<Boolean>
/** The current session, or `null` when logged out. */
val session: Flow<Session?>
suspend fun login()
/** Derived convenience kept for the existing feature ViewModels (home/checkin/plan/profile). */
val isLoggedIn: Flow<Boolean>
get() = session.map { it != null }
suspend fun requestSmsCode(phone: String): SmsCodeResult
suspend fun login(phone: String, code: String): LoginResult
suspend fun logout()
}

View File

@@ -0,0 +1,11 @@
package com.mingyuan.android_client.core.model
/**
* A logged-in session. Deliberately minimal for now: the token is the only thing needed to decide
* whether the user is logged in, and the user profile is still provided by [ProfileRepository].
* When the real backend lands, this grows a `user: AuthUser` field and the local data source grows
* a user cache.
*/
data class Session(
val token: String,
)

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()