feat(android): detail-screen seed extras (audit v2 #11)
AppBar headers on AlbumDetail / ArtistDetail / PlaylistDetail now render the real title during the Loading state instead of the "Album" / "Artist" / "Playlist" placeholder. Mirrors Flutter's go_router extra: seed pattern. Implementation: * DetailSeedCache — process-singleton with three LRU buckets (albums / artists / playlists, 32 entries each). Hilt-injected into MainActivity and exposed via LocalDetailSeedCache so any composable can stash before navigating. * AlbumCard / ArtistCard / PlaylistCard stash their Ref on click; every existing nav call site automatically benefits — no callback shape change needed. * Each detail VM peeks the cache in refresh() and emits Loading(seed) so the AppBar reads from the carried seed. State carries across pull-to-refresh too (refresh keeps the seed of the previous Success / Loading rather than blanking to "Album"). * AlbumDetailUiState.Loading, ArtistDetailUiState.Loading, and PlaylistDetailUiState.Loading evolved from data object to data class Loading(val seed: T?) — backwards-compatible pattern-matching with is checks. Track-level "Go to album" / "Go to artist" call sites (TrackRow and NowPlaying TrackActions) only have an id, no Ref, so the AppBar still shows the placeholder for those — matches Flutter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -17,43 +18,51 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@Inject lateinit var seedCache: DetailSeedCache
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent { App() }
|
||||
setContent { App(seedCache = seedCache) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun App(
|
||||
seedCache: DetailSeedCache,
|
||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||
gate: AuthGateViewModel = hiltViewModel(),
|
||||
) {
|
||||
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
||||
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
||||
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
|
||||
val resolved = startDestination
|
||||
if (resolved == null) {
|
||||
BootSplash()
|
||||
} else {
|
||||
// No bottom nav, no drawer — navigation is per-screen via
|
||||
// the AppBar actions row + kebab (`MainAppBarActions`). The
|
||||
// MiniPlayer is included by each in-shell route's
|
||||
// `ShellScaffold` wrap; full-screen routes (NowPlaying /
|
||||
// Queue / unauthenticated) bypass the shell entirely.
|
||||
val navController = rememberNavController()
|
||||
MinstrelNavGraph(
|
||||
navController = navController,
|
||||
startDestination = resolved,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
CompositionLocalProvider(LocalDetailSeedCache provides seedCache) {
|
||||
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
|
||||
val resolved = startDestination
|
||||
if (resolved == null) {
|
||||
BootSplash()
|
||||
} else {
|
||||
// No bottom nav, no drawer — navigation is per-screen via
|
||||
// the AppBar actions row + kebab (`MainAppBarActions`). The
|
||||
// MiniPlayer is included by each in-shell route's
|
||||
// `ShellScaffold` wrap; full-screen routes (NowPlaying /
|
||||
// Queue / unauthenticated) bypass the shell entirely.
|
||||
val navController = rememberNavController()
|
||||
MinstrelNavGraph(
|
||||
navController = navController,
|
||||
startDestination = resolved,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ fun AlbumDetailScreen(
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) {
|
||||
when (val s = state) {
|
||||
AlbumDetailUiState.Loading -> LoadingCentered()
|
||||
is AlbumDetailUiState.Loading -> LoadingCentered()
|
||||
is AlbumDetailUiState.Error -> EmptyState(
|
||||
title = "Couldn't load album",
|
||||
body = s.message,
|
||||
@@ -125,7 +125,8 @@ fun AlbumDetailScreen(
|
||||
|
||||
private fun titleFor(state: AlbumDetailUiState): String = when (state) {
|
||||
is AlbumDetailUiState.Success -> state.detail.album.title
|
||||
else -> "Album"
|
||||
is AlbumDetailUiState.Loading -> state.seed?.title ?: "Album"
|
||||
is AlbumDetailUiState.Error -> "Album"
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+12
-3
@@ -7,7 +7,9 @@ import androidx.navigation.toRoute
|
||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.AlbumDetailRef
|
||||
import com.fabledsword.minstrel.models.AlbumRef
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -23,7 +25,7 @@ import javax.inject.Inject
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
sealed interface AlbumDetailUiState {
|
||||
data object Loading : AlbumDetailUiState
|
||||
data class Loading(val seed: AlbumRef? = null) : AlbumDetailUiState
|
||||
data class Success(val detail: AlbumDetailRef) : AlbumDetailUiState
|
||||
data class Error(val message: String) : AlbumDetailUiState
|
||||
}
|
||||
@@ -33,13 +35,15 @@ class AlbumDetailViewModel @Inject constructor(
|
||||
private val repository: LibraryRepository,
|
||||
private val likes: LikesRepository,
|
||||
private val player: PlayerController,
|
||||
private val seedCache: DetailSeedCache,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val route: AlbumDetail = savedStateHandle.toRoute()
|
||||
val albumId: String = route.id
|
||||
|
||||
private val internal = MutableStateFlow<AlbumDetailUiState>(AlbumDetailUiState.Loading)
|
||||
private val internal =
|
||||
MutableStateFlow<AlbumDetailUiState>(AlbumDetailUiState.Loading())
|
||||
val uiState: StateFlow<AlbumDetailUiState> = internal.asStateFlow()
|
||||
|
||||
val albumLiked: StateFlow<Boolean> =
|
||||
@@ -64,7 +68,12 @@ class AlbumDetailViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
internal.value = AlbumDetailUiState.Loading
|
||||
val carried = when (val cur = internal.value) {
|
||||
is AlbumDetailUiState.Loading -> cur.seed
|
||||
is AlbumDetailUiState.Success -> cur.detail.album
|
||||
is AlbumDetailUiState.Error -> null
|
||||
}
|
||||
internal.value = AlbumDetailUiState.Loading(carried ?: seedCache.peekAlbum(albumId))
|
||||
try {
|
||||
val detail = repository.refreshAlbumDetail(albumId)
|
||||
internal.value = AlbumDetailUiState.Success(detail)
|
||||
|
||||
@@ -76,7 +76,7 @@ fun ArtistDetailScreen(
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) {
|
||||
when (val s = state) {
|
||||
ArtistDetailUiState.Loading -> LoadingCentered()
|
||||
is ArtistDetailUiState.Loading -> LoadingCentered()
|
||||
is ArtistDetailUiState.Error -> EmptyState(
|
||||
title = "Couldn't load artist",
|
||||
body = s.message,
|
||||
@@ -98,7 +98,8 @@ fun ArtistDetailScreen(
|
||||
|
||||
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||
is ArtistDetailUiState.Success -> state.detail.artist.name
|
||||
else -> "Artist"
|
||||
is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist"
|
||||
is ArtistDetailUiState.Error -> "Artist"
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+12
-3
@@ -7,7 +7,9 @@ import androidx.navigation.toRoute
|
||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -22,7 +24,7 @@ import javax.inject.Inject
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
sealed interface ArtistDetailUiState {
|
||||
data object Loading : ArtistDetailUiState
|
||||
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
|
||||
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
|
||||
data class Error(val message: String) : ArtistDetailUiState
|
||||
}
|
||||
@@ -32,13 +34,15 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
private val repository: LibraryRepository,
|
||||
private val likes: LikesRepository,
|
||||
private val player: PlayerController,
|
||||
private val seedCache: DetailSeedCache,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val route: ArtistDetail = savedStateHandle.toRoute()
|
||||
val artistId: String = route.id
|
||||
|
||||
private val internal = MutableStateFlow<ArtistDetailUiState>(ArtistDetailUiState.Loading)
|
||||
private val internal =
|
||||
MutableStateFlow<ArtistDetailUiState>(ArtistDetailUiState.Loading())
|
||||
val uiState: StateFlow<ArtistDetailUiState> = internal.asStateFlow()
|
||||
|
||||
val artistLiked: StateFlow<Boolean> =
|
||||
@@ -61,7 +65,12 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
internal.value = ArtistDetailUiState.Loading
|
||||
val carried = when (val cur = internal.value) {
|
||||
is ArtistDetailUiState.Loading -> cur.seed
|
||||
is ArtistDetailUiState.Success -> cur.detail.artist
|
||||
is ArtistDetailUiState.Error -> null
|
||||
}
|
||||
internal.value = ArtistDetailUiState.Loading(carried ?: seedCache.peekArtist(artistId))
|
||||
try {
|
||||
val detail = repository.refreshArtistDetail(artistId)
|
||||
internal.value = ArtistDetailUiState.Success(detail)
|
||||
|
||||
@@ -27,12 +27,15 @@ import com.composables.icons.lucide.Disc3
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import androidx.compose.material3.Icon
|
||||
import com.fabledsword.minstrel.models.AlbumRef
|
||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||
|
||||
/**
|
||||
* One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp
|
||||
* square cover). Tap fires `onClick` — wired to `AlbumDetail` nav in
|
||||
* Phase 5.5.
|
||||
* Phase 5.5. The tile stashes its `album` in [LocalDetailSeedCache]
|
||||
* before firing onClick so the detail screen's AppBar renders title /
|
||||
* cover / counts immediately without waiting for the fetch.
|
||||
*/
|
||||
@Composable
|
||||
fun AlbumCard(
|
||||
@@ -40,10 +43,14 @@ fun AlbumCard(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val seedCache = LocalDetailSeedCache.current
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(176.dp)
|
||||
.clickable(onClick = onClick),
|
||||
.clickable {
|
||||
seedCache.stashAlbum(album)
|
||||
onClick()
|
||||
},
|
||||
color = Color.Transparent,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
|
||||
@@ -27,10 +27,13 @@ import coil3.compose.AsyncImage
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.User
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||
|
||||
/**
|
||||
* One artist tile. Circular cover; name centered beneath. Tap fires
|
||||
* `onClick` — wired to `ArtistDetail` nav in Phase 5.5.
|
||||
* `onClick` — wired to `ArtistDetail` nav in Phase 5.5. Stashes the
|
||||
* `artist` in [LocalDetailSeedCache] on click so the ArtistDetail
|
||||
* AppBar renders the name before the fetch lands.
|
||||
*/
|
||||
@Composable
|
||||
fun ArtistCard(
|
||||
@@ -38,10 +41,14 @@ fun ArtistCard(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val seedCache = LocalDetailSeedCache.current
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(144.dp)
|
||||
.clickable(onClick = onClick),
|
||||
.clickable {
|
||||
seedCache.stashArtist(artist)
|
||||
onClick()
|
||||
},
|
||||
color = Color.Transparent,
|
||||
) {
|
||||
Column(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.fabledsword.minstrel.nav
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.fabledsword.minstrel.models.AlbumRef
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* In-memory hand-off for detail-screen "seed" data — the lightweight
|
||||
* Ref the caller already has when navigating to AlbumDetail /
|
||||
* ArtistDetail / PlaylistDetail. The detail VM peeks the seed on
|
||||
* init so the AppBar header (title / cover / counts) renders
|
||||
* immediately, before the network round-trip completes.
|
||||
*
|
||||
* Mirrors Flutter's go_router `extra:` parameter pattern. Bounded
|
||||
* per-bucket via insertion-order LRU so back/forward navigation
|
||||
* doesn't grow unbounded.
|
||||
*/
|
||||
@Singleton
|
||||
class DetailSeedCache @Inject constructor() {
|
||||
private val albums = lru<AlbumRef>(MAX_ENTRIES)
|
||||
private val artists = lru<ArtistRef>(MAX_ENTRIES)
|
||||
private val playlists = lru<PlaylistRef>(MAX_ENTRIES)
|
||||
|
||||
@Synchronized fun stashAlbum(ref: AlbumRef) { albums[ref.id] = ref }
|
||||
@Synchronized fun stashArtist(ref: ArtistRef) { artists[ref.id] = ref }
|
||||
@Synchronized fun stashPlaylist(ref: PlaylistRef) { playlists[ref.id] = ref }
|
||||
|
||||
@Synchronized fun peekAlbum(id: String): AlbumRef? = albums[id]
|
||||
@Synchronized fun peekArtist(id: String): ArtistRef? = artists[id]
|
||||
@Synchronized fun peekPlaylist(id: String): PlaylistRef? = playlists[id]
|
||||
|
||||
private companion object {
|
||||
private const val MAX_ENTRIES = 32
|
||||
private fun <V> lru(max: Int): MutableMap<String, V> =
|
||||
object : LinkedHashMap<String, V>(max, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: Map.Entry<String, V>) = size > max
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CompositionLocal carrying the app's [DetailSeedCache]. Provided
|
||||
* at the [com.fabledsword.minstrel.MainActivity] root so any screen
|
||||
* can stash a seed before calling `navController.navigate(...)`.
|
||||
*/
|
||||
val LocalDetailSeedCache = staticCompositionLocalOf<DetailSeedCache> {
|
||||
error("DetailSeedCache not provided — wrap content in CompositionLocalProvider")
|
||||
}
|
||||
+16
-5
@@ -65,6 +65,7 @@ import com.fabledsword.minstrel.models.PlaylistTrackRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
@@ -98,7 +99,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
|
||||
sealed interface PlaylistDetailUiState {
|
||||
data object Loading : PlaylistDetailUiState
|
||||
data class Loading(val seed: PlaylistRef? = null) : PlaylistDetailUiState
|
||||
data class Success(val detail: PlaylistDetailRef) : PlaylistDetailUiState
|
||||
data class Error(val message: String) : PlaylistDetailUiState
|
||||
}
|
||||
@@ -111,13 +112,15 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
private val likes: LikesRepository,
|
||||
private val player: PlayerController,
|
||||
private val eventsStream: EventsStream,
|
||||
private val seedCache: DetailSeedCache,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val route: PlaylistDetail = savedStateHandle.toRoute()
|
||||
val playlistId: String = route.id
|
||||
|
||||
private val internal = MutableStateFlow<PlaylistDetailUiState>(PlaylistDetailUiState.Loading)
|
||||
private val internal =
|
||||
MutableStateFlow<PlaylistDetailUiState>(PlaylistDetailUiState.Loading())
|
||||
val uiState: StateFlow<PlaylistDetailUiState> = internal.asStateFlow()
|
||||
|
||||
/**
|
||||
@@ -178,7 +181,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
try {
|
||||
internal.value = PlaylistDetailUiState.Loading
|
||||
val carried = when (val cur = internal.value) {
|
||||
is PlaylistDetailUiState.Loading -> cur.seed
|
||||
is PlaylistDetailUiState.Success -> cur.detail.playlist
|
||||
is PlaylistDetailUiState.Error -> null
|
||||
}
|
||||
internal.value = PlaylistDetailUiState.Loading(
|
||||
carried ?: seedCache.peekPlaylist(playlistId),
|
||||
)
|
||||
val detail = repository.refreshDetail(playlistId)
|
||||
internal.value = PlaylistDetailUiState.Success(detail)
|
||||
} catch (
|
||||
@@ -287,7 +297,7 @@ private fun PlaylistDetailContent(
|
||||
navController: NavHostController,
|
||||
) {
|
||||
when (state) {
|
||||
PlaylistDetailUiState.Loading -> LoadingCentered()
|
||||
is PlaylistDetailUiState.Loading -> LoadingCentered()
|
||||
is PlaylistDetailUiState.Error -> ErrorBlock(state.message, viewModel::refresh)
|
||||
is PlaylistDetailUiState.Success -> {
|
||||
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
||||
@@ -314,7 +324,8 @@ private fun PlaylistDetailContent(
|
||||
|
||||
private fun currentTitle(state: PlaylistDetailUiState): String = when (state) {
|
||||
is PlaylistDetailUiState.Success -> state.detail.playlist.name
|
||||
else -> "Playlist"
|
||||
is PlaylistDetailUiState.Loading -> state.seed?.name ?: "Playlist"
|
||||
is PlaylistDetailUiState.Error -> "Playlist"
|
||||
}
|
||||
|
||||
// ─── Body composables ────────────────────────────────────────────────
|
||||
|
||||
+6
-1
@@ -26,6 +26,7 @@ import coil3.compose.AsyncImage
|
||||
import com.composables.icons.lucide.ListMusic
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||
|
||||
/**
|
||||
@@ -43,10 +44,14 @@ fun PlaylistCard(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val seedCache = LocalDetailSeedCache.current
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(176.dp)
|
||||
.clickable(onClick = onClick),
|
||||
.clickable {
|
||||
seedCache.stashPlaylist(playlist)
|
||||
onClick()
|
||||
},
|
||||
color = Color.Transparent,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
|
||||
Reference in New Issue
Block a user