refactor(android): migrate 8 per-screen UiStates to shared UiState<T>
History/Hidden/Requests/PlaylistsList/AddToPlaylist/Home/Liked/Library each had its own sealed interface with Loading/Empty/Success(payload)/ Error variants. Collapsed to the generic shared/UiState<T> introduced in the prior commit. Library carries two lists (artists + albums) so its payload is wrapped in a new LibraryData record; the test was updated to assert against UiState.Success<LibraryData>. The 3 detail screens (Artist/Album/Playlist Detail) keep their own sealed interfaces for now since they include a Loading(seed: ...) variant that does not fit UiState<T>.
This commit is contained in:
@@ -23,6 +23,7 @@ import com.fabledsword.minstrel.history.data.HistoryEntry
|
|||||||
import com.fabledsword.minstrel.history.data.HistoryRepository
|
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.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -49,13 +50,6 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
sealed interface HistoryUiState {
|
|
||||||
data object Loading : HistoryUiState
|
|
||||||
data object Empty : HistoryUiState
|
|
||||||
data class Success(val entries: List<HistoryEntry>) : HistoryUiState
|
|
||||||
data class Error(val message: String) : HistoryUiState
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -72,18 +66,18 @@ class HistoryTabViewModel @Inject constructor(
|
|||||||
* only when there's nothing cached AND the refresh failed — a
|
* only when there's nothing cached AND the refresh failed — a
|
||||||
* failed refresh over a populated cache stays silent.
|
* failed refresh over a populated cache stays silent.
|
||||||
*/
|
*/
|
||||||
val uiState: StateFlow<HistoryUiState> =
|
val uiState: StateFlow<UiState<List<HistoryEntry>>> =
|
||||||
combine(repository.observeHistory(), refreshError) { page, err ->
|
combine(repository.observeHistory(), refreshError) { page, err ->
|
||||||
when {
|
when {
|
||||||
page != null && page.entries.isNotEmpty() -> HistoryUiState.Success(page.entries)
|
page != null && page.entries.isNotEmpty() -> UiState.Success(page.entries)
|
||||||
page != null -> HistoryUiState.Empty
|
page != null -> UiState.Empty
|
||||||
err != null -> HistoryUiState.Error(err)
|
err != null -> UiState.Error(err)
|
||||||
else -> HistoryUiState.Loading
|
else -> UiState.Loading
|
||||||
}
|
}
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
initialValue = HistoryUiState.Loading,
|
initialValue = UiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -117,17 +111,17 @@ fun HistoryTab(
|
|||||||
val playingTrackId = playerState.currentTrack?.id
|
val playingTrackId = playerState.currentTrack?.id
|
||||||
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
HistoryUiState.Loading -> LoadingCentered()
|
UiState.Loading -> LoadingCentered()
|
||||||
HistoryUiState.Empty -> EmptyState(
|
UiState.Empty -> EmptyState(
|
||||||
title = "No listening history yet",
|
title = "No listening history yet",
|
||||||
body = "Play something — your recent plays will show up here.",
|
body = "Play something — your recent plays will show up here.",
|
||||||
)
|
)
|
||||||
is HistoryUiState.Error -> EmptyState(
|
is UiState.Error -> EmptyState(
|
||||||
title = "Couldn't load history",
|
title = "Couldn't load history",
|
||||||
body = s.message,
|
body = s.message,
|
||||||
)
|
)
|
||||||
is HistoryUiState.Success -> HistoryList(
|
is UiState.Success -> HistoryList(
|
||||||
entries = s.entries,
|
entries = s.data,
|
||||||
playingTrackId = playingTrackId,
|
playingTrackId = playingTrackId,
|
||||||
onPlay = viewModel::playTrack,
|
onPlay = viewModel::playTrack,
|
||||||
onNavigateToAlbum = onNavigateToAlbum,
|
onNavigateToAlbum = onNavigateToAlbum,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
|||||||
import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
|
import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
|
||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
||||||
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
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
|
||||||
@@ -113,13 +114,6 @@ data class HomeSections(
|
|||||||
lastPlayedArtists.isEmpty()
|
lastPlayedArtists.isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface HomeUiState {
|
|
||||||
data object Loading : HomeUiState
|
|
||||||
data object Empty : HomeUiState
|
|
||||||
data class Success(val sections: HomeSections) : HomeUiState
|
|
||||||
data class Error(val message: String) : HomeUiState
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -204,10 +198,10 @@ class HomeViewModel @Inject constructor(
|
|||||||
status.join()
|
status.join()
|
||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<HomeUiState> = combineHomeFlows().stateIn(
|
val uiState: StateFlow<UiState<HomeSections>> = combineHomeFlows().stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
initialValue = HomeUiState.Loading,
|
initialValue = UiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -236,8 +230,8 @@ class HomeViewModel @Inject constructor(
|
|||||||
private fun combineHomeFlows() =
|
private fun combineHomeFlows() =
|
||||||
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
||||||
val merged = sections.copy(playlists = playlists)
|
val merged = sections.copy(playlists = playlists)
|
||||||
if (merged.isAllEmpty) HomeUiState.Empty else HomeUiState.Success(merged)
|
if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged)
|
||||||
}.catch { e -> emit(HomeUiState.Error(ErrorCopy.fromThrowable(e))) }
|
}.catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Screen ──────────────────────────────────────────────────────────
|
// ─── Screen ──────────────────────────────────────────────────────────
|
||||||
@@ -276,19 +270,19 @@ fun HomeScreen(
|
|||||||
) {
|
) {
|
||||||
Crossfade(targetState = state, label = "home-state") { s ->
|
Crossfade(targetState = state, label = "home-state") { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
HomeUiState.Loading -> HomeSkeletonContent()
|
UiState.Loading -> HomeSkeletonContent()
|
||||||
HomeUiState.Empty -> EmptyState(
|
UiState.Empty -> EmptyState(
|
||||||
title = "Welcome to Minstrel",
|
title = "Welcome to Minstrel",
|
||||||
body = "Nothing to show yet — scan a folder in your server " +
|
body = "Nothing to show yet — scan a folder in your server " +
|
||||||
"settings, then come back here for system playlists " +
|
"settings, then come back here for system playlists " +
|
||||||
"and recommendations.",
|
"and recommendations.",
|
||||||
)
|
)
|
||||||
is HomeUiState.Error -> EmptyState(
|
is UiState.Error -> EmptyState(
|
||||||
title = "Couldn't load home",
|
title = "Couldn't load home",
|
||||||
body = s.message,
|
body = s.message,
|
||||||
)
|
)
|
||||||
is HomeUiState.Success -> HomeSuccessContent(
|
is UiState.Success -> HomeSuccessContent(
|
||||||
sections = s.sections,
|
sections = s.data,
|
||||||
systemStatus = systemStatus,
|
systemStatus = systemStatus,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail
|
|||||||
import com.fabledsword.minstrel.nav.Library
|
import com.fabledsword.minstrel.nav.Library
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Shuffle
|
import com.composables.icons.lucide.Shuffle
|
||||||
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
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
|
||||||
@@ -151,18 +152,18 @@ private fun ArtistsTab(
|
|||||||
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
||||||
Crossfade(targetState = state, label = "library-artists") { s ->
|
Crossfade(targetState = state, label = "library-artists") { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
LibraryUiState.Loading -> SkeletonArtistsGrid()
|
UiState.Loading -> SkeletonArtistsGrid()
|
||||||
LibraryUiState.Empty -> EmptyState(
|
UiState.Empty -> EmptyState(
|
||||||
title = "No artists yet",
|
title = "No artists yet",
|
||||||
body = "Scan a folder in your server settings to " +
|
body = "Scan a folder in your server settings to " +
|
||||||
"populate the library.",
|
"populate the library.",
|
||||||
)
|
)
|
||||||
is LibraryUiState.Error -> ErrorRetry(
|
is UiState.Error -> ErrorRetry(
|
||||||
message = s.message,
|
message = s.message,
|
||||||
onRetry = { viewModel.refresh() },
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is LibraryUiState.Success -> ArtistsGrid(
|
is UiState.Success -> ArtistsGrid(
|
||||||
artists = s.artists,
|
artists = s.data.artists,
|
||||||
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -179,18 +180,18 @@ private fun AlbumsTab(
|
|||||||
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
||||||
Crossfade(targetState = state, label = "library-albums") { s ->
|
Crossfade(targetState = state, label = "library-albums") { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
LibraryUiState.Loading -> SkeletonAlbumsGrid()
|
UiState.Loading -> SkeletonAlbumsGrid()
|
||||||
LibraryUiState.Empty -> EmptyState(
|
UiState.Empty -> EmptyState(
|
||||||
title = "No albums yet",
|
title = "No albums yet",
|
||||||
body = "Scan a folder in your server settings to " +
|
body = "Scan a folder in your server settings to " +
|
||||||
"populate the library.",
|
"populate the library.",
|
||||||
)
|
)
|
||||||
is LibraryUiState.Error -> ErrorRetry(
|
is UiState.Error -> ErrorRetry(
|
||||||
message = s.message,
|
message = s.message,
|
||||||
onRetry = { viewModel.refresh() },
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is LibraryUiState.Success -> AlbumsGrid(
|
is UiState.Success -> AlbumsGrid(
|
||||||
albums = s.albums,
|
albums = s.data.albums,
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,12 @@ import com.fabledsword.minstrel.models.AlbumRef
|
|||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI state for the Library screen. Sealed interface — each composable
|
* Payload carried by `UiState.Success` for the Library screen. The
|
||||||
* branch handles exactly the cases it cares about; the compiler
|
* grid renders artists and albums side-by-side, so the state wraps
|
||||||
* enforces exhaustiveness.
|
* both lists into a single value; the generic `shared/UiState<T>` then
|
||||||
*
|
* provides Loading / Empty / Error around it.
|
||||||
* - Loading: initial state before the first DAO emission.
|
|
||||||
* - Empty: DAO emitted but the cache holds no artists and no albums
|
|
||||||
* (fresh install, never synced).
|
|
||||||
* - Success: DAO emitted with content.
|
|
||||||
* - Error: an exception bubbled from the DAO/repository layer; the
|
|
||||||
* message is surfaced as-is in the empty-state copy.
|
|
||||||
*/
|
*/
|
||||||
sealed interface LibraryUiState {
|
data class LibraryData(
|
||||||
data object Loading : LibraryUiState
|
val artists: List<ArtistRef>,
|
||||||
data object Empty : LibraryUiState
|
val albums: List<AlbumRef>,
|
||||||
data class Success(
|
)
|
||||||
val artists: List<ArtistRef>,
|
|
||||||
val albums: List<AlbumRef>,
|
|
||||||
) : LibraryUiState
|
|
||||||
data class Error(val message: String) : LibraryUiState
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -24,24 +25,24 @@ class LibraryViewModel @Inject constructor(
|
|||||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val uiState: StateFlow<LibraryUiState> =
|
val uiState: StateFlow<UiState<LibraryData>> =
|
||||||
combine(
|
combine(
|
||||||
repository.observeArtists(),
|
repository.observeArtists(),
|
||||||
repository.observeAlbums(),
|
repository.observeAlbums(),
|
||||||
) { artists, albums ->
|
) { artists, albums ->
|
||||||
if (artists.isEmpty() && albums.isEmpty()) {
|
if (artists.isEmpty() && albums.isEmpty()) {
|
||||||
LibraryUiState.Empty
|
UiState.Empty
|
||||||
} else {
|
} else {
|
||||||
LibraryUiState.Success(artists, albums)
|
UiState.Success(LibraryData(artists, albums))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { e ->
|
.catch { e ->
|
||||||
emit(LibraryUiState.Error(ErrorCopy.fromThrowable(e)))
|
emit(UiState.Error(ErrorCopy.fromThrowable(e)))
|
||||||
}
|
}
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
initialValue = LibraryUiState.Loading,
|
initialValue = UiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import com.fabledsword.minstrel.models.TrackRef
|
|||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
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 com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
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
|
||||||
@@ -66,13 +67,6 @@ data class LikedSections(
|
|||||||
get() = artists.isEmpty() && albums.isEmpty() && tracks.isEmpty()
|
get() = artists.isEmpty() && albums.isEmpty() && tracks.isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface LikedTabUiState {
|
|
||||||
data object Loading : LikedTabUiState
|
|
||||||
data object Empty : LikedTabUiState
|
|
||||||
data class Success(val sections: LikedSections) : LikedTabUiState
|
|
||||||
data class Error(val message: String) : LikedTabUiState
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -99,19 +93,19 @@ class LikedTabViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<LikedTabUiState> = combine(
|
val uiState: StateFlow<UiState<LikedSections>> = combine(
|
||||||
repository.observeLikedArtists(),
|
repository.observeLikedArtists(),
|
||||||
repository.observeLikedAlbums(),
|
repository.observeLikedAlbums(),
|
||||||
repository.observeLikedTracks(),
|
repository.observeLikedTracks(),
|
||||||
) { artists, albums, tracks ->
|
) { artists, albums, tracks ->
|
||||||
val sections = LikedSections(artists, albums, tracks)
|
val sections = LikedSections(artists, albums, tracks)
|
||||||
if (sections.isAllEmpty) LikedTabUiState.Empty else LikedTabUiState.Success(sections)
|
if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections)
|
||||||
}
|
}
|
||||||
.catch { e -> emit(LikedTabUiState.Error(ErrorCopy.fromThrowable(e))) }
|
.catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) }
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
initialValue = LikedTabUiState.Loading,
|
initialValue = UiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,23 +135,23 @@ fun LikedTab(
|
|||||||
val playingTrackId = playerState.currentTrack?.id
|
val playingTrackId = playerState.currentTrack?.id
|
||||||
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
LikedTabUiState.Loading -> LoadingCentered()
|
UiState.Loading -> LoadingCentered()
|
||||||
LikedTabUiState.Empty -> EmptyState(
|
UiState.Empty -> EmptyState(
|
||||||
title = "No likes yet",
|
title = "No likes yet",
|
||||||
body = "Tap the heart on an artist, album, or track to start " +
|
body = "Tap the heart on an artist, album, or track to start " +
|
||||||
"building your liked collection.",
|
"building your liked collection.",
|
||||||
)
|
)
|
||||||
is LikedTabUiState.Error -> EmptyState(
|
is UiState.Error -> EmptyState(
|
||||||
title = "Couldn't load likes",
|
title = "Couldn't load likes",
|
||||||
body = s.message,
|
body = s.message,
|
||||||
)
|
)
|
||||||
is LikedTabUiState.Success -> LikedContent(
|
is UiState.Success -> LikedContent(
|
||||||
sections = s.sections,
|
sections = s.data,
|
||||||
playingTrackId = playingTrackId,
|
playingTrackId = playingTrackId,
|
||||||
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
onTrackClick = { index ->
|
onTrackClick = { index ->
|
||||||
viewModel.playTracks(s.sections.tracks, index)
|
viewModel.playTracks(s.data.tracks, index)
|
||||||
},
|
},
|
||||||
onUnlike = viewModel::unlikeTrack,
|
onUnlike = viewModel::unlikeTrack,
|
||||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
|||||||
+11
-17
@@ -29,6 +29,7 @@ import com.fabledsword.minstrel.models.PlaylistRef
|
|||||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||||
import com.fabledsword.minstrel.nav.Playlists
|
import com.fabledsword.minstrel.nav.Playlists
|
||||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||||
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
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.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -49,13 +50,6 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
sealed interface PlaylistsListUiState {
|
|
||||||
data object Loading : PlaylistsListUiState
|
|
||||||
data object Empty : PlaylistsListUiState
|
|
||||||
data class Success(val playlists: List<PlaylistRef>) : PlaylistsListUiState
|
|
||||||
data class Error(val message: String) : PlaylistsListUiState
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -80,22 +74,22 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
runCatching { repository.refreshList() }
|
runCatching { repository.refreshList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<PlaylistsListUiState> =
|
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
||||||
repository.observeAll()
|
repository.observeAll()
|
||||||
.map { list ->
|
.map { list ->
|
||||||
if (list.isEmpty()) {
|
if (list.isEmpty()) {
|
||||||
PlaylistsListUiState.Empty
|
UiState.Empty
|
||||||
} else {
|
} else {
|
||||||
PlaylistsListUiState.Success(list)
|
UiState.Success(list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { e ->
|
.catch { e ->
|
||||||
emit(PlaylistsListUiState.Error(ErrorCopy.fromThrowable(e)))
|
emit(UiState.Error(ErrorCopy.fromThrowable(e)))
|
||||||
}
|
}
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
initialValue = PlaylistsListUiState.Loading,
|
initialValue = UiState.Loading,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,19 +121,19 @@ fun PlaylistsListScreen(
|
|||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
PlaylistsListUiState.Loading -> LoadingCentered()
|
UiState.Loading -> LoadingCentered()
|
||||||
PlaylistsListUiState.Empty -> EmptyState(
|
UiState.Empty -> EmptyState(
|
||||||
title = "No playlists yet",
|
title = "No playlists yet",
|
||||||
body = "System playlists (For You / Discover / Songs like…) " +
|
body = "System playlists (For You / Discover / Songs like…) " +
|
||||||
"appear once your library has enough plays. Create your " +
|
"appear once your library has enough plays. Create your " +
|
||||||
"own playlist from any album or track in the meantime.",
|
"own playlist from any album or track in the meantime.",
|
||||||
)
|
)
|
||||||
is PlaylistsListUiState.Error -> EmptyState(
|
is UiState.Error -> EmptyState(
|
||||||
title = "Couldn't load playlists",
|
title = "Couldn't load playlists",
|
||||||
body = s.message,
|
body = s.message,
|
||||||
)
|
)
|
||||||
is PlaylistsListUiState.Success -> PlaylistsGrid(
|
is UiState.Success -> PlaylistsGrid(
|
||||||
playlists = s.playlists,
|
playlists = s.data,
|
||||||
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.composables.icons.lucide.ArchiveRestore
|
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.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -35,19 +36,19 @@ fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
HiddenTabUiState.Loading -> LoadingCentered()
|
UiState.Loading -> LoadingCentered()
|
||||||
HiddenTabUiState.Empty -> EmptyState(
|
UiState.Empty -> EmptyState(
|
||||||
title = "No hidden tracks",
|
title = "No hidden tracks",
|
||||||
body = "Flag a track from its menu (bad rip, wrong tags, etc.) " +
|
body = "Flag a track from its menu (bad rip, wrong tags, etc.) " +
|
||||||
"and it'll show up here. The track stays out of system " +
|
"and it'll show up here. The track stays out of system " +
|
||||||
"playlists until you unhide it.",
|
"playlists until you unhide it.",
|
||||||
)
|
)
|
||||||
is HiddenTabUiState.Error -> EmptyState(
|
is UiState.Error -> EmptyState(
|
||||||
title = "Couldn't load hidden tracks",
|
title = "Couldn't load hidden tracks",
|
||||||
body = s.message,
|
body = s.message,
|
||||||
)
|
)
|
||||||
is HiddenTabUiState.Success -> HiddenList(
|
is UiState.Success -> HiddenList(
|
||||||
rows = s.rows,
|
rows = s.data,
|
||||||
onUnflag = viewModel::unflag,
|
onUnflag = viewModel::unflag,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-12
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
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.Job
|
||||||
@@ -19,13 +20,6 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
sealed interface HiddenTabUiState {
|
|
||||||
data object Loading : HiddenTabUiState
|
|
||||||
data object Empty : HiddenTabUiState
|
|
||||||
data class Success(val rows: List<QuarantineRef>) : HiddenTabUiState
|
|
||||||
data class Error(val message: String) : HiddenTabUiState
|
|
||||||
}
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class HiddenTabViewModel @Inject constructor(
|
class HiddenTabViewModel @Inject constructor(
|
||||||
private val repository: QuarantineRepository,
|
private val repository: QuarantineRepository,
|
||||||
@@ -41,17 +35,17 @@ class HiddenTabViewModel @Inject constructor(
|
|||||||
* refresh failed. Optimistic flag/unflag write the cache, so the
|
* refresh failed. Optimistic flag/unflag write the cache, so the
|
||||||
* list updates without waiting on the server.
|
* list updates without waiting on the server.
|
||||||
*/
|
*/
|
||||||
val uiState: StateFlow<HiddenTabUiState> =
|
val uiState: StateFlow<UiState<List<QuarantineRef>>> =
|
||||||
combine(repository.observeMine(), refreshError) { rows, err ->
|
combine(repository.observeMine(), refreshError) { rows, err ->
|
||||||
when {
|
when {
|
||||||
rows.isNotEmpty() -> HiddenTabUiState.Success(rows)
|
rows.isNotEmpty() -> UiState.Success(rows)
|
||||||
err != null -> HiddenTabUiState.Error(err)
|
err != null -> UiState.Error(err)
|
||||||
else -> HiddenTabUiState.Empty
|
else -> UiState.Empty
|
||||||
}
|
}
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
initialValue = HiddenTabUiState.Loading,
|
initialValue = UiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import com.composables.icons.lucide.X
|
|||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.models.RequestStatus
|
import com.fabledsword.minstrel.models.RequestStatus
|
||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Requests
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
@@ -84,18 +85,18 @@ fun RequestsScreen(
|
|||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
RequestsUiState.Loading -> LoadingCentered()
|
UiState.Loading -> LoadingCentered()
|
||||||
RequestsUiState.Empty -> EmptyState(
|
UiState.Empty -> EmptyState(
|
||||||
title = "Nothing requested yet",
|
title = "Nothing requested yet",
|
||||||
body = "Use Discover to ask Lidarr for new music; your " +
|
body = "Use Discover to ask Lidarr for new music; your " +
|
||||||
"requests show up here.",
|
"requests show up here.",
|
||||||
)
|
)
|
||||||
is RequestsUiState.Error -> EmptyState(
|
is UiState.Error -> EmptyState(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
body = s.message,
|
body = s.message,
|
||||||
)
|
)
|
||||||
is RequestsUiState.Success -> RequestList(
|
is UiState.Success -> RequestList(
|
||||||
rows = s.requests,
|
rows = s.data,
|
||||||
onCancel = viewModel::cancel,
|
onCancel = viewModel::cancel,
|
||||||
onListen = { id -> navController.navigate(id) },
|
onListen = { id -> navController.navigate(id) },
|
||||||
)
|
)
|
||||||
|
|||||||
+13
-19
@@ -6,6 +6,7 @@ import com.fabledsword.minstrel.api.ErrorCopy
|
|||||||
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 com.fabledsword.minstrel.requests.data.RequestsRepository
|
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
||||||
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -18,21 +19,14 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
|
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
|
||||||
|
|
||||||
sealed interface RequestsUiState {
|
|
||||||
data object Loading : RequestsUiState
|
|
||||||
data object Empty : RequestsUiState
|
|
||||||
data class Success(val requests: List<RequestRef>) : RequestsUiState
|
|
||||||
data class Error(val message: String) : RequestsUiState
|
|
||||||
}
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class RequestsViewModel @Inject constructor(
|
class RequestsViewModel @Inject constructor(
|
||||||
private val repository: RequestsRepository,
|
private val repository: RequestsRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<RequestsUiState>(RequestsUiState.Loading)
|
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
||||||
val uiState: StateFlow<RequestsUiState> = internal.asStateFlow()
|
val uiState: StateFlow<UiState<List<RequestRef>>> = internal.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
@@ -44,18 +38,18 @@ class RequestsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
internal.value = RequestsUiState.Loading
|
internal.value = UiState.Loading
|
||||||
try {
|
try {
|
||||||
val rows = repository.listMine()
|
val rows = repository.listMine()
|
||||||
internal.value = if (rows.isEmpty()) {
|
internal.value = if (rows.isEmpty()) {
|
||||||
RequestsUiState.Empty
|
UiState.Empty
|
||||||
} else {
|
} else {
|
||||||
RequestsUiState.Success(rows)
|
UiState.Success(rows)
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
internal.value = RequestsUiState.Error(ErrorCopy.fromThrowable(e))
|
internal.value = UiState.Error(ErrorCopy.fromThrowable(e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,12 +61,12 @@ class RequestsViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun cancel(id: String) {
|
fun cancel(id: String) {
|
||||||
val before = internal.value
|
val before = internal.value
|
||||||
if (before is RequestsUiState.Success) {
|
if (before is UiState.Success) {
|
||||||
val filtered = before.requests.filterNot { it.id == id }
|
val filtered = before.data.filterNot { it.id == id }
|
||||||
internal.value = if (filtered.isEmpty()) {
|
internal.value = if (filtered.isEmpty()) {
|
||||||
RequestsUiState.Empty
|
UiState.Empty
|
||||||
} else {
|
} else {
|
||||||
RequestsUiState.Success(filtered)
|
UiState.Success(filtered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -80,11 +74,11 @@ class RequestsViewModel @Inject constructor(
|
|||||||
val (_, updated) = repository.cancel(id)
|
val (_, updated) = repository.cancel(id)
|
||||||
if (updated != null) {
|
if (updated != null) {
|
||||||
internal.update { state ->
|
internal.update { state ->
|
||||||
if (state !is RequestsUiState.Success) {
|
if (state !is UiState.Success) {
|
||||||
// Mid-refresh — let the refresh result win, but
|
// Mid-refresh — let the refresh result win, but
|
||||||
// splice the cancelled row in so it shows the new
|
// splice the cancelled row in so it shows the new
|
||||||
// status if the user re-pulls.
|
// status if the user re-pulls.
|
||||||
RequestsUiState.Success(listOf(updated))
|
UiState.Success(listOf(updated))
|
||||||
} else {
|
} else {
|
||||||
// Re-insert the cancelled row in its old position
|
// Re-insert the cancelled row in its old position
|
||||||
// would require remembering the prior ordering;
|
// would require remembering the prior ordering;
|
||||||
|
|||||||
+5
-4
@@ -27,6 +27,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.composables.icons.lucide.ListMusic
|
import com.composables.icons.lucide.ListMusic
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ModalBottomSheet that lists the caller's user-owned playlists and
|
* ModalBottomSheet that lists the caller's user-owned playlists and
|
||||||
@@ -55,16 +56,16 @@ fun AddToPlaylistSheet(
|
|||||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
AddToPlaylistUiState.Loading -> Box(
|
UiState.Loading -> Box(
|
||||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) }
|
) { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) }
|
||||||
is AddToPlaylistUiState.Error -> Text(
|
is UiState.Error -> Text(
|
||||||
text = s.message,
|
text = s.message,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
)
|
)
|
||||||
is AddToPlaylistUiState.Success -> if (s.playlists.isEmpty()) {
|
is UiState.Success -> if (s.data.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "You haven't created any playlists yet.",
|
text = "You haven't created any playlists yet.",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -72,7 +73,7 @@ fun AddToPlaylistSheet(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||||
items(items = s.playlists, key = { it.id }) { p ->
|
items(items = s.data, key = { it.id }) { p ->
|
||||||
PlaylistPickRow(playlist = p, onClick = { onPick(p.id) })
|
PlaylistPickRow(playlist = p, onClick = { onPick(p.id) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-11
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||||
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -17,12 +18,6 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
sealed interface AddToPlaylistUiState {
|
|
||||||
data object Loading : AddToPlaylistUiState
|
|
||||||
data class Success(val playlists: List<PlaylistRef>) : AddToPlaylistUiState
|
|
||||||
data class Error(val message: String) : AddToPlaylistUiState
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backs [AddToPlaylistSheet] with the caller's user-owned playlists.
|
* Backs [AddToPlaylistSheet] with the caller's user-owned playlists.
|
||||||
* System playlists (For You / Discover / etc.) are filtered out at
|
* System playlists (For You / Discover / etc.) are filtered out at
|
||||||
@@ -33,9 +28,9 @@ class AddToPlaylistViewModel @Inject constructor(
|
|||||||
repository: PlaylistsRepository,
|
repository: PlaylistsRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val uiState: StateFlow<AddToPlaylistUiState> = repository
|
val uiState: StateFlow<UiState<List<PlaylistRef>>> = repository
|
||||||
.observeUserPlaylists()
|
.observeUserPlaylists()
|
||||||
.map { list -> AddToPlaylistUiState.Success(list) as AddToPlaylistUiState }
|
.map { list -> UiState.Success(list) as UiState<List<PlaylistRef>> }
|
||||||
.onStart {
|
.onStart {
|
||||||
// Best-effort refresh so the sheet reflects newly-created
|
// Best-effort refresh so the sheet reflects newly-created
|
||||||
// playlists from another device. Cached list shows first;
|
// playlists from another device. Cached list shows first;
|
||||||
@@ -43,12 +38,12 @@ class AddToPlaylistViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { repository.refreshList() }
|
runCatching { repository.refreshList() }
|
||||||
}
|
}
|
||||||
emit(AddToPlaylistUiState.Loading)
|
emit(UiState.Loading)
|
||||||
}
|
}
|
||||||
.catch { e -> emit(AddToPlaylistUiState.Error(ErrorCopy.fromThrowable(e))) }
|
.catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) }
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
initialValue = AddToPlaylistUiState.Loading,
|
initialValue = UiState.Loading,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-8
@@ -6,6 +6,7 @@ import com.fabledsword.minstrel.library.data.LibraryRepository
|
|||||||
import com.fabledsword.minstrel.models.AlbumRef
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.testutil.MainDispatcherExtension
|
import com.fabledsword.minstrel.testutil.MainDispatcherExtension
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
@@ -32,7 +33,7 @@ class LibraryViewModelTest {
|
|||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(LibraryUiState.Loading, vm.uiState.value)
|
assertEquals(UiState.Loading, vm.uiState.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: tests below collect the resolved state directly. With
|
// Note: tests below collect the resolved state directly. With
|
||||||
@@ -55,7 +56,7 @@ class LibraryViewModelTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
vm.uiState.test {
|
vm.uiState.test {
|
||||||
assertEquals(LibraryUiState.Empty, awaitItem())
|
assertEquals(UiState.Empty, awaitItem())
|
||||||
cancelAndConsumeRemainingEvents()
|
cancelAndConsumeRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,11 +76,11 @@ class LibraryViewModelTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
vm.uiState.test {
|
vm.uiState.test {
|
||||||
val success = awaitItem() as LibraryUiState.Success
|
val success = awaitItem() as UiState.Success<LibraryData>
|
||||||
assertEquals(1, success.artists.size)
|
assertEquals(1, success.data.artists.size)
|
||||||
assertEquals(1, success.albums.size)
|
assertEquals(1, success.data.albums.size)
|
||||||
assertEquals("a1", success.artists[0].id)
|
assertEquals("a1", success.data.artists[0].id)
|
||||||
assertEquals("al1", success.albums[0].id)
|
assertEquals("al1", success.data.albums[0].id)
|
||||||
cancelAndConsumeRemainingEvents()
|
cancelAndConsumeRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +99,7 @@ class LibraryViewModelTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
vm.uiState.test {
|
vm.uiState.test {
|
||||||
val error = awaitItem() as LibraryUiState.Error
|
val error = awaitItem() as UiState.Error
|
||||||
// ErrorCopy maps a non-HTTP/non-IO throwable to the generic
|
// ErrorCopy maps a non-HTTP/non-IO throwable to the generic
|
||||||
// fallback rather than leaking the raw exception text.
|
// fallback rather than leaking the raw exception text.
|
||||||
assertEquals("Something went wrong.", error.message)
|
assertEquals("Something went wrong.", error.message)
|
||||||
|
|||||||
Reference in New Issue
Block a user