feat: auth screen with auto-redirect, sync favorites/history with backend

This commit is contained in:
nk
2026-06-02 19:12:07 +03:00
parent d4adb1e7be
commit a83672b455
2934 changed files with 97351 additions and 163 deletions

View 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")
}
}
)
}
}

View 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
}
}
}