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:
@@ -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,24 +82,22 @@ 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 {
|
val req = async { requestsRepo.list().size }
|
||||||
val req = async { requestsRepo.list().size }
|
val qua = async { quarantineRepo.list().size }
|
||||||
val qua = async { quarantineRepo.list().size }
|
val usr = async { usersRepo.list().size }
|
||||||
val usr = async { usersRepo.list().size }
|
AdminCounts(req.await(), qua.await(), usr.await())
|
||||||
AdminCounts(req.await(), qua.await(), usr.await())
|
|
||||||
}
|
|
||||||
internal.value = AdminLandingUiState.Success(counts)
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
|
||||||
) {
|
|
||||||
internal.value = AdminLandingUiState.Error(
|
|
||||||
e.message ?: "Admin counts load failed",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
internal.value = AdminLandingUiState.Success(counts)
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.value = AdminLandingUiState.Error(
|
||||||
|
e.message ?: "Admin counts load failed",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
+5
-1
@@ -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(
|
||||||
|
|||||||
+15
-16
@@ -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,23 +39,21 @@ 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()
|
internal.value = if (rows.isEmpty()) {
|
||||||
internal.value = if (rows.isEmpty()) {
|
AdminQuarantineUiState.Empty
|
||||||
AdminQuarantineUiState.Empty
|
} else {
|
||||||
} else {
|
AdminQuarantineUiState.Success(rows)
|
||||||
AdminQuarantineUiState.Success(rows)
|
|
||||||
}
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
|
||||||
) {
|
|
||||||
internal.value = AdminQuarantineUiState.Error(
|
|
||||||
e.message ?: "Admin quarantine load failed",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.value = AdminQuarantineUiState.Error(
|
||||||
|
e.message ?: "Admin quarantine load failed",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+15
-16
@@ -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,23 +41,21 @@ 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()
|
internal.value = if (rows.isEmpty()) {
|
||||||
internal.value = if (rows.isEmpty()) {
|
AdminRequestsUiState.Empty
|
||||||
AdminRequestsUiState.Empty
|
} else {
|
||||||
} else {
|
AdminRequestsUiState.Success(rows)
|
||||||
AdminRequestsUiState.Success(rows)
|
|
||||||
}
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
|
||||||
) {
|
|
||||||
internal.value = AdminRequestsUiState.Error(
|
|
||||||
e.message ?: "Admin requests load failed",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.value = AdminRequestsUiState.Error(
|
||||||
|
e.message ?: "Admin requests load failed",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+13
-14
@@ -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,21 +31,19 @@ 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()
|
internal.value = if (users.isEmpty()) {
|
||||||
internal.value = if (users.isEmpty()) {
|
AdminUsersUiState.Empty
|
||||||
AdminUsersUiState.Empty
|
} else {
|
||||||
} else {
|
AdminUsersUiState.Success(users)
|
||||||
AdminUsersUiState.Success(users)
|
|
||||||
}
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
|
||||||
) {
|
|
||||||
internal.value = AdminUsersUiState.Error(e.message ?: "Users load failed")
|
|
||||||
}
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.value = AdminUsersUiState.Error(e.message ?: "Users load failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,21 +70,19 @@ 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()
|
internal.value = if (page.entries.isEmpty()) {
|
||||||
internal.value = if (page.entries.isEmpty()) {
|
HistoryUiState.Empty
|
||||||
HistoryUiState.Empty
|
} else {
|
||||||
} else {
|
HistoryUiState.Success(page.entries)
|
||||||
HistoryUiState.Success(page.entries)
|
|
||||||
}
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
|
||||||
) {
|
|
||||||
internal.value = HistoryUiState.Error(e.message ?: "History load failed")
|
|
||||||
}
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.value = HistoryUiState.Error(e.message ?: "History load failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,17 +134,19 @@ private fun ArtistsTab(
|
|||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
when (val s = state) {
|
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
||||||
LibraryUiState.Loading -> LoadingCentered()
|
when (val s = state) {
|
||||||
LibraryUiState.Empty -> EmptyState(
|
LibraryUiState.Loading -> LoadingCentered()
|
||||||
title = "No artists yet",
|
LibraryUiState.Empty -> EmptyState(
|
||||||
body = "Scan a folder in your server settings to populate the library.",
|
title = "No artists yet",
|
||||||
)
|
body = "Scan a folder in your server settings to populate the library.",
|
||||||
is LibraryUiState.Error -> ErrorRetry(message = s.message, onRetry = {})
|
)
|
||||||
is LibraryUiState.Success -> ArtistsGrid(
|
is LibraryUiState.Error -> ErrorRetry(message = s.message, onRetry = {})
|
||||||
artists = s.artists,
|
is LibraryUiState.Success -> ArtistsGrid(
|
||||||
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
artists = s.artists,
|
||||||
)
|
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,17 +156,19 @@ private fun AlbumsTab(
|
|||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
when (val s = state) {
|
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
||||||
LibraryUiState.Loading -> LoadingCentered()
|
when (val s = state) {
|
||||||
LibraryUiState.Empty -> EmptyState(
|
LibraryUiState.Loading -> LoadingCentered()
|
||||||
title = "No albums yet",
|
LibraryUiState.Empty -> EmptyState(
|
||||||
body = "Scan a folder in your server settings to populate the library.",
|
title = "No albums yet",
|
||||||
)
|
body = "Scan a folder in your server settings to populate the library.",
|
||||||
is LibraryUiState.Error -> ErrorRetry(message = s.message, onRetry = {})
|
)
|
||||||
is LibraryUiState.Success -> AlbumsGrid(
|
is LibraryUiState.Error -> ErrorRetry(message = s.message, onRetry = {})
|
||||||
albums = s.albums,
|
is LibraryUiState.Success -> AlbumsGrid(
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
albums = s.albums,
|
||||||
)
|
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+13
-14
@@ -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,21 +31,19 @@ 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()
|
internal.value = if (rows.isEmpty()) {
|
||||||
internal.value = if (rows.isEmpty()) {
|
HiddenTabUiState.Empty
|
||||||
HiddenTabUiState.Empty
|
} else {
|
||||||
} else {
|
HiddenTabUiState.Success(rows)
|
||||||
HiddenTabUiState.Success(rows)
|
|
||||||
}
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
|
||||||
) {
|
|
||||||
internal.value = HiddenTabUiState.Error(e.message ?: "Hidden load failed")
|
|
||||||
}
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.value = HiddenTabUiState.Error(e.message ?: "Hidden load failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user