From f604ad42e8fafc3c36eb8f00aa39e2c0e7413132 Mon Sep 17 00:00:00 2001 From: nk Date: Tue, 2 Jun 2026 21:31:16 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=D1=80=D0=B5=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=20=D0=B2=D1=81=D0=B5=D1=85=20=D1=8D=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=20+=20=D0=BF=D0=BB=D0=B5=D0=B5=D1=80?= =?UTF-8?q?=20+=20=D0=BE=D1=84=D0=B8=D1=86=D0=B8=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20mono-=D0=BB=D0=BE=D0=B3=D0=BE=D1=82=D0=B8=D0=BF?= =?UTF-8?q?=D1=8B=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - экраны (Станции/Избранное/История/Записи/Настройки/Вход): двухцветные заголовки, токены темы, EmptyState, анимации появления и перестановки - AuthScreen: брендовый локап (AppMark + RadiolaWordmark) - PlayerBottomSheet: живой эфир — LiveEqualizer вместо перемотки, Crossfade трека и play/pause, pressScale, анимация избранного/записи - кнопки музыкальных сервисов: монохромные официальные логотипы (vector drawable из Simple Icons CC0 + Yandex), маппинг serviceLogoRes - DeeplinkBottomSheet: сетка сервисов с логотипами --- .../java/com/radiola/ui/auth/AuthScreen.kt | 161 +++++-- .../ui/components/DeeplinkBottomSheet.kt | 106 ++++- .../com/radiola/ui/components/ServiceLogos.kt | 24 + .../radiola/ui/favorites/FavoritesScreen.kt | 100 ++-- .../com/radiola/ui/history/HistoryScreen.kt | 82 ++-- .../radiola/ui/player/PlayerBottomSheet.kt | 384 +++++++++------ .../radiola/ui/recordings/RecordingsScreen.kt | 259 ++++++----- .../com/radiola/ui/settings/SettingsScreen.kt | 438 +++++++++++++----- .../com/radiola/ui/stations/StationsScreen.kt | 75 ++- .../main/res/drawable/ic_service_apple.xml | 9 + .../main/res/drawable/ic_service_deezer.xml | 9 + .../main/res/drawable/ic_service_spotify.xml | 9 + .../main/res/drawable/ic_service_tidal.xml | 9 + app/src/main/res/drawable/ic_service_vk.xml | 10 + .../main/res/drawable/ic_service_yandex.xml | 10 + .../main/res/drawable/ic_service_youtube.xml | 9 + 16 files changed, 1195 insertions(+), 499 deletions(-) create mode 100644 app/src/main/java/com/radiola/ui/components/ServiceLogos.kt create mode 100644 app/src/main/res/drawable/ic_service_apple.xml create mode 100644 app/src/main/res/drawable/ic_service_deezer.xml create mode 100644 app/src/main/res/drawable/ic_service_spotify.xml create mode 100644 app/src/main/res/drawable/ic_service_tidal.xml create mode 100644 app/src/main/res/drawable/ic_service_vk.xml create mode 100644 app/src/main/res/drawable/ic_service_yandex.xml create mode 100644 app/src/main/res/drawable/ic_service_youtube.xml diff --git a/app/src/main/java/com/radiola/ui/auth/AuthScreen.kt b/app/src/main/java/com/radiola/ui/auth/AuthScreen.kt index 24292fe..e7c7cb0 100644 --- a/app/src/main/java/com/radiola/ui/auth/AuthScreen.kt +++ b/app/src/main/java/com/radiola/ui/auth/AuthScreen.kt @@ -1,16 +1,25 @@ package com.radiola.ui.auth +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape 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.draw.clip +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.radiola.ui.theme.AppMark +import com.radiola.ui.theme.RadiolaTheme +import com.radiola.ui.theme.RadiolaWordmark @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -23,6 +32,10 @@ fun AuthScreen( val email by viewModel.email.collectAsState() var code by remember { mutableStateOf("") } var showCodeInput by remember { mutableStateOf(false) } + // Отслеживаем фокус на полях ввода для акцентной рамки + var emailFocused by remember { mutableStateOf(false) } + var codeFocused by remember { mutableStateOf(false) } + val colors = RadiolaTheme.colors LaunchedEffect(state) { when (state) { @@ -32,36 +45,48 @@ fun AuthScreen( } } - Scaffold( - topBar = { - TopAppBar( - title = { Text("Вход в radiOLA") }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) - ) - } - ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .background(colors.bgBase), + contentAlignment = Alignment.Center + ) { Column( modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally + .fillMaxWidth() + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) ) { + // Логотип + AppMark(size = 84.dp) + Spacer(Modifier.height(4.dp)) + RadiolaWordmark(fontSize = 26) + Spacer(Modifier.height(8.dp)) + + // Заголовок Text( text = if (showCodeInput) "Введите код из письма" else "Добро пожаловать", - style = MaterialTheme.typography.headlineSmall + style = MaterialTheme.typography.headlineLarge, + color = colors.textPrimary, + textAlign = TextAlign.Center ) + // Подзаголовок Text( - text = if (showCodeInput) "Мы отправили 6-значный код на ваш email" else "Войдите, чтобы синхронизировать избранное и историю между устройствами", + text = if (showCodeInput) + "Мы отправили 6-значный код на $email" + else + "Войдите, чтобы синхронизировать избранное и историю", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = colors.textSecondary, + textAlign = TextAlign.Center ) + Spacer(Modifier.height(4.dp)) + if (!showCodeInput) { + // Поле ввода email с акцентной рамкой при фокусе OutlinedTextField( value = email, onValueChange = viewModel::onEmailChange, @@ -72,18 +97,43 @@ fun AuthScreen( imeAction = ImeAction.Done ), keyboardActions = KeyboardActions(onDone = { viewModel.requestCode() }), - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { emailFocused = it.isFocused }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, + unfocusedBorderColor = colors.border, + focusedLabelColor = colors.accent, + unfocusedLabelColor = colors.textSecondary, + focusedTextColor = colors.textPrimary, + unfocusedTextColor = colors.textPrimary, + cursorColor = colors.accent, + focusedContainerColor = colors.surface, + unfocusedContainerColor = colors.surface + ), + shape = RoundedCornerShape(14.dp) ) Button( onClick = { viewModel.requestCode() }, modifier = Modifier.fillMaxWidth(), - enabled = state !is AuthViewModel.AuthState.Loading + enabled = state !is AuthViewModel.AuthState.Loading, + colors = ButtonDefaults.buttonColors( + containerColor = colors.accent, + contentColor = colors.bgBase, + disabledContainerColor = colors.accentDim.copy(alpha = 0.5f), + disabledContentColor = colors.bgBase.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(14.dp) ) { if (state is AuthViewModel.AuthState.Loading) { - CircularProgressIndicator(modifier = Modifier.size(20.dp)) + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = colors.bgBase, + strokeWidth = 2.dp + ) } else { - Text("Получить код") + Text("Получить код", style = MaterialTheme.typography.labelLarge) } } @@ -92,16 +142,15 @@ fun AuthScreen( onClick = onSkip, modifier = Modifier.fillMaxWidth() ) { - Text("Продолжить без входа") + Text( + "Продолжить без входа", + color = colors.textSecondary, + style = MaterialTheme.typography.labelLarge + ) } } } else { - Text( - text = "Код отправлен на $email", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - + // Поле ввода кода с акцентной рамкой при фокусе OutlinedTextField( value = code, onValueChange = { if (it.length <= 6) code = it.uppercase() }, @@ -111,21 +160,44 @@ fun AuthScreen( keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Done ), - keyboardActions = KeyboardActions(onDone = { - viewModel.verifyCode(code) - }), - modifier = Modifier.fillMaxWidth() + keyboardActions = KeyboardActions(onDone = { viewModel.verifyCode(code) }), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { codeFocused = it.isFocused }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, + unfocusedBorderColor = colors.border, + focusedLabelColor = colors.accent, + unfocusedLabelColor = colors.textSecondary, + focusedTextColor = colors.textPrimary, + unfocusedTextColor = colors.textPrimary, + cursorColor = colors.accent, + focusedContainerColor = colors.surface, + unfocusedContainerColor = colors.surface + ), + shape = RoundedCornerShape(14.dp) ) Button( onClick = { viewModel.verifyCode(code) }, modifier = Modifier.fillMaxWidth(), - enabled = state !is AuthViewModel.AuthState.Loading && code.length == 6 + enabled = state !is AuthViewModel.AuthState.Loading && code.length == 6, + colors = ButtonDefaults.buttonColors( + containerColor = colors.accent, + contentColor = colors.bgBase, + disabledContainerColor = colors.accentDim.copy(alpha = 0.5f), + disabledContentColor = colors.bgBase.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(14.dp) ) { if (state is AuthViewModel.AuthState.Loading) { - CircularProgressIndicator(modifier = Modifier.size(20.dp)) + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = colors.bgBase, + strokeWidth = 2.dp + ) } else { - Text("Войти") + Text("Войти", style = MaterialTheme.typography.labelLarge) } } @@ -136,20 +208,29 @@ fun AuthScreen( viewModel.dismissError() } ) { - Text("Отправить код повторно") + Text( + "Отправить код повторно", + color = colors.textSecondary, + style = MaterialTheme.typography.labelLarge + ) } } } } + // Диалог ошибки if (state is AuthViewModel.AuthState.Error) { val errorMessage = (state as AuthViewModel.AuthState.Error).message AlertDialog( onDismissRequest = viewModel::dismissError, - title = { Text("Ошибка") }, - text = { Text(errorMessage) }, + containerColor = colors.elevated, + title = { Text("Ошибка", color = colors.textPrimary) }, + text = { Text(errorMessage, color = colors.textSecondary) }, confirmButton = { - TextButton(onClick = viewModel::dismissError) { + TextButton( + onClick = viewModel::dismissError, + colors = ButtonDefaults.textButtonColors(contentColor = colors.accent) + ) { Text("OK") } } diff --git a/app/src/main/java/com/radiola/ui/components/DeeplinkBottomSheet.kt b/app/src/main/java/com/radiola/ui/components/DeeplinkBottomSheet.kt index 5784889..d955e4b 100644 --- a/app/src/main/java/com/radiola/ui/components/DeeplinkBottomSheet.kt +++ b/app/src/main/java/com/radiola/ui/components/DeeplinkBottomSheet.kt @@ -1,19 +1,29 @@ package com.radiola.ui.components +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape 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.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.Music import com.radiola.deeplink.DeeplinkNavigator import com.radiola.domain.model.DeeplinkService import com.radiola.domain.model.Track import com.radiola.ui.player.PlayerViewModel +import com.radiola.ui.theme.RadiolaTheme +import com.radiola.ui.theme.pressScale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -25,21 +35,44 @@ fun DeeplinkBottomSheet( ) { val context = androidx.compose.ui.platform.LocalContext.current val enabledServices by viewModel.enabledServices.collectAsState() + val colors = RadiolaTheme.colors ModalBottomSheet( onDismissRequest = onDismiss, - modifier = modifier + modifier = modifier, + containerColor = colors.elevated ) { - Text( - text = "Найти трек", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) - ) - LazyColumn { + Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) { + Text( + text = "Найти трек", + style = MaterialTheme.typography.titleLarge, + color = colors.textPrimary + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "${track.artist} — ${track.song}", + style = MaterialTheme.typography.bodyMedium, + color = colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(20.dp)) + } + + // Сетка кнопок сервисов — монохромные, без официальных логотипов + LazyVerticalGrid( + columns = GridCells.Fixed(4), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(bottom = 32.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { items(enabledServices) { service -> - ListItem( - headlineContent = { Text(service.displayName) }, - modifier = Modifier.clickable { + ServiceGridBtn( + service = service, + onClick = { DeeplinkNavigator.openSearch(context, track, service) onDismiss() } @@ -48,3 +81,52 @@ fun DeeplinkBottomSheet( } } } + +/** Монохромная кнопка сервиса в сетке боттомшита. */ +@Composable +private fun ServiceGridBtn( + service: DeeplinkService, + onClick: () -> Unit +) { + val colors = RadiolaTheme.colors + val interaction = remember { MutableInteractionSource() } + Column( + modifier = Modifier + .pressScale(interactionSource = interaction) + .clickable(interactionSource = interaction, indication = null, onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Box( + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(colors.surface2), + contentAlignment = Alignment.Center + ) { + val logoRes = serviceLogoRes(service) + if (logoRes != null) { + Icon( + painter = androidx.compose.ui.res.painterResource(logoRes), + contentDescription = service.displayName, + tint = colors.textSecondary, + modifier = Modifier.size(24.dp) + ) + } else { + Icon( + imageVector = Lucide.Music, + contentDescription = service.displayName, + tint = colors.textSecondary, + modifier = Modifier.size(22.dp) + ) + } + } + Text( + text = service.displayName, + style = MaterialTheme.typography.labelSmall, + color = colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} diff --git a/app/src/main/java/com/radiola/ui/components/ServiceLogos.kt b/app/src/main/java/com/radiola/ui/components/ServiceLogos.kt new file mode 100644 index 0000000..cb92d7b --- /dev/null +++ b/app/src/main/java/com/radiola/ui/components/ServiceLogos.kt @@ -0,0 +1,24 @@ +package com.radiola.ui.components + +import androidx.annotation.DrawableRes +import com.radiola.R +import com.radiola.domain.model.DeeplinkService + +/** + * Официальные монохромные логотипы музыкальных сервисов (vector drawable). + * Геометрия — из public-domain набора Simple Icons (CC0) и свободного знака Yandex Music. + * Сами бренды — товарные знаки правообладателей; используются как one-color версии + * для номинативного указания «открыть трек в сервисе». + * Для BOOM официального знака нет — вернётся null (рисуется нейтральная иконка-нота). + */ +@DrawableRes +fun serviceLogoRes(service: DeeplinkService): Int? = when (service.serviceId) { + "yandex" -> R.drawable.ic_service_yandex + "vk" -> R.drawable.ic_service_vk + "spotify" -> R.drawable.ic_service_spotify + "apple" -> R.drawable.ic_service_apple + "youtube" -> R.drawable.ic_service_youtube + "tidal" -> R.drawable.ic_service_tidal + "deezer" -> R.drawable.ic_service_deezer + else -> null +} diff --git a/app/src/main/java/com/radiola/ui/favorites/FavoritesScreen.kt b/app/src/main/java/com/radiola/ui/favorites/FavoritesScreen.kt index 4749714..6ecd9b9 100644 --- a/app/src/main/java/com/radiola/ui/favorites/FavoritesScreen.kt +++ b/app/src/main/java/com/radiola/ui/favorites/FavoritesScreen.kt @@ -1,5 +1,10 @@ package com.radiola.ui.favorites +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -7,14 +12,20 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.composables.icons.lucide.Heart +import com.composables.icons.lucide.Lucide import com.radiola.domain.model.Station import com.radiola.ui.components.EmptyState import com.radiola.ui.components.StationCard +import com.radiola.ui.theme.RadiolaTheme import androidx.compose.foundation.layout.Arrangement -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun FavoritesScreen( onStationClick: (Station) -> Unit, @@ -23,40 +34,69 @@ fun FavoritesScreen( ) { val favorites by viewModel.favorites.collectAsState() val favoriteIds by viewModel.favoriteIds.collectAsState() + val colors = RadiolaTheme.colors - Scaffold( - topBar = { - TopAppBar( - title = { Text("Избранное") }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 20.dp) + ) { + // Заголовок с двухцветным текстом и счётчиком + Row( + modifier = Modifier.padding(top = 20.dp, bottom = 20.dp), + verticalAlignment = androidx.compose.ui.Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = colors.textPrimary)) { append("Из") } + withStyle(SpanStyle(color = colors.accent)) { append("бранное") } + }, + style = MaterialTheme.typography.headlineLarge + ) + if (favorites.isNotEmpty()) { + Text( + text = "${favorites.size}", + style = MaterialTheme.typography.titleMedium, + color = colors.textSecondary, + modifier = Modifier.padding(bottom = 4.dp) ) - ) + } } - ) { padding -> - if (favorites.isEmpty()) { - EmptyState( - message = "Нет избранных станций", - modifier = Modifier.fillMaxSize().padding(padding) - ) - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = modifier - .fillMaxSize() - .padding(padding), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(favorites, key = { it.id }) { station -> - StationCard( - station = station, - isFavorite = favoriteIds.contains(station.id), - onClick = { onStationClick(station) }, - onFavoriteClick = { viewModel.toggleFavorite(station) } + + Crossfade( + targetState = favorites.isEmpty(), + label = "favoritesState" + ) { isEmpty -> + if (isEmpty) { + AnimatedVisibility( + visible = true, + enter = fadeIn() + slideInVertically() + ) { + EmptyState( + message = "Нет избранных станций", + icon = Lucide.Heart, + modifier = Modifier.fillMaxSize() ) } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + items(favorites, key = { it.id }) { station -> + StationCard( + station = station, + isFavorite = favoriteIds.contains(station.id), + onClick = { onStationClick(station) }, + onFavoriteClick = { viewModel.toggleFavorite(station) }, + modifier = Modifier.animateItemPlacement() + ) + } + } } } } diff --git a/app/src/main/java/com/radiola/ui/history/HistoryScreen.kt b/app/src/main/java/com/radiola/ui/history/HistoryScreen.kt index b65fb78..bb89fae 100644 --- a/app/src/main/java/com/radiola/ui/history/HistoryScreen.kt +++ b/app/src/main/java/com/radiola/ui/history/HistoryScreen.kt @@ -1,20 +1,31 @@ package com.radiola.ui.history +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.ExperimentalFoundationApi 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.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.composables.icons.lucide.History +import com.composables.icons.lucide.Lucide import com.radiola.domain.model.Track import com.radiola.ui.components.DeeplinkBottomSheet import com.radiola.ui.components.EmptyState import com.radiola.ui.components.SearchBar import com.radiola.ui.components.TrackListItem +import com.radiola.ui.theme.RadiolaTheme -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun HistoryScreen( modifier: Modifier = Modifier, @@ -23,41 +34,60 @@ fun HistoryScreen( val history by viewModel.history.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState() var selectedTrack by remember { mutableStateOf(null) } + val colors = RadiolaTheme.colors - Scaffold( - topBar = { - TopAppBar( - title = { Text("История") }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) - ) - } - ) { padding -> - Column( - modifier = modifier - .fillMaxSize() - .padding(padding) - ) { - SearchBar( - query = searchQuery, - onQueryChange = viewModel::onSearchQueryChange, - placeholder = "Поиск в истории...", - modifier = Modifier.padding(16.dp) - ) - if (history.isEmpty()) { - EmptyState(message = "История пуста", modifier = Modifier.fillMaxSize()) + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 20.dp) + ) { + // Двухцветный заголовок + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = colors.textPrimary)) { append("Исто") } + withStyle(SpanStyle(color = colors.accent)) { append("рия") } + }, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.padding(top = 20.dp, bottom = 16.dp) + ) + + SearchBar( + query = searchQuery, + onQueryChange = viewModel::onSearchQueryChange, + placeholder = "Поиск в истории...", + modifier = Modifier.padding(bottom = 16.dp) + ) + + Crossfade( + targetState = history.isEmpty(), + label = "historyState" + ) { isEmpty -> + if (isEmpty) { + AnimatedVisibility( + visible = true, + enter = fadeIn() + slideInVertically() + ) { + EmptyState( + message = "История пуста", + icon = Lucide.History, + modifier = Modifier.fillMaxSize() + ) + } } else { LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp) + contentPadding = PaddingValues(bottom = 16.dp) ) { items(history) { track -> TrackListItem( track = track, onClick = { selectedTrack = track } ) - Divider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f)) + // Разделитель с низким alpha + HorizontalDivider( + color = colors.border.copy(alpha = 0.5f), + thickness = 0.5.dp + ) } } } diff --git a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt index 968fd44..429ea23 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt @@ -1,7 +1,12 @@ package com.radiola.ui.player +import android.util.Log +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -12,27 +17,34 @@ 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.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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp 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.Mic +import com.composables.icons.lucide.MicOff +import com.composables.icons.lucide.Music import com.composables.icons.lucide.Pause import com.composables.icons.lucide.Play +import com.composables.icons.lucide.Radio 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.deeplink.DeeplinkNavigator import com.radiola.domain.model.DeeplinkService import com.radiola.domain.model.Station -import com.radiola.deeplink.DeeplinkNavigator import com.radiola.domain.model.Track +import com.radiola.ui.theme.LiveEqualizer +import com.radiola.ui.theme.Motion +import com.radiola.ui.theme.RadiolaTheme +import com.radiola.ui.theme.pressScale @Composable fun PlayerBottomSheet( @@ -51,176 +63,250 @@ fun PlayerBottomSheet( ) { val context = LocalContext.current val enabledServices by viewModel.enabledServices.collectAsState() + val colors = RadiolaTheme.colors Column( modifier = modifier .fillMaxWidth() - .background( - brush = Brush.verticalGradient( - colors = listOf(Color(0xFF1a1a2e), Color(0xFF121212)) - ) - ) - .padding(24.dp), + .background(colors.bgBase) + .padding(horizontal = 24.dp, vertical = 20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - 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)) + // Метка «В ЭФИРЕ» + Text( + text = "В ЭФИРЕ", + style = MaterialTheme.typography.labelSmall, + color = colors.accent, + letterSpacing = 2.sp, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(16.dp)) + + // Обложка станции/трека Box( modifier = Modifier - .size(240.dp) - .clip(RoundedCornerShape(20.dp)) - .background( - Brush.linearGradient(listOf(Color(0xFF667eea), Color(0xFF764ba2))) + .size(220.dp) + .clip(RoundedCornerShape(24.dp)) + .background(colors.surface2), + contentAlignment = Alignment.Center + ) { + val coverModel = track?.coverUrl ?: station?.coverUrl + if (!coverModel.isNullOrBlank()) { + AsyncImage( + model = coverModel, + contentDescription = station?.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) - ) { - AsyncImage( - model = track?.coverUrl ?: station?.coverUrl, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = track?.song ?: "", - style = MaterialTheme.typography.headlineMedium, - maxLines = 1 - ) - Text( - text = track?.artist ?: "", - style = MaterialTheme.typography.bodyLarge, - color = Color(0xFF888888), - maxLines = 1 - ) - Spacer(modifier = Modifier.height(20.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 8.dp) - ) { - items(enabledServices) { service -> - DeeplinkButton( - 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") - } + } else { + Icon( + imageVector = Lucide.Radio, + contentDescription = null, + tint = colors.textMuted, + modifier = Modifier.size(56.dp) ) } } - Spacer(modifier = Modifier.height(30.dp)) + Spacer(Modifier.height(22.dp)) + + // Название трека и исполнитель с Crossfade при смене + Crossfade( + targetState = track?.song to track?.artist, + animationSpec = tween(Motion.Medium), + label = "trackInfo" + ) { (song, artist) -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = song ?: (station?.name ?: ""), + style = MaterialTheme.typography.headlineLarge, + color = colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + Spacer(Modifier.height(4.dp)) + Text( + text = artist ?: (station?.genre ?: ""), + style = MaterialTheme.typography.bodyLarge, + color = colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } + Spacer(Modifier.height(20.dp)) + + // Живой эквалайзер — вместо прогресс-бара (эфир нельзя перематывать) + LiveEqualizer( + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + playing = isPlaying, + color = colors.accent + ) + Spacer(Modifier.height(24.dp)) + + // Управление воспроизведением Row( horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically ) { - ControlButton( - size = 56.dp, - icon = Lucide.SkipBack, - onClick = onPrevious + // Кнопка избранного + val heartTint by animateColorAsState( + targetValue = if (isFavorite) colors.accent else colors.textSecondary, + animationSpec = tween(Motion.Medium), + label = "heartTint" ) - ControlButton( - size = 72.dp, - isPlay = true, - isPlaying = isPlaying, - onClick = onPlayPause - ) - ControlButton( - size = 56.dp, - icon = Lucide.SkipForward, - onClick = onNext + PlayerIconBtn(size = 44.dp) { + IconButton(onClick = onToggleFavorite, modifier = Modifier.size(44.dp)) { + Icon(Lucide.Heart, "Избранное", tint = heartTint, modifier = Modifier.size(22.dp)) + } + } + + // Кнопка «предыдущая станция» + PlayerIconBtn(size = 48.dp) { + IconButton(onClick = onPrevious, modifier = Modifier.size(48.dp)) { + Icon(Lucide.SkipBack, "Предыдущая", tint = colors.textPrimary, modifier = Modifier.size(24.dp)) + } + } + + // Главная кнопка play/pause + val playInteraction = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .size(68.dp) + .clip(CircleShape) + .background(colors.accent) + .pressScale(interactionSource = playInteraction) + .clickable(interactionSource = playInteraction, indication = null, onClick = onPlayPause), + contentAlignment = Alignment.Center + ) { + Crossfade( + targetState = isPlaying, + animationSpec = tween(Motion.Fast), + label = "playPause" + ) { playing -> + Icon( + imageVector = if (playing) Lucide.Pause else Lucide.Play, + contentDescription = if (playing) "Пауза" else "Воспроизвести", + tint = colors.bgBase, + modifier = Modifier.size(30.dp) + ) + } + } + + // Кнопка «следующая станция» + PlayerIconBtn(size = 48.dp) { + IconButton(onClick = onNext, modifier = Modifier.size(48.dp)) { + Icon(Lucide.SkipForward, "Следующая", tint = colors.textPrimary, modifier = Modifier.size(24.dp)) + } + } + + // Кнопка записи + val recordTint by animateColorAsState( + targetValue = if (isRecording) colors.live else colors.textSecondary, + animationSpec = tween(Motion.Medium), + label = "recordTint" ) + PlayerIconBtn(size = 44.dp) { + IconButton(onClick = onToggleRecording, modifier = Modifier.size(44.dp)) { + Crossfade( + targetState = isRecording, + animationSpec = tween(Motion.Fast), + label = "recordIcon" + ) { recording -> + Icon( + imageVector = if (recording) Lucide.MicOff else Lucide.Mic, + contentDescription = if (recording) "Остановить запись" else "Запись", + tint = recordTint, + modifier = Modifier.size(20.dp) + ) + } + } + } + } + Spacer(Modifier.height(20.dp)) + + // Ряд кнопок музыкальных сервисов + if (enabledServices.isNotEmpty()) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 4.dp) + ) { + items(enabledServices) { service -> + ServiceDeeplinkBtn( + service = service, + onClick = { + track?.let { t -> + Log.d("PlayerBottomSheet", "Deeplink: ${t.artist} - ${t.song}") + DeeplinkNavigator.openSearch(context, t, service) + } ?: Log.d("PlayerBottomSheet", "Deeplink нажат, но трек null") + } + ) + } + } } } } +/** Обёртка для иконок-кнопок управления — прозрачный фон. */ @Composable -private fun DeeplinkButton( - service: DeeplinkService, - onClick: () -> Unit, - modifier: Modifier = Modifier +private fun PlayerIconBtn( + size: Dp, + content: @Composable () -> Unit ) { - Box( - modifier = modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background(Color(0xFF2A2A2A)) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center + Box(modifier = Modifier.size(size), contentAlignment = Alignment.Center) { + content() + } +} + +/** Монохромная кнопка сервиса для поиска трека (без официальных логотипов). */ +@Composable +private fun ServiceDeeplinkBtn( + service: DeeplinkService, + onClick: () -> Unit +) { + val colors = RadiolaTheme.colors + val interaction = remember { MutableInteractionSource() } + Column( + modifier = Modifier + .pressScale(interactionSource = interaction) + .clickable(interactionSource = interaction, indication = null, onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(colors.surface2), + contentAlignment = Alignment.Center + ) { + val logoRes = com.radiola.ui.components.serviceLogoRes(service) + if (logoRes != null) { + Icon( + painter = androidx.compose.ui.res.painterResource(logoRes), + contentDescription = service.displayName, + tint = colors.textSecondary, + modifier = Modifier.size(22.dp) + ) + } else { + Icon( + imageVector = Lucide.Music, + contentDescription = service.displayName, + tint = colors.textSecondary, + modifier = Modifier.size(20.dp) + ) + } + } Text( - text = service.displayName.take(2), + text = service.displayName.take(8), style = MaterialTheme.typography.labelSmall, - color = Color(0xFF888888) + color = colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } - -@Composable -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 -) { - Box( - modifier = modifier - .size(size) - .clip(CircleShape) - .background(if (isPlay) Color.White else Color(0xFF2A2A2A)) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center - ) { - if (isPlay) { - Icon( - imageVector = if (isPlaying) Lucide.Pause else Lucide.Play, - contentDescription = if (isPlaying) "Pause" else "Play", - 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) - ) - } - } -} diff --git a/app/src/main/java/com/radiola/ui/recordings/RecordingsScreen.kt b/app/src/main/java/com/radiola/ui/recordings/RecordingsScreen.kt index 1fab710..9608266 100644 --- a/app/src/main/java/com/radiola/ui/recordings/RecordingsScreen.kt +++ b/app/src/main/java/com/radiola/ui/recordings/RecordingsScreen.kt @@ -1,30 +1,46 @@ package com.radiola.ui.recordings +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape 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.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Mic import com.composables.icons.lucide.Play import com.composables.icons.lucide.Trash2 import com.radiola.domain.model.Recording +import com.radiola.ui.theme.RadiolaTheme +import com.radiola.ui.theme.pressScale +import com.radiola.ui.components.EmptyState import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.concurrent.TimeUnit +@OptIn(ExperimentalFoundationApi::class) @Composable fun RecordingsScreen( viewModel: RecordingsViewModel = hiltViewModel() @@ -32,89 +48,97 @@ fun RecordingsScreen( val recordings by viewModel.recordings.collectAsState() val isRecording by viewModel.isRecording.collectAsState() val context = LocalContext.current + val colors = RadiolaTheme.colors Column( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .padding(16.dp) + .padding(horizontal = 20.dp) ) { + // Двухцветный заголовок Text( - text = "Записи", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(bottom = 16.dp) + text = buildAnnotatedString { + withStyle(SpanStyle(color = colors.textPrimary)) { append("За") } + withStyle(SpanStyle(color = colors.accent)) { append("писи") } + }, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.padding(top = 20.dp, bottom = 16.dp) ) + // Баннер активной записи if (isRecording) { - Card( + Row( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp), - colors = CardDefaults.cardColors( - containerColor = Color(0xFFFF5252).copy(alpha = 0.1f) - ) + .clip(RoundedCornerShape(16.dp)) + .background(colors.live.copy(alpha = 0.12f)) + .border(1.dp, colors.live.copy(alpha = 0.4f), RoundedCornerShape(16.dp)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - 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) - ) - } + Row(verticalAlignment = Alignment.CenterVertically) { + // Индикатор записи + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(colors.live) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = "Идёт запись", + style = MaterialTheme.typography.labelLarge, + color = colors.live + ) } } + Spacer(modifier = Modifier.height(16.dp)) } - 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) } + Crossfade( + targetState = recordings.isEmpty(), + label = "recordingsState" + ) { isEmpty -> + if (isEmpty) { + AnimatedVisibility( + visible = true, + enter = fadeIn() + slideInVertically() + ) { + EmptyState( + message = "Нет записей", + icon = Lucide.Mic, + modifier = Modifier.fillMaxSize() ) } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(bottom = 16.dp) + ) { + items(recordings, key = { it.id }) { recording -> + RecordingItem( + recording = recording, + onPlay = { + // TODO: воспроизвести запись через 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) }, + modifier = Modifier.animateItemPlacement() + ) + } + } } } } @@ -124,72 +148,81 @@ fun RecordingsScreen( private fun RecordingItem( recording: Recording, onPlay: () -> Unit, - onDelete: () -> Unit + onDelete: () -> Unit, + modifier: Modifier = Modifier ) { + val colors = RadiolaTheme.colors 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) } ?: "??:??" + val interactionPlay = remember { MutableInteractionSource() } - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(colors.surface) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Row( + // Круглая кнопка play + Box( modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + .size(44.dp) + .clip(CircleShape) + .background(colors.surface2) + .pressScale(interactionSource = interactionPlay) + .clickable(interactionSource = interactionPlay, indication = null, onClick = onPlay), + contentAlignment = Alignment.Center ) { - IconButton( - onClick = onPlay, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Lucide.Play, - contentDescription = "Воспроизвести", - tint = MaterialTheme.colorScheme.primary - ) - } + Icon( + imageVector = Lucide.Play, + contentDescription = "Воспроизвести", + tint = colors.accent, + modifier = Modifier.size(18.dp) + ) + } - Column( - modifier = Modifier.weight(1f) - ) { + // Метаданные записи + Column(modifier = Modifier.weight(1f)) { + Text( + text = recording.stationName, + style = MaterialTheme.typography.titleMedium, + color = colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!recording.trackName.isNullOrBlank()) { Text( - text = recording.stationName, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = recording.trackName ?: "", + text = recording.trackName, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = colors.textSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis ) - Text( - text = "${dateFormat.format(Date(recording.startTime))} • $durationText", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } + Text( + text = "${dateFormat.format(Date(recording.startTime))} · $durationText", + style = MaterialTheme.typography.labelSmall, + color = colors.textMuted + ) + } - IconButton( - onClick = onDelete, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Lucide.Trash2, - contentDescription = "Удалить", - tint = Color(0xFFFF5252) - ) - } + // Кнопка удаления + IconButton( + onClick = onDelete, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Lucide.Trash2, + contentDescription = "Удалить", + tint = colors.live, + modifier = Modifier.size(18.dp) + ) } } } diff --git a/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt b/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt index ec77746..e6d025a 100644 --- a/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt @@ -1,18 +1,35 @@ package com.radiola.ui.settings +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border 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.CircleShape +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.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.User import com.radiola.domain.model.DeeplinkService import com.radiola.domain.model.StationTestStatus +import com.radiola.ui.theme.Motion +import com.radiola.ui.theme.RadiolaTheme @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -33,156 +50,343 @@ fun SettingsScreen( val currentUser by viewModel.currentUser.collectAsState() val presets = listOf("Flat", "Rock", "Pop", "Jazz", "Bass") var showReport by remember { mutableStateOf(false) } + val colors = RadiolaTheme.colors - Scaffold( - topBar = { - TopAppBar( - title = { Text("Настройки") }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + contentPadding = PaddingValues(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + item { + // Двухцветный заголовок + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = colors.textPrimary)) { append("Нас") } + withStyle(SpanStyle(color = colors.accent)) { append("тройки") } + }, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.padding(top = 20.dp) ) } - ) { padding -> - LazyColumn( - modifier = modifier - .fillMaxSize() - .padding(padding), - 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 { + + // --- Профиль --- + item { + SectionLabel("ПРОФИЛЬ") + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(colors.surface) + .border(1.dp, colors.border, RoundedCornerShape(16.dp)) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + // Аватар-заглушка + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(colors.surface2), + contentAlignment = Alignment.Center + ) { + Icon(Lucide.User, null, tint = colors.textMuted, modifier = Modifier.size(22.dp)) + } + Column(modifier = Modifier.weight(1f)) { + if (isLoggedIn && currentUser != null) { Text( text = currentUser?.email ?: "", - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.titleMedium, + color = colors.textPrimary ) - Spacer(modifier = Modifier.height(4.dp)) - OutlinedButton( - onClick = { viewModel.logout() }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Выйти") - } + Text( + text = "Аккаунт активен", + style = MaterialTheme.typography.labelMedium, + color = colors.textSecondary + ) + } else { + Text( + text = "Вы не вошли", + style = MaterialTheme.typography.titleMedium, + color = colors.textPrimary + ) + Text( + text = "Вход не обязателен — для синхронизации", + style = MaterialTheme.typography.labelMedium, + color = colors.textSecondary + ) + } + } + if (isLoggedIn && currentUser != null) { + OutlinedButton( + onClick = { viewModel.logout() }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.textSecondary), + border = androidx.compose.foundation.BorderStroke(1.dp, colors.border) + ) { + Text("Выйти") } } else { Button( onClick = onNavigateToAuth, - modifier = Modifier.fillMaxWidth() + colors = ButtonDefaults.buttonColors( + containerColor = colors.accent, + contentColor = colors.bgBase + ), + shape = RoundedCornerShape(10.dp) ) { - Text("Войти") + Text("Войти", fontWeight = FontWeight.SemiBold) } } } + } - item { - Divider() - } - - item { - Text("Таймер сна", style = MaterialTheme.typography.titleMedium) + // --- Таймер сна --- + item { + SectionLabel("ТАЙМЕР СНА") + Spacer(Modifier.height(8.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(colors.surface) + .border(1.dp, colors.border, RoundedCornerShape(16.dp)) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Отключить через", + style = MaterialTheme.typography.titleMedium, + color = colors.textPrimary + ) + Text( + text = "$sleepTimer мин", + style = MaterialTheme.typography.titleMedium, + color = colors.accent, + fontWeight = FontWeight.SemiBold + ) + } Slider( value = sleepTimer.toFloat(), onValueChange = { viewModel.setSleepTimer(it.toInt()) }, valueRange = 5f..120f, - steps = 22 + steps = 22, + colors = SliderDefaults.colors( + thumbColor = colors.accent, + activeTrackColor = colors.accent, + inactiveTrackColor = colors.surface2 + ) ) - Text("$sleepTimer мин", style = MaterialTheme.typography.bodyMedium) } - item { - Text("Эквалайзер", style = MaterialTheme.typography.titleMedium) - SingleChoiceSegmentedButtonRow { - presets.forEach { preset -> - SegmentedButton( - selected = equalizerPreset == preset, - onClick = { viewModel.setEqualizerPreset(preset) }, - shape = MaterialTheme.shapes.small - ) { - Text(preset) - } + } + + // --- Эквалайзер --- + item { + SectionLabel("ЭКВАЛАЙЗЕР") + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(colors.surface) + .border(1.dp, colors.border, RoundedCornerShape(16.dp)) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + presets.forEach { preset -> + val selected = equalizerPreset == preset + val bgColor by animateColorAsState( + targetValue = if (selected) colors.accent else colors.surface2, + animationSpec = tween(Motion.Medium), + label = "eqSegment" + ) + val textColor by animateColorAsState( + targetValue = if (selected) colors.bgBase else colors.textSecondary, + animationSpec = tween(Motion.Medium), + label = "eqText" + ) + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .background(bgColor) + .clickable { viewModel.setEqualizerPreset(preset) } + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = preset, + style = MaterialTheme.typography.labelLarge, + color = textColor, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal + ) } } } - item { - Text("Музыкальные сервисы", style = MaterialTheme.typography.titleMedium) - Column { - DeeplinkService.entries.forEach { service -> - val checked = service.serviceId in enabledServices - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { viewModel.toggleService(service.serviceId, !checked) } - .padding(vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(service.displayName) - Switch( - checked = checked, - onCheckedChange = { viewModel.toggleService(service.serviceId, it) } + } + + // --- Музыкальные сервисы --- + item { + SectionLabel("МУЗЫКАЛЬНЫЕ СЕРВИСЫ") + Spacer(Modifier.height(8.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(colors.surface) + .border(1.dp, colors.border, RoundedCornerShape(16.dp)) + ) { + DeeplinkService.entries.forEachIndexed { index, service -> + val checked = service.serviceId in enabledServices + val trackColor by animateColorAsState( + targetValue = if (checked) colors.accent else colors.surface2, + animationSpec = tween(Motion.Medium), + label = "switchTrack" + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { viewModel.toggleService(service.serviceId, !checked) } + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = service.displayName, + style = MaterialTheme.typography.titleMedium, + color = colors.textPrimary + ) + Switch( + checked = checked, + onCheckedChange = { viewModel.toggleService(service.serviceId, it) }, + colors = SwitchDefaults.colors( + checkedThumbColor = colors.bgBase, + checkedTrackColor = colors.accent, + uncheckedThumbColor = colors.textMuted, + uncheckedTrackColor = colors.surface2 ) - } + ) + } + if (index < DeeplinkService.entries.size - 1) { + HorizontalDivider( + color = colors.border, + modifier = Modifier.padding(horizontal = 16.dp) + ) } } } - item { + } + + // --- Запись эфира --- + item { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(colors.surface) + .border(1.dp, colors.border, RoundedCornerShape(16.dp)) + ) { Row( modifier = Modifier .fillMaxWidth() .clickable { viewModel.setRecordingEnabled(!isRecordingEnabled) } - .padding(vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Text("Запись эфира") + Column { + Text( + text = "Запись эфира", + style = MaterialTheme.typography.titleMedium, + color = colors.textPrimary + ) + Text( + text = "Сохранять в файл при воспроизведении", + style = MaterialTheme.typography.labelMedium, + color = colors.textSecondary + ) + } Switch( checked = isRecordingEnabled, - onCheckedChange = { viewModel.setRecordingEnabled(it) } + onCheckedChange = { viewModel.setRecordingEnabled(it) }, + colors = SwitchDefaults.colors( + checkedThumbColor = colors.bgBase, + checkedTrackColor = colors.accent, + uncheckedThumbColor = colors.textMuted, + uncheckedTrackColor = colors.surface2 + ) ) } } - item { - Divider() - } - item { - Text("Тестирование станций", style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(8.dp)) + } + + // --- Тестирование станций --- + item { + SectionLabel("ТЕСТИРОВАНИЕ СТАНЦИЙ") + Spacer(Modifier.height(8.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(colors.surface) + .border(1.dp, colors.border, RoundedCornerShape(16.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(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") - } + Text( + text = "Проверено $testProgress из $testTotal", + style = MaterialTheme.typography.bodyMedium, + color = colors.textSecondary + ) + LinearProgressIndicator( + progress = { if (testTotal > 0) testProgress.toFloat() / testTotal else 0f }, + modifier = Modifier.fillMaxWidth(), + color = colors.accent, + trackColor = colors.surface2 + ) } 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("Очистить") - } + Text("Всего: ${testResults.size}", color = colors.textPrimary, style = MaterialTheme.typography.bodyMedium) + Text("Работают + метаданные: $ok", color = Color(0xFF4CAF50), style = MaterialTheme.typography.bodyMedium) + Text("Работают без метаданных: $okNoMeta", color = Color(0xFFFF9800), style = MaterialTheme.typography.bodyMedium) + Text("Оффлайн: $offline", color = colors.live, style = MaterialTheme.typography.bodyMedium) + Text("Ошибки: $error", color = colors.live, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { showReport = true }, + colors = ButtonDefaults.buttonColors( + containerColor = colors.accent, + contentColor = colors.bgBase + ) + ) { + Text("Подробный отчёт") + } + OutlinedButton( + onClick = { viewModel.clearTestResults() }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.textSecondary), + border = androidx.compose.foundation.BorderStroke(1.dp, colors.border) + ) { + Text("Очистить") } } } else { - Button( + OutlinedButton( onClick = { viewModel.startTesting() }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.accent), + border = androidx.compose.foundation.BorderStroke(1.dp, colors.accent) ) { Text("Провести тестирование") } @@ -191,18 +395,26 @@ fun SettingsScreen( } } + // Диалог отчёта if (showReport) { AlertDialog( onDismissRequest = { showReport = false }, - title = { Text("Результаты тестирования") }, + containerColor = colors.elevated, + title = { + Text( + "Результаты тестирования", + color = colors.textPrimary, + style = MaterialTheme.typography.titleLarge + ) + }, 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) + StationTestStatus.OFFLINE -> colors.live + StationTestStatus.ERROR -> colors.live } Column(modifier = Modifier.padding(vertical = 4.dp)) { Text( @@ -219,17 +431,31 @@ fun SettingsScreen( result.errorMessage?.let { append(" | $it") } }, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = colors.textMuted ) } } } }, confirmButton = { - TextButton(onClick = { showReport = false }) { + TextButton( + onClick = { showReport = false }, + colors = ButtonDefaults.textButtonColors(contentColor = colors.accent) + ) { Text("Закрыть") } } ) } } + +/** Подпись секции: заглавные буквы, textMuted, labelSmall. */ +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = RadiolaTheme.colors.textMuted, + letterSpacing = 1.sp + ) +} diff --git a/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt b/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt index 19cb9f3..354860c 100644 --- a/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt +++ b/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt @@ -1,5 +1,9 @@ package com.radiola.ui.stations +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan @@ -9,12 +13,16 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.radiola.domain.model.Station import com.radiola.ui.components.* +import com.radiola.ui.theme.RadiolaTheme -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun StationsScreen( onStationClick: (Station) -> Unit, @@ -28,43 +36,63 @@ fun StationsScreen( val isLoading by viewModel.isLoading.collectAsState() val error by viewModel.error.collectAsState() val favoriteIds by viewModel.favoriteIds.collectAsState() + val colors = RadiolaTheme.colors - Column(modifier = modifier.fillMaxSize()) { - TopAppBar( - title = { Text("Радио") }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 20.dp) + ) { + // Двухцветный заголовок экрана + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = colors.textPrimary)) { append("Откройте ") } + withStyle(SpanStyle(color = colors.accent)) { append("радио") } + }, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.padding(top = 20.dp, bottom = 16.dp) ) + when { isLoading && stations.isEmpty() -> Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = colors.accent) } - stations.isEmpty() -> Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) + + stations.isEmpty() -> { + AnimatedVisibility( + visible = true, + enter = fadeIn() + slideInVertically() ) { - EmptyState(message = error ?: "Станции не найдены") - if (selectedTag != null) { - Button(onClick = { viewModel.onTagSelected(null) }) { - Text("Показать все") + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + EmptyState(message = error ?: "Станции не найдены") + if (selectedTag != null) { + OutlinedButton( + onClick = { viewModel.onTagSelected(null) }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = colors.accent + ), + border = androidx.compose.foundation.BorderStroke(1.dp, colors.accent) + ) { + 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) + contentPadding = PaddingValues(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) ) { item(span = { GridItemSpan(maxLineSpan) }) { SearchBar( @@ -85,7 +113,8 @@ fun StationsScreen( station = station, isFavorite = favoriteIds.contains(station.id), onClick = { onStationClick(station) }, - onFavoriteClick = { viewModel.toggleFavorite(station) } + onFavoriteClick = { viewModel.toggleFavorite(station) }, + modifier = Modifier.animateItemPlacement() ) } } diff --git a/app/src/main/res/drawable/ic_service_apple.xml b/app/src/main/res/drawable/ic_service_apple.xml new file mode 100644 index 0000000..1e0a80c --- /dev/null +++ b/app/src/main/res/drawable/ic_service_apple.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_service_deezer.xml b/app/src/main/res/drawable/ic_service_deezer.xml new file mode 100644 index 0000000..864a10d --- /dev/null +++ b/app/src/main/res/drawable/ic_service_deezer.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_service_spotify.xml b/app/src/main/res/drawable/ic_service_spotify.xml new file mode 100644 index 0000000..f5eb296 --- /dev/null +++ b/app/src/main/res/drawable/ic_service_spotify.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_service_tidal.xml b/app/src/main/res/drawable/ic_service_tidal.xml new file mode 100644 index 0000000..7a5e719 --- /dev/null +++ b/app/src/main/res/drawable/ic_service_tidal.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_service_vk.xml b/app/src/main/res/drawable/ic_service_vk.xml new file mode 100644 index 0000000..cdac0a0 --- /dev/null +++ b/app/src/main/res/drawable/ic_service_vk.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_service_yandex.xml b/app/src/main/res/drawable/ic_service_yandex.xml new file mode 100644 index 0000000..be96c87 --- /dev/null +++ b/app/src/main/res/drawable/ic_service_yandex.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_service_youtube.xml b/app/src/main/res/drawable/ic_service_youtube.xml new file mode 100644 index 0000000..d8b4b12 --- /dev/null +++ b/app/src/main/res/drawable/ic_service_youtube.xml @@ -0,0 +1,9 @@ + + +