Android #618 offline-mode UX + Sonos polish + server DRY #81
@@ -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
|
||||||
|
|||||||
+10
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user