feat(android): PullToRefreshScaffold + Home / Album / Artist detail
Closes audit #8 first wave. New shared widget wraps Material3's PullToRefreshBox with isRefreshing state managed internally; consumers pass a suspend onRefresh that the wrapper awaits before hiding the indicator (no heuristic delays). ViewModel pattern: refresh() now returns Job so the screen can `.join()` it from the wrapper. Trivial change — adding `: Job =` between the function signature and the existing viewModelScope.launch body. Existing fire-and-forget callers continue to work since they discard the return value. Wired into HomeScreen, AlbumDetailScreen, ArtistDetailScreen. Library tabs + detail / list / admin screens follow in next commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,7 +53,9 @@ import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
|||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
@@ -100,15 +102,19 @@ class HomeViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Fire-and-forget initial pull; the screen renders cached sections
|
refresh()
|
||||||
// immediately and refreshes as the IDs land. Errors map to the
|
}
|
||||||
// Error state via the combine() chain's catch below.
|
|
||||||
viewModelScope.launch {
|
/**
|
||||||
runCatching { homeRepository.refreshIndex() }
|
* Pulls both /home/index and the playlists list. Returns the Job
|
||||||
}
|
* for the combined refresh so a pull-to-refresh wrapper can await
|
||||||
viewModelScope.launch {
|
* actual completion before hiding the indicator.
|
||||||
runCatching { playlistsRepository.refreshList() }
|
*/
|
||||||
}
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
val home = launch { runCatching { homeRepository.refreshIndex() } }
|
||||||
|
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
||||||
|
home.join()
|
||||||
|
lists.join()
|
||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<HomeUiState> = combineHomeFlows().stateIn(
|
val uiState: StateFlow<HomeUiState> = combineHomeFlows().stateIn(
|
||||||
@@ -170,7 +176,10 @@ fun HomeScreen(
|
|||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
PullToRefreshScaffold(
|
||||||
|
onRefresh = { viewModel.refresh().join() },
|
||||||
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
HomeUiState.Loading -> LoadingCentered()
|
HomeUiState.Loading -> LoadingCentered()
|
||||||
HomeUiState.Empty -> EmptyState(
|
HomeUiState.Empty -> EmptyState(
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
|
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
|
||||||
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||||
@@ -88,7 +89,10 @@ fun AlbumDetailScreen(
|
|||||||
},
|
},
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { inner ->
|
) { inner ->
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
PullToRefreshScaffold(
|
||||||
|
onRefresh = { viewModel.refresh().join() },
|
||||||
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
AlbumDetailUiState.Loading -> LoadingCentered()
|
AlbumDetailUiState.Loading -> LoadingCentered()
|
||||||
is AlbumDetailUiState.Error -> EmptyState(
|
is AlbumDetailUiState.Error -> EmptyState(
|
||||||
|
|||||||
+10
-11
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.models.AlbumDetailRef
|
|||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -62,17 +63,15 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
viewModelScope.launch {
|
internal.value = AlbumDetailUiState.Loading
|
||||||
internal.value = AlbumDetailUiState.Loading
|
try {
|
||||||
try {
|
val detail = repository.refreshAlbumDetail(albumId)
|
||||||
val detail = repository.refreshAlbumDetail(albumId)
|
internal.value = AlbumDetailUiState.Success(detail)
|
||||||
internal.value = AlbumDetailUiState.Success(detail)
|
} catch (
|
||||||
} catch (
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
) {
|
||||||
) {
|
internal.value = AlbumDetailUiState.Error(e.message ?: "Album load failed")
|
||||||
internal.value = AlbumDetailUiState.Error(e.message ?: "Album load failed")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import com.fabledsword.minstrel.models.ArtistRef
|
|||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -70,7 +71,10 @@ fun ArtistDetailScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
PullToRefreshScaffold(
|
||||||
|
onRefresh = { viewModel.refresh().join() },
|
||||||
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
ArtistDetailUiState.Loading -> LoadingCentered()
|
ArtistDetailUiState.Loading -> LoadingCentered()
|
||||||
is ArtistDetailUiState.Error -> EmptyState(
|
is ArtistDetailUiState.Error -> EmptyState(
|
||||||
|
|||||||
+10
-11
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.models.ArtistDetailRef
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -59,17 +60,15 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
viewModelScope.launch {
|
internal.value = ArtistDetailUiState.Loading
|
||||||
internal.value = ArtistDetailUiState.Loading
|
try {
|
||||||
try {
|
val detail = repository.refreshArtistDetail(artistId)
|
||||||
val detail = repository.refreshArtistDetail(artistId)
|
internal.value = ArtistDetailUiState.Success(detail)
|
||||||
internal.value = ArtistDetailUiState.Success(detail)
|
} catch (
|
||||||
} catch (
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
) {
|
||||||
) {
|
internal.value = ArtistDetailUiState.Error(e.message ?: "Artist load failed")
|
||||||
internal.value = ArtistDetailUiState.Error(e.message ?: "Artist load failed")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps [content] in a Material3 PullToRefreshBox so the user can
|
||||||
|
* swipe-down to trigger [onRefresh]. The wrapper manages the
|
||||||
|
* `isRefreshing` indicator while [onRefresh] is in flight.
|
||||||
|
*
|
||||||
|
* [onRefresh] is suspend: pass `{ viewModel.refresh().join() }` so the
|
||||||
|
* indicator hides exactly when the underlying coroutine completes,
|
||||||
|
* not on a heuristic delay. ViewModels whose existing `refresh()`
|
||||||
|
* launched into viewModelScope just need to start returning the Job
|
||||||
|
* (`fun refresh(): Job = viewModelScope.launch { ... }`).
|
||||||
|
*
|
||||||
|
* Mirrors the Flutter `RefreshIndicator` wrapping that every list +
|
||||||
|
* grid has in the web/mobile client — Android v1 was missing this
|
||||||
|
* everywhere (audit #8).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PullToRefreshScaffold(
|
||||||
|
onRefresh: suspend () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
onRefresh = {
|
||||||
|
scope.launch {
|
||||||
|
isRefreshing = true
|
||||||
|
try {
|
||||||
|
onRefresh()
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user