1373 lines
44 KiB
Markdown
1373 lines
44 KiB
Markdown
# 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<UiState>`. 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<String>,
|
||
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<String> = _searchQuery.asStateFlow()
|
||
|
||
private val _selectedTag = MutableStateFlow<String?>(null)
|
||
val selectedTag: StateFlow<String?> = _selectedTag.asStateFlow()
|
||
|
||
private val _isLoading = MutableStateFlow(false)
|
||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||
|
||
private val _error = MutableStateFlow<String?>(null)
|
||
val error: StateFlow<String?> = _error.asStateFlow()
|
||
|
||
val stations: StateFlow<List<Station>> = 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<List<String>> = 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<Boolean> = playerController.isPlaying
|
||
val currentStationPrefix: StateFlow<String?> = playerController.currentStationPrefix
|
||
|
||
private val _currentTrack = MutableStateFlow<Track?>(null)
|
||
val currentTrack: StateFlow<Track?> = _currentTrack.asStateFlow()
|
||
|
||
private val _enabledServices = MutableStateFlow<List<DeeplinkService>>(emptyList())
|
||
val enabledServices: StateFlow<List<DeeplinkService>> = _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<List<Station>> = 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<String> = _searchQuery.asStateFlow()
|
||
|
||
val history: StateFlow<List<Track>> = 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<Int> = settingsRepository.getSleepTimerMinutes()
|
||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 30)
|
||
|
||
val enabledServices: StateFlow<Set<String>> = settingsRepository.getEnabledDeeplinkServices()
|
||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
|
||
|
||
val equalizerPreset: StateFlow<String> = settingsRepository.getEqualizerPreset()
|
||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Flat")
|
||
|
||
val isRecordingEnabled: StateFlow<Boolean> = 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?**
|