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

View File

@@ -6,9 +6,12 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
@@ -16,12 +19,16 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.Lucide
import com.radiola.domain.model.Station
@Composable
fun StationCard(
station: Station,
isFavorite: Boolean,
onClick: () -> Unit,
onFavoriteClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
@@ -31,34 +38,54 @@ fun StationCard(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E))
) {
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(
Brush.linearGradient(
colors = listOf(
Color(0xFF667eea),
Color(0xFF764ba2)
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(
Brush.linearGradient(
colors = listOf(
Color(0xFF667eea),
Color(0xFF764ba2)
)
)
)
) {
AsyncImage(
model = station.coverUrl,
contentDescription = station.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
) {
AsyncImage(
model = station.coverUrl,
contentDescription = station.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
}
Text(
text = station.name,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(12.dp),
maxLines = 1
)
}
IconButton(
onClick = onFavoriteClick,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(32.dp)
.background(
color = Color.Black.copy(alpha = 0.4f),
shape = RoundedCornerShape(8.dp)
)
) {
Icon(
imageVector = Lucide.Heart,
contentDescription = if (isFavorite) "В избранном" else "Добавить в избранное",
tint = if (isFavorite) Color(0xFFFF4081) else Color.White,
modifier = Modifier.size(18.dp)
)
}
Text(
text = station.name,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(12.dp),
maxLines = 1
)
}
}
}

View File

@@ -12,6 +12,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.radiola.domain.model.Station
import com.radiola.ui.components.EmptyState
import com.radiola.ui.components.StationCard
import androidx.compose.foundation.layout.Arrangement
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -21,6 +22,7 @@ fun FavoritesScreen(
viewModel: FavoritesViewModel = hiltViewModel()
) {
val favorites by viewModel.favorites.collectAsState()
val favoriteIds by viewModel.favoriteIds.collectAsState()
Scaffold(
topBar = {
@@ -50,7 +52,9 @@ fun FavoritesScreen(
items(favorites, key = { it.id }) { station ->
StationCard(
station = station,
onClick = { onStationClick(station) }
isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) }
)
}
}

View File

@@ -4,17 +4,43 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.Station
import com.radiola.domain.repository.FavoritesRepository
import com.radiola.domain.usecase.ToggleFavoriteUseCase
import com.radiola.domain.usecase.auth.PushFavoriteUseCase
import com.radiola.domain.usecase.auth.SyncFavoritesUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class FavoritesViewModel @Inject constructor(
favoritesRepository: FavoritesRepository
private val favoritesRepository: FavoritesRepository,
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
private val pushFavoriteUseCase: PushFavoriteUseCase,
private val syncFavoritesUseCase: SyncFavoritesUseCase
) : ViewModel() {
val favorites: StateFlow<List<Station>> = favoritesRepository.getFavorites()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val favoriteIds: StateFlow<Set<Int>> = favoritesRepository.getFavorites()
.map { list -> list.map { it.id }.toSet() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
init {
viewModelScope.launch {
syncFavoritesUseCase()
}
}
fun toggleFavorite(station: Station) {
viewModelScope.launch {
val currentlyFavorite = favoriteIds.value.contains(station.id)
toggleFavoriteUseCase(station)
pushFavoriteUseCase(station.id, !currentlyFavorite)
}
}
}

View File

@@ -12,7 +12,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
fun BottomNavBar(navController: NavController) {
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
NavigationBar {
NavDestinations.items.forEach { destination ->
NavDestinations.items.filter { it.showInBottomBar }.forEach { destination ->
NavigationBarItem(
icon = { Icon(destination.icon, contentDescription = destination.labelRes) },
label = { Text(destination.labelRes) },

View File

@@ -4,20 +4,24 @@ import androidx.compose.ui.graphics.vector.ImageVector
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.History
import com.composables.icons.lucide.Home
import com.composables.icons.lucide.Mic
import com.composables.icons.lucide.Radio
import com.composables.icons.lucide.Settings
sealed class NavDestinations(
val route: String,
val labelRes: String,
val icon: ImageVector
val icon: ImageVector,
val showInBottomBar: Boolean = true
) {
data object Stations : NavDestinations("stations", "Радио", Lucide.Home)
data object Stations : NavDestinations("stations", "Радио", Lucide.Radio)
data object Favorites : NavDestinations("favorites", "Избранное", Lucide.Heart)
data object History : NavDestinations("history", "История", Lucide.History)
data object Recordings : NavDestinations("recordings", "Записи", Lucide.Mic)
data object Settings : NavDestinations("settings", "Настройки", Lucide.Settings)
data object Auth : NavDestinations("auth", "Вход", Lucide.Settings, showInBottomBar = false)
companion object {
val items = listOf(Stations, Favorites, History, Settings)
val items = listOf(Stations, Favorites, History, Recordings, Settings)
}
}

View File

@@ -14,14 +14,21 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import android.util.Log
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Pause
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.SkipBack
import com.composables.icons.lucide.SkipForward
import com.composables.icons.lucide.Circle
import com.composables.icons.lucide.Square
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Station
import com.radiola.deeplink.DeeplinkNavigator
@@ -33,6 +40,12 @@ fun PlayerBottomSheet(
track: Track?,
isPlaying: Boolean,
onPlayPause: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
isFavorite: Boolean,
onToggleFavorite: () -> Unit,
isRecording: Boolean,
onToggleRecording: () -> Unit,
modifier: Modifier = Modifier,
viewModel: PlayerViewModel = hiltViewModel()
) {
@@ -50,12 +63,39 @@ fun PlayerBottomSheet(
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
station?.let {
Text(
text = it.name,
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF888888)
)
station?.let { s ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = s.name,
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF888888)
)
IconButton(
onClick = onToggleFavorite,
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = Lucide.Heart,
contentDescription = "Избранное",
tint = if (isFavorite) Color(0xFFFF4081) else Color.White,
modifier = Modifier.size(16.dp)
)
}
IconButton(
onClick = onToggleRecording,
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = if (isRecording) Lucide.Square else Lucide.Circle,
contentDescription = if (isRecording) "Остановить запись" else "Запись",
tint = if (isRecording) Color(0xFFFF5252) else Color(0xFFFF5252),
modifier = Modifier.size(16.dp)
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Box(
@@ -96,8 +136,9 @@ fun PlayerBottomSheet(
service = service,
onClick = {
track?.let { t ->
Log.d("PlayerBottomSheet", "DeeplinkButton clicked, track=${t.artist} - ${t.song}")
DeeplinkNavigator.openSearch(context, t, service)
}
} ?: Log.d("PlayerBottomSheet", "DeeplinkButton clicked but track is null")
}
)
}
@@ -107,14 +148,22 @@ fun PlayerBottomSheet(
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically
) {
ControlButton(size = 56.dp, onClick = { })
ControlButton(
size = 56.dp,
icon = Lucide.SkipBack,
onClick = onPrevious
)
ControlButton(
size = 72.dp,
isPlay = true,
isPlaying = isPlaying,
onClick = onPlayPause
)
ControlButton(size = 56.dp, onClick = { })
ControlButton(
size = 56.dp,
icon = Lucide.SkipForward,
onClick = onNext
)
}
}
}
@@ -146,6 +195,7 @@ private fun ControlButton(
size: androidx.compose.ui.unit.Dp,
isPlay: Boolean = false,
isPlaying: Boolean = false,
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -164,6 +214,13 @@ private fun ControlButton(
tint = Color.Black,
modifier = Modifier.size(size * 0.4f)
)
} else if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(size * 0.4f)
)
}
}
}

View File

@@ -7,10 +7,18 @@ import com.radiola.domain.model.Station
import com.radiola.domain.model.Track
import com.radiola.domain.repository.SettingsRepository
import com.radiola.domain.repository.StationRepository
import com.radiola.domain.repository.NowPlayingRepository
import com.radiola.domain.repository.RecordingRepository
import com.radiola.domain.usecase.GetNowPlayingUseCase
import com.radiola.domain.usecase.GetStationsUseCase
import com.radiola.domain.usecase.SearchTrackInServiceUseCase
import com.radiola.domain.repository.TrackHistoryRepository
import com.radiola.domain.usecase.ToggleFavoriteUseCase
import com.radiola.domain.usecase.auth.PushHistoryUseCase
import com.radiola.service.PlayerController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -19,9 +27,15 @@ import javax.inject.Inject
class PlayerViewModel @Inject constructor(
private val playerController: PlayerController,
private val stationRepository: StationRepository,
private val nowPlayingRepository: NowPlayingRepository,
private val getStationsUseCase: GetStationsUseCase,
private val getNowPlayingUseCase: GetNowPlayingUseCase,
private val searchTrackInServiceUseCase: SearchTrackInServiceUseCase,
private val settingsRepository: SettingsRepository
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
private val trackHistoryRepository: TrackHistoryRepository,
private val settingsRepository: SettingsRepository,
private val recordingRepository: RecordingRepository,
private val pushHistoryUseCase: PushHistoryUseCase
) : ViewModel() {
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
@@ -36,31 +50,113 @@ class PlayerViewModel @Inject constructor(
private val _enabledServices = MutableStateFlow<List<DeeplinkService>>(emptyList())
val enabledServices: StateFlow<List<DeeplinkService>> = _enabledServices.asStateFlow()
private val _stations = MutableStateFlow<List<Station>>(emptyList())
val stations: StateFlow<List<Station>> = _stations.asStateFlow()
private val _playlist = MutableStateFlow<List<Station>>(emptyList())
val playlist: StateFlow<List<Station>> = _playlist.asStateFlow()
val isRecording: StateFlow<Boolean> = recordingRepository.isRecording
private var nowPlayingJob: Job? = null
init {
playerController.onSkipToNext = { playNext() }
playerController.onSkipToPrevious = { playPrevious() }
viewModelScope.launch {
getStationsUseCase().collect { _stations.value = it }
}
viewModelScope.launch {
settingsRepository.getEnabledDeeplinkServices().collect { ids ->
_enabledServices.value = DeeplinkService.entries.filter { it.serviceId in ids }
}
}
viewModelScope.launch {
currentStationPrefix.collect { prefix ->
prefix?.let { p ->
// Find station by prefix from repository
// Note: repository only has getStationById; we use a workaround
// In real implementation, add getStationByPrefix to repository
_currentTrack
.filterNotNull()
.distinctUntilChanged()
.collect { track ->
trackHistoryRepository.addTrack(track)
}
}
}
fun play(station: Station, playlist: List<Station>? = null) {
_currentStation.value = station
_currentTrack.value = null
_playlist.value = playlist ?: _stations.value
playerController.play(station.streamUrl, station.prefix, station.name)
viewModelScope.launch { pushHistoryUseCase(station.id) }
nowPlayingJob?.cancel()
nowPlayingJob = viewModelScope.launch {
// Polling loop for Record API now playing
launch {
while (true) {
nowPlayingRepository.refreshNowPlaying()
delay(10_000)
}
}
// Collect now playing for this station (API has priority: covers + accurate metadata)
launch {
getNowPlayingUseCase(station.id)
.distinctUntilChanged()
.collect { track ->
if (track != null) {
_currentTrack.value = track
playerController.updateMetadata(
track.song,
track.artist,
track.coverUrl ?: "",
station.name
)
}
}
}
// Fallback: Icy metadata from stream for stations not in Record API
launch {
playerController.icyTitle
.filterNotNull()
.distinctUntilChanged()
.collect { icyTitle ->
// Only use Icy if no API track is currently active
if (_currentTrack.value == null) {
val track = parseIcyTitle(icyTitle)
if (track != null) {
_currentTrack.value = track
playerController.updateMetadata(
track.song,
track.artist,
"",
station.name
)
}
}
}
}
}
}
fun play(station: Station) {
_currentStation.value = station
playerController.play(station.streamUrl, station.prefix)
viewModelScope.launch {
getNowPlayingUseCase(station.prefix).collect { track ->
_currentTrack.value = track
private fun parseIcyTitle(title: String?): Track? {
if (title.isNullOrBlank()) return null
val separators = listOf(" - ", "", " ")
for (sep in separators) {
val parts = title.split(sep, limit = 2)
if (parts.size == 2) {
return Track(
artist = parts[0].trim(),
song = parts[1].trim(),
coverUrl = null,
stationName = _currentStation.value?.name ?: ""
)
}
}
// No separator found: treat entire string as song title
return Track(
artist = "",
song = title.trim(),
coverUrl = null,
stationName = _currentStation.value?.name ?: ""
)
}
fun pause() {
@@ -68,14 +164,49 @@ class PlayerViewModel @Inject constructor(
}
fun resume() {
playerController.exoPlayer.play()
playerController.play()
}
fun togglePlayPause() {
if (isPlaying.value) pause() else resume()
}
fun playNext() {
val current = _currentStation.value ?: return
val list = _playlist.value
if (list.isEmpty()) return
val index = list.indexOfFirst { it.id == current.id }
val next = list.getOrNull((index + 1).mod(list.size))
next?.let { play(it, list) }
}
fun playPrevious() {
val current = _currentStation.value ?: return
val list = _playlist.value
if (list.isEmpty()) return
val index = list.indexOfFirst { it.id == current.id }
val prev = list.getOrNull((index - 1).mod(list.size))
prev?.let { play(it, list) }
}
fun getDeeplinkUrl(track: Track, service: DeeplinkService): String {
return searchTrackInServiceUseCase(track, service)
}
fun toggleFavorite(station: Station) {
viewModelScope.launch {
toggleFavoriteUseCase(station)
}
}
fun toggleRecording() {
viewModelScope.launch {
if (recordingRepository.isRecording.value) {
recordingRepository.stopRecording()
} else {
val station = _currentStation.value ?: return@launch
recordingRepository.startRecording(station, _currentTrack.value)
}
}
}
}

View File

@@ -0,0 +1,195 @@
package com.radiola.ui.recordings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.Trash2
import com.radiola.domain.model.Recording
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
@Composable
fun RecordingsScreen(
viewModel: RecordingsViewModel = hiltViewModel()
) {
val recordings by viewModel.recordings.collectAsState()
val isRecording by viewModel.isRecording.collectAsState()
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(16.dp)
) {
Text(
text = "Записи",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
if (isRecording) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
colors = CardDefaults.cardColors(
containerColor = Color(0xFFFF5252).copy(alpha = 0.1f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(12.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color(0xFFFF5252))
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Идёт запись...",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFFFF5252)
)
}
}
}
}
if (recordings.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Нет записей",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(recordings, key = { it.id }) { recording ->
RecordingItem(
recording = recording,
onPlay = {
// TODO: play recording via external player or ExoPlayer
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
setDataAndType(
androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
java.io.File(recording.filePath)
),
"audio/*"
)
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
},
onDelete = { viewModel.deleteRecording(recording.id) }
)
}
}
}
}
}
@Composable
private fun RecordingItem(
recording: Recording,
onPlay: () -> Unit,
onDelete: () -> Unit
) {
val dateFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault())
val durationText = recording.duration?.let { ms ->
val minutes = TimeUnit.MILLISECONDS.toMinutes(ms)
val seconds = TimeUnit.MILLISECONDS.toSeconds(ms) % 60
String.format("%02d:%02d", minutes, seconds)
} ?: "??:??"
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
IconButton(
onClick = onPlay,
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Lucide.Play,
contentDescription = "Воспроизвести",
tint = MaterialTheme.colorScheme.primary
)
}
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = recording.stationName,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = recording.trackName ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${dateFormat.format(Date(recording.startTime))}$durationText",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(
onClick = onDelete,
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Lucide.Trash2,
contentDescription = "Удалить",
tint = Color(0xFFFF5252)
)
}
}
}
}

View File

@@ -0,0 +1,29 @@
package com.radiola.ui.recordings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.Recording
import com.radiola.domain.repository.RecordingRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class RecordingsViewModel @Inject constructor(
private val recordingRepository: RecordingRepository
) : ViewModel() {
val recordings: StateFlow<List<Recording>> = recordingRepository.getRecordings()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val isRecording: StateFlow<Boolean> = recordingRepository.isRecording
fun deleteRecording(id: Long) {
viewModelScope.launch {
recordingRepository.deleteRecording(id)
}
}
}

View File

@@ -3,16 +3,21 @@ package com.radiola.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.StationTestStatus
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateToAuth: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel()
) {
@@ -20,7 +25,14 @@ fun SettingsScreen(
val enabledServices by viewModel.enabledServices.collectAsState()
val equalizerPreset by viewModel.equalizerPreset.collectAsState()
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
val isTesting by viewModel.isTesting.collectAsState()
val testProgress by viewModel.testProgress.collectAsState()
val testTotal by viewModel.testTotal.collectAsState()
val testResults by viewModel.testResults.collectAsState()
val isLoggedIn by viewModel.isLoggedIn.collectAsState()
val currentUser by viewModel.currentUser.collectAsState()
val presets = listOf("Flat", "Rock", "Pop", "Jazz", "Bass")
var showReport by remember { mutableStateOf(false) }
Scaffold(
topBar = {
@@ -39,6 +51,37 @@ fun SettingsScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text("Профиль", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
if (isLoggedIn && currentUser != null) {
Column {
Text(
text = currentUser?.email ?: "",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(
onClick = { viewModel.logout() },
modifier = Modifier.fillMaxWidth()
) {
Text("Выйти")
}
}
} else {
Button(
onClick = onNavigateToAuth,
modifier = Modifier.fillMaxWidth()
) {
Text("Войти")
}
}
}
item {
Divider()
}
item {
Text("Таймер сна", style = MaterialTheme.typography.titleMedium)
Slider(
@@ -99,6 +142,94 @@ fun SettingsScreen(
)
}
}
item {
Divider()
}
item {
Text("Тестирование станций", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
if (isTesting) {
Column {
LinearProgressIndicator(
progress = { if (testTotal > 0) testProgress.toFloat() / testTotal else 0f },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(4.dp))
Text("Проверено $testProgress из $testTotal")
}
} else if (testResults.isNotEmpty()) {
val ok = testResults.count { it.status == StationTestStatus.OK }
val okNoMeta = testResults.count { it.status == StationTestStatus.OK_NO_META }
val offline = testResults.count { it.status == StationTestStatus.OFFLINE }
val error = testResults.count { it.status == StationTestStatus.ERROR }
Column {
Text("Всего: ${testResults.size}")
Text("Работают + метаданные: $ok", color = Color(0xFF4CAF50))
Text("Работают без метаданных: $okNoMeta", color = Color(0xFFFF9800))
Text("Оффлайн: $offline", color = Color(0xFFFF5252))
Text("Ошибки: $error", color = Color(0xFFFF5252))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { showReport = true }) {
Text("Подробный отчёт")
}
OutlinedButton(onClick = { viewModel.clearTestResults() }) {
Text("Очистить")
}
}
}
} else {
Button(
onClick = { viewModel.startTesting() },
modifier = Modifier.fillMaxWidth()
) {
Text("Провести тестирование")
}
}
}
}
}
if (showReport) {
AlertDialog(
onDismissRequest = { showReport = false },
title = { Text("Результаты тестирования") },
text = {
LazyColumn(modifier = Modifier.heightIn(max = 400.dp)) {
items(testResults) { result ->
val color = when (result.status) {
StationTestStatus.OK -> Color(0xFF4CAF50)
StationTestStatus.OK_NO_META -> Color(0xFFFF9800)
StationTestStatus.OFFLINE -> Color(0xFFFF5252)
StationTestStatus.ERROR -> Color(0xFFFF5252)
}
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Text(
text = result.stationName,
style = MaterialTheme.typography.bodyMedium,
color = color
)
Text(
text = buildString {
append("${result.status.name}")
result.httpCode?.let { append(" | HTTP $it") }
result.icyTitle?.let { append(" | Icy: $it") }
result.nowPlayingTrack?.let { append(" | NP: $it") }
result.errorMessage?.let { append(" | $it") }
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
},
confirmButton = {
TextButton(onClick = { showReport = false }) {
Text("Закрыть")
}
}
)
}
}

View File

@@ -3,7 +3,12 @@ package com.radiola.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.StationTestResult
import com.radiola.domain.repository.SettingsRepository
import com.radiola.domain.usecase.TestStationsUseCase
import com.radiola.domain.usecase.auth.GetAuthStateUseCase
import com.radiola.domain.usecase.auth.GetCurrentUserUseCase
import com.radiola.domain.usecase.auth.LogoutUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@@ -11,7 +16,11 @@ import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository
private val settingsRepository: SettingsRepository,
private val testStationsUseCase: TestStationsUseCase,
getAuthStateUseCase: GetAuthStateUseCase,
getCurrentUserUseCase: GetCurrentUserUseCase,
private val logoutUseCase: LogoutUseCase
) : ViewModel() {
val sleepTimerMinutes: StateFlow<Int> = settingsRepository.getSleepTimerMinutes()
@@ -26,6 +35,24 @@ class SettingsViewModel @Inject constructor(
val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isLoggedIn: StateFlow<Boolean> = getAuthStateUseCase()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val currentUser = getCurrentUserUseCase()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
private val _isTesting = MutableStateFlow(false)
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
private val _testProgress = MutableStateFlow(0)
val testProgress: StateFlow<Int> = _testProgress.asStateFlow()
private val _testTotal = MutableStateFlow(0)
val testTotal: StateFlow<Int> = _testTotal.asStateFlow()
private val _testResults = MutableStateFlow<List<StationTestResult>>(emptyList())
val testResults: StateFlow<List<StationTestResult>> = _testResults.asStateFlow()
fun setSleepTimer(minutes: Int) {
viewModelScope.launch { settingsRepository.setSleepTimerMinutes(minutes) }
}
@@ -45,4 +72,30 @@ class SettingsViewModel @Inject constructor(
fun setRecordingEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) }
}
fun startTesting() {
viewModelScope.launch {
_isTesting.value = true
_testProgress.value = 0
_testTotal.value = 0
_testResults.value = emptyList()
val results = mutableListOf<StationTestResult>()
testStationsUseCase().collect { progress ->
_testProgress.value = progress.current
_testTotal.value = progress.total
progress.result?.let { results.add(it) }
}
_testResults.value = results
_isTesting.value = false
}
}
fun clearTestResults() {
_testResults.value = emptyList()
}
fun logout() {
viewModelScope.launch { logoutUseCase() }
}
}

View File

@@ -2,13 +2,16 @@ package com.radiola.ui.stations
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.radiola.domain.model.Station
import com.radiola.ui.components.*
@OptIn(ExperimentalMaterial3Api::class)
@@ -24,54 +27,68 @@ fun StationsScreen(
val selectedTag by viewModel.selectedTag.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
val favoriteIds by viewModel.favoriteIds.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Радио") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
Column(modifier = modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Радио") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
}
) { padding ->
Column(
modifier = modifier
.fillMaxSize()
.padding(padding)
) {
SearchBar(
query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
modifier = Modifier.padding(16.dp)
)
FilterChips(
tags = tags,
selectedTag = selectedTag,
onTagSelected = viewModel::onTagSelected,
modifier = Modifier.padding(vertical = 8.dp)
)
when {
isLoading -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) {
CircularProgressIndicator()
}
error != null -> EmptyState(message = error!!)
stations.isEmpty() -> EmptyState(message = "Станции не найдены")
else -> LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
)
when {
isLoading && stations.isEmpty() -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
stations.isEmpty() -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(stations, key = { it.id }) { station ->
StationCard(
station = station,
onClick = { onStationClick(station) }
)
EmptyState(message = error ?: "Станции не найдены")
if (selectedTag != null) {
Button(onClick = { viewModel.onTagSelected(null) }) {
Text("Показать все")
}
}
}
}
else -> LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item(span = { GridItemSpan(maxLineSpan) }) {
SearchBar(
query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
FilterChips(
tags = tags,
selectedTag = selectedTag,
onTagSelected = viewModel::onTagSelected,
modifier = Modifier.padding(vertical = 8.dp)
)
}
items(stations, key = { it.id }) { station ->
StationCard(
station = station,
isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) }
)
}
}
}
}
}

View File

@@ -3,8 +3,11 @@ package com.radiola.ui.stations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.Station
import com.radiola.domain.repository.FavoritesRepository
import com.radiola.domain.repository.StationRepository
import com.radiola.domain.usecase.GetStationsUseCase
import com.radiola.domain.usecase.PlayStationUseCase
import com.radiola.domain.usecase.RefreshStationsUseCase
import com.radiola.domain.usecase.ToggleFavoriteUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
@@ -14,8 +17,11 @@ import javax.inject.Inject
@HiltViewModel
class StationsViewModel @Inject constructor(
private val getStationsUseCase: GetStationsUseCase,
private val refreshStationsUseCase: RefreshStationsUseCase,
private val playStationUseCase: PlayStationUseCase,
private val toggleFavoriteUseCase: ToggleFavoriteUseCase
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
private val favoritesRepository: FavoritesRepository,
private val stationRepository: StationRepository
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
@@ -37,7 +43,9 @@ class StationsViewModel @Inject constructor(
) { allStations, query, tag ->
allStations
.filter { station ->
tag == null || station.tags.contains(tag) || station.genre.equals(tag, ignoreCase = true)
tag == null ||
station.genre.equals(tag, ignoreCase = true) ||
station.tags.any { it.equals(tag, ignoreCase = true) }
}
.filter { station ->
query.isBlank() ||
@@ -46,10 +54,22 @@ class StationsViewModel @Inject constructor(
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val tags: StateFlow<List<String>> = getStationsUseCase()
.map { stations -> stations.flatMap { it.tags }.distinct().sorted() }
val tags: StateFlow<List<String>> = stationRepository.getTags()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val favoriteIds: StateFlow<Set<Int>> = favoritesRepository.getFavorites()
.map { list -> list.map { it.id }.toSet() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
init {
viewModelScope.launch {
_isLoading.value = true
refreshStationsUseCase()
.onFailure { _error.value = it.localizedMessage ?: "Ошибка загрузки" }
_isLoading.value = false
}
}
fun onSearchQueryChange(query: String) {
_searchQuery.value = query
}