feat(android): #618 Phase 4 — row-level offline-unavailable affordance
android / Build + lint + test (push) Has been cancelled

TrackRow now consumes the LocalServerHealth CompositionLocal (provided
once at MainActivity from ServerHealthController.state). When the server
is Offline or ServerDown and the track id isn't in LocalCachedTrackIds,
the row dims to 0.4 alpha and a tap fires a Toast instead of attempting
playback. Replaces the silent "tap-and-fail-to-OfflineException" UX with
explicit at-a-glance signaling of which rows in a long list will work.

Trailing slot (kebab / like / playlist-add) stays interactive so write
affordances can route through MutationQueue — Phase 5 gates those at
the action level.
This commit is contained in:
2026-06-04 12:37:42 -04:00
parent 48de720514
commit 1daea79f64
3 changed files with 49 additions and 3 deletions
@@ -21,6 +21,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.connectivity.LocalServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealthController
import com.fabledsword.minstrel.nav.DetailSeedCache import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph import com.fabledsword.minstrel.nav.MinstrelNavGraph
@@ -38,6 +41,7 @@ import javax.inject.Inject
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache @Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds @Inject lateinit var cachedTrackIds: CachedTrackIds
@Inject lateinit var serverHealth: ServerHealthController
// Flipped to true when the user taps the media notification (or // Flipped to true when the user taps the media notification (or
// any other entry point that asks for the full player). The App // any other entry point that asks for the full player). The App
@@ -54,6 +58,7 @@ class MainActivity : ComponentActivity() {
App( App(
seedCache = seedCache, seedCache = seedCache,
cachedTrackIds = cachedTrackIds, cachedTrackIds = cachedTrackIds,
serverHealth = serverHealth,
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(), pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false }, onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
) )
@@ -86,6 +91,7 @@ class MainActivity : ComponentActivity() {
private fun App( private fun App(
seedCache: DetailSeedCache, seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds, cachedTrackIds: CachedTrackIds,
serverHealth: ServerHealthController,
pendingOpenNowPlaying: StateFlow<Boolean>, pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedNowPlaying: () -> Unit, onOpenedNowPlaying: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(), themeVm: ThemePreferenceViewModel = hiltViewModel(),
@@ -93,11 +99,13 @@ private fun App(
) { ) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle() val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle() val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle() val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) { MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider( CompositionLocalProvider(
LocalDetailSeedCache provides seedCache, LocalDetailSeedCache provides seedCache,
LocalCachedTrackIds provides cached, LocalCachedTrackIds provides cached,
LocalServerHealth provides health,
) { ) {
val startDestination by gate.startDestination.collectAsStateWithLifecycle() val startDestination by gate.startDestination.collectAsStateWithLifecycle()
val resolved = startDestination val resolved = startDestination
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.connectivity package com.fabledsword.minstrel.connectivity
import androidx.compose.runtime.staticCompositionLocalOf
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.update.data.VersionCheckController import com.fabledsword.minstrel.update.data.VersionCheckController
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -51,3 +52,12 @@ class ServerHealthController @Inject constructor(
initialValue = ServerHealth.Healthy, initialValue = ServerHealth.Healthy,
) )
} }
/**
* Reactive [ServerHealth] snapshot provided once at the app root from the
* controller's StateFlow. Lets leaf composables (TrackRow gating, write-
* affordance disabling) branch on health without each ViewModel re-
* injecting the controller. Defaults to Healthy so unwrapped previews
* and tests don't crash.
*/
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.shared.widgets package com.fabledsword.minstrel.shared.widgets
import android.widget.Toast
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -12,8 +13,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.minstrel.connectivity.LocalServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealth
private const val OFFLINE_UNAVAILABLE_ALPHA = 0.4f
private const val OFFLINE_TAP_MESSAGE = "Not downloaded — connect to play"
/** /**
* Shared track-list row. Replaces the 5 per-screen `TrackRow`s — every * Shared track-list row. Replaces the 5 per-screen `TrackRow`s — every
@@ -30,6 +37,14 @@ import androidx.compose.ui.unit.dp
* for applying its own alpha if it should match (the row doesn't * for applying its own alpha if it should match (the row doesn't
* cascade because the trailing slot's content is the caller's, not * cascade because the trailing slot's content is the caller's, not
* ours). * ours).
*
* Reads [LocalServerHealth] + [LocalCachedTrackIds] and intercepts taps
* on tracks that aren't downloaded when the server is unreachable —
* fires a Toast instead of attempting playback. The text dims so the
* user can see at a glance which rows in a long list will work offline.
* The trailing slot stays interactive so the kebab / like / playlist-
* add affordances can still queue mutations for offline replay (Phase
* 5 of #618 gates those at the action level).
*/ */
@Composable @Composable
fun TrackRow( fun TrackRow(
@@ -45,15 +60,28 @@ fun TrackRow(
leading: @Composable () -> Unit = {}, leading: @Composable () -> Unit = {},
trailing: @Composable RowScope.() -> Unit = {}, trailing: @Composable RowScope.() -> Unit = {},
) { ) {
val context = LocalContext.current
val offlineUnavailable = LocalServerHealth.current != ServerHealth.Healthy &&
trackId !in LocalCachedTrackIds.current
val titleColor = if (nowPlaying) { val titleColor = if (nowPlaying) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
} }
val effectiveAlpha = if (offlineUnavailable) {
minOf(contentAlpha, OFFLINE_UNAVAILABLE_ALPHA)
} else {
contentAlpha
}
val effectiveOnClick: () -> Unit = if (offlineUnavailable) {
{ Toast.makeText(context, OFFLINE_TAP_MESSAGE, Toast.LENGTH_SHORT).show() }
} else {
onClick
}
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick) .clickable(enabled = enabled, onClick = effectiveOnClick)
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = horizontalArrangement, horizontalArrangement = horizontalArrangement,
@@ -63,7 +91,7 @@ fun TrackRow(
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = titleColor.copy(alpha = contentAlpha), color = titleColor.copy(alpha = effectiveAlpha),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -71,7 +99,7 @@ fun TrackRow(
Text( Text(
text = artist, text = artist,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = effectiveAlpha),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )