android auth: add step-based email/register/2fa flow and startup route
Some checks failed
Android CI / android (push) Has been cancelled
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-10 00:12:42 +03:00
parent 09a77bd4d7
commit c609a7d72d
13 changed files with 616 additions and 96 deletions

View File

@@ -765,3 +765,18 @@
- group invites.
- Removed direct `block by user id` controls from Settings UI as requested.
- Removed extra bottom Settings actions (`Profile` row and `Back to chats` button) and kept categorized section layout.
### Step 113 - Auth flow redesign (email -> password/register -> 2FA) + startup no-flicker
- Added step-based auth domain/use-cases for:
- `GET /api/v1/auth/check-email`
- `POST /api/v1/auth/register`
- login with optional `otp_code` / `recovery_code`.
- Updated Android login UI to multi-step flow:
- step 1: email input,
- step 2: password for existing account or register form (`name`, `username`, `password`) for new account,
- step 3: 2FA OTP/recovery code when backend requires it.
- Improved login error mapping for 2FA-required responses, so app switches to OTP step instead of generic invalid-password message.
- Removed auth screen flash on startup:
- introduced dedicated `startup` route with session-check loader,
- delayed auth/chats navigation until session check is finished.
- Added safe fallback in `MainActivity` theme bootstrap to prevent crash if `ThemeRepository` injection is unexpectedly unavailable during startup.

View File

@@ -34,7 +34,11 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val savedThemeMode = runBlocking { themeRepository.getThemeMode() }
val savedThemeMode = if (this::themeRepository.isInitialized) {
runBlocking { themeRepository.getThemeMode() }
} else {
AppThemeMode.SYSTEM
}
AppCompatDelegate.setDefaultNightMode(
when (savedThemeMode) {
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO

View File

@@ -7,12 +7,15 @@ import ru.daemonlord.messenger.core.token.StoredAccount
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.CheckEmailStatusDto
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto
import ru.daemonlord.messenger.data.common.ApiErrorMode
import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
@@ -30,12 +33,56 @@ class NetworkAuthRepository @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : AuthRepository {
override suspend fun login(email: String, password: String): AppResult<AuthUser> = withContext(ioDispatcher) {
override suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus> = withContext(ioDispatcher) {
val normalized = email.trim().lowercase()
if (normalized.isBlank()) return@withContext AppResult.Error(AppError.Server("Email is required"))
try {
AppResult.Success(authApiService.checkEmailStatus(normalized).toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun register(
email: String,
name: String,
username: String,
password: String,
): AppResult<Unit> = withContext(ioDispatcher) {
val normalizedEmail = email.trim().lowercase()
val normalizedName = name.trim()
val normalizedUsername = username.trim().removePrefix("@")
if (normalizedEmail.isBlank() || normalizedName.isBlank() || normalizedUsername.isBlank() || password.isBlank()) {
return@withContext AppResult.Error(AppError.Server("Email, name, username and password are required"))
}
try {
authApiService.register(
request = RegisterRequestDto(
email = normalizedEmail,
name = normalizedName,
username = normalizedUsername,
password = password,
)
)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun login(
email: String,
password: String,
otpCode: String?,
recoveryCode: String?,
): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
val tokenResponse = authApiService.login(
request = LoginRequestDto(
email = email,
password = password,
otpCode = otpCode?.trim()?.ifBlank { null },
recoveryCode = recoveryCode?.trim()?.ifBlank { null },
)
)
tokenRepository.saveTokens(
@@ -183,4 +230,13 @@ class NetworkAuthRepository @Inject constructor(
)
}
private fun CheckEmailStatusDto.toDomain(): AuthEmailStatus {
return AuthEmailStatus(
email = email,
registered = registered,
emailVerified = emailVerified,
twofaEnabled = twofaEnabled,
)
}
}

View File

@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.data.common
import retrofit2.HttpException
import ru.daemonlord.messenger.domain.common.AppError
import java.io.IOException
import org.json.JSONObject
enum class ApiErrorMode {
DEFAULT,
@@ -13,15 +14,24 @@ fun Throwable.toAppError(mode: ApiErrorMode = ApiErrorMode.DEFAULT): AppError {
return when (this) {
is IOException -> AppError.Network
is HttpException -> when (mode) {
ApiErrorMode.LOGIN -> when (code()) {
400, 401, 403 -> AppError.InvalidCredentials
else -> AppError.Server(message = message())
ApiErrorMode.LOGIN -> {
val detail = extractErrorDetail()
when (code()) {
400, 401, 403 -> {
if (detail?.contains("2fa code required", ignoreCase = true) == true) {
AppError.Server(message = detail)
} else {
AppError.InvalidCredentials
}
}
else -> AppError.Server(message = detail ?: message())
}
}
ApiErrorMode.DEFAULT -> if (code() == 401 || code() == 403) {
AppError.Unauthorized
} else {
AppError.Server(message = message())
AppError.Server(message = extractErrorDetail() ?: message())
}
}
@@ -29,3 +39,11 @@ fun Throwable.toAppError(mode: ApiErrorMode = ApiErrorMode.DEFAULT): AppError {
}
}
private fun HttpException.extractErrorDetail(): String? {
return runCatching {
val body = response()?.errorBody()?.string()?.trim().orEmpty()
if (body.isBlank()) return@runCatching null
val json = JSONObject(body)
json.optString("detail").takeIf { it.isNotBlank() }
}.getOrNull()
}

View File

@@ -0,0 +1,9 @@
package ru.daemonlord.messenger.domain.auth.model
data class AuthEmailStatus(
val email: String,
val registered: Boolean,
val emailVerified: Boolean,
val twofaEnabled: Boolean,
)

View File

@@ -2,10 +2,18 @@ package ru.daemonlord.messenger.domain.auth.repository
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
import ru.daemonlord.messenger.domain.common.AppResult
interface AuthRepository {
suspend fun login(email: String, password: String): AppResult<AuthUser>
suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus>
suspend fun register(email: String, name: String, username: String, password: String): AppResult<Unit>
suspend fun login(
email: String,
password: String,
otpCode: String? = null,
recoveryCode: String? = null,
): AppResult<AuthUser>
suspend fun refreshTokens(): AppResult<Unit>
suspend fun getMe(): AppResult<AuthUser>
suspend fun restoreSession(): AppResult<AuthUser>

View File

@@ -0,0 +1,15 @@
package ru.daemonlord.messenger.domain.auth.usecase
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class CheckEmailStatusUseCase @Inject constructor(
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(email: String): AppResult<AuthEmailStatus> {
return authRepository.checkEmailStatus(email)
}
}

View File

@@ -8,7 +8,17 @@ import javax.inject.Inject
class LoginUseCase @Inject constructor(
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(email: String, password: String): AppResult<AuthUser> {
return authRepository.login(email = email, password = password)
suspend operator fun invoke(
email: String,
password: String,
otpCode: String? = null,
recoveryCode: String? = null,
): AppResult<AuthUser> {
return authRepository.login(
email = email,
password = password,
otpCode = otpCode,
recoveryCode = recoveryCode,
)
}
}

View File

@@ -0,0 +1,24 @@
package ru.daemonlord.messenger.domain.auth.usecase
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class RegisterUseCase @Inject constructor(
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(
email: String,
name: String,
username: String,
password: String,
): AppResult<Unit> {
return authRepository.register(
email = email,
name = name,
username = username,
password = password,
)
}
}

View File

@@ -1,10 +1,25 @@
package ru.daemonlord.messenger.ui.auth
enum class AuthStep {
EMAIL,
PASSWORD,
REGISTER,
OTP,
}
data class AuthUiState(
val step: AuthStep = AuthStep.EMAIL,
val email: String = "",
val name: String = "",
val username: String = "",
val password: String = "",
val otpCode: String = "",
val recoveryCode: String = "",
val useRecoveryCode: Boolean = false,
val isCheckingSession: Boolean = true,
val isLoading: Boolean = false,
val isAuthenticated: Boolean = false,
val successMessage: String? = null,
val errorMessage: String? = null,
)

View File

@@ -8,8 +8,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.domain.auth.usecase.CheckEmailStatusUseCase
import ru.daemonlord.messenger.domain.auth.usecase.LoginUseCase
import ru.daemonlord.messenger.domain.auth.usecase.LogoutUseCase
import ru.daemonlord.messenger.domain.auth.usecase.RegisterUseCase
import ru.daemonlord.messenger.domain.auth.usecase.RestoreSessionUseCase
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
@@ -18,6 +20,8 @@ import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
private val restoreSessionUseCase: RestoreSessionUseCase,
private val checkEmailStatusUseCase: CheckEmailStatusUseCase,
private val registerUseCase: RegisterUseCase,
private val loginUseCase: LoginUseCase,
private val logoutUseCase: LogoutUseCase,
) : ViewModel() {
@@ -30,23 +34,220 @@ class AuthViewModel @Inject constructor(
}
fun onEmailChanged(value: String) {
_uiState.update { it.copy(email = value, errorMessage = null) }
_uiState.update {
it.copy(
email = value,
errorMessage = null,
successMessage = null,
)
}
}
fun onNameChanged(value: String) {
_uiState.update { it.copy(name = value, errorMessage = null) }
}
fun onUsernameChanged(value: String) {
_uiState.update { it.copy(username = value.replace("@", ""), errorMessage = null) }
}
fun onPasswordChanged(value: String) {
_uiState.update { it.copy(password = value, errorMessage = null) }
}
fun login() {
val state = uiState.value
if (state.email.isBlank() || state.password.isBlank()) {
_uiState.update { it.copy(errorMessage = "Email and password are required.") }
return
fun onOtpCodeChanged(value: String) {
_uiState.update { it.copy(otpCode = value.filter(Char::isDigit).take(8), errorMessage = null) }
}
fun onRecoveryCodeChanged(value: String) {
_uiState.update {
it.copy(
recoveryCode = value.uppercase().filter { ch -> ch.isLetterOrDigit() || ch == '-' }.take(32),
errorMessage = null,
)
}
}
fun toggleRecoveryCodeMode() {
_uiState.update {
it.copy(
useRecoveryCode = !it.useRecoveryCode,
errorMessage = null,
successMessage = null,
)
}
}
fun backToEmailStep() {
_uiState.update {
it.copy(
step = AuthStep.EMAIL,
password = "",
otpCode = "",
recoveryCode = "",
useRecoveryCode = false,
errorMessage = null,
successMessage = null,
)
}
}
fun continueWithEmail() {
val email = uiState.value.email.trim().lowercase()
if (email.isBlank()) {
_uiState.update { it.copy(errorMessage = "Enter email.") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
_uiState.update {
it.copy(
isLoading = true,
errorMessage = null,
successMessage = null,
)
}
when (val result = checkEmailStatusUseCase(email)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
email = result.data.email,
isLoading = false,
step = if (result.data.registered) AuthStep.PASSWORD else AuthStep.REGISTER,
errorMessage = null,
successMessage = if (result.data.registered) null else "This email is not registered. Complete sign up.",
)
}
}
is AppResult.Error -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = result.reason.toUiMessage(),
successMessage = null,
)
}
}
}
}
}
fun submitAuthStep() {
when (uiState.value.step) {
AuthStep.EMAIL -> continueWithEmail()
AuthStep.REGISTER -> register()
AuthStep.PASSWORD -> loginWithoutOtp()
AuthStep.OTP -> loginWithOtp()
}
}
private fun register() {
val state = uiState.value
if (state.name.isBlank() || state.username.isBlank() || state.password.isBlank()) {
_uiState.update { it.copy(errorMessage = "Name, username and password are required.") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
when (
val result = registerUseCase(
email = state.email.trim().lowercase(),
name = state.name.trim(),
username = state.username.trim().removePrefix("@"),
password = state.password,
)
) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isLoading = false,
step = AuthStep.PASSWORD,
successMessage = "Account created. Use password to sign in.",
errorMessage = null,
)
}
}
is AppResult.Error -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = result.reason.toUiMessage(),
successMessage = null,
)
}
}
}
}
}
private fun loginWithoutOtp() {
val state = uiState.value
if (state.password.isBlank()) {
_uiState.update { it.copy(errorMessage = "Password is required.") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
when (val result = loginUseCase(state.email.trim(), state.password)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isLoading = false,
isAuthenticated = true,
errorMessage = null,
successMessage = null,
)
}
}
is AppResult.Error -> {
val isOtpRequired = (result.reason as? AppError.Server)
?.message
?.contains("2fa code required", ignoreCase = true) == true
if (isOtpRequired) {
_uiState.update {
it.copy(
isLoading = false,
step = AuthStep.OTP,
errorMessage = null,
successMessage = "Enter 2FA code or recovery code.",
)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
isAuthenticated = false,
errorMessage = result.reason.toUiMessage(),
successMessage = null,
)
}
}
}
}
}
}
private fun loginWithOtp() {
val state = uiState.value
val otpCode = state.otpCode.trim().ifBlank { null }
val recoveryCode = state.recoveryCode.trim().ifBlank { null }
if (!state.useRecoveryCode && otpCode.isNullOrBlank()) {
_uiState.update { it.copy(errorMessage = "Enter 2FA code.") }
return
}
if (state.useRecoveryCode && recoveryCode.isNullOrBlank()) {
_uiState.update { it.copy(errorMessage = "Enter recovery code.") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
when (
val result = loginUseCase(
email = state.email.trim(),
password = state.password,
otpCode = if (state.useRecoveryCode) null else otpCode,
recoveryCode = if (state.useRecoveryCode) recoveryCode else null,
)
) {
is AppResult.Success -> {
_uiState.update {
it.copy(
@@ -56,7 +257,6 @@ class AuthViewModel @Inject constructor(
)
}
}
is AppResult.Error -> {
_uiState.update {
it.copy(
@@ -76,11 +276,18 @@ class AuthViewModel @Inject constructor(
runCatching { logoutUseCase() }
_uiState.update {
it.copy(
step = AuthStep.EMAIL,
email = "",
name = "",
username = "",
password = "",
otpCode = "",
recoveryCode = "",
useRecoveryCode = false,
isLoading = false,
isAuthenticated = false,
errorMessage = null,
successMessage = null,
)
}
}
@@ -103,7 +310,6 @@ class AuthViewModel @Inject constructor(
)
}
}
is AppResult.Error -> {
val keepAuthenticatedOffline = result.reason is AppError.Network
_uiState.update {
@@ -123,7 +329,7 @@ class AuthViewModel @Inject constructor(
AppError.InvalidCredentials -> "Invalid email or password."
AppError.Network -> "Network error. Check your connection."
AppError.Unauthorized -> "Session expired. Please sign in again."
is AppError.Server -> "Server error. Please try again."
is AppError.Server -> message ?: "Server error. Please try again."
is AppError.Unknown -> "Unknown error. Please try again."
}
}

View File

@@ -27,12 +27,21 @@ import androidx.compose.ui.unit.dp
fun LoginScreen(
state: AuthUiState,
onEmailChanged: (String) -> Unit,
onNameChanged: (String) -> Unit,
onUsernameChanged: (String) -> Unit,
onPasswordChanged: (String) -> Unit,
onLoginClick: () -> Unit,
onOtpCodeChanged: (String) -> Unit,
onRecoveryCodeChanged: (String) -> Unit,
onToggleRecoveryCodeMode: () -> Unit,
onContinueEmail: () -> Unit,
onSubmitStep: () -> Unit,
onBackToEmail: () -> Unit,
onOpenVerifyEmail: () -> Unit,
onOpenResetPassword: () -> Unit,
) {
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val isBusy = state.isLoading
Box(
modifier = Modifier
.fillMaxSize()
@@ -50,44 +59,158 @@ fun LoginScreen(
Text(
text = "Messenger Login",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 24.dp),
modifier = Modifier.padding(bottom = 14.dp),
)
val subtitle = when (state.step) {
AuthStep.EMAIL -> "Enter your email to continue"
AuthStep.PASSWORD -> "Enter password for ${state.email}"
AuthStep.REGISTER -> "Create account for ${state.email}"
AuthStep.OTP -> "Two-factor authentication is enabled"
}
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 18.dp),
)
when (state.step) {
AuthStep.EMAIL -> {
OutlinedTextField(
value = state.email,
onValueChange = onEmailChanged,
label = { Text(text = "Email") },
singleLine = true,
enabled = !state.isLoading,
enabled = !isBusy,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
.padding(bottom = 16.dp),
)
Button(
onClick = onContinueEmail,
enabled = !isBusy,
modifier = Modifier.fillMaxWidth(),
) {
if (isBusy) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.padding(2.dp))
} else {
Text("Continue")
}
}
}
AuthStep.PASSWORD, AuthStep.REGISTER, AuthStep.OTP -> {
OutlinedTextField(
value = state.email,
onValueChange = {},
readOnly = true,
label = { Text(text = "Email") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
)
TextButton(
onClick = onBackToEmail,
enabled = !isBusy,
modifier = Modifier
.align(Alignment.Start)
.padding(bottom = 6.dp),
) {
Text("Change email")
}
if (state.step == AuthStep.REGISTER) {
OutlinedTextField(
value = state.name,
onValueChange = onNameChanged,
label = { Text(text = "Name") },
singleLine = true,
enabled = !isBusy,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
)
OutlinedTextField(
value = state.username,
onValueChange = onUsernameChanged,
label = { Text(text = "Username") },
singleLine = true,
enabled = !isBusy,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
)
}
if (state.step != AuthStep.OTP) {
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChanged,
label = { Text(text = "Password") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
enabled = !state.isLoading,
enabled = !isBusy,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
.padding(bottom = 10.dp),
)
}
Button(
onClick = onLoginClick,
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth(),
if (state.step == AuthStep.OTP) {
TextButton(
onClick = onToggleRecoveryCodeMode,
enabled = !isBusy,
modifier = Modifier
.align(Alignment.Start)
.padding(bottom = 4.dp),
) {
if (state.isLoading) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.padding(2.dp),
Text(if (state.useRecoveryCode) "Use OTP code" else "Use recovery code")
}
if (state.useRecoveryCode) {
OutlinedTextField(
value = state.recoveryCode,
onValueChange = onRecoveryCodeChanged,
label = { Text(text = "Recovery code") },
singleLine = true,
enabled = !isBusy,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
)
} else {
Text(text = "Login")
OutlinedTextField(
value = state.otpCode,
onValueChange = onOtpCodeChanged,
label = { Text(text = "2FA code") },
singleLine = true,
enabled = !isBusy,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
)
}
}
Button(
onClick = onSubmitStep,
enabled = !isBusy,
modifier = Modifier.fillMaxWidth(),
) {
if (isBusy) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.padding(2.dp))
} else {
Text(
when (state.step) {
AuthStep.PASSWORD -> "Sign in"
AuthStep.REGISTER -> "Create account"
AuthStep.OTP -> "Confirm 2FA"
AuthStep.EMAIL -> "Continue"
}
)
}
}
}
}
@@ -99,16 +222,25 @@ fun LoginScreen(
modifier = Modifier.padding(top = 12.dp),
)
}
if (!state.successMessage.isNullOrBlank()) {
Text(
text = state.successMessage,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
TextButton(
onClick = onOpenVerifyEmail,
enabled = !state.isLoading,
enabled = !isBusy,
modifier = Modifier.padding(top = 8.dp),
) {
Text(text = "Verify email by token")
}
TextButton(
onClick = onOpenResetPassword,
enabled = !state.isLoading,
enabled = !isBusy,
) {
Text(text = "Forgot password")
}

View File

@@ -68,6 +68,7 @@ import ru.daemonlord.messenger.ui.profile.ProfileRoute
import ru.daemonlord.messenger.ui.settings.SettingsRoute
private object Routes {
const val Startup = "startup"
const val AuthGraph = "auth_graph"
const val Login = "login"
const val VerifyEmail = "verify_email"
@@ -167,27 +168,34 @@ fun MessengerNavHost(
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = Routes.AuthGraph,
startDestination = Routes.Startup,
) {
composable(route = Routes.Startup) {
SessionCheckingScreen()
}
navigation(
route = Routes.AuthGraph,
startDestination = Routes.Login,
) {
composable(route = Routes.Login) {
if (uiState.isCheckingSession) {
SessionCheckingScreen()
} else {
LoginScreen(
state = uiState,
onEmailChanged = viewModel::onEmailChanged,
onNameChanged = viewModel::onNameChanged,
onUsernameChanged = viewModel::onUsernameChanged,
onPasswordChanged = viewModel::onPasswordChanged,
onLoginClick = viewModel::login,
onOtpCodeChanged = viewModel::onOtpCodeChanged,
onRecoveryCodeChanged = viewModel::onRecoveryCodeChanged,
onToggleRecoveryCodeMode = viewModel::toggleRecoveryCodeMode,
onContinueEmail = viewModel::continueWithEmail,
onSubmitStep = viewModel::submitAuthStep,
onBackToEmail = viewModel::backToEmailStep,
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
)
}
}
}
composable(
route = "${Routes.VerifyEmail}?token={token}",