diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index fb642b7c..2bbc5187 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -53,7 +53,9 @@ import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.MainAppBarActions +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -100,15 +102,19 @@ class HomeViewModel @Inject constructor( ) : ViewModel() { init { - // Fire-and-forget initial pull; the screen renders cached sections - // 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() } - } - viewModelScope.launch { - runCatching { playlistsRepository.refreshList() } - } + refresh() + } + + /** + * Pulls both /home/index and the playlists list. Returns the Job + * for the combined refresh so a pull-to-refresh wrapper can await + * actual completion before hiding the indicator. + */ + fun refresh(): Job = viewModelScope.launch { + val home = launch { runCatching { homeRepository.refreshIndex() } } + val lists = launch { runCatching { playlistsRepository.refreshList() } } + home.join() + lists.join() } val uiState: StateFlow = combineHomeFlows().stateIn( @@ -170,7 +176,10 @@ fun HomeScreen( }, ) { inner -> 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) { HomeUiState.Loading -> LoadingCentered() HomeUiState.Empty -> EmptyState( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt index 0c394143..b49aceea 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt @@ -56,6 +56,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.shared.widgets.EmptyState 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.TrackActionsViewModel import com.fabledsword.minstrel.theme.FabledSwordFlatTokens @@ -88,7 +89,10 @@ fun AlbumDetailScreen( }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { inner -> - Box(modifier = Modifier.fillMaxSize().padding(inner)) { + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { when (val s = state) { AlbumDetailUiState.Loading -> LoadingCentered() is AlbumDetailUiState.Error -> EmptyState( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt index 324c06fa..9cd0d52f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt @@ -10,6 +10,7 @@ import com.fabledsword.minstrel.models.AlbumDetailRef import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.player.PlayerController import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -62,17 +63,15 @@ class AlbumDetailViewModel @Inject constructor( refresh() } - fun refresh() { - viewModelScope.launch { - internal.value = AlbumDetailUiState.Loading - try { - val detail = repository.refreshAlbumDetail(albumId) - internal.value = AlbumDetailUiState.Success(detail) - } catch ( - @Suppress("TooGenericExceptionCaught") e: Throwable, - ) { - internal.value = AlbumDetailUiState.Error(e.message ?: "Album load failed") - } + fun refresh(): Job = viewModelScope.launch { + internal.value = AlbumDetailUiState.Loading + try { + val detail = repository.refreshAlbumDetail(albumId) + internal.value = AlbumDetailUiState.Success(detail) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = AlbumDetailUiState.Error(e.message ?: "Album load failed") } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt index b63b3a38..e2b02ad1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt @@ -49,6 +49,7 @@ import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.LikeButton +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -70,7 +71,10 @@ fun ArtistDetailScreen( ) }, ) { inner -> - Box(modifier = Modifier.fillMaxSize().padding(inner)) { + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { when (val s = state) { ArtistDetailUiState.Loading -> LoadingCentered() is ArtistDetailUiState.Error -> EmptyState( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt index 7363775b..bd9c0492 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt @@ -10,6 +10,7 @@ import com.fabledsword.minstrel.models.ArtistDetailRef import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.player.PlayerController import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -59,17 +60,15 @@ class ArtistDetailViewModel @Inject constructor( } } - fun refresh() { - viewModelScope.launch { - internal.value = ArtistDetailUiState.Loading - try { - val detail = repository.refreshArtistDetail(artistId) - internal.value = ArtistDetailUiState.Success(detail) - } catch ( - @Suppress("TooGenericExceptionCaught") e: Throwable, - ) { - internal.value = ArtistDetailUiState.Error(e.message ?: "Artist load failed") - } + fun refresh(): Job = viewModelScope.launch { + internal.value = ArtistDetailUiState.Loading + try { + val detail = repository.refreshArtistDetail(artistId) + internal.value = ArtistDetailUiState.Success(detail) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = ArtistDetailUiState.Error(e.message ?: "Artist load failed") } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PullToRefreshScaffold.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PullToRefreshScaffold.kt new file mode 100644 index 00000000..4a83d0ed --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PullToRefreshScaffold.kt @@ -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() + } +}