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 56554d31..711bf89d 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 @@ -47,7 +47,6 @@ import com.composables.icons.lucide.Heart import com.composables.icons.lucide.History import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Music -import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.home.data.HomeRepository import com.fabledsword.minstrel.library.widgets.AlbumCard import com.fabledsword.minstrel.library.widgets.ArtistCard @@ -66,6 +65,7 @@ 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.asCacheFirstStateFlow import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar @@ -81,7 +81,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow @@ -196,11 +195,8 @@ class HomeViewModel @Inject constructor( status.join() } - val uiState: StateFlow> = combineHomeFlows().stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), - initialValue = UiState.Loading, - ) + val uiState: StateFlow> = + combineHomeFlows().asCacheFirstStateFlow(viewModelScope) /** * Five home-section flows merged into the sections struct without @@ -229,7 +225,7 @@ class HomeViewModel @Inject constructor( observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists -> val merged = sections.copy(playlists = playlists) if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged) - }.catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) } + } } // ─── Screen ────────────────────────────────────────────────────────── 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 ba05e112..e2af8795 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 @@ -2,22 +2,17 @@ package com.fabledsword.minstrel.library.ui import androidx.lifecycle.ViewModel 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 com.fabledsword.minstrel.shared.asCacheFirstStateFlow import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject -private const val SHARE_STOP_TIMEOUT_MS = 5_000L - @HiltViewModel class LibraryViewModel @Inject constructor( private val repository: LibraryRepository, @@ -36,14 +31,7 @@ class LibraryViewModel @Inject constructor( UiState.Success(LibraryData(artists, albums)) } } - .catch { e -> - emit(UiState.Error(ErrorCopy.fromThrowable(e))) - } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), - initialValue = UiState.Loading, - ) + .asCacheFirstStateFlow(viewModelScope) /** * Pull-to-refresh entry point. Triggers /api/library/sync via the 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 541a2afc..9587e7f2 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 @@ -25,7 +25,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController -import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.library.widgets.AlbumCard import com.fabledsword.minstrel.library.widgets.ArtistCard @@ -36,6 +35,7 @@ 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.asCacheFirstStateFlow import com.fabledsword.minstrel.shared.widgets.TrackRow import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow @@ -46,16 +46,11 @@ import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject -private const val SHARE_STOP_TIMEOUT_MS = 5_000L - // ─── State ─────────────────────────────────────────────────────────── data class LikedSections( @@ -100,13 +95,7 @@ class LikedTabViewModel @Inject constructor( ) { artists, albums, tracks -> val sections = LikedSections(artists, albums, tracks) if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections) - } - .catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), - initialValue = UiState.Loading, - ) + }.asCacheFirstStateFlow(viewModelScope) /** * Build a queue from the currently-displayed liked tracks and start 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 2e3a2677..cfdb2e78 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 @@ -21,13 +21,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController -import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.events.EventsStream 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.shared.asCacheFirstStateFlow import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.LoadingCentered @@ -35,17 +35,12 @@ import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject -private const val SHARE_STOP_TIMEOUT_MS = 5_000L - // ─── State ─────────────────────────────────────────────────────────── // ─── ViewModel ─────────────────────────────────────────────────────── @@ -81,14 +76,7 @@ class PlaylistsListViewModel @Inject constructor( UiState.Success(list) } } - .catch { e -> - emit(UiState.Error(ErrorCopy.fromThrowable(e))) - } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), - initialValue = UiState.Loading, - ) + .asCacheFirstStateFlow(viewModelScope) } // ─── Screen ────────────────────────────────────────────────────────── diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/CacheFirstStateFlow.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/CacheFirstStateFlow.kt new file mode 100644 index 00000000..39cd22e2 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/CacheFirstStateFlow.kt @@ -0,0 +1,31 @@ +package com.fabledsword.minstrel.shared + +import com.fabledsword.minstrel.api.ErrorCopy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.stateIn + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + +/** + * Terminal operator for the cache-first VM idiom: a Flow that maps + * cache emissions to `UiState.Success` / `UiState.Empty` becomes a + * subscriber-active StateFlow that starts in [UiState.Loading] and + * routes any upstream throwable through [ErrorCopy] into + * [UiState.Error]. + * + * Replaces the catch + stateIn(viewModelScope, WhileSubscribed, + * Loading) tail that every cache-first ViewModel writes today. + */ +fun Flow>.asCacheFirstStateFlow( + scope: CoroutineScope, +): StateFlow> = this + .catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) } + .stateIn( + scope = scope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = UiState.Loading, + ) 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 f8cffd92..3175daed 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 @@ -2,22 +2,17 @@ package com.fabledsword.minstrel.shared.widgets.trackactions import androidx.lifecycle.ViewModel 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 com.fabledsword.minstrel.shared.asCacheFirstStateFlow import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject -private const val SHARE_STOP_TIMEOUT_MS = 5_000L - /** * Backs [AddToPlaylistSheet] with the caller's user-owned playlists. * System playlists (For You / Discover / etc.) are filtered out at @@ -42,10 +37,5 @@ class AddToPlaylistViewModel @Inject constructor( } emit(UiState.Loading) } - .catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), - initialValue = UiState.Loading, - ) + .asCacheFirstStateFlow(viewModelScope) }