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
+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,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user