Workout list: live-refresh on ACTION_NEW_DATA without blink

Long-running per-line fetches (e.g. ExploreSync's catalog walk) hold the device's busy task for the entire session, so the busy→idle edge WorkoutListActivity refreshes on only fires at session end. Listen for ACTION_NEW_DATA too so each newly imported activity appears as it lands.

Add a silent loadSummaries path that skips the isLoading flips, and disable the RecyclerView change animator so the dashboard row's notifyItemChanged(0) doesn't cross-fade — both would otherwise read as a distracting blink during an active sync.
This commit is contained in:
Ingvar Stepanyan
2026-06-06 21:26:51 +02:00
committed by José Rebelo
parent c1946573fb
commit 3b9b4126ba
2 changed files with 69 additions and 28 deletions
@@ -14,6 +14,8 @@ import android.widget.Toast
import androidx.activity.viewModels
import androidx.core.content.FileProvider
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
@@ -52,26 +54,36 @@ class WorkoutListActivity : AbstractListActivity<BaseActivitySummary>() {
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != GBDevice.ACTION_DEVICE_CHANGED) {
LOG.warn("Got unexpected action {}", intent.action)
return
}
val device = intent.getParcelableExtra<GBDevice>(GBDevice.EXTRA_DEVICE)
if (device == null) {
LOG.error("Got device changed without device")
return
}
if (device != gbDevice) {
return
}
if (device.isBusy) {
swipeLayout.isRefreshing = true
} else {
val wasBusy = swipeLayout.isRefreshing
swipeLayout.isRefreshing = false
if (wasBusy) {
refresh()
when (intent.action) {
GBDevice.ACTION_DEVICE_CHANGED -> {
val device = intent.getParcelableExtra<GBDevice>(GBDevice.EXTRA_DEVICE)
if (device == null) {
LOG.error("Got device changed without device")
return
}
if (device != gbDevice) {
return
}
if (device.isBusy) {
swipeLayout.isRefreshing = true
} else {
val wasBusy = swipeLayout.isRefreshing
swipeLayout.isRefreshing = false
if (wasBusy) {
refresh()
}
}
}
// ExploreSync (and other long-running fetches) hold the
// device busy for the whole catalog walk, so the
// busy→idle edge above only fires at session end.
// Refresh on every per-line flush as well so newly
// imported activities appear in the list right away.
// Pass silent=true so the dashboard's loading shimmer
// and swipe-refresh spinner don't blink on every
// incremental update.
GBApplication.ACTION_NEW_DATA -> refresh(silent = true)
else -> LOG.warn("Got unexpected action {}", intent.action)
}
}
}
@@ -132,7 +144,10 @@ class WorkoutListActivity : AbstractListActivity<BaseActivitySummary>() {
// Load and apply saved quick filter
applySavedQuickFilter()
val filterLocal = IntentFilter(GBDevice.ACTION_DEVICE_CHANGED)
val filterLocal = IntentFilter().apply {
addAction(GBDevice.ACTION_DEVICE_CHANGED)
addAction(GBApplication.ACTION_NEW_DATA)
}
LocalBroadcastManager.getInstance(this).registerReceiver(receiver, filterLocal)
super.onCreate(savedInstanceState)
@@ -195,6 +210,13 @@ class WorkoutListActivity : AbstractListActivity<BaseActivitySummary>() {
setItemAdapter(workoutSummariesAdapter)
// The dashboard row gets a notifyItemChanged on every silent
// refresh during sync; the default ChangeAnimator cross-fades
// that, which reads as a distracting opacity blink. Disable
// just the change animation — add/remove animations still run.
val recycler = findViewById<RecyclerView>(R.id.itemListView)
(recycler.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
swipeLayout = findViewById(R.id.list_activity_swipe_layout)
swipeLayout.setOnRefreshListener {
if (GBApplication.getPrefs().refreshOnSwipe()) {
@@ -478,6 +500,10 @@ class WorkoutListActivity : AbstractListActivity<BaseActivitySummary>() {
}
override fun refresh() {
refresh(silent = false)
}
private fun refresh(silent: Boolean) {
gbDevice?.let { device ->
viewModel.loadSummaries(
device,
@@ -486,7 +512,8 @@ class WorkoutListActivity : AbstractListActivity<BaseActivitySummary>() {
dateToFilter,
nameContainsFilter,
deviceFilter,
itemsFilter
itemsFilter,
silent
)
}
}
@@ -42,9 +42,17 @@ class WorkoutListViewModel : ViewModel() {
dateToFilter: Long,
nameContainsFilter: String?,
deviceFilter: Long,
itemsFilter: List<Long>?
itemsFilter: List<Long>?,
/** When true, skip the [isLoading] / [isDashboardLoading]
* flips so the swipe-refresh spinner and dashboard shimmer
* don't blink. Used by per-line refreshes during
* long-running syncs, where the loading animation
* distracts from the actual content updates. */
silent: Boolean = false
) {
_isLoading.value = true
if (!silent) {
_isLoading.value = true
}
_error.value = null
viewModelScope.launch {
@@ -70,18 +78,22 @@ class WorkoutListViewModel : ViewModel() {
_summaries.value = allSummaries
loadDashboardStats(gbDevice, summaries)
loadDashboardStats(gbDevice, summaries, silent)
} catch (e: Exception) {
LOG.error("Error loading summaries", e)
_error.value = "Error loading summaries: ${e.localizedMessage}"
} finally {
_isLoading.value = false
if (!silent) {
_isLoading.value = false
}
}
}
}
private fun loadDashboardStats(gbDevice: GBDevice, summaries: List<BaseActivitySummary>) {
_isDashboardLoading.value = true
private fun loadDashboardStats(gbDevice: GBDevice, summaries: List<BaseActivitySummary>, silent: Boolean) {
if (!silent) {
_isDashboardLoading.value = true
}
viewModelScope.launch {
try {
@@ -94,7 +106,9 @@ class WorkoutListViewModel : ViewModel() {
LOG.error("Error loading dashboard stats", e)
_error.value = "Error loading dashboard stats: ${e.localizedMessage}"
} finally {
_isDashboardLoading.value = false
if (!silent) {
_isDashboardLoading.value = false
}
}
}
}