feat(android): #618 Phase 4 — row-level offline-unavailable affordance
android / Build + lint + test (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
+10
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user