all: add collection utils

Add new shortcut utilities for collecting StateFlows in a safe manner.

The priamry addition here is collectImmediately. collectImmediately
just calls block with the existing value initially, which helps remove
a good amount of bugs regarding state initialization. Sure, it is a bit
inefficient given that it will also initialize on startup, but this is
okay.

The other utilities are the same, but simply remove the launch
boilerplate.
This commit is contained in:
OxygenCobalt 2022-06-21 10:51:56 -06:00
parent 8465cda637
commit 16eccee8e5
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
17 changed files with 97 additions and 75 deletions

View file

@ -33,7 +33,8 @@ import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
/**
* A wrapper around the home fragment that shows the playback fragment and controls the more
@ -60,7 +61,6 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
// the screen is too small because of course we have to.
if (requireActivity().isInMultiWindowMode) {
val config = resources.configuration
if (config.screenHeightDp < 250 || config.screenWidthDp < 250) {
binding.layoutTooSmall.visibility = View.VISIBLE
}
@ -69,9 +69,9 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
// --- VIEWMODEL SETUP ---
launch { navModel.mainNavigationAction.collect(::handleMainNavigation) }
launch { navModel.exploreNavigationItem.collect(::handleExploreNavigation) }
launch { playbackModel.song.collect(::updateSong) }
collect(navModel.mainNavigationAction, ::handleMainNavigation)
collect(navModel.exploreNavigationItem, ::handleExploreNavigation)
collectImmediately(playbackModel.song, ::updateSong)
}
override fun onResume() {

View file

@ -44,9 +44,9 @@ import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.MenuFragment
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.canScroll
import org.oxycblt.auxio.util.collectWith
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logEOrThrow
import org.oxycblt.auxio.util.showToast
@ -87,10 +87,10 @@ class AlbumDetailFragment :
// -- VIEWMODEL SETUP ---
launch { detailModel.currentAlbum.collect(::handleItemChange) }
launch { detailModel.albumData.collect(detailAdapter.data::submitList) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
launch { playbackModel.song.collectWith(playbackModel.parent, ::updatePlayback) }
collectImmediately(detailModel.currentAlbum, ::handleItemChange)
collectImmediately(detailModel.albumData, detailAdapter.data::submitList)
collectImmediately(playbackModel.song, playbackModel.parent, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
}
override fun onDestroyBinding(binding: FragmentDetailBinding) {

View file

@ -41,9 +41,9 @@ import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.MenuFragment
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.collectWith
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logEOrThrow
import org.oxycblt.auxio.util.showToast
@ -83,10 +83,10 @@ class ArtistDetailFragment :
// --- VIEWMODEL SETUP ---
launch { detailModel.currentArtist.collect(::handleItemChange) }
launch { detailModel.artistData.collect(detailAdapter.data::submitList) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
launch { playbackModel.song.collectWith(playbackModel.parent, ::updatePlayback) }
collectImmediately(detailModel.currentArtist, ::handleItemChange)
collectImmediately(detailModel.artistData, detailAdapter.data::submitList)
collectImmediately(playbackModel.song, playbackModel.parent, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
}
override fun onDestroyBinding(binding: FragmentDetailBinding) {

View file

@ -42,9 +42,9 @@ import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.MenuFragment
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.collectWith
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logEOrThrow
import org.oxycblt.auxio.util.showToast
@ -83,10 +83,10 @@ class GenreDetailFragment :
// --- VIEWMODEL SETUP ---
launch { detailModel.currentGenre.collect(::handleItemChange) }
launch { detailModel.genreData.collect(detailAdapter.data::submitList) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
launch { playbackModel.song.collectWith(playbackModel.parent, ::updatePlayback) }
collectImmediately(detailModel.currentGenre, ::handleItemChange)
collectImmediately(detailModel.genreData, detailAdapter.data::submitList)
collectImmediately(playbackModel.song, playbackModel.parent, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
}
override fun onDestroyBinding(binding: FragmentDetailBinding) {

View file

@ -27,8 +27,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.launch
class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
private val detailModel: DetailViewModel by androidActivityViewModels()
@ -45,7 +45,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
override fun onBindingCreated(binding: DialogSongDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
detailModel.setSongId(args.songId)
launch { detailModel.currentSong.collect(::updateSong) }
collectImmediately(detailModel.currentSong, ::updateSong)
}
override fun onDestroy() {

View file

@ -56,9 +56,10 @@ import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getColorStateListSafe
import org.oxycblt.auxio.util.getSystemBarInsetsCompat
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
@ -140,11 +141,11 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
// --- VIEWMODEL SETUP ---
launch { homeModel.isFastScrolling.collect(::updateFastScrolling) }
launch { homeModel.currentTab.collect(::updateCurrentTab) }
launch { homeModel.recreateTabs.collect(::handleRecreateTabs) }
launch { indexerModel.state.collect(::handleIndexerState) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
collect(homeModel.isFastScrolling, ::updateFastScrolling)
collectImmediately(homeModel.recreateTabs, ::handleRecreateTabs)
collectImmediately(homeModel.currentTab, ::updateCurrentTab)
collectImmediately(indexerModel.state, ::handleIndexerState)
collect(navModel.exploreNavigationItem, ::handleNavigation)
}
override fun onDestroyBinding(binding: FragmentHomeBinding) {
@ -280,7 +281,10 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
is Indexer.State.Complete -> handleIndexerResponse(binding, state.response)
is Indexer.State.Indexing -> handleIndexingState(binding, state.indexing)
null -> {
logD("Indexer is in indeterminate state, doing nothing")
logD("Indexer is in indeterminate state")
binding.homeFab.hide()
binding.homeIndexingContainer.visibility = View.INVISIBLE
binding.homePager.visibility = View.INVISIBLE
}
}
}

View file

@ -30,8 +30,8 @@ import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.SyncBackingData
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logEOrThrow
/**
@ -49,7 +49,7 @@ class AlbumListFragment : HomeListFragment<Album>() {
adapter = homeAdapter
}
launch { homeModel.albums.collect(homeAdapter.data::replaceList) }
collectImmediately(homeModel.albums, homeAdapter.data::replaceList)
}
override fun getPopup(pos: Int): String? {

View file

@ -30,8 +30,8 @@ import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.SyncBackingData
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logEOrThrow
/**
@ -49,7 +49,7 @@ class ArtistListFragment : HomeListFragment<Artist>() {
adapter = homeAdapter
}
launch { homeModel.artists.collect(homeAdapter.data::replaceList) }
collectImmediately(homeModel.artists, homeAdapter.data::replaceList)
}
override fun getPopup(pos: Int): String? {

View file

@ -30,8 +30,8 @@ import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.SyncBackingData
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logEOrThrow
/**
@ -49,7 +49,7 @@ class GenreListFragment : HomeListFragment<Genre>() {
adapter = homeAdapter
}
launch { homeModel.genres.collect(homeAdapter.data::replaceList) }
collectImmediately(homeModel.genres, homeAdapter.data::replaceList)
}
override fun getPopup(pos: Int): String? {

View file

@ -30,9 +30,9 @@ import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.SyncBackingData
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logEOrThrow
/**
@ -51,7 +51,7 @@ class SongListFragment : HomeListFragment<Song>() {
adapter = homeAdapter
}
launch { homeModel.songs.collect(homeAdapter.data::replaceList) }
collectImmediately(homeModel.songs, homeAdapter.data::replaceList)
}
override fun getPopup(pos: Int): String? {

View file

@ -34,10 +34,10 @@ import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getDrawableSafe
import org.oxycblt.auxio.util.getSystemBarInsetsCompat
import org.oxycblt.auxio.util.getSystemGestureInsetsCompat
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.textSafe
@ -116,13 +116,13 @@ class PlaybackPanelFragment :
// --- VIEWMODEL SETUP --
launch { playbackModel.song.collect(::updateSong) }
launch { playbackModel.parent.collect(::updateParent) }
launch { playbackModel.positionSecs.collect(::updatePosition) }
launch { playbackModel.repeatMode.collect(::updateRepeat) }
launch { playbackModel.isPlaying.collect(::updatePlaying) }
launch { playbackModel.isShuffled.collect(::updateShuffled) }
launch { playbackModel.nextUp.collect(::updateNextUp) }
collectImmediately(playbackModel.song, ::updateSong)
collectImmediately(playbackModel.parent, ::updateParent)
collectImmediately(playbackModel.positionSecs, ::updatePosition)
collectImmediately(playbackModel.repeatMode, ::updateRepeat)
collectImmediately(playbackModel.isPlaying, ::updatePlaying)
collectImmediately(playbackModel.isShuffled, ::updateShuffled)
collectImmediately(playbackModel.nextUp, ::updateNextUp)
logD("Fragment Created")
}

View file

@ -28,7 +28,7 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.collectImmediately
/**
* A [Fragment] that shows the queue and enables editing as well.
@ -53,7 +53,7 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
// --- VIEWMODEL SETUP ----
launch { playbackModel.nextUp.collect(::updateQueue) }
collectImmediately(playbackModel.nextUp, ::updateQueue)
}
override fun onDestroyBinding(binding: FragmentQueueBinding) {

View file

@ -43,9 +43,10 @@ import org.oxycblt.auxio.ui.MenuFragment
import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.util.androidViewModels
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getSystemServiceSafe
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logW
/**
@ -101,8 +102,8 @@ class SearchFragment :
// --- VIEWMODEL SETUP ---
launch { searchModel.searchResults.collect(::updateResults) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
collectImmediately(searchModel.searchResults, ::updateResults)
collect(navModel.exploreNavigationItem, ::handleNavigation)
}
override fun onDestroyBinding(binding: FragmentSearchBinding) {

View file

@ -37,9 +37,9 @@ import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.getSystemBarInsetsCompat
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.textSafe
@ -66,10 +66,10 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
binding.aboutFaq.setOnClickListener { openLinkInBrowser(LINK_FAQ) }
binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) }
launch { homeModel.songs.collect(::updateSongCount) }
launch { homeModel.albums.collect(::updateAlbumCount) }
launch { homeModel.artists.collect(::updateArtistCount) }
launch { homeModel.genres.collect(::updateGenreCount) }
collectImmediately(homeModel.songs, ::updateSongCount)
collectImmediately(homeModel.albums, ::updateAlbumCount)
collectImmediately(homeModel.artists, ::updateArtistCount)
collectImmediately(homeModel.genres, ::updateGenreCount)
}
private fun updateSongCount(songs: List<Song>) {

View file

@ -45,8 +45,8 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
@ -159,26 +159,47 @@ val @receiver:ColorRes Int.stateList
get() = ColorStateList.valueOf(this)
/**
* Collect a [stateFlow] into [block] a UI-safe way.
* Collect a [stateFlow] into [block] eventually.
*
* This does have an initializing call, but it usually occurs ~100ms into draw-time, which might not
* be ideal for some views. This should be used in cases where the state only needs to be updated
* during runtime.
*/
fun <T> Fragment.collect(stateFlow: StateFlow<T>, block: (T) -> Unit) {
launch { stateFlow.collect(block) }
}
/**
* Collect a [stateFlow] into [block] immediately.
*
* This method automatically calls [block] when initially starting to ensure UI state consistency.
* This does nominally mean that there are two initializing collections, but this is considered
* okay. [block] should be a function pointer in order to ensure lifecycle consistency.
*
* Only use this if your code absolutely needs to have a good state for ~100ms of draw-time.
* Otherwise, it's somewhat in-efficient.
* This should be used for state the absolutely needs to be shown at draw-time.
*/
fun <T> Fragment.collectImmediately(stateFlow: StateFlow<T>, block: (T) -> Unit) {
block(stateFlow.value)
launch { stateFlow.collect(block) }
}
/** Like [collectImmediately], but with two [StateFlow] values. */
fun <T1, T2> Fragment.collectImmediately(
a: StateFlow<T1>,
b: StateFlow<T2>,
block: (T1, T2) -> Unit
) {
block(a.value, b.value)
val combine = a.combine(b) { first, second -> Pair(first, second) }
launch { combine.collect { block(it.first, it.second) } }
}
/**
* Launches [block] in a lifecycle-aware coroutine once [state] is reached. This is primarily a
* shortcut intended to correctly launch a co-routine on a fragment in a way that won't cause
* miscellaneous coroutine insanity.
*/
fun Fragment.launch(
private fun Fragment.launch(
state: Lifecycle.State = Lifecycle.State.STARTED,
block: suspend CoroutineScope.() -> Unit
) {
@ -209,18 +230,6 @@ inline fun <reified T : AndroidViewModel> Fragment.androidActivityViewModels() =
val AndroidViewModel.application: Application
get() = getApplication()
/**
* Combines the called flow with the given flow and then collects them both into [block]. This is a
* bit of a dumb hack with [combine], as when we have to combine flows, we often just want to call
* the same block with both functions, and not do any transformations.
*/
suspend inline fun <T1, T2> Flow<T1>.collectWith(
other: Flow<T2>,
crossinline block: (T1, T2) -> Unit
) {
combine(this, other) { a, b -> a to b }.collect { block(it.first, it.second) }
}
/**
* Shortcut for querying all items in a database and running [block] with the cursor returned. Will
* not run if the cursor is null.

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ui_scroll_thumb" />
</selector>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/transparent" />
</selector>