From 28309c201ebff7e56f22967eef63b6976a3a63b0 Mon Sep 17 00:00:00 2001 From: nk Date: Mon, 1 Jun 2026 12:54:36 +0300 Subject: [PATCH] chore: add Lucide icons dependency and replace Material Icons in navigation --- app/build.gradle.kts | 1 + .../radiola/ui/navigation/NavDestinations.kt | 18 +- .../plans/2026-06-01-radiola-ui-screens.md | 1372 +++++++++++++++++ gradle/libs.versions.toml | 2 + 4 files changed, 1384 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-01-radiola-ui-screens.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa127c4..463e2c5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,6 +93,7 @@ dependencies { implementation(libs.media3.session) implementation(libs.coil.compose) + implementation(libs.lucide) testImplementation(libs.junit) testImplementation(libs.mockk) diff --git a/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt b/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt index 76cadb5..4190eed 100644 --- a/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt +++ b/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt @@ -1,21 +1,21 @@ package com.radiola.ui.navigation -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.History 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.Settings sealed class NavDestinations( val route: String, val labelRes: String, val icon: ImageVector ) { - data object Stations : NavDestinations("stations", "Радио", Icons.Default.Home) - data object Favorites : NavDestinations("favorites", "Избранное", Icons.Default.Favorite) - data object History : NavDestinations("history", "История", Icons.Default.History) - data object Settings : NavDestinations("settings", "Настройки", Icons.Default.Settings) + data object Stations : NavDestinations("stations", "Радио", Lucide.Home) + data object Favorites : NavDestinations("favorites", "Избранное", Lucide.Heart) + data object History : NavDestinations("history", "История", Lucide.History) + data object Settings : NavDestinations("settings", "Настройки", Lucide.Settings) companion object { val items = listOf(Stations, Favorites, History, Settings) diff --git a/docs/superpowers/plans/2026-06-01-radiola-ui-screens.md b/docs/superpowers/plans/2026-06-01-radiola-ui-screens.md new file mode 100644 index 0000000..b1ab5eb --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-radiola-ui-screens.md @@ -0,0 +1,1372 @@ +# radiOLA UI Screens Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build all Jetpack Compose UI screens for radiOLA in Spotify Dark style — Stations, Player (BottomSheet), Favorites, History, Settings — with ViewModels, state management, and Hilt integration. + +**Architecture:** MVI-like state flow in ViewModels. Compose UI observes `StateFlow`. Business logic delegated to Use Cases. Coil for images. Material3 components. + +**Design Reference:** Spotify Dark — dark background (#121212), surface cards (#1E1E1E), gradient station covers, bottom mini-player, expanded bottom sheet player with deep link row. + +--- + +## File Structure + +``` +app/src/main/java/com/radiola/ +├── ui/ +│ ├── components/ # Reusable UI components +│ │ ├── StationCard.kt +│ │ ├── TrackListItem.kt +│ │ ├── SearchBar.kt +│ │ ├── FilterChips.kt +│ │ ├── MiniPlayer.kt +│ │ └── EmptyState.kt +│ ├── stations/ +│ │ ├── StationsScreen.kt +│ │ └── StationsViewModel.kt +│ ├── player/ +│ │ ├── PlayerBottomSheet.kt +│ │ └── PlayerViewModel.kt +│ ├── favorites/ +│ │ ├── FavoritesScreen.kt +│ │ └── FavoritesViewModel.kt +│ ├── history/ +│ │ ├── HistoryScreen.kt +│ │ └── HistoryViewModel.kt +│ ├── settings/ +│ │ ├── SettingsScreen.kt +│ │ └── SettingsViewModel.kt +│ └── theme/ +│ ├── Color.kt (already exists, may extend) +│ ├── Theme.kt +│ └── Type.kt +``` + +--- + +## Task 1: Shared UI Components + +**Files:** +- Create: `app/src/main/java/com/radiola/ui/components/StationCard.kt` +- Create: `app/src/main/java/com/radiola/ui/components/TrackListItem.kt` +- Create: `app/src/main/java/com/radiola/ui/components/SearchBar.kt` +- Create: `app/src/main/java/com/radiola/ui/components/FilterChips.kt` +- Create: `app/src/main/java/com/radiola/ui/components/MiniPlayer.kt` +- Create: `app/src/main/java/com/radiola/ui/components/EmptyState.kt` + +- [ ] **Step 1: Write `StationCard.kt`** + +```kotlin +package com.radiola.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.radiola.domain.model.Station + +@Composable +fun StationCard( + station: Station, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .aspectRatio(1f) + .clickable(onClick = onClick), + 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) + ) + ) + ) + ) { + 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 + ) + } + } +} +``` + +- [ ] **Step 2: Write `TrackListItem.kt`** + +```kotlin +package com.radiola.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +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.unit.dp +import coil.compose.AsyncImage +import com.radiola.domain.model.Track +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun TrackListItem( + track: Track, + timestamp: Long? = null, + onClick: () -> Unit = {}, + onSwipeAction: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = track.coverUrl, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(8.dp)) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "${track.artist} — ${track.song}", + style = MaterialTheme.typography.bodyMedium, + maxLines = 1 + ) + Text( + text = track.stationName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + timestamp?.let { + Text( + text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + } + } +} +``` + +- [ ] **Step 3: Write `SearchBar.kt`** + +```kotlin +package com.radiola.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + placeholder: String = "Поиск станции...", + modifier: Modifier = Modifier +) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier + .fillMaxWidth() + .background(Color(0xFF2A2A2A), RoundedCornerShape(8.dp)), + placeholder = { Text(placeholder, color = Color(0xFF888888)) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = Color(0xFF888888)) }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color(0xFF2A2A2A), + unfocusedContainerColor = Color(0xFF2A2A2A), + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White + ) + ) +} +``` + +- [ ] **Step 4: Write `FilterChips.kt`** + +```kotlin +package com.radiola.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun FilterChips( + tags: List, + selectedTag: String?, + onTagSelected: (String?) -> Unit, + modifier: Modifier = Modifier +) { + LazyRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + item { + FilterChip( + selected = selectedTag == null, + onClick = { onTagSelected(null) }, + label = { Text("Все") } + ) + } + items(tags) { tag -> + FilterChip( + selected = selectedTag == tag, + onClick = { onTagSelected(tag) }, + label = { Text(tag) } + ) + } + } +} +``` + +- [ ] **Step 5: Write `MiniPlayer.kt`** + +```kotlin +package com.radiola.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Pause +import com.composables.icons.lucide.Play +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.Color +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.radiola.domain.model.Track + +@Composable +fun MiniPlayer( + stationName: String, + track: Track?, + isPlaying: Boolean, + onClick: () -> Unit, + onPlayPause: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(64.dp) + .background(Color(0xFF1E1E1E)) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = track?.coverUrl, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(6.dp)) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stationName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1 + ) + Text( + text = track?.let { "${it.artist} — ${it.song}" } ?: "", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + maxLines = 1 + ) + } + IconButton(onClick = onPlayPause) { + Icon( + imageVector = if (isPlaying) Lucide.Pause else Lucide.Play, + contentDescription = if (isPlaying) "Pause" else "Play", + tint = Color.White + ) + } + } +} +``` + +- [ ] **Step 6: Write `EmptyState.kt`** + +```kotlin +package com.radiola.ui.components + +import androidx.compose.foundation.layout.* +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.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun EmptyState( + message: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = Color(0xFF888888) + ) + } +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add app/src/main/java/com/radiola/ui/components/ +git commit -m "feat(ui): add shared components (StationCard, TrackListItem, SearchBar, FilterChips, MiniPlayer, EmptyState)" +``` + +--- + +## Task 2: StationsScreen + ViewModel + +**Files:** +- Create: `app/src/main/java/com/radiola/ui/stations/StationsViewModel.kt` +- Create: `app/src/main/java/com/radiola/ui/stations/StationsScreen.kt` + +- [ ] **Step 1: Write `StationsViewModel.kt`** + +```kotlin +package com.radiola.ui.stations + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.radiola.domain.model.Station +import com.radiola.domain.usecase.GetStationsUseCase +import com.radiola.domain.usecase.PlayStationUseCase +import com.radiola.domain.usecase.ToggleFavoriteUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class StationsViewModel @Inject constructor( + private val getStationsUseCase: GetStationsUseCase, + private val playStationUseCase: PlayStationUseCase, + private val toggleFavoriteUseCase: ToggleFavoriteUseCase +) : ViewModel() { + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _selectedTag = MutableStateFlow(null) + val selectedTag: StateFlow = _selectedTag.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + val stations: StateFlow> = combine( + getStationsUseCase(), + _searchQuery, + _selectedTag + ) { allStations, query, tag -> + allStations + .filter { station -> + tag == null || station.tags.contains(tag) || station.genre.equals(tag, ignoreCase = true) + } + .filter { station -> + query.isBlank() || + station.name.contains(query, ignoreCase = true) || + station.genre.contains(query, ignoreCase = true) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val tags: StateFlow> = getStationsUseCase() + .map { stations -> stations.flatMap { it.tags }.distinct().sorted() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun onSearchQueryChange(query: String) { + _searchQuery.value = query + } + + fun onTagSelected(tag: String?) { + _selectedTag.value = tag + } + + fun playStation(station: Station) { + viewModelScope.launch { + playStationUseCase(station) + } + } + + fun toggleFavorite(station: Station) { + viewModelScope.launch { + toggleFavoriteUseCase(station) + } + } +} +``` + +- [ ] **Step 2: Write `StationsScreen.kt`** + +```kotlin +package com.radiola.ui.stations + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +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.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.radiola.ui.components.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StationsScreen( + onStationClick: (Int) -> Unit, + modifier: Modifier = Modifier, + viewModel: StationsViewModel = hiltViewModel() +) { + val stations by viewModel.stations.collectAsState() + val tags by viewModel.tags.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + val selectedTag by viewModel.selectedTag.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val error by viewModel.error.collectAsState() + + 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, + 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), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(stations, key = { it.id }) { station -> + StationCard( + station = station, + onClick = { onStationClick(station.id) } + ) + } + } + } + } + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/src/main/java/com/radiola/ui/stations/ +git commit -m "feat(ui): add StationsScreen and StationsViewModel" +``` + +--- + +## Task 3: PlayerBottomSheet + PlayerViewModel + +**Files:** +- Create: `app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt` +- Create: `app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt` + +- [ ] **Step 1: Write `PlayerViewModel.kt`** + +```kotlin +package com.radiola.ui.player + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.radiola.domain.model.DeeplinkService +import com.radiola.domain.model.PlayerState +import com.radiola.domain.model.Station +import com.radiola.domain.model.Track +import com.radiola.domain.repository.SettingsRepository +import com.radiola.domain.usecase.GetNowPlayingUseCase +import com.radiola.domain.usecase.SearchTrackInServiceUseCase +import com.radiola.service.PlayerController +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PlayerViewModel @Inject constructor( + private val playerController: PlayerController, + private val getNowPlayingUseCase: GetNowPlayingUseCase, + private val searchTrackInServiceUseCase: SearchTrackInServiceUseCase, + private val settingsRepository: SettingsRepository +) : ViewModel() { + + val isPlaying: StateFlow = playerController.isPlaying + val currentStationPrefix: StateFlow = playerController.currentStationPrefix + + private val _currentTrack = MutableStateFlow(null) + val currentTrack: StateFlow = _currentTrack.asStateFlow() + + private val _enabledServices = MutableStateFlow>(emptyList()) + val enabledServices: StateFlow> = _enabledServices.asStateFlow() + + init { + viewModelScope.launch { + settingsRepository.getEnabledDeeplinkServices().collect { ids -> + _enabledServices.value = DeeplinkService.entries.filter { it.serviceId in ids } + } + } + } + + fun play(station: Station) { + playerController.play(station.streamUrl, station.prefix) + viewModelScope.launch { + getNowPlayingUseCase(station.prefix).collect { track -> + _currentTrack.value = track + } + } + } + + fun pause() { + playerController.pause() + } + + fun resume(station: Station) { + if (playerController.currentStationPrefix.value == station.prefix) { + playerController.exoPlayer.play() + } else { + play(station) + } + } + + fun getDeeplinkUrl(track: Track, service: DeeplinkService): String { + return searchTrackInServiceUseCase(track, service) + } +} +``` + +- [ ] **Step 2: Write `PlayerBottomSheet.kt`** + +```kotlin +package com.radiola.ui.player + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +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.Brush +import androidx.compose.ui.graphics.Color +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.radiola.domain.model.DeeplinkService +import com.radiola.domain.model.Station +import com.radiola.domain.model.Track + +@Composable +fun PlayerBottomSheet( + station: Station?, + track: Track?, + isPlaying: Boolean, + onPlayPause: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PlayerViewModel = hiltViewModel() +) { + val context = LocalContext.current + val enabledServices by viewModel.enabledServices.collectAsState() + + Column( + modifier = modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf(Color(0xFF1a1a2e), Color(0xFF121212)) + ) + ) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + station?.let { + Text( + text = it.name, + style = MaterialTheme.typography.labelMedium, + color = Color(0xFF888888) + ) + } + Spacer(modifier = Modifier.height(24.dp)) + Box( + modifier = Modifier + .size(240.dp) + .clip(RoundedCornerShape(20.dp)) + .background( + Brush.linearGradient(listOf(Color(0xFF667eea), Color(0xFF764ba2))) + ) + ) { + 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 -> + val url = viewModel.getDeeplinkUrl(t, service) + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + } + ) + } + } + Spacer(modifier = Modifier.height(30.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ControlButton(size = 56.dp, onClick = { }) + ControlButton( + size = 72.dp, + isPlay = true, + isPlaying = isPlaying, + onClick = onPlayPause + ) + ControlButton(size = 56.dp, onClick = { }) + } + } +} + +@Composable +private fun DeeplinkButton( + service: DeeplinkService, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFF2A2A2A)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Text( + text = service.displayName.take(2), + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF888888) + ) + } +} + +@Composable +private fun ControlButton( + size: androidx.compose.ui.unit.Dp, + isPlay: Boolean = false, + isPlaying: Boolean = false, + 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) + ) + } + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/src/main/java/com/radiola/ui/player/ +git commit -m "feat(ui): add PlayerBottomSheet and PlayerViewModel" +``` + +--- + +## Task 4: FavoritesScreen + ViewModel + +**Files:** +- Create: `app/src/main/java/com/radiola/ui/favorites/FavoritesViewModel.kt` +- Create: `app/src/main/java/com/radiola/ui/favorites/FavoritesScreen.kt` + +- [ ] **Step 1: Write `FavoritesViewModel.kt`** + +```kotlin +package com.radiola.ui.favorites + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.radiola.domain.model.Station +import com.radiola.domain.repository.FavoritesRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class FavoritesViewModel @Inject constructor( + favoritesRepository: FavoritesRepository +) : ViewModel() { + + val favorites: StateFlow> = favoritesRepository.getFavorites() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) +} +``` + +- [ ] **Step 2: Write `FavoritesScreen.kt`** + +```kotlin +package com.radiola.ui.favorites + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +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.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.radiola.ui.components.EmptyState +import com.radiola.ui.components.StationCard + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FavoritesScreen( + onStationClick: (Int) -> Unit, + modifier: Modifier = Modifier, + viewModel: FavoritesViewModel = hiltViewModel() +) { + val favorites by viewModel.favorites.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Избранное") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) + } + ) { 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, + onClick = { onStationClick(station.id) } + ) + } + } + } + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/src/main/java/com/radiola/ui/favorites/ +git commit -m "feat(ui): add FavoritesScreen and FavoritesViewModel" +``` + +--- + +## Task 5: HistoryScreen + ViewModel + +**Files:** +- Create: `app/src/main/java/com/radiola/ui/history/HistoryViewModel.kt` +- Create: `app/src/main/java/com/radiola/ui/history/HistoryScreen.kt` + +- [ ] **Step 1: Write `HistoryViewModel.kt`** + +```kotlin +package com.radiola.ui.history + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.radiola.domain.model.Track +import com.radiola.domain.repository.TrackHistoryRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HistoryViewModel @Inject constructor( + private val trackHistoryRepository: TrackHistoryRepository +) : ViewModel() { + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + val history: StateFlow> = combine( + trackHistoryRepository.getHistory(), + _searchQuery + ) { tracks, query -> + if (query.isBlank()) tracks else tracks.filter { + it.artist.contains(query, ignoreCase = true) || + it.song.contains(query, ignoreCase = true) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun onSearchQueryChange(query: String) { + _searchQuery.value = query + } + + fun removeTrack(track: Track) { + viewModelScope.launch { + trackHistoryRepository.removeTrack(track) + } + } +} +``` + +- [ ] **Step 2: Write `HistoryScreen.kt`** + +```kotlin +package com.radiola.ui.history + +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.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.radiola.ui.components.EmptyState +import com.radiola.ui.components.SearchBar +import com.radiola.ui.components.TrackListItem + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HistoryScreen( + modifier: Modifier = Modifier, + viewModel: HistoryViewModel = hiltViewModel() +) { + val history by viewModel.history.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + + 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()) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items(history) { track -> + TrackListItem( + track = track, + onClick = { /* TODO: open deeplink bottom sheet */ } + ) + Divider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f)) + } + } + } + } + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/src/main/java/com/radiola/ui/history/ +git commit -m "feat(ui): add HistoryScreen and HistoryViewModel" +``` + +--- + +## Task 6: SettingsScreen + ViewModel + +**Files:** +- Create: `app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt` +- Create: `app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt` + +- [ ] **Step 1: Write `SettingsViewModel.kt`** + +```kotlin +package com.radiola.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.radiola.domain.model.DeeplinkService +import com.radiola.domain.repository.SettingsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val settingsRepository: SettingsRepository +) : ViewModel() { + + val sleepTimerMinutes: StateFlow = settingsRepository.getSleepTimerMinutes() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 30) + + val enabledServices: StateFlow> = settingsRepository.getEnabledDeeplinkServices() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) + + val equalizerPreset: StateFlow = settingsRepository.getEqualizerPreset() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Flat") + + val isRecordingEnabled: StateFlow = settingsRepository.isRecordingEnabled() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + fun setSleepTimer(minutes: Int) { + viewModelScope.launch { settingsRepository.setSleepTimerMinutes(minutes) } + } + + fun toggleService(serviceId: String, enabled: Boolean) { + viewModelScope.launch { + val current = enabledServices.value.toMutableSet() + if (enabled) current.add(serviceId) else current.remove(serviceId) + settingsRepository.setEnabledDeeplinkServices(current) + } + } + + fun setEqualizerPreset(preset: String) { + viewModelScope.launch { settingsRepository.setEqualizerPreset(preset) } + } + + fun setRecordingEnabled(enabled: Boolean) { + viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) } + } +} +``` + +- [ ] **Step 2: Write `SettingsScreen.kt`** + +```kotlin +package com.radiola.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.radiola.domain.model.DeeplinkService + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + modifier: Modifier = Modifier, + viewModel: SettingsViewModel = hiltViewModel() +) { + val sleepTimer by viewModel.sleepTimerMinutes.collectAsState() + val enabledServices by viewModel.enabledServices.collectAsState() + val equalizerPreset by viewModel.equalizerPreset.collectAsState() + val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState() + val presets = listOf("Flat", "Rock", "Pop", "Jazz", "Bass") + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Настройки") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) + } + ) { padding -> + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Text("Таймер сна", style = MaterialTheme.typography.titleMedium) + Slider( + value = sleepTimer.toFloat(), + onValueChange = { viewModel.setSleepTimer(it.toInt()) }, + valueRange = 5f..120f, + steps = 22 + ) + 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 { + 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 { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { viewModel.setRecordingEnabled(!isRecordingEnabled) } + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Запись эфира") + Switch( + checked = isRecordingEnabled, + onCheckedChange = { viewModel.setRecordingEnabled(it) } + ) + } + } + } + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/src/main/java/com/radiola/ui/settings/ +git commit -m "feat(ui): add SettingsScreen and SettingsViewModel" +``` + +--- + +## Task 7: Wire Screens into Navigation + +**Files:** +- Modify: `app/src/main/java/com/radiola/MainActivity.kt` + +- [ ] **Step 1: Overwrite `MainActivity.kt`** + +```kotlin +package com.radiola + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.radiola.ui.favorites.FavoritesScreen +import com.radiola.ui.history.HistoryScreen +import com.radiola.ui.navigation.BottomNavBar +import com.radiola.ui.navigation.NavDestinations +import com.radiola.ui.player.PlayerBottomSheet +import com.radiola.ui.settings.SettingsScreen +import com.radiola.ui.stations.StationsScreen +import com.radiola.ui.theme.RadiolaTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + RadiolaTheme { + val navController = rememberNavController() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showPlayer by remember { mutableStateOf(false) } + + Scaffold( + bottomBar = { BottomNavBar(navController) } + ) { paddingValues -> + NavHost( + navController = navController, + startDestination = NavDestinations.Stations.route, + modifier = Modifier.padding(paddingValues) + ) { + composable(NavDestinations.Stations.route) { + StationsScreen(onStationClick = { showPlayer = true }) + } + composable(NavDestinations.Favorites.route) { + FavoritesScreen(onStationClick = { showPlayer = true }) + } + composable(NavDestinations.History.route) { + HistoryScreen() + } + composable(NavDestinations.Settings.route) { + SettingsScreen() + } + } + } + + if (showPlayer) { + ModalBottomSheet( + onDismissRequest = { showPlayer = false }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.background + ) { + PlayerBottomSheet( + station = null, + track = null, + isPlaying = false, + onPlayPause = { } + ) + } + } + } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/src/main/java/com/radiola/MainActivity.kt +git commit -m "feat(ui): wire all screens into NavHost with BottomSheet player" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- ✅ StationsScreen — grid, search, filter chips +- ✅ PlayerBottomSheet — expanded player with cover, track info, deep link buttons, controls +- ✅ MiniPlayer — collapsed bar with cover, station, track, play/pause +- ✅ FavoritesScreen — grid of favorites +- ✅ HistoryScreen — list with search, timestamps +- ✅ SettingsScreen — sleep timer, equalizer, deep link toggles, recording toggle +- ✅ All ViewModels with Hilt injection + +**2. Placeholder scan:** +- `// TODO: open deeplink bottom sheet` in HistoryScreen — intentional, will be handled when connecting Player interactions +- `onPlayPause` in MainActivity is a stub — needs PlayerService integration in next plan +- No other placeholders. + +**3. Type consistency:** +- ViewModel states use `StateFlow` consistently +- UI states observed via `collectAsState()` +- All imports match package structure + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-01-radiola-ui-screens.md`. + +**Two execution options:** + +1. **Subagent-Driven (recommended)** — fresh subagent per task, review between tasks +2. **Inline Execution** — batch execution with checkpoints + +**Which approach?** diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8396a63..abd172d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ datastore = "1.1.1" media3 = "1.3.1" coil = "2.6.0" navigation = "2.7.7" +lucide = "1.0.0" mockk = "1.13.11" turbine = "1.1.0" coroutinesTest = "1.8.1" @@ -38,6 +39,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } +lucide = { group = "com.composables", name = "icons-lucide", version.ref = "lucide" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.2.0" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }