From c609a7d72d34a64831718203fb3e817904646c00 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 10 Mar 2026 00:12:42 +0300 Subject: [PATCH] android auth: add step-based email/register/2fa flow and startup route --- android/CHANGELOG.md | 15 + .../ru/daemonlord/messenger/MainActivity.kt | 6 +- .../auth/repository/NetworkAuthRepository.kt | 58 +++- .../messenger/data/common/ApiErrorMapper.kt | 26 +- .../domain/auth/model/AuthEmailStatus.kt | 9 + .../domain/auth/repository/AuthRepository.kt | 10 +- .../auth/usecase/CheckEmailStatusUseCase.kt | 15 + .../domain/auth/usecase/LoginUseCase.kt | 14 +- .../domain/auth/usecase/RegisterUseCase.kt | 24 ++ .../messenger/ui/auth/AuthUiState.kt | 15 + .../messenger/ui/auth/AuthViewModel.kt | 226 ++++++++++++++- .../messenger/ui/auth/LoginScreen.kt | 260 +++++++++++++----- .../messenger/ui/navigation/AppNavGraph.kt | 34 ++- 13 files changed, 616 insertions(+), 96 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthEmailStatus.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/CheckEmailStatusUseCase.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/RegisterUseCase.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index b5a4932..ac7286f 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt index 60dd739..2f14412 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt @@ -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 diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt index 8be0a0b..163bf4d 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt @@ -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 = withContext(ioDispatcher) { + override suspend fun checkEmailStatus(email: String): AppResult = 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 = 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 = 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, + ) + } + } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/common/ApiErrorMapper.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/common/ApiErrorMapper.kt index e41ec99..5922d05 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/common/ApiErrorMapper.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/common/ApiErrorMapper.kt @@ -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() +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthEmailStatus.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthEmailStatus.kt new file mode 100644 index 0000000..e09dc79 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthEmailStatus.kt @@ -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, +) + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/AuthRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/AuthRepository.kt index a03a543..a021c17 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/AuthRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/AuthRepository.kt @@ -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 + suspend fun checkEmailStatus(email: String): AppResult + suspend fun register(email: String, name: String, username: String, password: String): AppResult + suspend fun login( + email: String, + password: String, + otpCode: String? = null, + recoveryCode: String? = null, + ): AppResult suspend fun refreshTokens(): AppResult suspend fun getMe(): AppResult suspend fun restoreSession(): AppResult diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/CheckEmailStatusUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/CheckEmailStatusUseCase.kt new file mode 100644 index 0000000..818001e --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/CheckEmailStatusUseCase.kt @@ -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 { + return authRepository.checkEmailStatus(email) + } +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/LoginUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/LoginUseCase.kt index ee00025..f4b09ab 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/LoginUseCase.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/LoginUseCase.kt @@ -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 { - return authRepository.login(email = email, password = password) + suspend operator fun invoke( + email: String, + password: String, + otpCode: String? = null, + recoveryCode: String? = null, + ): AppResult { + return authRepository.login( + email = email, + password = password, + otpCode = otpCode, + recoveryCode = recoveryCode, + ) } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/RegisterUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/RegisterUseCase.kt new file mode 100644 index 0000000..69b0f0a --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/RegisterUseCase.kt @@ -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 { + return authRepository.register( + email = email, + name = name, + username = username, + password = password, + ) + } +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthUiState.kt index 52dc5f4..d6d6865 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthUiState.kt @@ -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, ) + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt index ce2fa00..1337851 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt @@ -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.") } + 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." } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt index c66e12b..86c3c78 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt @@ -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() @@ -47,71 +56,194 @@ fun LoginScreen( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = "Messenger Login", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 24.dp), - ) - - OutlinedTextField( - value = state.email, - onValueChange = onEmailChanged, - label = { Text(text = "Email") }, - singleLine = true, - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp), - ) - - OutlinedTextField( - value = state.password, - onValueChange = onPasswordChanged, - label = { Text(text = "Password") }, - visualTransformation = PasswordVisualTransformation(), - singleLine = true, - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - ) - - Button( - onClick = onLoginClick, - enabled = !state.isLoading, - modifier = Modifier.fillMaxWidth(), - ) { - if (state.isLoading) { - CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier.padding(2.dp), - ) - } else { - Text(text = "Login") - } - } - - if (!state.errorMessage.isNullOrBlank()) { Text( - text = state.errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 12.dp), + text = "Messenger Login", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 14.dp), ) - } - TextButton( - onClick = onOpenVerifyEmail, - enabled = !state.isLoading, - modifier = Modifier.padding(top = 8.dp), - ) { - Text(text = "Verify email by token") - } - TextButton( - onClick = onOpenResetPassword, - enabled = !state.isLoading, - ) { - Text(text = "Forgot password") - } + + 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 = !isBusy, + modifier = Modifier + .fillMaxWidth() + .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 = !isBusy, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + ) + } + + if (state.step == AuthStep.OTP) { + TextButton( + onClick = onToggleRecoveryCodeMode, + enabled = !isBusy, + modifier = Modifier + .align(Alignment.Start) + .padding(bottom = 4.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 { + 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" + } + ) + } + } + } + } + + if (!state.errorMessage.isNullOrBlank()) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + 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 = !isBusy, + modifier = Modifier.padding(top = 8.dp), + ) { + Text(text = "Verify email by token") + } + TextButton( + onClick = onOpenResetPassword, + enabled = !isBusy, + ) { + Text(text = "Forgot password") + } } } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt index 1755baa..347eac0 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt @@ -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,25 +168,32 @@ 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, - onPasswordChanged = viewModel::onPasswordChanged, - onLoginClick = viewModel::login, - onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) }, - onOpenResetPassword = { navController.navigate(Routes.ResetPassword) }, - ) - } + LoginScreen( + state = uiState, + onEmailChanged = viewModel::onEmailChanged, + onNameChanged = viewModel::onNameChanged, + onUsernameChanged = viewModel::onUsernameChanged, + onPasswordChanged = viewModel::onPasswordChanged, + 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) }, + ) } }