From 1daea79f64fd7b74932466fa23daf1330b1c6759 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 12:37:42 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20#618=20Phase=204=20=E2=80=94?= =?UTF-8?q?=20row-level=20offline-unavailable=20affordance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../com/fabledsword/minstrel/MainActivity.kt | 8 +++++ .../connectivity/ServerHealthController.kt | 10 ++++++ .../minstrel/shared/widgets/TrackRow.kt | 34 +++++++++++++++++-- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt index f1203b98..cfbe9396 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt @@ -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, 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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt index e0aa1f2c..eb6c1a81 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt @@ -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 } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt index 0b5c95c1..a80c343b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt @@ -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, )