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.shared.widgets.EmptyState
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.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -80,8 +82,7 @@ class AdminLandingViewModel @Inject constructor(
refresh()
}
fun refresh() {
viewModelScope.launch {
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminLandingUiState.Loading
try {
val counts = coroutineScope {
@@ -100,7 +101,6 @@ class AdminLandingViewModel @Inject constructor(
}
}
}
}
// ─── Screen ──────────────────────────────────────────────────────────
@@ -130,7 +130,10 @@ fun AdminLandingScreen(
)
},
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
AdminLandingUiState.Loading -> LoadingCentered()
is AdminLandingUiState.Error -> EmptyState(
@@ -37,6 +37,7 @@ import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -64,7 +65,10 @@ fun AdminQuarantineScreen(
)
},
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
AdminQuarantineUiState.Loading -> LoadingCentered()
AdminQuarantineUiState.Empty -> EmptyState(
@@ -6,6 +6,7 @@ import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -38,8 +39,7 @@ class AdminQuarantineViewModel @Inject constructor(
}
}
fun refresh() {
viewModelScope.launch {
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminQuarantineUiState.Loading
try {
val rows = repository.list()
@@ -56,7 +56,6 @@ class AdminQuarantineViewModel @Inject constructor(
)
}
}
}
fun resolve(trackId: String) = act(trackId) { repository.resolve(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.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -63,7 +64,10 @@ fun AdminRequestsScreen(
)
},
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
AdminRequestsUiState.Loading -> LoadingCentered()
AdminRequestsUiState.Empty -> EmptyState(
@@ -6,6 +6,7 @@ import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -40,8 +41,7 @@ class AdminRequestsViewModel @Inject constructor(
}
}
fun refresh() {
viewModelScope.launch {
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminRequestsUiState.Loading
try {
val rows = repository.list()
@@ -58,7 +58,6 @@ class AdminRequestsViewModel @Inject constructor(
)
}
}
}
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.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -73,7 +74,10 @@ fun AdminUsersScreen(
)
},
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
AdminUsersUiState.Loading -> LoadingCentered()
AdminUsersUiState.Empty -> EmptyState(
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.models.AdminUserRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -30,8 +31,7 @@ class AdminUsersViewModel @Inject constructor(
refresh()
}
fun refresh() {
viewModelScope.launch {
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminUsersUiState.Loading
try {
val users = repository.list()
@@ -46,7 +46,6 @@ class AdminUsersViewModel @Inject constructor(
internal.value = AdminUsersUiState.Error(e.message ?: "Users load failed")
}
}
}
fun setAdmin(id: String, isAdmin: Boolean) = patch(id, { it.copy(isAdmin = 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.player.PlayerController
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.trackactions.TrackActionsButton
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -68,8 +70,7 @@ class HistoryTabViewModel @Inject constructor(
refresh()
}
fun refresh() {
viewModelScope.launch {
fun refresh(): Job = viewModelScope.launch {
internal.value = HistoryUiState.Loading
try {
val page = repository.fetchPage()
@@ -84,7 +85,6 @@ class HistoryTabViewModel @Inject constructor(
internal.value = HistoryUiState.Error(e.message ?: "History load failed")
}
}
}
/** Single-track play, matching the Flutter history-row behavior. */
fun playTrack(track: TrackRef) {
@@ -102,7 +102,7 @@ fun HistoryTab(
viewModel: HistoryTabViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) {
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) {
HistoryUiState.Loading -> LoadingCentered()
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.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
/**
@@ -133,6 +134,7 @@ private fun ArtistsTab(
navController: NavHostController,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) {
LibraryUiState.Loading -> LoadingCentered()
LibraryUiState.Empty -> EmptyState(
@@ -146,6 +148,7 @@ private fun ArtistsTab(
)
}
}
}
@Composable
private fun AlbumsTab(
@@ -153,6 +156,7 @@ private fun AlbumsTab(
navController: NavHostController,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) {
LibraryUiState.Loading -> LoadingCentered()
LibraryUiState.Empty -> EmptyState(
@@ -166,6 +170,7 @@ private fun AlbumsTab(
)
}
}
}
@Composable
private fun ArtistsGrid(
@@ -2,20 +2,24 @@ package com.fabledsword.minstrel.library.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.library.data.LibraryRepository
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
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@HiltViewModel
class LibraryViewModel @Inject constructor(
private val repository: LibraryRepository,
repository: LibraryRepository,
private val syncController: SyncController,
) : ViewModel() {
val uiState: StateFlow<LibraryUiState> =
@@ -37,4 +41,13 @@ class LibraryViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
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.shared.widgets.EmptyState
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.trackactions.TrackActionsButton
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
@@ -80,9 +82,11 @@ class LikedTabViewModel @Inject constructor(
) : ViewModel() {
init {
viewModelScope.launch {
runCatching { repository.refreshIds() }
refresh()
}
fun refresh(): Job = viewModelScope.launch {
runCatching { repository.refreshIds() }
}
val uiState: StateFlow<LikedTabUiState> = combine(
@@ -122,7 +126,7 @@ fun LikedTab(
viewModel: LikedTabViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) {
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) {
LikedTabUiState.Loading -> LoadingCentered()
LikedTabUiState.Empty -> EmptyState(
@@ -28,11 +28,12 @@ import com.composables.icons.lucide.ArchiveRestore
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@Composable
fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) {
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) {
HiddenTabUiState.Loading -> LoadingCentered()
HiddenTabUiState.Empty -> EmptyState(
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.quarantine.data.QuarantineRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -30,8 +31,7 @@ class HiddenTabViewModel @Inject constructor(
refresh()
}
fun refresh() {
viewModelScope.launch {
fun refresh(): Job = viewModelScope.launch {
internal.value = HiddenTabUiState.Loading
try {
val rows = repository.listMine()
@@ -46,7 +46,6 @@ class HiddenTabViewModel @Inject constructor(
internal.value = HiddenTabUiState.Error(e.message ?: "Hidden load failed")
}
}
}
/**
* Optimistic unhide: drop the row from local state immediately,