[Q1 Quality] WearOS - Display last sync time and sync status#4969
[Q1 Quality] WearOS - Display last sync time and sync status#4969
Conversation
There was a problem hiding this comment.
Pull request overview
Adds WearOS UI affordances for podcast refresh/sync by surfacing refresh-in-progress state on the main list and showing the last refresh time (or failure/never states) in Settings.
Changes:
- Adds a “refreshing” header indicator on the main Watch list screen driven by
Settings.refreshStateFlow. - Shows last refresh time / refresh status text under the “Refresh now” chip in Settings (with new absolute-time formatting utility).
- Prevents redundant refresh triggers from Settings while a refresh is already in progress.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/settings/SettingsViewModel.kt | Adds guard to avoid re-triggering refresh while already refreshing. |
| wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/settings/SettingsScreen.kt | Displays refresh status text and formats last refresh time for Settings. |
| wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/WatchListScreenViewModel.kt | Injects Settings and exposes refreshState in UI state via refreshStateFlow. |
| wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/WatchListScreen.kt | Renders a rotating refresh icon + label header when refresh is in progress. |
| wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/TimeFormatUtil.kt | New helper to format refresh timestamps for Wear settings (absolute time + “Just now”). |
| modules/services/localization/src/main/res/values/strings.xml | Adds new localized strings for “Just now” and absolute “Last refresh: %s”. |
09e855a to
808e27c
Compare
ba95ebb to
231c232
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
wear/src/test/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/podcast/PodcastViewModelTest.kt:83
- These tests now run with a
StandardTestDispatcher, butPodcastViewModel.uiStateis aStateFlowwithinitialValue = UiState.Emptyand the upstream collection runs onviewModelScope. Depending on coroutine scheduling, the first TurbineawaitItem()can be the initialEmptyvalue, making this assertion flaky. Make the test deterministic by advancing the test scheduler before collecting, or by asserting the initialEmptyemission and then awaiting the subsequentLoadedemission.
fun `when podcast found with episodes, then emits Loaded state with episodes`() = runTest(coroutineRule.testDispatcher) {
val podcast = createTestPodcast(uuid = testPodcastUuid)
val episodes = listOf(
createTestEpisode(uuid = "ep1", title = "Episode 1"),
createTestEpisode(uuid = "ep2", title = "Episode 2"),
231c232 to
d9ac200
Compare
d9ac200 to
6d5f062
Compare
Generated by 🚫 Danger |
| curvedComposable { | ||
| Icon( | ||
| painter = painterResource(IR.drawable.ic_cloud_off), | ||
| contentDescription = "Offline", |
There was a problem hiding this comment.
The contentDescription is hardcoded as "Offline" instead of using a localized string resource. For proper accessibility and internationalization, this should reference a string from the localization module (e.g., stringResource(LR.string.offline) or similar). This ensures screen readers announce the icon status in the user's language.
There was a problem hiding this comment.
There is TalkBack on Wear OS so this would be worth doing.
geekygecko
left a comment
There was a problem hiding this comment.
I'm not sure if it's just me, but there's a crash happening. I can't run it on my Pixel 3 watch.
With that commented out the pull to refresh works well!
| fun TimeTextWithConnectivity( | ||
| isConnected: Boolean, | ||
| modifier: Modifier = Modifier, | ||
| ) { |
There was a problem hiding this comment.
The app is crashing on my Pixel 3 watch unless I comment out the following:
AppScaffold(
// timeText = { TimeTextWithConnectivity(isConnected = isConnected) },
) {
The stack trace is a bit strange as it doesn't include any Pocket Casts code:
Process: au.com.shiftyjelly.pocketcasts.debug, PID: 6301
java.lang.IllegalArgumentException: either height or width should be bounded
at androidx.wear.compose.foundation.CurvedLayoutKt$CurvedLayout$2$1.measure-3p2s80s(CurvedLayout.kt:121)
at androidx.compose.ui.node.InnerNodeCoordinator.measure-BRTryo0(InnerNodeCoordinator.kt:128)
at androidx.compose.ui.node.MeasurePassDelegate$performMeasureBlock$1.invoke(MeasurePassDelegate.kt:173)
at androidx.compose.ui.node.MeasurePassDelegate$performMeasureBlock$1.invoke(MeasurePassDelegate.kt:172)
at androidx.compose.runtime.snapshots.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:699)
at androidx.compose.ui.node.MeasurePassDelegate.remeasure-BRTryo0(MeasurePassDelegate.kt:1064)
at androidx.compose.ui.node.MeasurePassDelegate.measure-BRTryo0(MeasurePassDelegate.kt:470)
at androidx.compose.foundation.layout.BoxMeasurePolicy.measure-3p2s80s(Box.kt:145)
at androidx.compose.ui.node.InnerNodeCoordinator.measure-BRTryo0(InnerNodeCoordinator.kt:128)
at androidx.compose.ui.node.MeasurePassDelegate$performMeasureBlock$1.invoke(MeasurePassDelegate.kt:173)
at androidx.compose.ui.node.MeasurePassDelegate$performMeasureBlock$1.invoke(MeasurePassDelegate.kt:172)
at androidx.compose.runtime.snapshots.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:699)
at androidx.compose.ui.node.MeasurePassDelegate.remeasure-BRTryo0(MeasurePassDelegate.kt:1064)
at androidx.compose.ui.node.MeasurePassDelegate.measure-BRTryo0(MeasurePassDelegate.kt:470)
at androidx.wear.compose.foundation.CurvedComposableChild.initializeMeasure(CurvedComposable.kt:119)
at androidx.wear.compose.foundation.ContainerChild.initializeMeasure(CurvedContainer.kt:73)
at androidx.wear.compose.foundation.CurvedLayoutKt$CurvedLayout$2$1.measure-3p2s80s(CurvedLayout.kt:137)
at androidx.compose.ui.node.InnerNodeCoordinator.measure-BRTryo0(InnerNodeCoordinator.kt:128)
at androidx.compose.ui.node.MeasurePassDelegate$performMeasureBlock$1.invoke(MeasurePassDelegate.kt:173)
at androidx.compose.ui.node.MeasurePassDelegate$performMeasureBlock$1.invoke(MeasurePassDelegate.kt:172)
at androidx.compose.runtime.snapshots.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:699)
at androidx.compose.ui.node.MeasurePassDelegate.remeasure-BRTryo0(MeasurePassDelegate.kt:1064)
at androidx.compose.ui.node.MeasurePassDelegate.measure-BRTryo0(MeasurePassDelegate.kt:470)
at androidx.compose.foundation.layout.BoxMeasurePolicy.measure-3p2s80s(Box.kt:145)
at androidx.compose.ui.node.InnerNodeCoordinator.measure-BRTryo0(InnerNodeCoordinator.kt:128)
at com.google.android.horologist.compose.layout.ScrollAwayKt$scrollAwayImpl$1$1.measure-3p2s80s(ScrollAway.kt:132)
at androidx.compose.ui.node.BackwardsCompatNode.measure-3p2s80s(BackwardsCompatNode.kt:303)
at androidx.compose.ui.node.LayoutModifierNodeCoordinator.measure-BRTryo0(LayoutModifierNodeCoordinator.kt:190)
at androidx.compose.foundation.layout.FillNode.measure-3p2s80s(Size.kt:721)
at androidx.compose.ui.node.LayoutModifierNodeCoordinator.measure-BRTryo0(LayoutModifierNodeCoordinator.kt:190)
at androidx.compose.ui.node.MeasurePassDelegate$performMeasureBlock$1.invoke(MeasurePassDelegate.kt:173)
at androidx.compose.ui.node.MeasurePassDelegate$performMeasureBlock$1.invoke(MeasurePassDelegate.kt:172)
at androidx.compose.runtime.snapshots.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:699)
at androidx.compose.ui.node.MeasurePassDelegate.remeasure-BRTryo0(MeasurePassDelegate.kt:1064)
at androidx.compose.ui.node.MeasurePassDelegate.measure-BRTryo0(MeasurePassDelegate.kt:470)
at androidx.compose.foundation.layout.BoxMeasurePolicy.measure-3p2s80s(Box.kt:168)
at androidx.compose.ui.node.InnerNodeCoordinator.measure-BRTryo0(InnerNodeCoordinator.kt:128)
at androidx.compose.ui.node.MeasurePassDelegate$performMeasureBlock$1.invoke(MeasurePassDelegate.kt:173)
2026-02-19 17:22:17.766 6301-6301 AndroidRuntime au....shiftyjelly.pocketcasts.debug E at androidx.compose.ui.node.MeasurePassDelegate$performMeasureBlock$1.invoke(MeasurePassDelegate.kt:172) (Fix with AI)
at androidx.compose.runtime.snapshots.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:699)
at androidx.compose.ui.node.MeasurePassDelegate.remeasure-BRTryo0(MeasurePassDelegate.kt:1064)
at androidx.compose.ui.node.MeasurePassDelegate.measure-BRTryo0(MeasurePassDelegate.kt:470)
at androidx.compose.ui.layout.RootMeasurePolicy.measure-3p2s80s(RootMeasurePolicy.kt:37)
at androidx.compose.ui.node.InnerNodeCoordinator.measure-BRTryo0(InnerNodeCoordinator.kt:128)
at androidx.compose.ui.platform.AndroidComposeView$RootModifierNode.measure-3p2s80s(AndroidComposeView.android.kt:3384)
at androidx.compose.ui.node.LayoutModifierNodeCoordinator.measure-BRTryo0(LayoutModifierNodeCoordinator.kt:190)
at androidx.compose.ui.node.MeasurePassDelegate$performMeasureBlock$1.invoke(MeasurePassDelegate.kt:173)
at androidx.compose.ui.node.MeasurePassDelegate$performMeasureBlock$1.invoke(MeasurePassDelegate.kt:172)
at androidx.compose.runtime.snapshots.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:728)
at androidx.compose.ui.node.MeasurePassDelegate.remeasure-BRTryo0(MeasurePassDelegate.kt:1064)
at androidx.compose.ui.node.LayoutNode.remeasure-_Sx5XlM$ui(LayoutNode.kt:1281)
at androidx.compose.ui.node.MeasureAndLayoutDelegate.doRemeasure-sdFAvZA(MeasureAndLayoutDelegate.kt:378)
at androidx.compose.ui.node.MeasureAndLayoutDelegate.remeasureOnly(MeasureAndLayoutDelegate.kt:652)
at androidx.compose.ui.node.MeasureAndLayoutDelegate.measureOnly(MeasureAndLayoutDelegate.kt:446)
at androidx.compose.ui.platform.AndroidComposeView.onMeasure(AndroidComposeView.android.kt:1847)
at android.view.View.measure(View.java:28595)
at androidx.compose.ui.platform.AbstractComposeView.internalOnMeasure$ui(ComposeView.android.kt:314)
at androidx.compose.ui.platform.AbstractComposeView.onMeasure(ComposeView.android.kt:301)
at android.view.View.measure(View.java:28595)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:7031)
at android.widget.FrameLayout.onMeasure(FrameLayout.java:194)
at android.view.View.measure(View.java:28595)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:7031)
at android.widget.LinearLayout.measureChildBeforeLayout(LinearLayout.java:1608)
at android.widget.LinearLayout.measureVertical(LinearLayout.java:878)
at android.widget.LinearLayout.onMeasure(LinearLayout.java:721)
at android.view.View.measure(View.java:28595)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:7031)
at android.widget.FrameLayout.onMeasure(FrameLayout.java:194)
at com.android.internal.policy.DecorView.onMeasure(DecorView.java:758)
at android.view.View.measure(View.java:28595)
at android.view.ViewRootImpl.performMeasure(ViewRootImpl.java:5088)
at android.view.ViewRootImpl.measureHierarchy(ViewRootImpl.java:3436)
at android.view.ViewRootImpl.performTraversals(ViewRootImpl.java:3749)
at android.view.ViewRootImpl.doTraversal(ViewRootImpl.java:3127)
at android.view.ViewRootImpl$TraversalRunnable.run(ViewRootImpl.java:10807)
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1630)
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1639)
at android.view.Choreographer.doCallbacks(Choreographer.java:1235)
at android.view.Choreographer.doFrame(Choreographer.java:1164)
at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:1613)
at android.os.Handler.handleCallback(Handler.java:1070)
at android.os.Handler.dispatchMessage(Handler.java:125)
at android.os.Looper.dispatchMessage(Looper.java:333)
at android.os.Looper.loopOnce(Looper.java:263)
at android.os.Looper.loop(Looper.java:367)
at android.app.ActivityThread.main(ActivityThread.java:9287)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:566)
2026-02-19 17:22:17.767 6301-6301 AndroidRuntime au....shiftyjelly.pocketcasts.debug E at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:929)
There was a problem hiding this comment.
wow, it never happened on my end. pushed new changes, hopefully that solves the issue!
There was a problem hiding this comment.
This is still crashing on my Pixel Watch.
I changed it to the following, and it seems to work.
@Composable
fun TimeTextWithConnectivity(
isConnected: Boolean,
modifier: Modifier = Modifier,
) {
ResponsiveTimeText(
modifier = modifier,
startCurvedContent = if (isConnected) null else {
{
curvedComposable {
OfflineIcon()
}
}
},
startLinearContent = if (isConnected) null else {
{
OfflineIcon()
}
},
)
}
@Composable
fun OfflineIcon() {
Icon(
painter = painterResource(IR.drawable.ic_cloud_off),
contentDescription = "Offline",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.size(16.dp),
)
}
It looks like the following:
| navController = navController, | ||
| state = navState, | ||
| AppScaffold( | ||
| timeText = { TimeTextWithConnectivity(isConnected = isConnected) }, |
|
closing this one in favor of smaller ones that aim to achieve granular goals |

Description
Now we display a refresh status indicator on the main screen. We also display the last refresh time under the Refresh now chip under Settings.
Fixes PCDROID-433
Testing Instructions
Screenshots or Screencast
Checklist
./gradlew spotlessApplyto automatically apply formatting/linting)modules/services/localization/src/main/res/values/strings.xml