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:
1 parent
afae5d94df
commit
f01fc71f54
6 files changed
+205
-19
No files matched your search
@@ -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<Song?>(null) }
|
||||
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
|
||||
var infoSheetSong by remember { mutableStateOf<Song?>(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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 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<Genre>,
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user