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 com.fabledsword.minstrel.auth.ui.AuthGateViewModel
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.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph
@@ -38,6 +41,7 @@ import javax.inject.Inject
class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds
@Inject lateinit var serverHealth: ServerHealthController
// Flipped to true when the user taps the media notification (or
// any other entry point that asks for the full player). The App
@@ -54,6 +58,7 @@ class MainActivity : ComponentActivity() {
App(
seedCache = seedCache,
cachedTrackIds = cachedTrackIds,
serverHealth = serverHealth,
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
)
@@ -86,6 +91,7 @@ class MainActivity : ComponentActivity() {
private fun App(
seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds,
serverHealth: ServerHealthController,
pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedNowPlaying: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(),
@@ -93,11 +99,13 @@ private fun App(
) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider(
LocalDetailSeedCache provides seedCache,
LocalCachedTrackIds provides cached,
LocalServerHealth provides health,
) {
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
val resolved = startDestination
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.connectivity
import androidx.compose.runtime.staticCompositionLocalOf
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.update.data.VersionCheckController
import kotlinx.coroutines.CoroutineScope
@@ -51,3 +52,12 @@ class ServerHealthController @Inject constructor(
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
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -12,8 +13,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
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
@@ -30,6 +37,14 @@ import androidx.compose.ui.unit.dp
* 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
* 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
fun TrackRow(
@@ -45,15 +60,28 @@ fun TrackRow(
leading: @Composable () -> Unit = {},
trailing: @Composable RowScope.() -> Unit = {},
) {
val context = LocalContext.current
val offlineUnavailable = LocalServerHealth.current != ServerHealth.Healthy &&
trackId !in LocalCachedTrackIds.current
val titleColor = if (nowPlaying) {
MaterialTheme.colorScheme.primary
} else {
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(
modifier = modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
.clickable(enabled = enabled, onClick = effectiveOnClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = horizontalArrangement,
@@ -63,7 +91,7 @@ fun TrackRow(
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = titleColor.copy(alpha = contentAlpha),
color = titleColor.copy(alpha = effectiveAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -71,7 +99,7 @@ fun TrackRow(
Text(
text = artist,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = effectiveAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)