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.TabActive
import com.mingyuan.android_client.core.designsystem.theme.TabBackground import com.mingyuan.android_client.core.designsystem.theme.TabBackground
import com.mingyuan.android_client.core.designsystem.theme.TabInactive 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.CHECKIN_ROUTE
import com.mingyuan.android_client.feature.checkin.CheckinScreen import com.mingyuan.android_client.feature.checkin.CheckinScreen
import com.mingyuan.android_client.feature.home.HOME_ROUTE import com.mingyuan.android_client.feature.home.HOME_ROUTE
@@ -74,18 +76,18 @@ fun AppNavHost() {
HomeScreen( HomeScreen(
onNavigateToCheckin = { navigateToTab(CHECKIN_ROUTE) }, onNavigateToCheckin = { navigateToTab(CHECKIN_ROUTE) },
onNavigateToPlan = { navigateToTab(PLAN_ROUTE) }, onNavigateToPlan = { navigateToTab(PLAN_ROUTE) },
onNavigateToProfile = { navigateToTab(PROFILE_ROUTE) }, onNavigateToAuth = { navController.navigate(AUTH_ROUTE) },
) )
} }
composable(CHECKIN_ROUTE) { composable(CHECKIN_ROUTE) {
CheckinScreen(onNavigateToProfile = { navigateToTab(PROFILE_ROUTE) }) CheckinScreen(onNavigateToAuth = { navController.navigate(AUTH_ROUTE) })
} }
composable(PLAN_ROUTE) { composable(PLAN_ROUTE) {
PlanScreen( PlanScreen(
onNavigateToPlanSubmit = { type, periodKey -> onNavigateToPlanSubmit = { type, periodKey ->
navController.navigate(planSubmitRoute(type, periodKey)) navController.navigate(planSubmitRoute(type, periodKey))
}, },
onNavigateToProfile = { navigateToTab(PROFILE_ROUTE) }, onNavigateToAuth = { navController.navigate(AUTH_ROUTE) },
) )
} }
composable( composable(
@@ -100,8 +102,14 @@ fun AppNavHost() {
) { ) {
PlanSubmitScreen(onBack = { navController.popBackStack() }) PlanSubmitScreen(onBack = { navController.popBackStack() })
} }
composable(AUTH_ROUTE) {
AuthScreen(onLoggedIn = { navController.popBackStack() })
}
composable(PROFILE_ROUTE) { 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 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.FakeCheckinRepository
import com.mingyuan.android_client.core.data.repository.FakeHomeRepository import com.mingyuan.android_client.core.data.repository.FakeHomeRepository
import com.mingyuan.android_client.core.data.repository.FakePlanRepository 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 com.mingyuan.android_client.core.domain.repository.SessionRepository
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
@@ -39,4 +46,11 @@ abstract class DataModule {
@Binds @Binds
@Singleton @Singleton
abstract fun bindSessionRepository(impl: FakeSessionRepository): SessionRepository 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 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.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.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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 @Singleton
class FakeSessionRepository @Inject constructor() : SessionRepository { class FakeSessionRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : SessionRepository {
private val _isLoggedIn = MutableStateFlow(true) private companion object {
override val isLoggedIn: Flow<Boolean> = _isLoggedIn 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() { private var pendingCode: String? = null
delay(300) private var pendingPhone: String? = null
_isLoggedIn.value = true
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() { override suspend fun logout() {
delay(300) dataStore.edit { it.remove(TOKEN_KEY) }
_isLoggedIn.value = false
} }
} }

View File

@@ -8,5 +8,5 @@ android {
dependencies { dependencies {
implementation(project(":core:model")) 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 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.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 { 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() 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 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.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.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier 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. */ /** Navigation route for this feature, referenced by the app-level NavHost. */
const val AUTH_ROUTE = "auth" const val AUTH_ROUTE = "auth"
private val PHONE_PATTERN = Regex("^1\\d{10}$")
@Composable @Composable
fun AuthScreen(modifier: Modifier = Modifier) { fun AuthScreen(
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onLoggedIn: () -> Unit,
Text(text = "Auth") 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 package com.mingyuan.android_client.feature.auth
import androidx.lifecycle.ViewModel 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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject 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 @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 @Composable
fun CheckinScreen( fun CheckinScreen(
onNavigateToProfile: () -> Unit, onNavigateToAuth: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: CheckinViewModel = hiltViewModel(), viewModel: CheckinViewModel = hiltViewModel(),
) { ) {
@@ -68,7 +68,7 @@ fun CheckinScreen(
LaunchedEffect(uiState) { LaunchedEffect(uiState) {
if (uiState is CheckinUiState.LoggedOut) { if (uiState is CheckinUiState.LoggedOut) {
onNavigateToProfile() onNavigateToAuth()
} }
} }

View File

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

View File

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

View File

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

View File

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