feat(android): pull-to-refresh — library tabs + admin + hidden

Final wave of audit #8. Library tabs (Artists/Albums via LibraryVM
refreshing through SyncController; Liked via LikesRepository;
History/Hidden via their own VMs) and all four admin screens
(Landing/Requests/Quarantine/Users) now support swipe-down refresh.

Per-VM change is uniform: refresh() returns Job so the
PullToRefreshScaffold wrapper can await it before hiding the
indicator.

Audit #8 user-visible parity now complete across all screens that
benefit. Search/Queue/NowPlaying/Settings intentionally excluded —
Search is query-driven, Queue is local state, NowPlaying/Settings
are forms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 18:25:50 -04:00
parent 4ca10e2afa
commit cf07a2a5a8
13 changed files with 158 additions and 124 deletions
@@ -45,7 +45,9 @@ import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AdminUsers import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
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.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -80,8 +82,7 @@ class AdminLandingViewModel @Inject constructor(
refresh() refresh()
} }
fun refresh() { fun refresh(): Job = viewModelScope.launch {
viewModelScope.launch {
internal.value = AdminLandingUiState.Loading internal.value = AdminLandingUiState.Loading
try { try {
val counts = coroutineScope { val counts = coroutineScope {
@@ -100,7 +101,6 @@ class AdminLandingViewModel @Inject constructor(
} }
} }
} }
}
// ─── Screen ────────────────────────────────────────────────────────── // ─── Screen ──────────────────────────────────────────────────────────
@@ -130,7 +130,10 @@ fun AdminLandingScreen(
) )
}, },
) { 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) {
AdminLandingUiState.Loading -> LoadingCentered() AdminLandingUiState.Loading -> LoadingCentered()
is AdminLandingUiState.Error -> EmptyState( is AdminLandingUiState.Error -> EmptyState(
@@ -37,6 +37,7 @@ import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.nav.AdminQuarantine import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -64,7 +65,10 @@ fun AdminQuarantineScreen(
) )
}, },
) { 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) {
AdminQuarantineUiState.Loading -> LoadingCentered() AdminQuarantineUiState.Loading -> LoadingCentered()
AdminQuarantineUiState.Empty -> EmptyState( AdminQuarantineUiState.Empty -> EmptyState(
@@ -6,6 +6,7 @@ import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.AdminQuarantineItemRef import com.fabledsword.minstrel.models.AdminQuarantineItemRef
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.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -38,8 +39,7 @@ class AdminQuarantineViewModel @Inject constructor(
} }
} }
fun refresh() { fun refresh(): Job = viewModelScope.launch {
viewModelScope.launch {
internal.value = AdminQuarantineUiState.Loading internal.value = AdminQuarantineUiState.Loading
try { try {
val rows = repository.list() val rows = repository.list()
@@ -56,7 +56,6 @@ class AdminQuarantineViewModel @Inject constructor(
) )
} }
} }
}
fun resolve(trackId: String) = act(trackId) { repository.resolve(it) } fun resolve(trackId: String) = act(trackId) { repository.resolve(it) }
fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(it) } fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(it) }
@@ -36,6 +36,7 @@ import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.nav.AdminRequests import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -63,7 +64,10 @@ fun AdminRequestsScreen(
) )
}, },
) { 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) {
AdminRequestsUiState.Loading -> LoadingCentered() AdminRequestsUiState.Loading -> LoadingCentered()
AdminRequestsUiState.Empty -> EmptyState( AdminRequestsUiState.Empty -> EmptyState(
@@ -6,6 +6,7 @@ import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestRef
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.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -40,8 +41,7 @@ class AdminRequestsViewModel @Inject constructor(
} }
} }
fun refresh() { fun refresh(): Job = viewModelScope.launch {
viewModelScope.launch {
internal.value = AdminRequestsUiState.Loading internal.value = AdminRequestsUiState.Loading
try { try {
val rows = repository.list() val rows = repository.list()
@@ -58,7 +58,6 @@ class AdminRequestsViewModel @Inject constructor(
) )
} }
} }
}
fun approve(id: String) = decide(id) { repository.approve(it) } fun approve(id: String) = decide(id) { repository.approve(it) }
@@ -43,6 +43,7 @@ import com.fabledsword.minstrel.models.AdminUserRef
import com.fabledsword.minstrel.nav.AdminUsers import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -73,7 +74,10 @@ fun AdminUsersScreen(
) )
}, },
) { 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) {
AdminUsersUiState.Loading -> LoadingCentered() AdminUsersUiState.Loading -> LoadingCentered()
AdminUsersUiState.Empty -> EmptyState( AdminUsersUiState.Empty -> EmptyState(
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminUsersRepository import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.models.AdminUserRef import com.fabledsword.minstrel.models.AdminUserRef
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.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -30,8 +31,7 @@ class AdminUsersViewModel @Inject constructor(
refresh() refresh()
} }
fun refresh() { fun refresh(): Job = viewModelScope.launch {
viewModelScope.launch {
internal.value = AdminUsersUiState.Loading internal.value = AdminUsersUiState.Loading
try { try {
val users = repository.list() val users = repository.list()
@@ -46,7 +46,6 @@ class AdminUsersViewModel @Inject constructor(
internal.value = AdminUsersUiState.Error(e.message ?: "Users load failed") internal.value = AdminUsersUiState.Error(e.message ?: "Users load failed")
} }
} }
}
fun setAdmin(id: String, isAdmin: Boolean) = patch(id, { it.copy(isAdmin = isAdmin) }) { fun setAdmin(id: String, isAdmin: Boolean) = patch(id, { it.copy(isAdmin = isAdmin) }) {
repository.setAdmin(it, isAdmin) repository.setAdmin(it, isAdmin)
@@ -29,9 +29,11 @@ import com.fabledsword.minstrel.history.data.HistoryRepository
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
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.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -68,8 +70,7 @@ class HistoryTabViewModel @Inject constructor(
refresh() refresh()
} }
fun refresh() { fun refresh(): Job = viewModelScope.launch {
viewModelScope.launch {
internal.value = HistoryUiState.Loading internal.value = HistoryUiState.Loading
try { try {
val page = repository.fetchPage() val page = repository.fetchPage()
@@ -84,7 +85,6 @@ class HistoryTabViewModel @Inject constructor(
internal.value = HistoryUiState.Error(e.message ?: "History load failed") internal.value = HistoryUiState.Error(e.message ?: "History load failed")
} }
} }
}
/** Single-track play, matching the Flutter history-row behavior. */ /** Single-track play, matching the Flutter history-row behavior. */
fun playTrack(track: TrackRef) { fun playTrack(track: TrackRef) {
@@ -102,7 +102,7 @@ fun HistoryTab(
viewModel: HistoryTabViewModel = hiltViewModel(), viewModel: HistoryTabViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) { PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) { when (val s = state) {
HistoryUiState.Loading -> LoadingCentered() HistoryUiState.Loading -> LoadingCentered()
HistoryUiState.Empty -> EmptyState( HistoryUiState.Empty -> EmptyState(
@@ -44,6 +44,7 @@ import com.fabledsword.minstrel.nav.Library
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
/** /**
@@ -133,6 +134,7 @@ private fun ArtistsTab(
navController: NavHostController, navController: NavHostController,
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) { when (val s = state) {
LibraryUiState.Loading -> LoadingCentered() LibraryUiState.Loading -> LoadingCentered()
LibraryUiState.Empty -> EmptyState( LibraryUiState.Empty -> EmptyState(
@@ -146,6 +148,7 @@ private fun ArtistsTab(
) )
} }
} }
}
@Composable @Composable
private fun AlbumsTab( private fun AlbumsTab(
@@ -153,6 +156,7 @@ private fun AlbumsTab(
navController: NavHostController, navController: NavHostController,
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) { when (val s = state) {
LibraryUiState.Loading -> LoadingCentered() LibraryUiState.Loading -> LoadingCentered()
LibraryUiState.Empty -> EmptyState( LibraryUiState.Empty -> EmptyState(
@@ -166,6 +170,7 @@ private fun AlbumsTab(
) )
} }
} }
}
@Composable @Composable
private fun ArtistsGrid( private fun ArtistsGrid(
@@ -2,20 +2,24 @@ package com.fabledsword.minstrel.library.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.library.data.LibraryRepository
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
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@HiltViewModel @HiltViewModel
class LibraryViewModel @Inject constructor( class LibraryViewModel @Inject constructor(
private val repository: LibraryRepository, repository: LibraryRepository,
private val syncController: SyncController,
) : ViewModel() { ) : ViewModel() {
val uiState: StateFlow<LibraryUiState> = val uiState: StateFlow<LibraryUiState> =
@@ -37,4 +41,13 @@ class LibraryViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = LibraryUiState.Loading, initialValue = LibraryUiState.Loading,
) )
/**
* Pull-to-refresh entry point. Triggers /api/library/sync via the
* SyncController; the resulting Room writes flow through the
* observeArtists/observeAlbums flows to re-render the grids.
*/
fun refresh(): Job = viewModelScope.launch {
syncController.syncSafe()
}
} }
@@ -40,9 +40,11 @@ import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
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.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
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
@@ -80,9 +82,11 @@ class LikedTabViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
init { init {
viewModelScope.launch { refresh()
runCatching { repository.refreshIds() }
} }
fun refresh(): Job = viewModelScope.launch {
runCatching { repository.refreshIds() }
} }
val uiState: StateFlow<LikedTabUiState> = combine( val uiState: StateFlow<LikedTabUiState> = combine(
@@ -122,7 +126,7 @@ fun LikedTab(
viewModel: LikedTabViewModel = hiltViewModel(), viewModel: LikedTabViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) { PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) { when (val s = state) {
LikedTabUiState.Loading -> LoadingCentered() LikedTabUiState.Loading -> LoadingCentered()
LikedTabUiState.Empty -> EmptyState( LikedTabUiState.Empty -> EmptyState(
@@ -28,11 +28,12 @@ import com.composables.icons.lucide.ArchiveRestore
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.QuarantineRef import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@Composable @Composable
fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) { fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) { PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) { when (val s = state) {
HiddenTabUiState.Loading -> LoadingCentered() HiddenTabUiState.Loading -> LoadingCentered()
HiddenTabUiState.Empty -> EmptyState( HiddenTabUiState.Empty -> EmptyState(
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.models.QuarantineRef import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.quarantine.data.QuarantineRepository import com.fabledsword.minstrel.quarantine.data.QuarantineRepository
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.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -30,8 +31,7 @@ class HiddenTabViewModel @Inject constructor(
refresh() refresh()
} }
fun refresh() { fun refresh(): Job = viewModelScope.launch {
viewModelScope.launch {
internal.value = HiddenTabUiState.Loading internal.value = HiddenTabUiState.Loading
try { try {
val rows = repository.listMine() val rows = repository.listMine()
@@ -46,7 +46,6 @@ class HiddenTabViewModel @Inject constructor(
internal.value = HiddenTabUiState.Error(e.message ?: "Hidden load failed") internal.value = HiddenTabUiState.Error(e.message ?: "Hidden load failed")
} }
} }
}
/** /**
* Optimistic unhide: drop the row from local state immediately, * Optimistic unhide: drop the row from local state immediately,