Merge branch 'decouple' into 'develop'

Decouple list ViewModels from SwipeRefreshLayout

See merge request ultrasonic/ultrasonic!1428
This commit is contained in:
birdbird committed 2026-09-29 09:11:07 +00:00
commit d84c3a646e
14 files changed
+47 -40

No files matched your search

@@ -82,7 +82,7 @@ class AlbumListFragment(
listModel.viewModelScope.launch(
toastingExceptionHandler()
) {
listModel.withDelayedLoadingIndicator(swipeRefresh) {
listModel.withDelayedLoadingIndicator {
if (navArgs.byArtist) {
listModel.getAlbumsOfArtist(
id = navArgs.id!!,
@@ -42,7 +42,7 @@ class ArtistListFragment : EntryListFragment<ArtistOrIndex>() {
* The central function to pass a query to the model and return a LiveData object
*/
override fun getLiveData(refresh: Boolean, append: Boolean): LiveData<List<ArtistOrIndex>> =
listModel.getItems(navArgs.refresh || refresh, swipeRefresh!!)
listModel.getItems(navArgs.refresh || refresh)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -72,7 +72,7 @@ abstract class EntryListFragment<T : GenericEntry> :
currentSetting.musicFolderId = it.id
serverSettingsModel.updateItem(currentSetting)
}
listModel.refresh(swipeRefresh!!)
listModel.refresh()
}
}
}
@@ -122,7 +122,13 @@ abstract class MultiListFragment<T : Identifiable> :
// Setup refresh handler
swipeRefresh = view.findViewById(refreshListId)
swipeRefresh?.setOnRefreshListener {
listModel.refresh(swipeRefresh!!)
listModel.refresh()
}
// The model signals its loading state; forward it to the pull-to-refresh
// indicator. This keeps the View knowledge on this side of the boundary.
listModel.isRefreshing.observe(viewLifecycleOwner) { refreshing ->
swipeRefresh?.isRefreshing = refreshing
}
// Populate the LiveData. Skip the fetch on config changes (e.g. rotation) when data
@@ -61,7 +61,7 @@ class PlaylistsFragment : MultiListFragment<Playlist>() {
* The central function to pass a query to the model and return a LiveData object
*/
override fun getLiveData(refresh: Boolean, append: Boolean): LiveData<List<Playlist>> =
listModel.getItems(refresh, swipeRefresh!!)
listModel.getItems(refresh)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -241,7 +241,7 @@ class PlaylistsFragment : MultiListFragment<Playlist>() {
)
}
listModel.getItems(true, swipeRefresh!!)
listModel.getItems(true)
toast(resources.getString(R.string.playlist_updated_info, playlist.name))
}
}
@@ -39,7 +39,7 @@ class PodcastFragment : MultiListFragment<PodcastsChannel>() {
* The central function to pass a query to the model and return a LiveData object
*/
override fun getLiveData(refresh: Boolean, append: Boolean): LiveData<List<PodcastsChannel>> =
listModel.getItems(refresh, swipeRefresh!!)
listModel.getItems(refresh)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -40,7 +40,7 @@ class SelectGenreFragment : MultiListFragment<Genre>() {
* The central function to pass a query to the model and return a LiveData object
*/
override fun getLiveData(refresh: Boolean, append: Boolean): LiveData<List<Genre>> =
listModel.getItems(refresh, swipeRefresh!!)
listModel.getItems(refresh)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -69,7 +69,7 @@ class SharesFragment :
* The central function to pass a query to the model and return a LiveData object
*/
override fun getLiveData(refresh: Boolean, append: Boolean): LiveData<List<Share>> =
listModel.getItems(refresh, swipeRefresh!!)
listModel.getItems(refresh)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -310,7 +310,7 @@ class SharesFragment :
getMusicService().updateShare(share.id, description, millis)
}
listModel.getItems(true, swipeRefresh!!)
listModel.getItems(true)
toast(resources.getString(R.string.playlist_updated_info, share.name))
}
}
@@ -9,7 +9,6 @@ package org.moire.ultrasonic.model
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import java.text.Collator
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.ArtistOrIndex
@@ -24,11 +23,11 @@ class ArtistListModel(application: Application) : GenericListModel(application)
/**
* Retrieves all available Artists in a LiveData
*/
fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout): LiveData<List<ArtistOrIndex>> {
fun getItems(refresh: Boolean): LiveData<List<ArtistOrIndex>> {
// Don't reload the data if navigating back to the view that was active before.
// This way, we keep the scroll position
if (artists.value?.isEmpty() != false || refresh) {
backgroundLoadFromServer(refresh, swipe)
backgroundLoadFromServer(refresh)
}
return artists
}
@@ -10,9 +10,9 @@ package org.moire.ultrasonic.model
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -47,6 +47,13 @@ open class GenericListModel(application: Application) :
val musicFolders: MutableLiveData<List<MusicFolder>> = MutableLiveData(listOf())
/**
* The loading state of the current load operation. Fragments observe this to drive
* their pull-to-refresh indicator, keeping the View out of the model's API.
*/
private val _isRefreshing = MutableLiveData(false)
val isRefreshing: LiveData<Boolean> get() = _isRefreshing
open fun showSelectFolderHeader(): Boolean = false
/**
@@ -57,17 +64,17 @@ open class GenericListModel(application: Application) :
/**
* Refreshes the cached items from the server
*/
fun refresh(swipe: SwipeRefreshLayout) {
backgroundLoadFromServer(true, swipe)
fun refresh() {
backgroundLoadFromServer(true)
}
/**
* Trigger a load() and notify the UI that we are loading
*/
fun backgroundLoadFromServer(refresh: Boolean, swipe: SwipeRefreshLayout) {
fun backgroundLoadFromServer(refresh: Boolean) {
viewModelScope.launch {
withDelayedLoadingIndicator(swipe) {
loadFromServer(refresh, swipe)
withDelayedLoadingIndicator {
loadFromServer(refresh)
}
}
}
@@ -75,16 +82,15 @@ open class GenericListModel(application: Application) :
/**
* Avoid showing a loading indicator for requests that complete quickly, such as Room reads.
* A pull-to-refresh gesture may already have made the indicator visible; this preserves that.
* The indicator itself is driven through [isRefreshing], which the observing fragment
* forwards to its SwipeRefreshLayout.
*/
suspend fun <T> withDelayedLoadingIndicator(
swipe: SwipeRefreshLayout?,
block: suspend () -> T
): T {
suspend fun <T> withDelayedLoadingIndicator(block: suspend () -> T): T {
var indicatorJob: Job? = null
if (swipe != null && !swipe.isRefreshing) {
if (_isRefreshing.value != true) {
indicatorJob = viewModelScope.launch {
delay(LOADING_INDICATOR_DELAY_MS)
swipe.isRefreshing = true
_isRefreshing.value = true
}
}
@@ -92,14 +98,14 @@ open class GenericListModel(application: Application) :
block()
} finally {
indicatorJob?.cancel()
swipe?.isRefreshing = false
_isRefreshing.value = false
}
}
/**
* Calls the load() function with error handling
*/
private suspend fun loadFromServer(refresh: Boolean, swipe: SwipeRefreshLayout) {
private suspend fun loadFromServer(refresh: Boolean) {
withContext(Dispatchers.IO) {
val musicService = MusicServiceFactory.getMusicService()
val isOffline = ActiveServerProvider.isOffline()
@@ -111,7 +117,7 @@ open class GenericListModel(application: Application) :
// CommunicationError reports transport failures to the provider, which switches
// subsequent loads to the local catalog, and shows a unified message. API and
// authentication failures are deliberately left fully visible to the user.
CommunicationError.handleError(all, swipe.context)
CommunicationError.handleError(all, context)
}
}
}
@@ -9,7 +9,6 @@ package org.moire.ultrasonic.model
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.moire.ultrasonic.domain.Genre
import org.moire.ultrasonic.service.MusicService
@@ -22,11 +21,11 @@ class GenreListModel(application: Application) : GenericListModel(application) {
/**
* Retrieves the Genres in a LiveData
*/
fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout): LiveData<List<Genre>> {
fun getItems(refresh: Boolean): LiveData<List<Genre>> {
// Don't reload the data if navigating back to the view that was active before.
// This way, we keep the scroll position
if (genres.value?.isEmpty() != false || refresh) {
backgroundLoadFromServer(refresh, swipe)
backgroundLoadFromServer(refresh)
}
return genres
}
@@ -9,7 +9,6 @@ package org.moire.ultrasonic.model
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.koin.core.component.inject
import org.moire.ultrasonic.domain.Playlist
import org.moire.ultrasonic.service.MusicService
@@ -25,11 +24,11 @@ class PlaylistListModel(application: Application) : GenericListModel(application
/**
* Retrieves the Playlists in a LiveData
*/
fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout): LiveData<List<Playlist>> {
fun getItems(refresh: Boolean): LiveData<List<Playlist>> {
// Don't reload the data if navigating back to the view that was active before.
// This way, we keep the scroll position
if (playlists.value?.isEmpty() != false || refresh) {
backgroundLoadFromServer(refresh, swipe)
backgroundLoadFromServer(refresh)
}
return playlists
}
@@ -9,7 +9,6 @@ package org.moire.ultrasonic.model
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.moire.ultrasonic.domain.PodcastsChannel
import org.moire.ultrasonic.service.MusicService
@@ -22,11 +21,11 @@ class PodcastListModel(application: Application) : GenericListModel(application)
/**
* Retrieves the Podcast channels in a LiveData
*/
fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout): LiveData<List<PodcastsChannel>> {
fun getItems(refresh: Boolean): LiveData<List<PodcastsChannel>> {
// Don't reload the data if navigating back to the view that was active before.
// This way, we keep the scroll position
if (channels.value?.isEmpty() != false || refresh) {
backgroundLoadFromServer(refresh, swipe)
backgroundLoadFromServer(refresh)
}
return channels
}
@@ -9,7 +9,6 @@ package org.moire.ultrasonic.model
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.moire.ultrasonic.domain.Share
import org.moire.ultrasonic.service.MusicService
@@ -22,11 +21,11 @@ class ShareListModel(application: Application) : GenericListModel(application) {
/**
* Retrieves the Shares in a LiveData
*/
fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout): LiveData<List<Share>> {
fun getItems(refresh: Boolean): LiveData<List<Share>> {
// Don't reload the data if navigating back to the view that was active before.
// This way, we keep the scroll position
if (shares.value?.isEmpty() != false || refresh) {
backgroundLoadFromServer(refresh, swipe)
backgroundLoadFromServer(refresh)
}
return shares
}