feat: auth screen with auto-redirect, sync favorites/history with backend
This commit is contained in:
158
app/src/main/java/com/radiola/ui/auth/AuthScreen.kt
Normal file
158
app/src/main/java/com/radiola/ui/auth/AuthScreen.kt
Normal file
@@ -0,0 +1,158 @@
|
||||
package com.radiola.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AuthScreen(
|
||||
onAuthSuccess: () -> Unit,
|
||||
onSkip: (() -> Unit)? = null,
|
||||
viewModel: AuthViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val email by viewModel.email.collectAsState()
|
||||
var code by remember { mutableStateOf("") }
|
||||
var showCodeInput by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(state) {
|
||||
when (state) {
|
||||
is AuthViewModel.AuthState.CodeSent -> showCodeInput = true
|
||||
is AuthViewModel.AuthState.Success -> onAuthSuccess()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Вход в radiOLA") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = if (showCodeInput) "Введите код из письма" else "Добро пожаловать",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
|
||||
Text(
|
||||
text = if (showCodeInput) "Мы отправили 6-значный код на ваш email" else "Войдите, чтобы синхронизировать избранное и историю между устройствами",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
if (!showCodeInput) {
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = viewModel::onEmailChange,
|
||||
label = { Text("Email") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { viewModel.requestCode() }),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.requestCode() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = state !is AuthViewModel.AuthState.Loading
|
||||
) {
|
||||
if (state is AuthViewModel.AuthState.Loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
Text("Получить код")
|
||||
}
|
||||
}
|
||||
|
||||
if (onSkip != null) {
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Продолжить без входа")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "Код отправлен на $email",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { if (it.length <= 6) code = it.uppercase() },
|
||||
label = { Text("Код подтверждения") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.NumberPassword,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
viewModel.verifyCode(code)
|
||||
}),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.verifyCode(code) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = state !is AuthViewModel.AuthState.Loading && code.length == 6
|
||||
) {
|
||||
if (state is AuthViewModel.AuthState.Loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
Text("Войти")
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
showCodeInput = false
|
||||
code = ""
|
||||
viewModel.dismissError()
|
||||
}
|
||||
) {
|
||||
Text("Отправить код повторно")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state is AuthViewModel.AuthState.Error) {
|
||||
val errorMessage = (state as AuthViewModel.AuthState.Error).message
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::dismissError,
|
||||
title = { Text("Ошибка") },
|
||||
text = { Text(errorMessage) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::dismissError) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
70
app/src/main/java/com/radiola/ui/auth/AuthViewModel.kt
Normal file
70
app/src/main/java/com/radiola/ui/auth/AuthViewModel.kt
Normal file
@@ -0,0 +1,70 @@
|
||||
package com.radiola.ui.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.domain.usecase.auth.RequestMagicLinkUseCase
|
||||
import com.radiola.domain.usecase.auth.VerifyMagicLinkUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val requestMagicLinkUseCase: RequestMagicLinkUseCase,
|
||||
private val verifyMagicLinkUseCase: VerifyMagicLinkUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
sealed class AuthState {
|
||||
data object Idle : AuthState()
|
||||
data object Loading : AuthState()
|
||||
data object CodeSent : AuthState()
|
||||
data object Success : AuthState()
|
||||
data class Error(val message: String) : AuthState()
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<AuthState>(AuthState.Idle)
|
||||
val state: StateFlow<AuthState> = _state
|
||||
|
||||
private val _email = MutableStateFlow("")
|
||||
val email: StateFlow<String> = _email
|
||||
|
||||
fun onEmailChange(value: String) {
|
||||
_email.value = value
|
||||
}
|
||||
|
||||
fun requestCode() {
|
||||
val email = _email.value.trim()
|
||||
if (email.isBlank() || !email.contains("@")) {
|
||||
_state.value = AuthState.Error("Введите корректный email")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = AuthState.Loading
|
||||
requestMagicLinkUseCase(email)
|
||||
.onSuccess { _state.value = AuthState.CodeSent }
|
||||
.onFailure { _state.value = AuthState.Error(it.message ?: "Ошибка отправки") }
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyCode(code: String) {
|
||||
val email = _email.value.trim()
|
||||
if (code.length != 6) {
|
||||
_state.value = AuthState.Error("Код должен содержать 6 символов")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = AuthState.Loading
|
||||
verifyMagicLinkUseCase(email, code)
|
||||
.onSuccess { _state.value = AuthState.Success }
|
||||
.onFailure { _state.value = AuthState.Error(it.message ?: "Неверный код") }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
if (_state.value is AuthState.Error) {
|
||||
_state.value = AuthState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user