android auth: add step-based email/register/2fa flow and startup route
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user