refactor(android): extract asCacheFirstStateFlow helper (DRY 3d)

Five cache-first ViewModels all ended their flow pipeline with:

    .catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) }
    .stateIn(viewModelScope,
             SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
             UiState.Loading)

plus a per-file private const for the 5_000L share-stop timeout.

Extract to a single Flow<UiState<T>> -> StateFlow<UiState<T>> extension
in shared/CacheFirstStateFlow.kt; migrate Library, PlaylistsList,
LikedTab, AddToPlaylist, and Home VMs. Each VM drops the catch +
stateIn + ErrorCopy + SharingStarted imports + the local constant.

AddToPlaylistVM keeps its onStart { refresh; emit Loading } block
in front of the helper -- that re-emit is intentional UX for sheet
re-opens past the 5s subscriber timeout.
This commit is contained in:
2026-05-30 22:56:22 -04:00
parent 453f8a387b
commit fa90d6d6c5
6 changed files with 43 additions and 61 deletions
@@ -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<UiState<HomeSections>> = combineHomeFlows().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = UiState.Loading,
)
val uiState: StateFlow<UiState<HomeSections>> =
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 ──────────────────────────────────────────────────────────
@@ -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
@@ -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
@@ -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 ──────────────────────────────────────────────────────────
@@ -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 <T> Flow<UiState<T>>.asCacheFirstStateFlow(
scope: CoroutineScope,
): StateFlow<UiState<T>> = this
.catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) }
.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = UiState.Loading,
)
@@ -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)
}