feat(auth): 手机号验证码登录(会话模型 + 登录页 + 导航接入)
This commit is contained in:
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
api(libs.androidx.datastore.preferences)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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