diff --git a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt index 68fe457c..08fc9a8d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt @@ -23,6 +23,7 @@ import com.fabledsword.minstrel.history.data.HistoryEntry import com.fabledsword.minstrel.history.data.HistoryRepository import com.fabledsword.minstrel.models.TrackRef 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.EmptyState import com.fabledsword.minstrel.shared.widgets.LoadingCentered @@ -49,13 +50,6 @@ import javax.inject.Inject 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) : HistoryUiState - data class Error(val message: String) : HistoryUiState -} - // ─── ViewModel ─────────────────────────────────────────────────────── @HiltViewModel @@ -72,18 +66,18 @@ class HistoryTabViewModel @Inject constructor( * only when there's nothing cached AND the refresh failed — a * failed refresh over a populated cache stays silent. */ - val uiState: StateFlow = + val uiState: StateFlow>> = combine(repository.observeHistory(), refreshError) { page, err -> when { - page != null && page.entries.isNotEmpty() -> HistoryUiState.Success(page.entries) - page != null -> HistoryUiState.Empty - err != null -> HistoryUiState.Error(err) - else -> HistoryUiState.Loading + page != null && page.entries.isNotEmpty() -> UiState.Success(page.entries) + page != null -> UiState.Empty + err != null -> UiState.Error(err) + else -> UiState.Loading } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), - initialValue = HistoryUiState.Loading, + initialValue = UiState.Loading, ) init { @@ -117,17 +111,17 @@ fun HistoryTab( val playingTrackId = playerState.currentTrack?.id PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) { when (val s = state) { - HistoryUiState.Loading -> LoadingCentered() - HistoryUiState.Empty -> EmptyState( + UiState.Loading -> LoadingCentered() + UiState.Empty -> EmptyState( title = "No listening history yet", body = "Play something — your recent plays will show up here.", ) - is HistoryUiState.Error -> EmptyState( + is UiState.Error -> EmptyState( title = "Couldn't load history", body = s.message, ) - is HistoryUiState.Success -> HistoryList( - entries = s.entries, + is UiState.Success -> HistoryList( + entries = s.data, playingTrackId = playingTrackId, onPlay = viewModel::playTrack, onNavigateToAlbum = onNavigateToAlbum, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index 6be6524a..c0bc3a6c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -67,6 +67,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard import com.fabledsword.minstrel.playlists.widgets.PlaylistCard 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.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.MainAppBarActions @@ -113,13 +114,6 @@ data class HomeSections( 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 ─────────────────────────────────────────────────────── @HiltViewModel @@ -204,10 +198,10 @@ class HomeViewModel @Inject constructor( status.join() } - val uiState: StateFlow = combineHomeFlows().stateIn( + val uiState: StateFlow> = combineHomeFlows().stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), - initialValue = HomeUiState.Loading, + initialValue = UiState.Loading, ) /** @@ -236,8 +230,8 @@ class HomeViewModel @Inject constructor( private fun combineHomeFlows() = observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists -> val merged = sections.copy(playlists = playlists) - if (merged.isAllEmpty) HomeUiState.Empty else HomeUiState.Success(merged) - }.catch { e -> emit(HomeUiState.Error(ErrorCopy.fromThrowable(e))) } + if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged) + }.catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) } } // ─── Screen ────────────────────────────────────────────────────────── @@ -276,19 +270,19 @@ fun HomeScreen( ) { Crossfade(targetState = state, label = "home-state") { s -> when (s) { - HomeUiState.Loading -> HomeSkeletonContent() - HomeUiState.Empty -> EmptyState( + UiState.Loading -> HomeSkeletonContent() + UiState.Empty -> EmptyState( title = "Welcome to Minstrel", body = "Nothing to show yet — scan a folder in your server " + "settings, then come back here for system playlists " + "and recommendations.", ) - is HomeUiState.Error -> EmptyState( + is UiState.Error -> EmptyState( title = "Couldn't load home", body = s.message, ) - is HomeUiState.Success -> HomeSuccessContent( - sections = s.sections, + is UiState.Success -> HomeSuccessContent( + sections = s.data, systemStatus = systemStatus, offline = offline, onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt index fbdea37e..7b66bc32 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt @@ -46,6 +46,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.Library import com.composables.icons.lucide.Lucide 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.ErrorRetry import com.fabledsword.minstrel.shared.widgets.MainAppBarActions @@ -151,18 +152,18 @@ private fun ArtistsTab( PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) { Crossfade(targetState = state, label = "library-artists") { s -> when (s) { - LibraryUiState.Loading -> SkeletonArtistsGrid() - LibraryUiState.Empty -> EmptyState( + UiState.Loading -> SkeletonArtistsGrid() + UiState.Empty -> EmptyState( title = "No artists yet", body = "Scan a folder in your server settings to " + "populate the library.", ) - is LibraryUiState.Error -> ErrorRetry( + is UiState.Error -> ErrorRetry( message = s.message, onRetry = { viewModel.refresh() }, ) - is LibraryUiState.Success -> ArtistsGrid( - artists = s.artists, + is UiState.Success -> ArtistsGrid( + artists = s.data.artists, onArtistClick = { id -> navController.navigate(ArtistDetail(id)) }, ) } @@ -179,18 +180,18 @@ private fun AlbumsTab( PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) { Crossfade(targetState = state, label = "library-albums") { s -> when (s) { - LibraryUiState.Loading -> SkeletonAlbumsGrid() - LibraryUiState.Empty -> EmptyState( + UiState.Loading -> SkeletonAlbumsGrid() + UiState.Empty -> EmptyState( title = "No albums yet", body = "Scan a folder in your server settings to " + "populate the library.", ) - is LibraryUiState.Error -> ErrorRetry( + is UiState.Error -> ErrorRetry( message = s.message, onRetry = { viewModel.refresh() }, ) - is LibraryUiState.Success -> AlbumsGrid( - albums = s.albums, + is UiState.Success -> AlbumsGrid( + albums = s.data.albums, onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, ) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryUiState.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryUiState.kt index 3e2e51ea..ec406454 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryUiState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryUiState.kt @@ -4,23 +4,12 @@ import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.ArtistRef /** - * UI state for the Library screen. Sealed interface — each composable - * branch handles exactly the cases it cares about; the compiler - * enforces exhaustiveness. - * - * - 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. + * Payload carried by `UiState.Success` for the Library screen. The + * grid renders artists and albums side-by-side, so the state wraps + * both lists into a single value; the generic `shared/UiState` then + * provides Loading / Empty / Error around it. */ -sealed interface LibraryUiState { - data object Loading : LibraryUiState - data object Empty : LibraryUiState - data class Success( - val artists: List, - val albums: List, - ) : LibraryUiState - data class Error(val message: String) : LibraryUiState -} +data class LibraryData( + val artists: List, + val albums: List, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt index a10b0140..ba05e112 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.cache.sync.SyncController import com.fabledsword.minstrel.library.data.LibraryRepository +import com.fabledsword.minstrel.shared.UiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.SharingStarted @@ -24,24 +25,24 @@ class LibraryViewModel @Inject constructor( private val player: com.fabledsword.minstrel.player.PlayerController, ) : ViewModel() { - val uiState: StateFlow = + val uiState: StateFlow> = combine( repository.observeArtists(), repository.observeAlbums(), ) { artists, albums -> if (artists.isEmpty() && albums.isEmpty()) { - LibraryUiState.Empty + UiState.Empty } else { - LibraryUiState.Success(artists, albums) + UiState.Success(LibraryData(artists, albums)) } } .catch { e -> - emit(LibraryUiState.Error(ErrorCopy.fromThrowable(e))) + emit(UiState.Error(ErrorCopy.fromThrowable(e))) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), - initialValue = LibraryUiState.Loading, + initialValue = UiState.Loading, ) /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt index 3dbd043c..541a2afc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt @@ -35,6 +35,7 @@ import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail 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.EmptyState import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow @@ -66,13 +67,6 @@ data class LikedSections( 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 ─────────────────────────────────────────────────────── @HiltViewModel @@ -99,19 +93,19 @@ class LikedTabViewModel @Inject constructor( } } - val uiState: StateFlow = combine( + val uiState: StateFlow> = combine( repository.observeLikedArtists(), repository.observeLikedAlbums(), repository.observeLikedTracks(), ) { 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( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), - initialValue = LikedTabUiState.Loading, + initialValue = UiState.Loading, ) /** @@ -141,23 +135,23 @@ fun LikedTab( val playingTrackId = playerState.currentTrack?.id PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) { when (val s = state) { - LikedTabUiState.Loading -> LoadingCentered() - LikedTabUiState.Empty -> EmptyState( + UiState.Loading -> LoadingCentered() + UiState.Empty -> EmptyState( title = "No likes yet", body = "Tap the heart on an artist, album, or track to start " + "building your liked collection.", ) - is LikedTabUiState.Error -> EmptyState( + is UiState.Error -> EmptyState( title = "Couldn't load likes", body = s.message, ) - is LikedTabUiState.Success -> LikedContent( - sections = s.sections, + is UiState.Success -> LikedContent( + sections = s.data, playingTrackId = playingTrackId, onArtistClick = { id -> navController.navigate(ArtistDetail(id)) }, onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, onTrackClick = { index -> - viewModel.playTracks(s.sections.tracks, index) + viewModel.playTracks(s.data.tracks, index) }, onUnlike = viewModel::unlikeTrack, onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) }, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt index b8c9ef0a..b48b0ffc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt @@ -29,6 +29,7 @@ import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.nav.Playlists import com.fabledsword.minstrel.playlists.data.PlaylistsRepository +import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.LoadingCentered @@ -49,13 +50,6 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L // ─── State ─────────────────────────────────────────────────────────── -sealed interface PlaylistsListUiState { - data object Loading : PlaylistsListUiState - data object Empty : PlaylistsListUiState - data class Success(val playlists: List) : PlaylistsListUiState - data class Error(val message: String) : PlaylistsListUiState -} - // ─── ViewModel ─────────────────────────────────────────────────────── @HiltViewModel @@ -80,22 +74,22 @@ class PlaylistsListViewModel @Inject constructor( runCatching { repository.refreshList() } } - val uiState: StateFlow = + val uiState: StateFlow>> = repository.observeAll() .map { list -> if (list.isEmpty()) { - PlaylistsListUiState.Empty + UiState.Empty } else { - PlaylistsListUiState.Success(list) + UiState.Success(list) } } .catch { e -> - emit(PlaylistsListUiState.Error(ErrorCopy.fromThrowable(e))) + emit(UiState.Error(ErrorCopy.fromThrowable(e))) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), - initialValue = PlaylistsListUiState.Loading, + initialValue = UiState.Loading, ) } @@ -127,19 +121,19 @@ fun PlaylistsListScreen( modifier = Modifier.fillMaxSize().padding(inner), ) { when (val s = state) { - PlaylistsListUiState.Loading -> LoadingCentered() - PlaylistsListUiState.Empty -> EmptyState( + UiState.Loading -> LoadingCentered() + UiState.Empty -> EmptyState( title = "No playlists yet", body = "System playlists (For You / Discover / Songs like…) " + "appear once your library has enough plays. Create your " + "own playlist from any album or track in the meantime.", ) - is PlaylistsListUiState.Error -> EmptyState( + is UiState.Error -> EmptyState( title = "Couldn't load playlists", body = s.message, ) - is PlaylistsListUiState.Success -> PlaylistsGrid( - playlists = s.playlists, + is UiState.Success -> PlaylistsGrid( + playlists = s.data, onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) }, ) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt index 8841a387..bc30cc9f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composables.icons.lucide.ArchiveRestore import com.composables.icons.lucide.Lucide 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.LoadingCentered import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold @@ -35,19 +36,19 @@ fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) { val state by viewModel.uiState.collectAsStateWithLifecycle() PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) { when (val s = state) { - HiddenTabUiState.Loading -> LoadingCentered() - HiddenTabUiState.Empty -> EmptyState( + UiState.Loading -> LoadingCentered() + UiState.Empty -> EmptyState( title = "No hidden tracks", body = "Flag a track from its menu (bad rip, wrong tags, etc.) " + "and it'll show up here. The track stays out of system " + "playlists until you unhide it.", ) - is HiddenTabUiState.Error -> EmptyState( + is UiState.Error -> EmptyState( title = "Couldn't load hidden tracks", body = s.message, ) - is HiddenTabUiState.Success -> HiddenList( - rows = s.rows, + is UiState.Success -> HiddenList( + rows = s.data, onUnflag = viewModel::unflag, ) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt index eccc486e..c7831433 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.models.QuarantineRef +import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.quarantine.data.QuarantineRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -19,13 +20,6 @@ import javax.inject.Inject 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) : HiddenTabUiState - data class Error(val message: String) : HiddenTabUiState -} - @HiltViewModel class HiddenTabViewModel @Inject constructor( private val repository: QuarantineRepository, @@ -41,17 +35,17 @@ class HiddenTabViewModel @Inject constructor( * refresh failed. Optimistic flag/unflag write the cache, so the * list updates without waiting on the server. */ - val uiState: StateFlow = + val uiState: StateFlow>> = combine(repository.observeMine(), refreshError) { rows, err -> when { - rows.isNotEmpty() -> HiddenTabUiState.Success(rows) - err != null -> HiddenTabUiState.Error(err) - else -> HiddenTabUiState.Empty + rows.isNotEmpty() -> UiState.Success(rows) + err != null -> UiState.Error(err) + else -> UiState.Empty } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), - initialValue = HiddenTabUiState.Loading, + initialValue = UiState.Loading, ) init { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt index 7e57b759..e9d25aca 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt @@ -46,6 +46,7 @@ import com.composables.icons.lucide.X import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestStatus import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.Requests import com.fabledsword.minstrel.shared.widgets.EmptyState @@ -84,18 +85,18 @@ fun RequestsScreen( modifier = Modifier.fillMaxSize().padding(inner), ) { when (val s = state) { - RequestsUiState.Loading -> LoadingCentered() - RequestsUiState.Empty -> EmptyState( + UiState.Loading -> LoadingCentered() + UiState.Empty -> EmptyState( title = "Nothing requested yet", body = "Use Discover to ask Lidarr for new music; your " + "requests show up here.", ) - is RequestsUiState.Error -> EmptyState( + is UiState.Error -> EmptyState( title = "Couldn't load requests", body = s.message, ) - is RequestsUiState.Success -> RequestList( - rows = s.requests, + is UiState.Success -> RequestList( + rows = s.data, onCancel = viewModel::cancel, onListen = { id -> navController.navigate(id) }, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt index 7faff607..1b938506 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt @@ -6,6 +6,7 @@ import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.requests.data.RequestsRepository +import com.fabledsword.minstrel.shared.UiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -18,21 +19,14 @@ import javax.inject.Inject 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) : RequestsUiState - data class Error(val message: String) : RequestsUiState -} - @HiltViewModel class RequestsViewModel @Inject constructor( private val repository: RequestsRepository, private val eventsStream: EventsStream, ) : ViewModel() { - private val internal = MutableStateFlow(RequestsUiState.Loading) - val uiState: StateFlow = internal.asStateFlow() + private val internal = MutableStateFlow>>(UiState.Loading) + val uiState: StateFlow>> = internal.asStateFlow() init { refresh() @@ -44,18 +38,18 @@ class RequestsViewModel @Inject constructor( } fun refresh(): Job = viewModelScope.launch { - internal.value = RequestsUiState.Loading + internal.value = UiState.Loading try { val rows = repository.listMine() internal.value = if (rows.isEmpty()) { - RequestsUiState.Empty + UiState.Empty } else { - RequestsUiState.Success(rows) + UiState.Success(rows) } } catch ( @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) { val before = internal.value - if (before is RequestsUiState.Success) { - val filtered = before.requests.filterNot { it.id == id } + if (before is UiState.Success) { + val filtered = before.data.filterNot { it.id == id } internal.value = if (filtered.isEmpty()) { - RequestsUiState.Empty + UiState.Empty } else { - RequestsUiState.Success(filtered) + UiState.Success(filtered) } } viewModelScope.launch { @@ -80,11 +74,11 @@ class RequestsViewModel @Inject constructor( val (_, updated) = repository.cancel(id) if (updated != null) { internal.update { state -> - if (state !is RequestsUiState.Success) { + if (state !is UiState.Success) { // Mid-refresh — let the refresh result win, but // splice the cancelled row in so it shows the new // status if the user re-pulls. - RequestsUiState.Success(listOf(updated)) + UiState.Success(listOf(updated)) } else { // Re-insert the cancelled row in its old position // would require remembering the prior ordering; diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/AddToPlaylistSheet.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/AddToPlaylistSheet.kt index 6ad1a419..d5ffcae8 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/AddToPlaylistSheet.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/AddToPlaylistSheet.kt @@ -27,6 +27,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composables.icons.lucide.ListMusic import com.composables.icons.lucide.Lucide import com.fabledsword.minstrel.models.PlaylistRef +import com.fabledsword.minstrel.shared.UiState /** * 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), ) when (val s = state) { - AddToPlaylistUiState.Loading -> Box( + UiState.Loading -> Box( modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center, ) { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) } - is AddToPlaylistUiState.Error -> Text( + is UiState.Error -> Text( text = s.message, color = MaterialTheme.colorScheme.error, 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 = "You haven't created any playlists yet.", color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -72,7 +73,7 @@ fun AddToPlaylistSheet( ) } else { 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) }) } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/AddToPlaylistViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/AddToPlaylistViewModel.kt index 42c0e18f..f1bd4ff1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/AddToPlaylistViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/AddToPlaylistViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.playlists.data.PlaylistsRepository +import com.fabledsword.minstrel.shared.UiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -17,12 +18,6 @@ import javax.inject.Inject private const val SHARE_STOP_TIMEOUT_MS = 5_000L -sealed interface AddToPlaylistUiState { - data object Loading : AddToPlaylistUiState - data class Success(val playlists: List) : AddToPlaylistUiState - data class Error(val message: String) : AddToPlaylistUiState -} - /** * Backs [AddToPlaylistSheet] with the caller's user-owned playlists. * System playlists (For You / Discover / etc.) are filtered out at @@ -33,9 +28,9 @@ class AddToPlaylistViewModel @Inject constructor( repository: PlaylistsRepository, ) : ViewModel() { - val uiState: StateFlow = repository + val uiState: StateFlow>> = repository .observeUserPlaylists() - .map { list -> AddToPlaylistUiState.Success(list) as AddToPlaylistUiState } + .map { list -> UiState.Success(list) as UiState> } .onStart { // Best-effort refresh so the sheet reflects newly-created // playlists from another device. Cached list shows first; @@ -43,12 +38,12 @@ class AddToPlaylistViewModel @Inject constructor( viewModelScope.launch { 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( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), - initialValue = AddToPlaylistUiState.Loading, + initialValue = UiState.Loading, ) } diff --git a/android/app/src/test/java/com/fabledsword/minstrel/library/ui/LibraryViewModelTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/library/ui/LibraryViewModelTest.kt index a78d0097..d726121f 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/library/ui/LibraryViewModelTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/library/ui/LibraryViewModelTest.kt @@ -6,6 +6,7 @@ import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.testutil.MainDispatcherExtension import io.mockk.every import io.mockk.mockk @@ -32,7 +33,7 @@ class LibraryViewModelTest { mockk(relaxed = true), ) - assertEquals(LibraryUiState.Loading, vm.uiState.value) + assertEquals(UiState.Loading, vm.uiState.value) } // Note: tests below collect the resolved state directly. With @@ -55,7 +56,7 @@ class LibraryViewModelTest { ) vm.uiState.test { - assertEquals(LibraryUiState.Empty, awaitItem()) + assertEquals(UiState.Empty, awaitItem()) cancelAndConsumeRemainingEvents() } } @@ -75,11 +76,11 @@ class LibraryViewModelTest { ) vm.uiState.test { - val success = awaitItem() as LibraryUiState.Success - assertEquals(1, success.artists.size) - assertEquals(1, success.albums.size) - assertEquals("a1", success.artists[0].id) - assertEquals("al1", success.albums[0].id) + val success = awaitItem() as UiState.Success + assertEquals(1, success.data.artists.size) + assertEquals(1, success.data.albums.size) + assertEquals("a1", success.data.artists[0].id) + assertEquals("al1", success.data.albums[0].id) cancelAndConsumeRemainingEvents() } } @@ -98,7 +99,7 @@ class LibraryViewModelTest { ) 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 // fallback rather than leaking the raw exception text. assertEquals("Something went wrong.", error.message)