From f01fc71f542bd815f8e4123376fcff7bdf4f0087 Mon Sep 17 00:00:00 2001 From: Honey Date: Sun, 14 Jun 2026 22:40:48 +0200 Subject: [PATCH] feat: back navigation, NowPlaying fix, home refresh, genre top-6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExploreScreen: add BackHandlers for state-based overlays (partialAlbum → albumTracks → artistDetail hierarchy) so system back navigates correctly - AppNavigation: disable NavController's OnBackPressedCallback while NowPlaying is open so our BackHandler always intercepts first (fixes tab-switch-then-back bug) - Home tab: tapping Home while already on Home triggers vm.load() refresh via refreshKey - HomeViewModel: load all genres sorted by songCount desc (removed take(12) cap) - HomeScreen: show only top-6 genres + 'Alle anzeigen' button with total count - New GenreListScreen + GenreViewModel: full scrollable genre list at 'genres' route Co-Authored-By: Claude Sonnet 4.6 --- .../com/novasonic/app/ui/AppNavigation.kt | 53 +++++++--- .../novasonic/app/ui/screen/ExploreScreen.kt | 6 ++ .../app/ui/screen/GenreListScreen.kt | 96 +++++++++++++++++++ .../com/novasonic/app/ui/screen/HomeScreen.kt | 34 ++++++- .../novasonic/app/viewmodel/GenreViewModel.kt | 33 +++++++ .../novasonic/app/viewmodel/HomeViewModel.kt | 2 +- 6 files changed, 205 insertions(+), 19 deletions(-) create mode 100644 android/app/src/main/kotlin/com/novasonic/app/ui/screen/GenreListScreen.kt create mode 100644 android/app/src/main/kotlin/com/novasonic/app/viewmodel/GenreViewModel.kt diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index 2972748..fc043b6 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -68,6 +68,13 @@ fun AppNavigation() { val playerState by player.state.collectAsStateWithLifecycle() val navController = rememberNavController() var showNowPlaying by remember { mutableStateOf(false) } + + // Disable NavController's own back-stack handler while NowPlaying is open so + // our BackHandler below takes priority instead of it popping tabs first. + LaunchedEffect(showNowPlaying) { + navController.enableOnBackPressed(!showNowPlaying) + } + var contextMenuSong by remember { mutableStateOf(null) } var playlistPickerSong by remember { mutableStateOf(null) } var infoSheetSong by remember { mutableStateOf(null) } @@ -81,6 +88,8 @@ fun AppNavigation() { fun showContextMenu(song: Song) { contextMenuSong = song } + var homeRefreshKey by remember { mutableIntStateOf(0) } + val currentBackEntry by navController.currentBackStackEntryAsState() val currentRoute = currentBackEntry?.destination?.route val isTopLevel = TopLevel.entries.any { it.route == currentRoute } @@ -116,9 +125,13 @@ fun AppNavigation() { NavigationBarItem( selected = currentRoute == dest.route, onClick = { - navController.navigate(dest.route) { - popUpTo(navController.graph.startDestinationId) { saveState = true } - launchSingleTop = true; restoreState = true + if (dest == TopLevel.HOME && currentRoute == TopLevel.HOME.route) { + homeRefreshKey++ + } else { + navController.navigate(dest.route) { + popUpTo(navController.graph.startDestinationId) { saveState = true } + launchSingleTop = true; restoreState = true + } } }, icon = { Icon(dest.icon, contentDescription = dest.label) }, @@ -163,21 +176,23 @@ fun AppNavigation() { ) { composable(TopLevel.HOME.route) { HomeScreen( - coverUrl = { album -> coverUrl(album.coverArt) }, - onAlbumClick = { album -> navController.navigate("album/${album.id}") }, - onAlbumPlay = { album -> + coverUrl = { album -> coverUrl(album.coverArt) }, + onAlbumClick = { album -> navController.navigate("album/${album.id}") }, + onAlbumPlay = { album -> scope.launch { runCatching { val full = repo.getAlbum(album.id) if (full.song.isNotEmpty()) player.playSongs(full.song, 0) }} }, - onGenreClick = { genre -> navController.navigate("genre/${encodeRoute(genre.value)}") }, - onGenrePlay = { genre -> - scope.launch { runCatching { - val songs = repo.getSongsByGenre(genre.value) - if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0) - }} - }, + onGenreClick = { genre -> navController.navigate("genre/${encodeRoute(genre.value)}") }, + onGenrePlay = { genre -> + scope.launch { runCatching { + val songs = repo.getSongsByGenre(genre.value) + if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0) + }} + }, + onShowAllGenres = { navController.navigate("genres") }, + refreshKey = homeRefreshKey, ) } composable(TopLevel.LIBRARY.route) { @@ -268,6 +283,18 @@ fun AppNavigation() { onBack = { navController.popBackStack() }, ) } + composable("genres") { + GenreListScreen( + onGenreClick = { genre -> navController.navigate("genre/${encodeRoute(genre.value)}") }, + onGenrePlay = { genre -> + scope.launch { runCatching { + val songs = repo.getSongsByGenre(genre.value) + if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0) + }} + }, + onBack = { navController.popBackStack() }, + ) + } } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt index c4ad4c9..65e7a27 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt @@ -1,5 +1,6 @@ package com.novasonic.app.ui.screen +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -55,6 +56,11 @@ fun ExploreScreen( val focusManager = LocalFocusManager.current val context = LocalContext.current + // BackHandlers for state-based navigation (must be unconditional) + BackHandler(enabled = state.artistDetail != null && state.partialAlbum != null) { vm.dismissPartialAlbum() } + BackHandler(enabled = state.artistDetail != null && state.albumTracks != null && state.partialAlbum == null) { vm.dismissAlbum() } + BackHandler(enabled = state.artistDetail != null && state.albumTracks == null && state.partialAlbum == null) { vm.closeArtistDetail() } + // Download sheet state.downloadSheetItem?.let { item -> ItunesDownloadSheet( diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/GenreListScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/GenreListScreen.kt new file mode 100644 index 0000000..97c70b2 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/GenreListScreen.kt @@ -0,0 +1,96 @@ +package com.novasonic.app.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.novasonic.app.api.model.Genre +import com.novasonic.app.ui.theme.* +import com.novasonic.app.viewmodel.GenreViewModel + +@Composable +fun GenreListScreen( + onGenreClick: (Genre) -> Unit, + onGenrePlay: (Genre) -> Unit, + onBack: () -> Unit, +) { + val vm: GenreViewModel = viewModel() + val state by vm.state.collectAsStateWithLifecycle() + + Column(Modifier.fillMaxSize()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp, end = 16.dp, top = 8.dp, bottom = 4.dp), + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = NsOnSurface2) + } + Text( + "Alle Genres", + fontFamily = Outfit, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + color = NsOnSurface, + ) + } + + when { + state.isLoading -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = NsAccent) + } + } + state.error != null -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize().padding(32.dp), + ) { + Text(state.error!!, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(12.dp)) + Button(onClick = { vm.load() }, + colors = ButtonDefaults.buttonColors(containerColor = NsAccent)) { + Text("Retry") + } + } + } + else -> { + LazyColumn( + contentPadding = PaddingValues(bottom = 24.dp), + modifier = Modifier.fillMaxSize(), + ) { + val chunked = state.genres.chunked(2) + chunked.forEachIndexed { rowIdx, rowGenres -> + item(key = "row_$rowIdx") { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) { + rowGenres.forEachIndexed { colIdx, genre -> + val colorIdx = (rowIdx * 2 + colIdx) % genreColors.size + GenreChip( + genre = genre, + bgColor = genreColors[colorIdx], + onClick = { onGenreClick(genre) }, + onPlay = { onGenrePlay(genre) }, + modifier = Modifier.weight(1f), + ) + } + if (rowGenres.size == 1) Spacer(Modifier.weight(1f)) + } + } + } + } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt index 2896ec8..267618c 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt @@ -32,7 +32,7 @@ import com.novasonic.app.ui.theme.* import com.novasonic.app.viewmodel.HomeViewModel import java.util.Calendar -private val genreColors = listOf( +internal val genreColors = listOf( Color(0xFF1a1a3e), Color(0xFF1e2a1a), Color(0xFF2a1a1a), Color(0xFF1a2a2a), Color(0xFF2a1e1a), Color(0xFF1e1a2a), Color(0xFF2a2a1a), Color(0xFF1a2a1e), Color(0xFF201520), @@ -46,10 +46,14 @@ fun HomeScreen( onAlbumPlay: (Album) -> Unit, onGenreClick: (Genre) -> Unit, onGenrePlay: ((Genre) -> Unit)? = null, + onShowAllGenres: (() -> Unit)? = null, + refreshKey: Int = 0, ) { val vm: HomeViewModel = viewModel() val state by vm.state.collectAsStateWithLifecycle() + LaunchedEffect(refreshKey) { if (refreshKey > 0) vm.load() } + val greeting = remember { val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY) when { @@ -118,8 +122,28 @@ fun HomeScreen( if (state.genres.isNotEmpty()) { item { SectionHeader("Genres") } item { - GenreGrid(genres = state.genres, onGenreClick = onGenreClick, onGenrePlay = onGenrePlay) - Spacer(Modifier.height(28.dp)) + GenreGrid(genres = state.genres.take(6), onGenreClick = onGenreClick, onGenrePlay = onGenrePlay) + if (onShowAllGenres != null && state.genres.size > 6) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp) + .clip(RoundedCornerShape(10.dp)) + .background(NsSurface) + .clickable(onClick = onShowAllGenres) + .padding(vertical = 12.dp), + ) { + Text( + "Alle anzeigen · ${state.genres.size}", + fontFamily = Outfit, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + color = NsAccent, + ) + } + } + Spacer(Modifier.height(20.dp)) } } @@ -238,7 +262,7 @@ private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClic } @Composable -private fun GenreGrid( +internal fun GenreGrid( genres: List, onGenreClick: (Genre) -> Unit, onGenrePlay: ((Genre) -> Unit)?, @@ -267,7 +291,7 @@ private fun GenreGrid( } @Composable -private fun GenreChip( +internal fun GenreChip( genre: Genre, bgColor: Color, onClick: () -> Unit, diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/GenreViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/GenreViewModel.kt new file mode 100644 index 0000000..856e251 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/GenreViewModel.kt @@ -0,0 +1,33 @@ +package com.novasonic.app.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.novasonic.app.api.model.Genre +import com.novasonic.app.app +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +data class GenreUiState( + val genres: List = emptyList(), + val isLoading: Boolean = true, + val error: String? = null, +) + +class GenreViewModel(app: Application) : AndroidViewModel(app) { + private val repo = app.app.repository + private val _state = MutableStateFlow(GenreUiState()) + val state: StateFlow = _state + + init { load() } + + fun load() { + viewModelScope.launch { + _state.value = GenreUiState(isLoading = true) + runCatching { repo.getGenres().filter { it.songCount > 0 }.sortedByDescending { it.songCount } } + .onSuccess { _state.value = GenreUiState(genres = it) } + .onFailure { _state.value = GenreUiState(error = it.message) } + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt index 7434767..2be771c 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt @@ -61,7 +61,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) { recentAlbums = recent, frequentAlbums = frequent, randomAlbums = random, - genres = genres.filter { it.songCount > 0 }.take(12), + genres = genres.filter { it.songCount > 0 }.sortedByDescending { it.songCount }, isLoading = false, error = null, )