feat: back navigation, NowPlaying fix, home refresh, genre top-6

- 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 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-14 22:40:48 +02:00
1 parent afae5d94df
commit f01fc71f54
6 files changed
+192 -6

No files matched your search

@@ -68,6 +68,13 @@ fun AppNavigation() {
val playerState by player.state.collectAsStateWithLifecycle() val playerState by player.state.collectAsStateWithLifecycle()
val navController = rememberNavController() val navController = rememberNavController()
var showNowPlaying by remember { mutableStateOf(false) } 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<Song?>(null) } var contextMenuSong by remember { mutableStateOf<Song?>(null) }
var playlistPickerSong by remember { mutableStateOf<Song?>(null) } var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
var infoSheetSong by remember { mutableStateOf<Song?>(null) } var infoSheetSong by remember { mutableStateOf<Song?>(null) }
@@ -81,6 +88,8 @@ fun AppNavigation() {
fun showContextMenu(song: Song) { contextMenuSong = song } fun showContextMenu(song: Song) { contextMenuSong = song }
var homeRefreshKey by remember { mutableIntStateOf(0) }
val currentBackEntry by navController.currentBackStackEntryAsState() val currentBackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackEntry?.destination?.route val currentRoute = currentBackEntry?.destination?.route
val isTopLevel = TopLevel.entries.any { it.route == currentRoute } val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
@@ -116,10 +125,14 @@ fun AppNavigation() {
NavigationBarItem( NavigationBarItem(
selected = currentRoute == dest.route, selected = currentRoute == dest.route,
onClick = { onClick = {
if (dest == TopLevel.HOME && currentRoute == TopLevel.HOME.route) {
homeRefreshKey++
} else {
navController.navigate(dest.route) { navController.navigate(dest.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true } popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true; restoreState = true launchSingleTop = true; restoreState = true
} }
}
}, },
icon = { Icon(dest.icon, contentDescription = dest.label) }, icon = { Icon(dest.icon, contentDescription = dest.label) },
label = { label = {
@@ -178,6 +191,8 @@ fun AppNavigation() {
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0) if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
}} }}
}, },
onShowAllGenres = { navController.navigate("genres") },
refreshKey = homeRefreshKey,
) )
} }
composable(TopLevel.LIBRARY.route) { composable(TopLevel.LIBRARY.route) {
@@ -268,6 +283,18 @@ fun AppNavigation() {
onBack = { navController.popBackStack() }, 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() },
)
}
} }
} }
@@ -1,5 +1,6 @@
package com.novasonic.app.ui.screen package com.novasonic.app.ui.screen
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -55,6 +56,11 @@ fun ExploreScreen(
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val context = LocalContext.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 // Download sheet
state.downloadSheetItem?.let { item -> state.downloadSheetItem?.let { item ->
ItunesDownloadSheet( ItunesDownloadSheet(
@@ -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))
}
}
}
}
}
}
}
}
@@ -32,7 +32,7 @@ import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.HomeViewModel import com.novasonic.app.viewmodel.HomeViewModel
import java.util.Calendar import java.util.Calendar
private val genreColors = listOf( internal val genreColors = listOf(
Color(0xFF1a1a3e), Color(0xFF1e2a1a), Color(0xFF2a1a1a), Color(0xFF1a1a3e), Color(0xFF1e2a1a), Color(0xFF2a1a1a),
Color(0xFF1a2a2a), Color(0xFF2a1e1a), Color(0xFF1e1a2a), Color(0xFF1a2a2a), Color(0xFF2a1e1a), Color(0xFF1e1a2a),
Color(0xFF2a2a1a), Color(0xFF1a2a1e), Color(0xFF201520), Color(0xFF2a2a1a), Color(0xFF1a2a1e), Color(0xFF201520),
@@ -46,10 +46,14 @@ fun HomeScreen(
onAlbumPlay: (Album) -> Unit, onAlbumPlay: (Album) -> Unit,
onGenreClick: (Genre) -> Unit, onGenreClick: (Genre) -> Unit,
onGenrePlay: ((Genre) -> Unit)? = null, onGenrePlay: ((Genre) -> Unit)? = null,
onShowAllGenres: (() -> Unit)? = null,
refreshKey: Int = 0,
) { ) {
val vm: HomeViewModel = viewModel() val vm: HomeViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
LaunchedEffect(refreshKey) { if (refreshKey > 0) vm.load() }
val greeting = remember { val greeting = remember {
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY) val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
when { when {
@@ -118,8 +122,28 @@ fun HomeScreen(
if (state.genres.isNotEmpty()) { if (state.genres.isNotEmpty()) {
item { SectionHeader("Genres") } item { SectionHeader("Genres") }
item { item {
GenreGrid(genres = state.genres, onGenreClick = onGenreClick, onGenrePlay = onGenrePlay) GenreGrid(genres = state.genres.take(6), onGenreClick = onGenreClick, onGenrePlay = onGenrePlay)
Spacer(Modifier.height(28.dp)) 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 @Composable
private fun GenreGrid( internal fun GenreGrid(
genres: List<Genre>, genres: List<Genre>,
onGenreClick: (Genre) -> Unit, onGenreClick: (Genre) -> Unit,
onGenrePlay: ((Genre) -> Unit)?, onGenrePlay: ((Genre) -> Unit)?,
@@ -267,7 +291,7 @@ private fun GenreGrid(
} }
@Composable @Composable
private fun GenreChip( internal fun GenreChip(
genre: Genre, genre: Genre,
bgColor: Color, bgColor: Color,
onClick: () -> Unit, onClick: () -> Unit,
@@ -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<Genre> = 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<GenreUiState> = _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) }
}
}
}
@@ -61,7 +61,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) {
recentAlbums = recent, recentAlbums = recent,
frequentAlbums = frequent, frequentAlbums = frequent,
randomAlbums = random, randomAlbums = random,
genres = genres.filter { it.songCount > 0 }.take(12), genres = genres.filter { it.songCount > 0 }.sortedByDescending { it.songCount },
isLoading = false, isLoading = false,
error = null, error = null,
) )