diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b2f1d3da..d90da09e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -135,6 +135,17 @@ android:name="android.appwidget.provider" android:resource="@xml/compact_widget_info" /> + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_flagship_art.xml b/android/app/src/main/res/layout/widget_flagship_art.xml index 8d1857aa..8b8a76dd 100644 --- a/android/app/src/main/res/layout/widget_flagship_art.xml +++ b/android/app/src/main/res/layout/widget_flagship_art.xml @@ -1,105 +1,23 @@ - - - + + - - - - - - - - - - - - - - - - - - - - - - + android:layout_weight="1" /> + + diff --git a/android/app/src/main/res/layout/widget_flagship_art_large.xml b/android/app/src/main/res/layout/widget_flagship_art_large.xml new file mode 100644 index 00000000..d7344157 --- /dev/null +++ b/android/app/src/main/res/layout/widget_flagship_art_large.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/android/app/src/main/res/layout/widget_flagship_art_wide.xml b/android/app/src/main/res/layout/widget_flagship_art_wide.xml new file mode 100644 index 00000000..733feaf1 --- /dev/null +++ b/android/app/src/main/res/layout/widget_flagship_art_wide.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/android/app/src/main/res/layout/widget_flagship_controls.xml b/android/app/src/main/res/layout/widget_flagship_controls.xml new file mode 100644 index 00000000..0975d382 --- /dev/null +++ b/android/app/src/main/res/layout/widget_flagship_controls.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_flagship_panel.xml b/android/app/src/main/res/layout/widget_flagship_panel.xml new file mode 100644 index 00000000..d3109a51 --- /dev/null +++ b/android/app/src/main/res/layout/widget_flagship_panel.xml @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_mini_player.xml b/android/app/src/main/res/layout/widget_mini_player.xml index 73a5e53e..420785ad 100644 --- a/android/app/src/main/res/layout/widget_mini_player.xml +++ b/android/app/src/main/res/layout/widget_mini_player.xml @@ -1,107 +1,73 @@ + android:layout_height="match_parent"> - - - - - + android:layout_height="56dp" + android:layout_gravity="center_vertical" + android:background="@drawable/widget_mini_background" + android:clipToOutline="true" + android:outlineProvider="background"> + android:id="@+id/widget_content" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingStart="0dp" + android:paddingEnd="4dp"> + android:id="@+id/widget_thumbnail" + android:layout_width="56dp" + android:layout_height="56dp" + android:contentDescription="@string/widget_open_app" + android:scaleType="fitXY" + android:src="@drawable/widget_mini_placeholder" /> - - - - - - + android:layout_marginStart="12dp" + android:layout_weight="1" + android:orientation="vertical"> - + + + - - - + + + + diff --git a/android/app/src/main/res/layout/widget_replay.xml b/android/app/src/main/res/layout/widget_replay.xml new file mode 100644 index 00000000..715c3764 --- /dev/null +++ b/android/app/src/main/res/layout/widget_replay.xml @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values-v31/widget_dimens.xml b/android/app/src/main/res/values-v31/widget_dimens.xml new file mode 100644 index 00000000..4dfb19b4 --- /dev/null +++ b/android/app/src/main/res/values-v31/widget_dimens.xml @@ -0,0 +1,5 @@ + + + + 110dp + diff --git a/android/app/src/main/res/values/widget_dimens.xml b/android/app/src/main/res/values/widget_dimens.xml new file mode 100644 index 00000000..f5d8278c --- /dev/null +++ b/android/app/src/main/res/values/widget_dimens.xml @@ -0,0 +1,7 @@ + + + + 110dp + + 156dp + diff --git a/android/app/src/main/res/values/widget_strings.xml b/android/app/src/main/res/values/widget_strings.xml index 62e0038c..f27eb019 100644 --- a/android/app/src/main/res/values/widget_strings.xml +++ b/android/app/src/main/res/values/widget_strings.xml @@ -1,13 +1,25 @@ + Up next: %1$s + Elapsed time + Track duration + Playback mode enabled; tap to change Flick Player Compact player with transport controls Nothing playing + Unknown track Tap to open Flick Open Flick Play / pause + Play + Pause + Shuffle on. Tap to turn off + Shuffle off. Tap to turn on + Repeat off. Tap to change + Repeat one track. Tap to change + Repeat all tracks. Tap to change Next track Previous track Toggle shuffle @@ -19,4 +31,17 @@ Flick Player 2×2 Title and artist with transport controls + + Flick Replay + Today\'s listening at a glance + Flick Replay + Time + Plays + Today · %1$s + vs yesterday + %1$s listening time vs yesterday + %1$s plays vs yesterday + Nothing played yet today + Open today\'s recap + Flick Replay: %1$s listened and %2$d plays today diff --git a/android/app/src/main/res/values/widget_styles.xml b/android/app/src/main/res/values/widget_styles.xml new file mode 100644 index 00000000..8369fe1f --- /dev/null +++ b/android/app/src/main/res/values/widget_styles.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/app/src/main/res/xml/compact_widget_info.xml b/android/app/src/main/res/xml/compact_widget_info.xml index 8b807f46..49a0d25d 100644 --- a/android/app/src/main/res/xml/compact_widget_info.xml +++ b/android/app/src/main/res/xml/compact_widget_info.xml @@ -2,11 +2,12 @@ \ No newline at end of file + android:widgetCategory="home_screen" /> diff --git a/android/app/src/main/res/xml/flagship_widget_info.xml b/android/app/src/main/res/xml/flagship_widget_info.xml index 5adf12dc..9e9c0ed7 100644 --- a/android/app/src/main/res/xml/flagship_widget_info.xml +++ b/android/app/src/main/res/xml/flagship_widget_info.xml @@ -4,9 +4,12 @@ android:label="@string/widget_flagship_name" android:minHeight="180dp" android:minWidth="250dp" + android:minResizeWidth="250dp" + android:minResizeHeight="180dp" + android:initialLayout="@layout/widget_flagship_art" android:previewLayout="@layout/widget_flagship_art" - android:resizeMode="none" + android:resizeMode="horizontal|vertical" android:targetCellHeight="3" android:targetCellWidth="4" android:updatePeriodMillis="0" - android:widgetCategory="home_screen" /> \ No newline at end of file + android:widgetCategory="home_screen" /> diff --git a/android/app/src/main/res/xml/mini_player_widget_info.xml b/android/app/src/main/res/xml/mini_player_widget_info.xml index 231fb85b..9c0f98dd 100644 --- a/android/app/src/main/res/xml/mini_player_widget_info.xml +++ b/android/app/src/main/res/xml/mini_player_widget_info.xml @@ -3,7 +3,8 @@ android:label="@string/widget_mini_player_name" android:description="@string/widget_mini_player_desc" android:minWidth="260dp" - android:minHeight="40dp" + android:minHeight="56dp" + android:initialLayout="@layout/widget_mini_player" android:previewLayout="@layout/widget_mini_player" android:resizeMode="none" android:targetCellWidth="4" diff --git a/android/app/src/main/res/xml/replay_widget_info.xml b/android/app/src/main/res/xml/replay_widget_info.xml new file mode 100644 index 00000000..3c28c835 --- /dev/null +++ b/android/app/src/main/res/xml/replay_widget_info.xml @@ -0,0 +1,15 @@ + + diff --git a/android/app/src/test/kotlin/com/mossapps/flick/widgets/WidgetLaunchInboxTest.kt b/android/app/src/test/kotlin/com/mossapps/flick/widgets/WidgetLaunchInboxTest.kt new file mode 100644 index 00000000..07d29ebf --- /dev/null +++ b/android/app/src/test/kotlin/com/mossapps/flick/widgets/WidgetLaunchInboxTest.kt @@ -0,0 +1,34 @@ +package com.mossapps.flick.widgets + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class WidgetLaunchInboxTest { + @Test + fun coldStartTapIsDrainedOnceWhenDartIsReady() { + val inbox = WidgetLaunchInbox() + assertNull(inbox.offer("flickwidget://recap/open")) + assertEquals("flickwidget://recap/open", inbox.attach()) + assertNull(inbox.attach()) + } + + @Test + fun warmTapsAreDeliveredImmediately() { + val inbox = WidgetLaunchInbox() + assertNull(inbox.attach()) + assertEquals("flickwidget://queue/open", inbox.offer("flickwidget://queue/open")) + assertNull(inbox.attach()) + } + + @Test + fun ReattachmentKeepsTheLatestTapWithoutReplayingEarlierOnes() { + val inbox = WidgetLaunchInbox() + inbox.attach() + inbox.detach() + assertNull(inbox.offer("flickwidget://player/open")) + assertNull(inbox.offer("flickwidget://recap/open")) + assertEquals("flickwidget://recap/open", inbox.attach()) + assertNull(inbox.attach()) + } +} diff --git a/android/app/src/test/kotlin/com/mossapps/flick/widgets/WidgetLayoutPolicyTest.kt b/android/app/src/test/kotlin/com/mossapps/flick/widgets/WidgetLayoutPolicyTest.kt new file mode 100644 index 00000000..ed9fe81e --- /dev/null +++ b/android/app/src/test/kotlin/com/mossapps/flick/widgets/WidgetLayoutPolicyTest.kt @@ -0,0 +1,312 @@ +package com.mossapps.flick.widgets + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin + +class WidgetLayoutPolicyTest { + @Test + fun miniMatchesTheBottomBarsFixedGeometry() { + assertEquals(56, WidgetLayoutPolicy.miniDefault.heightDp) + assertEquals(140, WidgetLayoutPolicy.miniTextWidth(WidgetSize(260, 56), true, true)) + assertEquals(200, WidgetLayoutPolicy.miniTextWidth(WidgetSize(320, 112), true, true)) + assertEquals(256, WidgetLayoutPolicy.miniTextWidth(WidgetSize(320, 56), false, true)) + assertEquals(248, WidgetLayoutPolicy.miniTextWidth(WidgetSize(320, 56), true, false)) + } + + @Test + fun compactLeavesRoomForItsSmallPlayButton() { + assertEquals(68, WidgetLayoutPolicy.compactTextWidth(WidgetSize(156, 156), true)) + assertEquals(80, WidgetLayoutPolicy.compactTextWidth(WidgetSize(168, 168), true)) + assertEquals(136, WidgetLayoutPolicy.compactTextWidth(WidgetSize(168, 168), false)) + for (height in listOf(156, 168)) { + val playBottom = (height - 48) / 2 - 8 + 48 + val skipTop = height - 8 - 48 + assertTrue(playBottom <= skipTop) + } + } + + @Test + fun compactSquaresOnlyUnusuallyTallLegacyAllocations() { + for (size in listOf(WidgetLayoutPolicy.compactDefault, WidgetSize(110, 156), + WidgetSize(148, 222), WidgetSize(226, 263), WidgetSize(225, 110))) { + assertEquals(size, WidgetLayoutPolicy.compactCardSize(size, legacySizing = true)) + } + for (size in listOf(WidgetSize(110, 225), WidgetSize(148, 290), + WidgetSize(156, 235), WidgetSize(226, 400))) { + val card = WidgetLayoutPolicy.compactCardSize(size, legacySizing = true) + assertEquals(WidgetSize(size.widthDp, size.widthDp), card) + assertTrue(card.heightDp <= size.heightDp) + assertTrue(WidgetLayoutPolicy.compactTextWidth(card, true) >= 42) + } + val card = WidgetLayoutPolicy.compactCardSize(WidgetSize(148, 290), legacySizing = true) + assertEquals(CompactLayout.SHORT, WidgetLayoutPolicy.compactLayout(card)) + assertEquals(80, WidgetLayoutPolicy.compactTextWidth(card, true)) + } + + @Test + fun compactPreservesModernTargetCellSizingRegardlessOfAspectRatio() { + for (size in listOf(WidgetSize(110, 225), WidgetSize(148, 290), + WidgetSize(156, 235), WidgetSize(226, 263), WidgetSize(226, 400))) { + assertEquals(size, WidgetLayoutPolicy.compactCardSize(size, legacySizing = false)) + } + } + + @Test + fun compactReflowsUsingWidgetBoundsRatherThanThePhoneModel() { + assertEquals(CompactLayout.REGULAR, WidgetLayoutPolicy.compactLayout(WidgetSize(156, 156))) + assertEquals(CompactLayout.REGULAR, WidgetLayoutPolicy.compactLayout(WidgetLayoutPolicy.compactDefault)) + assertEquals(CompactLayout.SHORT, WidgetLayoutPolicy.compactLayout(WidgetSize(225, 110))) + assertEquals(CompactLayout.SHORT, WidgetLayoutPolicy.compactLayout(WidgetSize(110, 155))) + assertEquals(CompactLayout.NARROW, WidgetLayoutPolicy.compactLayout(WidgetSize(110, 156))) + assertEquals(CompactLayout.NARROW, WidgetLayoutPolicy.compactLayout(WidgetSize(155, 225))) + assertEquals(42, WidgetLayoutPolicy.compactTextWidth(WidgetSize(110, 110), true)) + assertEquals(98, WidgetLayoutPolicy.compactTextWidth(WidgetSize(110, 110), false)) + assertEquals(98, WidgetLayoutPolicy.compactTextWidth(WidgetSize(110, 156), true)) + assertEquals(157, WidgetLayoutPolicy.compactTextWidth(WidgetSize(225, 110), true)) + } + + @Test + fun compactOnlyShowsProgressWhenItFitsBetweenTheSkipButtons() { + for (height in listOf(110, 155, 156, 168, 225)) { + assertFalse(WidgetLayoutPolicy.compactShowsProgress(WidgetSize(110, height))) + assertFalse(WidgetLayoutPolicy.compactShowsProgress(WidgetSize(147, height))) + assertTrue(WidgetLayoutPolicy.compactShowsProgress(WidgetSize(148, height))) + assertTrue(WidgetLayoutPolicy.compactShowsProgress(WidgetSize(156, height))) + assertTrue(WidgetLayoutPolicy.compactShowsProgress(WidgetSize(225, height))) + } + } + + @Test + fun flagshipTilesAndCoversFitEverySupportedSize() { + for (width in listOf(250, 320, 400, 439, 440, 559, 560, 704)) { + for (height in listOf(180, 208, 240, 328, 500)) { + val geometry = WidgetLayoutPolicy.flagship(WidgetSize(width, height)) + assertTrue((geometry.gridWidthDp - 8) / 2 >= 48) + assertTrue((height - geometry.paddingDp * 2 - 8) / 2 >= 48) + assertTrue(geometry.textWidthDp >= 48) + assertEquals(width, geometry.paddingDp * 2 + geometry.panelWidthDp + + geometry.gapDp + geometry.gridWidthDp) + val coverRow = if (geometry.repeatInHeader) geometry.coverDp + 48 + else geometry.coverDp * (geometry.upcomingSlots + 1) + geometry.upcomingSlots * 8 + assertTrue(coverRow <= geometry.panelContentWidthDp) + val contentHeight = height - geometry.paddingDp * 2 - geometry.panelPaddingDp * 2 + // Cover, metadata, progress and timestamps must also fit vertically. + assertTrue(geometry.coverDp + 89 <= contentHeight) + } + } + } + + @Test + fun flagshipReflowsUpcomingArtAndRepeatRatherThanShrinkingTargets() { + val small = WidgetLayoutPolicy.flagship(WidgetSize(250, 180)) + assertTrue(small.repeatInHeader) + assertEquals(0, small.upcomingSlots) + val wide = WidgetLayoutPolicy.flagship(WidgetSize(440, 240)) + assertFalse(wide.repeatInHeader) + assertEquals(2, wide.upcomingSlots) + assertEquals(144, wide.gridWidthDp) + assertEquals(184, WidgetLayoutPolicy.flagship(WidgetSize(704, 328)).gridWidthDp) + } + + @Test + fun timestampsHandleLongTracksAndInvalidPositions() { + assertEquals("0:00", WidgetLayoutPolicy.time(-1)) + assertEquals("0:48", WidgetLayoutPolicy.time(48000)) + assertEquals("2:02", WidgetLayoutPolicy.time(122000)) + assertEquals("1:02:03", WidgetLayoutPolicy.time(3723000)) + } + + @Test + fun progressClampsInvalidAndExtremePositionsWithoutOverflow() { + assertEquals(0, WidgetLayoutPolicy.progress(50, 0)) + assertEquals(0, WidgetLayoutPolicy.progress(-50, 100)) + assertEquals(500, WidgetLayoutPolicy.progress(50, 100)) + assertEquals(1000, WidgetLayoutPolicy.progress(150, 100)) + assertEquals(1000, WidgetLayoutPolicy.progress(Long.MAX_VALUE, Long.MAX_VALUE)) + } + + @Test + fun replayKeepsStatsAndCoversInsideEverySupportedSize() { + for (width in listOf(250, 279, 280, 320, 359, 360, 500, 704)) { + for (height in listOf(110, 128, 160, 200, 240, 328)) { + val geometry = WidgetLayoutPolicy.replay(WidgetSize(width, height)) + assertTrue(geometry.covers >= 1) + assertTrue(geometry.artworkHeightDp <= geometry.contentHeightDp) + assertTrue(geometry.contentHeightDp >= 58) + assertTrue(geometry.valueSp >= 24) + assertTrue(geometry.metricsHeightDp + (if (geometry.showCaption) 18 else 0) <= geometry.contentHeightDp) + assertTrue(geometry.timeWidthDp > geometry.playsWidthDp) + assertEquals(width, geometry.statsWidthDp + geometry.artworkWidthDp + 12 + geometry.paddingDp * 2) + assertEquals(height, geometry.contentHeightDp + 20 + geometry.bodyGapDp + geometry.paddingDp * 2) + assertTrue(geometry.statsWidthDp >= 144) + assertTrue(geometry.brandTextWidthDp + 22 + 86 + 8 + geometry.paddingDp * 2 <= width) + } + } + assertEquals(1, WidgetLayoutPolicy.replay(WidgetSize(250, 110)).covers) + assertEquals(2, WidgetLayoutPolicy.replay(WidgetSize(300, 160)).covers) + assertEquals(3, WidgetLayoutPolicy.replay(WidgetSize(320, 160)).covers) + assertEquals(3, WidgetLayoutPolicy.replay(WidgetSize(400, 160)).covers) + assertTrue(WidgetLayoutPolicy.replay(WidgetSize(320, 180)).valueSp >= 35) + val expanded = WidgetLayoutPolicy.replay(WidgetSize(500, 240)) + val normal = WidgetLayoutPolicy.replay(WidgetLayoutPolicy.replayDefault) + assertTrue(expanded.coverDp > normal.coverDp) + assertTrue(expanded.valueSp > normal.valueSp) + } + + @Test + fun replayTimeAndDeltaLabelsMatchTheInAppRecapFormat() { + assertEquals("0m", WidgetLayoutPolicy.listeningTime(0)) + assertEquals("48m", WidgetLayoutPolicy.listeningTime(48 * 60L * 1000L)) + assertEquals("1h", WidgetLayoutPolicy.listeningTime(3_600_000)) + assertEquals("1h 5m", WidgetLayoutPolicy.listeningTime(65 * 60L * 1000L)) + assertEquals("0m", WidgetLayoutPolicy.listeningTime(-5)) + assertEquals("+10%", WidgetLayoutPolicy.percentLabel(10)) + assertEquals("-5%", WidgetLayoutPolicy.percentLabel(-5)) + assertEquals("0%", WidgetLayoutPolicy.percentLabel(0)) + } + + @Test + fun replayArtworkTintPicksReadableForegrounds() { + val light = ReplayPalette.artwork(0xFFF1D9C0.toInt()) + assertEquals(0xFF000000.toInt(), light.foreground) + assertEquals(0x13000000, light.border) + val dark = ReplayPalette.artwork(0xFF1F3D1F.toInt()) + assertEquals(0xFFFFFFFF.toInt(), dark.foreground) + assertEquals(0x13FFFFFF, dark.border) + assertEquals(0xFFF1F0EB.toInt(), ReplayPalette.NIGHT.foreground) + assertEquals(0xFFE3F369.toInt(), ReplayPalette.NIGHT.positive) + } + + @Test + fun replayAllocatesRealCoverCountsAndMoreRoomForDurationLabels() { + val minimum = WidgetLayoutPolicy.replay(WidgetSize(250, 110)) + assertTrue(minimum.timeWidthDp >= 80) + val fewerCovers = WidgetLayoutPolicy.replay(WidgetSize(360, 160), 1) + val threeCovers = WidgetLayoutPolicy.replay(WidgetSize(360, 160), 3) + assertEquals(1, fewerCovers.covers) + assertTrue(fewerCovers.timeWidthDp > threeCovers.timeWidthDp) + val empty = WidgetLayoutPolicy.replay(WidgetSize(250, 110), 0, false) + assertEquals(0, empty.covers) + assertTrue(empty.timeWidthDp > 0 && empty.playsWidthDp > 0) + assertTrue(empty.showCaption) + assertTrue(empty.metricsHeightDp + 18 <= empty.contentHeightDp) + assertTrue(empty.sleeves().isEmpty()) + } + + @Test + fun replaySleevesOverlapWithoutClippingAndKeepTheTopAlbumInFront() { + for (width in listOf(250, 280, 320, 400, 500, 704)) { + for (height in listOf(110, 128, 160, 240, 328)) { + for (count in 0..3) { + val geometry = WidgetLayoutPolicy.replay(WidgetSize(width, height), count) + val sleeves = geometry.sleeves() + assertEquals(geometry.covers, sleeves.size) + if (sleeves.isEmpty()) continue + assertEquals(0, sleeves.last().index) + assertEquals(geometry.coverDp.toFloat(), sleeves.last().sideDp, 0.01f) + assertEquals(0f, sleeves.last().rotation, 0.01f) + for ((sleeve, mirrored) in sleeves.zip(geometry.sleeves(rtl = true))) { + val radians = Math.toRadians(sleeve.rotation.toDouble()) + val halfExtent = sleeve.sideDp * (abs(cos(radians)) + abs(sin(radians))) / 2 + val centerX = sleeve.leftDp + sleeve.sideDp / 2 + val centerY = sleeve.topDp + sleeve.sideDp / 2 + assertTrue(centerX - halfExtent >= 0) + assertTrue(centerX + halfExtent <= geometry.artworkWidthDp) + assertTrue(centerY - halfExtent >= 0) + assertTrue(centerY + halfExtent <= geometry.artworkHeightDp) + assertEquals(geometry.artworkWidthDp.toFloat(), + sleeve.leftDp + mirrored.leftDp + sleeve.sideDp, 0.01f) + assertEquals(-sleeve.rotation, mirrored.rotation, 0.01f) + } + for ((back, front) in sleeves.zipWithNext()) { + assertTrue(back.leftDp < front.leftDp) + assertTrue(back.leftDp + back.sideDp > front.leftDp) + } + } + } + } + } + + @Test + fun replayOrbitAndEveryContourStayInsideTheArtworkCanvas() { + for (width in listOf(250, 280, 320, 400, 500, 704)) { + for (height in listOf(110, 128, 160, 240, 328)) { + for (count in 0..3) for (hasData in listOf(false, true)) { + val geometry = WidgetLayoutPolicy.replay(WidgetSize(width, height), count, hasData) + val artWidth = geometry.artworkWidthDp.toFloat() + val artHeight = geometry.artworkHeightDp.toFloat() + val orbit = ReplayOrbitGeometry.fit(artWidth, artHeight) + assertTrue(orbit.scale > 0f && orbit.scale <= 1f) + assertEquals(artWidth * 0.54f, orbit.radiusXDp, 0.01f) + assertEquals(artHeight * 0.34f, orbit.radiusYDp, 0.01f) + val radians = Math.toRadians(ReplayOrbitGeometry.ROTATION.toDouble()) + val strokes = listOf(0f to ReplayOrbitGeometry.STROKE_DP) + + ReplayOrbitGeometry.CONTOUR_INSETS_DP.map { it to ReplayOrbitGeometry.CONTOUR_STROKE_DP } + for ((inset, stroke) in strokes) { + val radiusX = (orbit.radiusXDp + inset) * orbit.scale + val radiusY = (orbit.radiusYDp + inset) * orbit.scale + val halfStroke = stroke * orbit.scale / 2 + val halfWidth = Math.hypot(radiusX * cos(radians), radiusY * sin(radians)) + halfStroke + val halfHeight = Math.hypot(radiusX * sin(radians), radiusY * cos(radians)) + halfStroke + assertTrue(artWidth / 2 - halfWidth >= ReplayOrbitGeometry.PADDING_DP - 0.001) + assertTrue(artHeight / 2 - halfHeight >= ReplayOrbitGeometry.PADDING_DP - 0.001) + // Include density scaling and the 360px cap used by the bitmap renderer. + for (density in listOf(0.75f, 1f, 1.5f, 2f, 3f, 4f)) { + val pixelsPerDp = minOf(density, 360f / maxOf(artWidth, artHeight)) + assertTrue((artWidth / 2 - halfWidth) * pixelsPerDp >= 1) + assertTrue((artHeight / 2 - halfHeight) * pixelsPerDp >= 1) + assertTrue((artWidth / 2 + halfWidth) * pixelsPerDp <= (artWidth * pixelsPerDp).roundToInt() - 1) + assertTrue((artHeight / 2 + halfHeight) * pixelsPerDp <= (artHeight * pixelsPerDp).roundToInt() - 1) + } + } + } + } + } + } + + @Test + fun replayMissingArtworkAndEmptyStatesKeepTheSameReadableMetricGrid() { + for (width in listOf(250, 320, 400, 704)) for (height in listOf(110, 160, 240, 328)) { + for (hasData in listOf(false, true)) { + val geometry = WidgetLayoutPolicy.replay(WidgetSize(width, height), 0, hasData) + assertEquals(0, geometry.covers) + assertTrue(geometry.statsWidthDp >= 144) + assertTrue(geometry.artworkHeightDp <= geometry.contentHeightDp) + assertTrue(geometry.metricsHeightDp + (if (geometry.showCaption) 18 else 0) <= geometry.contentHeightDp) + } + } + } + + @Test + fun replayAcidAndNightUseTheInAppNeonIdentity() { + assertEquals(0xFFE3F369.toInt(), ReplayPalette.ACID.background) + assertEquals(0xFF0A0A0A.toInt(), ReplayPalette.ACID.value) + assertEquals(0xFFFF5636.toInt(), ReplayPalette.ACID.accent) + assertEquals(ReplayPalette.ACID.background, ReplayPalette.NIGHT.value) + assertEquals(ReplayPalette.SIGNAL.background, ReplayPalette.NIGHT.accent) + assertEquals(ReplayPalette.ACID.positive, ReplayPalette.ACID.negative) + } + + @Test + fun replayColoredSurfacesKeepSmallLabelsReadableAtBothGradientEnds() { + val palettes = mutableListOf(ReplayPalette.NIGHT, ReplayPalette.SIGNAL, ReplayPalette.ACID) + for (r in 0..255 step 51) for (g in 0..255 step 51) for (b in 0..255 step 51) { + palettes.add(ReplayPalette.artwork(0xFF000000.toInt() or (r shl 16) or (g shl 8) or b)) + } + for (palette in palettes) for (surface in listOf(palette.background, palette.shaded)) { + assertTrue(ReplayPalette.contrast(palette.foreground, surface) >= 4.5f) + assertTrue(ReplayPalette.contrast(palette.secondary, surface) >= 4.5f) + assertTrue(ReplayPalette.contrast(palette.positive, surface) >= 4.5f) + assertTrue(ReplayPalette.contrast(palette.negative, surface) >= 4.5f) + assertTrue(ReplayPalette.contrast(palette.value, surface) >= 4.5f) + } + val dark = ReplayPalette.artwork(0xFF1F3D1F.toInt()) + assertTrue(ReplayPalette.luminance(dark.shaded) < ReplayPalette.luminance(dark.background)) + } +} diff --git a/android/app/src/test/kotlin/com/mossapps/flick/widgets/WidgetReplayStateTest.kt b/android/app/src/test/kotlin/com/mossapps/flick/widgets/WidgetReplayStateTest.kt new file mode 100644 index 00000000..c4f7c825 --- /dev/null +++ b/android/app/src/test/kotlin/com/mossapps/flick/widgets/WidgetReplayStateTest.kt @@ -0,0 +1,35 @@ +package com.mossapps.flick.widgets + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WidgetReplayStateTest { + @Test + fun staleOrPartiallyPublishedDaysHideStatisticsAndCovers() { + for (day in listOf(null, "", "2026-10-13")) { + val state = WidgetReplayState.snapshot(day, "2026-10-14", 90_000, 4, 20, 10, listOf("cover")) + assertFalse(state.fresh) + assertFalse(state.hasData) + assertEquals(0L, state.timeMs) + assertEquals(0, state.plays) + assertEquals(WidgetReplayState.UNKNOWN_DELTA, state.timeDelta) + assertTrue(state.covers.isEmpty()) + } + } + + @Test + fun currentDayPreservesRealStatisticsButEmptySnapshotsClearDecorations() { + val state = WidgetReplayState.snapshot("2026-10-14", "2026-10-14", 90_000, 4, 20, 10, listOf("cover")) + assertTrue(state.hasData) + assertEquals(4, state.plays) + assertEquals(20, state.timeDelta) + assertEquals(listOf("cover"), state.covers) + val empty = WidgetReplayState.snapshot("2026-10-14", "2026-10-14", -1, -1, 20, 10, listOf("cover")) + assertTrue(empty.fresh) + assertFalse(empty.hasData) + assertEquals(WidgetReplayState.UNKNOWN_DELTA, empty.playsDelta) + assertTrue(empty.covers.isEmpty()) + } +} diff --git a/android/app/src/test/kotlin/com/mossapps/flick/widgets/WidgetResourcesTest.kt b/android/app/src/test/kotlin/com/mossapps/flick/widgets/WidgetResourcesTest.kt new file mode 100644 index 00000000..98d7e846 --- /dev/null +++ b/android/app/src/test/kotlin/com/mossapps/flick/widgets/WidgetResourcesTest.kt @@ -0,0 +1,300 @@ +package com.mossapps.flick.widgets + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.w3c.dom.Element + +class WidgetResourcesTest { + private val androidNamespace = "http://schemas.android.com/apk/res/android" + + private fun resource(path: String): Element { + val factory = DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = true + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + } + return factory.newDocumentBuilder().parse(File("src/main/res/$path")).documentElement + } + + private fun Element.android(name: String): String = getAttributeNS(androidNamespace, name) + + @Test + fun onlyFlagshipAndReplayOfferResizing() { + for (name in listOf("mini_player", "compact", "flagship", "replay")) { + val provider = resource("xml/${name}_widget_info.xml") + val resizable = name == "flagship" || name == "replay" + assertEquals(if (resizable) "horizontal|vertical" else "none", provider.android("resizeMode")) + if (resizable) { + assertTrue(provider.android("minResizeWidth").isNotBlank()) + assertTrue(provider.android("minResizeHeight").isNotBlank()) + } else { + assertTrue(provider.android("minResizeWidth").isBlank()) + assertTrue(provider.android("minResizeHeight").isBlank()) + } + assertEquals(provider.android("previewLayout"), provider.android("initialLayout")) + } + } + + @Test + fun compactUsesCompatibleMinimumsWithoutOverridingItsTwoByTwoTarget() { + val provider = resource("xml/compact_widget_info.xml") + assertEquals("@dimen/widget_compact_min_width", provider.android("minWidth")) + assertEquals("@dimen/widget_compact_min_height", provider.android("minHeight")) + assertEquals("2", provider.android("targetCellWidth")) + assertEquals("2", provider.android("targetCellHeight")) + + fun dimensions(path: String): Map { + val elements = resource(path).getElementsByTagName("dimen") + return (0 until elements.length).map { elements.item(it) as Element } + .associate { it.getAttribute("name") to it.textContent } + } + val legacy = dimensions("values/widget_dimens.xml") + val modern = legacy + dimensions("values-v31/widget_dimens.xml") + assertEquals("110dp", legacy["widget_compact_min_width"]) + assertEquals("156dp", legacy["widget_compact_min_height"]) + assertEquals("110dp", modern["widget_compact_min_width"]) + assertEquals("110dp", modern["widget_compact_min_height"]) + } + + @Test + fun replayWidgetKeepsItsStatsInsideTheMinimumSize() { + val provider = resource("xml/replay_widget_info.xml") + assertEquals("250dp", provider.android("minWidth")) + assertEquals("110dp", provider.android("minHeight")) + assertEquals("4", provider.android("targetCellWidth")) + assertEquals("2", provider.android("targetCellHeight")) + + val root = resource("layout/widget_replay.xml") + assertEquals("@+id/replay_root", root.android("id")) + assertEquals("true", root.android("clipToOutline")) + assertEquals("background", root.android("outlineProvider")) + assertEquals("@drawable/widget_replay_background_acid", root.android("background")) + assertEquals("yes", root.android("importantForAccessibility")) + val covers = root.getElementsByTagName("ImageView") + val views = (0 until covers.length).map { covers.item(it) as Element } + assertFalse(views.any { it.android("id").startsWith("@+id/replay_cover_") }) + val artwork = views.single { it.android("id") == "@+id/replay_artwork" } + assertEquals("wrap_content", artwork.android("layout_width")) + assertEquals("wrap_content", artwork.android("layout_height")) + assertEquals("12dp", artwork.android("layout_marginStart")) + assertEquals("no", artwork.android("importantForAccessibility")) + val content = artwork.parentNode.parentNode as Element + assertEquals("noHideDescendants", content.android("importantForAccessibility")) + val rule = views.single { it.android("id") == "@+id/replay_rule" } + assertEquals("1dp", rule.android("layout_width")) + assertEquals("8dp", rule.android("layout_marginStart")) + assertEquals("8dp", rule.android("layout_marginEnd")) + assertEquals("86dp", views.single { it.android("id") == "@+id/replay_date" }.android("layout_width")) + val brandText = views.first { it.android("id") == "@+id/replay_brand_text" } + assertEquals("0dp", brandText.android("layout_width")) + assertEquals("1", brandText.android("layout_weight")) + val time = views.first { it.android("id") == "@+id/replay_time_value" }.parentNode as Element + val plays = views.first { it.android("id") == "@+id/replay_plays_value" }.parentNode as Element + assertEquals("1.5", time.android("layout_weight")) + assertEquals("1", plays.android("layout_weight")) + + val strings = resource("values/widget_strings.xml").getElementsByTagName("string") + val names = (0 until strings.length).map { (strings.item(it) as Element).getAttribute("name") } + for (name in listOf("widget_replay_name", "widget_replay_desc", "widget_replay_brand", + "widget_replay_time", "widget_replay_plays", "widget_replay_empty", + "widget_replay_open", "widget_replay_summary", "widget_replay_date", + "widget_replay_comparison", "widget_replay_time_change", "widget_replay_plays_change")) { + assertTrue(name, names.contains(name)) + } + } + + @Test + fun replayAcidSurfaceMatchesTheReplayPalette() { + val background = resource("drawable/widget_replay_background_acid.xml") + val solid = background.getElementsByTagName("solid").item(0) as Element + assertEquals("#FFE3F369", solid.android("color")) + for (name in listOf("widget_replay_background", "widget_replay_background_signal", "widget_replay_background_acid")) { + val corners = resource("drawable/$name.xml").getElementsByTagName("corners").item(0) as Element + assertEquals("24dp", corners.android("radius")) + } + } + + @Test + fun artworkSurfacesDeclareRoundedOutlineClipping() { + for (name in listOf("widget_compact", "widget_compact_short", "widget_compact_narrow", + "widget_flagship_art", "widget_flagship_art_wide", "widget_flagship_art_large", "widget_replay")) { + val root = resource("layout/$name.xml") + assertEquals("true", root.android("clipToOutline")) + assertEquals("background", root.android("outlineProvider")) + val background = root.android("background").removePrefix("@drawable/") + val corners = resource("drawable/$background.xml").getElementsByTagName("corners").item(0) as Element + assertEquals("24dp", corners.android("radius")) + } + } + + @Test + fun miniUsesOnePlainPlayButtonAndFlushLeftArtwork() { + val root = resource("layout/widget_mini_player.xml") + val buttons = root.getElementsByTagName("ImageButton") + assertEquals(1, buttons.length) + assertEquals("@+id/widget_play_pause", (buttons.item(0) as Element).android("id")) + assertEquals("@style/WidgetTransportButton", (buttons.item(0) as Element).getAttribute("style")) + val views = root.getElementsByTagName("ImageView") + val thumbnail = (0 until views.length).map { views.item(it) as Element } + .first { it.android("id") == "@+id/widget_thumbnail" } + assertEquals("56dp", thumbnail.android("layout_width")) + assertEquals("56dp", thumbnail.android("layout_height")) + assertTrue(thumbnail.android("layout_marginStart").isBlank()) + val corners = resource("drawable/widget_mini_background.xml").getElementsByTagName("corners").item(0) as Element + assertEquals("16dp", corners.android("radius")) + } + + @Test + fun compactKeepsItsArtworkFreeOfFooterGradients() { + val root = resource("layout/widget_compact.xml") + val elements = root.getElementsByTagName("*") + for (index in 0 until elements.length) { + val element = elements.item(index) as Element + assertFalse(element.android("background").contains("scrim")) + assertFalse(element.android("id").contains("footer")) + } + val inset = resource("drawable/widget_button_small_play.xml") + assertEquals("6dp", inset.android("insetLeft")) + assertEquals("6dp", inset.android("insetRight")) + val rows = root.getElementsByTagName("LinearLayout") + val metadata = (0 until rows.length).map { rows.item(it) as Element } + .first { it.android("id") == "@+id/compact_metadata" } + assertEquals("8dp", metadata.android("layout_marginBottom")) + + val views = root.getElementsByTagName("ImageView") + val progress = (0 until views.length).map { views.item(it) as Element } + .first { it.android("id") == "@+id/compact_progress" } + assertEquals("56dp", progress.android("layout_marginStart")) + assertEquals("56dp", progress.android("layout_marginEnd")) + } + + @Test + fun compactSmallLayoutsKeepMetadataAndAllThreeControlsInsideTheirBounds() { + fun Element.dp(name: String): Int = android(name).ifBlank { "0dp" }.removeSuffix("dp").toInt() + for (name in listOf("widget_compact_short", "widget_compact_narrow")) { + val root = resource("layout/$name.xml") + val elements = root.getElementsByTagName("*") + val views = (0 until elements.length).map { elements.item(it) as Element } + fun view(id: String) = views.single { it.android("id") == "@+id/$id" } + for (id in listOf("compact_art_bg", "compact_title", "compact_artist", + "compact_play_pause", "compact_prev", "compact_next", "compact_progress")) { + view(id) + } + assertFalse(views.any { it.android("background").contains("scrim") }) + val metadata = view("compact_metadata") + val play = view("compact_play_pause") + val controls = view("compact_controls") + assertEquals(48, controls.dp("layout_height")) + assertEquals("start", view("compact_prev").android("layout_gravity")) + assertEquals("end", view("compact_next").android("layout_gravity")) + assertEquals(56, view("compact_progress").dp("layout_marginStart")) + assertEquals(56, view("compact_progress").dp("layout_marginEnd")) + val artistGap = view("compact_artist").dp("layout_marginTop") + assertTrue(22 + artistGap + 18 <= metadata.dp("layout_height")) + + if (name == "widget_compact_short") { + assertEquals("top", metadata.android("layout_gravity")) + assertEquals("bottom", controls.android("layout_gravity")) + val playBottom = metadata.dp("layout_marginTop") + metadata.dp("layout_height") + for (height in listOf(110, 128, 144, 155)) { + val skipTop = height - controls.dp("layout_marginBottom") - 48 + assertTrue(playBottom <= skipTop) + } + val textWidth = 110 - metadata.dp("layout_marginStart") - metadata.dp("layout_marginEnd") - + play.dp("layout_marginStart") - 48 + assertEquals(WidgetLayoutPolicy.compactTextWidth(WidgetSize(110, 110), true), textWidth) + assertTrue(110 - controls.dp("layout_marginStart") - controls.dp("layout_marginEnd") >= 96) + } else { + val column = metadata.parentNode as Element + assertEquals(column, play.parentNode) + assertEquals(column, controls.parentNode) + assertEquals("vertical", column.android("orientation")) + assertEquals("center_vertical", column.android("layout_gravity")) + val contentHeight = metadata.dp("layout_height") + play.dp("layout_marginTop") + 48 + + play.dp("layout_marginBottom") + 48 + assertTrue(contentHeight + 12 <= 156) + val contentWidth = 110 - column.dp("layout_marginStart") - column.dp("layout_marginEnd") + assertTrue(contentWidth >= 96) + assertEquals(WidgetLayoutPolicy.compactTextWidth(WidgetSize(110, 156), true), contentWidth) + } + } + } + + @Test + fun compactSquareWrapperUsesHostWidthWithoutDependingOnVisibleAlbumArt() { + val root = resource("layout/widget_compact_square.xml") + assertEquals("RelativeLayout", root.tagName) + assertEquals("match_parent", root.android("layout_width")) + assertEquals("match_parent", root.android("layout_height")) + assertTrue(root.android("background").isBlank()) + + val bounds = root.getElementsByTagName("ImageView").item(0) as Element + assertEquals("@+id/compact_square_bounds", bounds.android("id")) + assertEquals("match_parent", bounds.android("layout_width")) + assertEquals("wrap_content", bounds.android("layout_height")) + assertEquals("true", bounds.android("adjustViewBounds")) + assertEquals("invisible", bounds.android("visibility")) + assertEquals("no", bounds.android("importantForAccessibility")) + assertEquals("@drawable/widget_compact_square_bounds", bounds.android("src")) + + val content = root.getElementsByTagName("FrameLayout").item(0) as Element + assertEquals("@+id/compact_square_content", content.android("id")) + assertEquals("match_parent", content.android("layout_width")) + assertEquals("wrap_content", content.android("layout_height")) + assertEquals("@id/compact_square_bounds", content.android("layout_alignTop")) + assertEquals("@id/compact_square_bounds", content.android("layout_alignBottom")) + + val drawable = resource("drawable/widget_compact_square_bounds.xml") + val size = drawable.getElementsByTagName("size").item(0) as Element + assertEquals("1dp", size.android("width")) + assertEquals(size.android("width"), size.android("height")) + val solid = drawable.getElementsByTagName("solid").item(0) as Element + assertEquals("@android:color/transparent", solid.android("color")) + } + + @Test + fun flagshipVariantsReserveTheCorrectControlGridWidth() { + for ((name, width) in listOf("widget_flagship_art" to 104, + "widget_flagship_art_wide" to 144, "widget_flagship_art_large" to 184)) { + val includes = resource("layout/$name.xml").getElementsByTagName("include") + val grid = (0 until includes.length).map { includes.item(it) as Element } + .first { it.getAttribute("layout") == "@layout/widget_flagship_controls" } + assertEquals("${width}dp", grid.android("layout_width")) + } + assertEquals(4, resource("layout/widget_flagship_controls.xml").getElementsByTagName("ImageButton").length) + } + + @Test + fun everyTransportButtonHasAnAccessibleLabelAnd48DpTarget() { + val styles = resource("values/widget_styles.xml").getElementsByTagName("style") + val transportStyle = (0 until styles.length).map { styles.item(it) as Element } + .first { it.getAttribute("name") == "WidgetTransportButton" } + val items = transportStyle.getElementsByTagName("item") + val dimensions = (0 until items.length).map { items.item(it) as Element } + .associate { it.getAttribute("name") to it.textContent } + assertEquals("48dp", dimensions["android:layout_width"]) + assertEquals("48dp", dimensions["android:layout_height"]) + + var count = 0 + for (name in listOf("widget_mini_player", "widget_compact", "widget_compact_short", "widget_compact_narrow", + "widget_flagship_panel", "widget_flagship_controls")) { + val buttons = resource("layout/$name.xml").getElementsByTagName("ImageButton") + for (index in 0 until buttons.length) { + val button = buttons.item(index) as Element + assertTrue(button.android("contentDescription").startsWith("@string/widget_")) + assertTrue(button.getAttribute("style") in listOf("@style/WidgetTransportButton", + "@style/WidgetCompactPlayButton", "@style/WidgetTileButton")) + for (dimension in listOf("layout_width", "layout_height")) { + val value = button.android(dimension).ifBlank { "48dp" } + if (value == "0dp") assertEquals("1", button.android("layout_weight")) + else if (value != "match_parent") assertTrue(value.removeSuffix("dp").toInt() >= 48) + } + count++ + } + } + assertEquals(16, count) + } +} diff --git a/lib/app/app.dart b/lib/app/app.dart index d5e57756..8e5d5af1 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -192,6 +192,18 @@ class _MainShellState extends ConsumerState _widgetIntentHandler = WidgetIntentHandler( ref, onOpenQueue: () => NavigationHelper.navigateToQueue(context), + onOpenRecap: () { + if (!mounted) return; + if (rootNavigatorKey.currentState != null) { + NavigationHelper.navigateToRecap(context); + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + NavigationHelper.navigateToRecap(context); + }); + WidgetsBinding.instance.scheduleFrame(); + }, ); unawaited(_widgetIntentHandler.attach()); @@ -529,6 +541,7 @@ class _MainShellState extends ConsumerState _currentSongSubscription.close(); _navigationIndexSubscription.close(); _widgetSyncSubscription.close(); + WidgetSyncService.instance.stopReplaySync(); _appPreferencesSubscription?.close(); unawaited(_widgetIntentHandler.detach()); _pageController.removeListener(_onPageScroll); diff --git a/lib/core/utils/navigation_helper.dart b/lib/core/utils/navigation_helper.dart index 03612911..2f2994d5 100644 --- a/lib/core/utils/navigation_helper.dart +++ b/lib/core/utils/navigation_helper.dart @@ -4,6 +4,7 @@ import 'package:flick/core/constants/app_constants.dart'; import 'package:flick/core/navigation/root_navigator.dart'; import 'package:flick/features/player/screens/full_player_screen.dart'; import 'package:flick/features/queue/screens/queue_screen.dart'; +import 'package:flick/features/recap/screens/listening_recap_screen.dart'; /// Helper class to prevent duplicate navigation to FullPlayerScreen class NavigationHelper { @@ -152,6 +153,38 @@ class NavigationHelper { ); } + /// Open today's listening recap, the destination of the Flick Replay widget. + static Future navigateToRecap(BuildContext context) { + final navigator = rootNavigatorKey.currentState != null + ? rootNavigatorKey.currentState! + : Navigator.of(context); + return navigator.push( + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => + const ListeningRecapScreen(), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + if (AppConstants.animationNormal == Duration.zero) { + return child; + } + + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ); + + return SlideTransition( + position: Tween( + begin: const Offset(0.12, 0.0), + end: Offset.zero, + ).animate(curvedAnimation), + child: FadeTransition(opacity: curvedAnimation, child: child), + ); + }, + transitionDuration: AppConstants.animationNormal, + ), + ); + } + /// Push on the root navigator so the persistent bottom bar is hidden. static Future pushOnRoot(BuildContext context, Route route) { final navigator = rootNavigatorKey.currentState; diff --git a/lib/features/settings/screens/widget_settings_screen.dart b/lib/features/settings/screens/widget_settings_screen.dart index 6d719aa5..1972ee24 100644 --- a/lib/features/settings/screens/widget_settings_screen.dart +++ b/lib/features/settings/screens/widget_settings_screen.dart @@ -19,7 +19,7 @@ class WidgetSettingsScreen extends ConsumerStatefulWidget { } class _WidgetSettingsScreenState extends ConsumerState { - static const _tabCount = 3; + static const _tabCount = 4; int _tab = 0; int _dir = 1; @@ -80,7 +80,9 @@ class _WidgetSettingsScreenState extends ConsumerState { ? const _MiniPlayerTab() : _tab == 1 ? const _FlagshipTab() - : const _CompactTab(), + : _tab == 2 + ? const _CompactTab() + : const _ReplayTab(), ), ), ), @@ -102,7 +104,7 @@ class _TabBar extends StatelessWidget { @override Widget build(BuildContext context) { - final tabs = [l10n.miniPlayer, '4×3 Flagship', '2×2']; + final tabs = [l10n.miniPlayer, '4×3 Flagship', '2×2', 'Replay']; return Row( children: List.generate(tabs.length * 2 - 1, (i) { if (i.isOdd) { @@ -655,6 +657,149 @@ class _CompactTab extends ConsumerWidget { } } +class _ReplayTab extends ConsumerWidget { + const _ReplayTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final prefs = ref.watch(appPreferencesProvider); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsSectionHeader('Background'), + SettingsCard( + children: [ + _ReplayLookOption( + label: 'Acid', + subtitle: 'Neon lime with signal-orange Replay accents', + icon: LucideIcons.sparkles, + value: 'acid', + groupValue: prefs.widgetReplayLook, + onChanged: (v) => _updateReplayLook(ref, v), + ), + const SettingsDivider(), + _ReplayLookOption( + label: 'Night', + subtitle: 'Ink card with the Replay signature accents', + icon: LucideIcons.moon, + value: 'night', + groupValue: prefs.widgetReplayLook, + onChanged: (v) => _updateReplayLook(ref, v), + ), + const SettingsDivider(), + _ReplayLookOption( + label: 'Signal', + subtitle: 'Signal orange surface from the Replay cover', + icon: LucideIcons.zap, + value: 'signal', + groupValue: prefs.widgetReplayLook, + onChanged: (v) => _updateReplayLook(ref, v), + ), + const SettingsDivider(), + _ReplayLookOption( + label: 'Album', + subtitle: "Tinted from today's top cover", + icon: LucideIcons.image, + value: 'artwork', + groupValue: prefs.widgetReplayLook, + onChanged: (v) => _updateReplayLook(ref, v), + ), + ], + ), + ], + ); + } + + void _updateReplayLook(WidgetRef ref, String value) { + ref.read(appPreferencesProvider.notifier).setWidgetReplayLook(value); + final prefs = ref.read(appPreferencesProvider); + WidgetSyncService.instance.pushCustomization( + prefs.copyWith(widgetReplayLook: value), + ); + } +} + +class _ReplayLookOption extends StatelessWidget { + const _ReplayLookOption({ + required this.label, + required this.subtitle, + required this.icon, + required this.value, + required this.groupValue, + required this.onChanged, + }); + + final String label; + final String subtitle; + final IconData icon; + final String value; + final String groupValue; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final selected = value == groupValue; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onChanged(value), + child: Padding( + padding: const EdgeInsets.all(AppConstants.spacingLg), + child: Row( + children: [ + Container( + width: AppConstants.containerSizeMd, + height: AppConstants.containerSizeMd, + decoration: BoxDecoration( + color: AppColors.glassBackgroundStrong, + borderRadius: BorderRadius.circular(AppConstants.radiusSm), + ), + child: Icon( + icon, + color: selected + ? context.adaptiveTextPrimary + : context.adaptiveTextTertiary, + size: AppConstants.iconSizeLg, + ), + ), + const SizedBox(width: AppConstants.spacingMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: context.adaptiveTextPrimary, + ), + ), + const SizedBox(height: AppConstants.spacingXs), + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: context.adaptiveTextSecondary, + ), + ), + ], + ), + ), + Icon( + selected + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked, + color: selected + ? context.adaptiveTextPrimary + : context.adaptiveTextTertiary, + size: 20, + ), + ], + ), + ), + ), + ); + } +} + class _OpacityOption extends StatelessWidget { const _OpacityOption({ required this.label, @@ -956,4 +1101,4 @@ class _AccentOption extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/providers/app_preferences_provider.dart b/lib/providers/app_preferences_provider.dart index 67fd7f06..76119904 100644 --- a/lib/providers/app_preferences_provider.dart +++ b/lib/providers/app_preferences_provider.dart @@ -447,6 +447,12 @@ class AppPreferencesNotifier extends Notifier { .setWidgetCompactTextScale(value); } + Future setWidgetReplayLook(String value) async { + if (state.widgetReplayLook == value) return; + state = state.copyWith(widgetReplayLook: value); + await ref.read(appPreferencesServiceProvider).setWidgetReplayLook(value); + } + Future setLyricsMatchAudioFilename(bool value) async { if (state.lyricsMatchAudioFilename == value) return; state = state.copyWith(lyricsMatchAudioFilename: value); diff --git a/lib/services/app_preferences_service.dart b/lib/services/app_preferences_service.dart index 79f70e86..66bea431 100644 --- a/lib/services/app_preferences_service.dart +++ b/lib/services/app_preferences_service.dart @@ -55,6 +55,7 @@ class AppPreferences { final double widgetTextScale; final double widgetFlagshipTextScale; final double widgetCompactTextScale; + final String widgetReplayLook; final bool lyricsMatchAudioFilename; final String leftActionButton; final String rightActionButton; @@ -173,6 +174,7 @@ class AppPreferences { this.widgetTextScale = 1.0, this.widgetFlagshipTextScale = 1.0, this.widgetCompactTextScale = 1.0, + this.widgetReplayLook = 'acid', this.lyricsMatchAudioFilename = false, this.leftActionButton = 'lyrics', this.rightActionButton = 'favorites', @@ -292,6 +294,7 @@ class AppPreferences { double? widgetTextScale, double? widgetFlagshipTextScale, double? widgetCompactTextScale, + String? widgetReplayLook, bool? lyricsMatchAudioFilename, String? leftActionButton, String? rightActionButton, @@ -433,6 +436,7 @@ class AppPreferences { widgetFlagshipTextScale ?? this.widgetFlagshipTextScale, widgetCompactTextScale: widgetCompactTextScale ?? this.widgetCompactTextScale, + widgetReplayLook: widgetReplayLook ?? this.widgetReplayLook, lyricsMatchAudioFilename: lyricsMatchAudioFilename ?? this.lyricsMatchAudioFilename, leftActionButton: leftActionButton ?? this.leftActionButton, @@ -584,6 +588,7 @@ class AppPreferencesService { static const _widgetTextScaleKey = 'widget_text_scale'; static const _widgetFlagshipTextScaleKey = 'widget_flagship_text_scale'; static const _widgetCompactTextScaleKey = 'widget_compact_text_scale'; + static const _widgetReplayLookKey = 'widget_replay_look'; static const _lyricsMatchAudioFilenameKey = 'lyrics_match_audio_filename'; static const _leftActionButtonKey = 'left_action_button'; static const _rightActionButtonKey = 'right_action_button'; @@ -742,6 +747,7 @@ class AppPreferencesService { prefs.getDouble(_widgetFlagshipTextScaleKey) ?? 1.0, widgetCompactTextScale: prefs.getDouble(_widgetCompactTextScaleKey) ?? 1.0, + widgetReplayLook: prefs.getString(_widgetReplayLookKey) ?? 'acid', lyricsMatchAudioFilename: prefs.getBool(_lyricsMatchAudioFilenameKey) ?? false, leftActionButton: prefs.getString(_leftActionButtonKey) ?? 'lyrics', @@ -1361,6 +1367,16 @@ class AppPreferencesService { await prefs.setDouble(_widgetCompactTextScaleKey, value); } + Future getWidgetReplayLook() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_widgetReplayLookKey) ?? 'acid'; + } + + Future setWidgetReplayLook(String value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_widgetReplayLookKey, value); + } + Future getLyricsMatchAudioFilename() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool(_lyricsMatchAudioFilenameKey) ?? false; diff --git a/lib/services/widget_intent_handler.dart b/lib/services/widget_intent_handler.dart index 0ab74000..16ab8fa6 100644 --- a/lib/services/widget_intent_handler.dart +++ b/lib/services/widget_intent_handler.dart @@ -9,46 +9,88 @@ import '../providers/player_provider.dart'; import 'package:flick/core/utils/dev_log.dart'; class WidgetIntentHandler { - WidgetIntentHandler(this._ref, {VoidCallback? onOpenQueue}) - : _onOpenQueue = onOpenQueue; + WidgetIntentHandler( + this._ref, { + VoidCallback? onOpenQueue, + VoidCallback? onOpenRecap, + }) : _onOpenQueue = onOpenQueue, + _onOpenRecap = onOpenRecap; final WidgetRef _ref; final VoidCallback? _onOpenQueue; + final VoidCallback? _onOpenRecap; MethodChannel? _channel; + int _generation = 0; Future attach() async { await detach(); + final generation = ++_generation; _channel = const MethodChannel('com.mossapps.flick/widget'); _channel!.setMethodCallHandler((call) async { + if (generation != _generation) return; if (call.method == 'dispatch') { final uriStr = call.arguments as String?; if (uriStr != null) { final uri = Uri.tryParse(uriStr); if (uri != null) _dispatch(uri); } + } else if (call.method == 'channelReady') { + await _drainInitialWidgetUri(generation); } }); + if (await _drainInitialWidgetUri(generation)) return; + if (generation != _generation) return; + // Compatibility for launch intents created by the home_widget plugin. + // Flick's custom WIDGET_LAUNCH action is drained by the native handshake. final launchUri = await HomeWidget.initiallyLaunchedFromHomeWidget(); - if (launchUri != null) { + if (generation == _generation && launchUri != null) { _dispatch(launchUri); } } + Future _drainInitialWidgetUri(int generation) async { + try { + final raw = await _channel?.invokeMethod('widgetHandlerReady'); + if (generation != _generation || raw == null) return false; + final uri = Uri.tryParse(raw); + if (uri == null) return false; + _dispatch(uri); + return true; + } on MissingPluginException { + return false; + } on PlatformException catch (e) { + devLog('WidgetIntentHandler: launch handshake failed: $e'); + return false; + } + } + Future detach() async { - _channel?.setMethodCallHandler(null); + ++_generation; + final channel = _channel; _channel = null; + channel?.setMethodCallHandler(null); + if (channel == null) return; + try { + await channel.invokeMethod('widgetHandlerDetached'); + } on MissingPluginException { + // The Activity/channel can already be gone during app disposal. + } on PlatformException catch (e) { + devLog('WidgetIntentHandler: detach failed: $e'); + } } void _dispatch(Uri? uri) { - if (uri == null) return; + if (uri == null || uri.scheme != 'flickwidget') return; try { final segments = uri.pathSegments; if (uri.host == 'player') { _handlePlayer(segments.isEmpty ? '' : segments.first); - } else if (uri.host == 'queue') { + } else if (uri.host == 'queue' && uri.path == '/open') { _onOpenQueue?.call(); + } else if (uri.host == 'recap' && uri.path == '/open') { + _onOpenRecap?.call(); } else { devLog('WidgetIntentHandler: unknown host ${uri.host}'); } @@ -58,6 +100,7 @@ class WidgetIntentHandler { } void _handlePlayer(String action) { + if (action == 'open' || action.isEmpty) return; final notifier = _ref.read(playerProvider.notifier); switch (action) { case 'play_pause': @@ -84,6 +127,7 @@ class WidgetIntentHandler { static const _provider = 'com.mossapps.flick.widgets.MiniPlayerWidgetProvider'; static const _flagshipProvider = 'com.mossapps.flick.widgets.FlagshipWidgetProvider'; static const _compactProvider = 'com.mossapps.flick.widgets.CompactWidgetProvider'; + static const _replayProvider = 'com.mossapps.flick.widgets.ReplayWidgetProvider'; Future _pushWidgetState() async { try { @@ -102,6 +146,7 @@ class WidgetIntentHandler { await HomeWidget.updateWidget(qualifiedAndroidName: _provider); await HomeWidget.updateWidget(qualifiedAndroidName: _flagshipProvider); await HomeWidget.updateWidget(qualifiedAndroidName: _compactProvider); + await HomeWidget.updateWidget(qualifiedAndroidName: _replayProvider); } catch (e) { devLog('WidgetIntentHandler: failed to push widget state: $e'); } diff --git a/lib/services/widget_replay_snapshot.dart b/lib/services/widget_replay_snapshot.dart new file mode 100644 index 00000000..682e0714 --- /dev/null +++ b/lib/services/widget_replay_snapshot.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +/// Value helpers for the Flick Replay widget sync path, independent of the +/// library database. +class WidgetReplaySnapshot { + WidgetReplaySnapshot._(); + + /// Stored delta value that marks the previous day as not comparable. + static const int unknownDelta = -1000; + + /// Native widgets can only load files, not remote/SAF URIs or directories. + static String localCover(String? raw) { + if (raw == null || raw.isEmpty) return ''; + try { + final uri = Uri.parse(raw); + if (uri.hasScheme && uri.scheme != 'file') return ''; + final file = uri.scheme == 'file' ? File.fromUri(uri) : File(raw); + return FileSystemEntity.typeSync(file.path) == FileSystemEntityType.file + ? file.path + : ''; + } on FormatException { + return ''; + } on FileSystemException { + return ''; + } + } + + /// Local-day key in the `yyyy-MM-dd` shape the native widget compares + /// against its own date to detect a stale snapshot. + static String dayKey(DateTime date) { + final month = date.month.toString().padLeft(2, '0'); + final day = date.day.toString().padLeft(2, '0'); + return '${date.year}-$month-$day'; + } + + /// Percent change clamped to ±999, or [unknownDelta] when either side is + /// zero and a percentage would be meaningless. + static int deltaPercent({required int current, required int previous}) { + if (previous <= 0 || current <= 0) return unknownDelta; + final percent = ((current - previous) / previous * 100).round(); + return percent.clamp(-999, 999); + } +} diff --git a/lib/services/widget_sync_service.dart b/lib/services/widget_sync_service.dart index c94f1ec5..f34c4bce 100644 --- a/lib/services/widget_sync_service.dart +++ b/lib/services/widget_sync_service.dart @@ -1,23 +1,35 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:home_widget/home_widget.dart'; +import '../data/repositories/recently_played_repository.dart'; import '../models/song.dart'; import '../providers/player_provider.dart'; import '../services/app_preferences_service.dart'; +import 'widget_replay_snapshot.dart'; import 'package:flick/core/utils/dev_log.dart'; class WidgetSyncService { - WidgetSyncService._(); - static final WidgetSyncService instance = WidgetSyncService._(); + WidgetSyncService({ + RecentlyPlayedRepository Function()? replayRepository, + DateTime Function()? clock, + }) : _replayRepositoryFactory = replayRepository ?? RecentlyPlayedRepository.new, + _clock = clock ?? DateTime.now; + static final WidgetSyncService instance = WidgetSyncService(); + + final RecentlyPlayedRepository Function() _replayRepositoryFactory; + final DateTime Function() _clock; + RecentlyPlayedRepository? _replayRepository; static const String _appGroup = 'group.com.mossapps.flick.widgets'; static const String miniPlayerProvider = 'com.mossapps.flick.widgets.MiniPlayerWidgetProvider'; static const String flagshipProvider = 'com.mossapps.flick.widgets.FlagshipWidgetProvider'; static const String compactProvider = 'com.mossapps.flick.widgets.CompactWidgetProvider'; + static const String replayProvider = 'com.mossapps.flick.widgets.ReplayWidgetProvider'; static const String keyFlagshipAccent = 'flick_widget_flagship_accent'; static const String keyFlagshipShowArtist = 'flick_widget_flagship_show_artist'; @@ -45,6 +57,22 @@ class WidgetSyncService { static const String keyIsShuffle = 'flick_widget_is_shuffle'; static const String keyLoopMode = 'flick_widget_loop_mode'; static const String keyQueueCount = 'flick_widget_queue_count'; + static const String keyNext1Id = 'flick_widget_next_1_id'; + static const String keyNext1Title = 'flick_widget_next_1_title'; + static const String keyNext1Art = 'flick_widget_next_1_art'; + static const String keyNext2Id = 'flick_widget_next_2_id'; + static const String keyNext2Title = 'flick_widget_next_2_title'; + static const String keyNext2Art = 'flick_widget_next_2_art'; + + static const String keyReplayLook = 'flick_widget_replay_look'; + static const String keyReplayDay = 'flick_widget_replay_day'; + static const String keyReplayTimeMs = 'flick_widget_replay_time_ms'; + static const String keyReplayPlays = 'flick_widget_replay_plays'; + static const String keyReplayTimeDelta = 'flick_widget_replay_time_delta'; + static const String keyReplayPlaysDelta = 'flick_widget_replay_plays_delta'; + static const String keyReplayCover1 = 'flick_widget_replay_cover_1'; + static const String keyReplayCover2 = 'flick_widget_replay_cover_2'; + static const String keyReplayCover3 = 'flick_widget_replay_cover_3'; bool _initialized = false; Timer? _debounce; @@ -52,6 +80,81 @@ class WidgetSyncService { bool? _lastPushedIsPlaying; bool? _lastPushedIsShuffle; int? _lastPushedLoopMode; + List _lastPushedUpcoming = const []; + List _lastPreviewData = const []; + bool _hasPushedReplay = false; + String? _replayDayAtPush; + List _lastReplayData = const []; + StreamSubscription? _replayHistorySubscription; + Timer? _replayDebounce; + Timer? _replayDayTimer; + bool _replayWatching = false; + Future? _replayRefresh; + bool _replayRefreshAgain = false; + + /// History is recorded after the listening threshold, not at song selection. + /// Observe the committed history so repeat plays and clearing it also refresh. + void startReplaySync() { + if (_replayWatching) return; + _replayWatching = true; + _watchReplayHistory(); + _scheduleReplayDayRefresh(); + } + + void _watchReplayHistory() { + if (!_replayWatching || _replayHistorySubscription != null) return; + try { + _replayRepository ??= _replayRepositoryFactory(); + _replayHistorySubscription = _replayRepository!.watchHistory().listen( + (_) { + _replayDebounce?.cancel(); + _replayDebounce = Timer(const Duration(milliseconds: 200), () { + unawaited(refreshReplay()); + }); + }, + onError: (Object error) { + devLog('WidgetSyncService replay history watch failed: $error'); + }, + ); + } catch (e) { + // Startup can precede the database; the next successful push retries. + devLog('WidgetSyncService replay history watch unavailable: $e'); + } + } + + void _scheduleReplayDayRefresh() { + _replayDayTimer?.cancel(); + if (!_replayWatching) return; + final now = _clock(); + final midnight = DateTime(now.year, now.month, now.day + 1); + _replayDayTimer = Timer(midnight.difference(now), () { + unawaited(refreshReplay()); + _scheduleReplayDayRefresh(); + }); + } + + void stopReplaySync() { + _replayWatching = false; + _replayDebounce?.cancel(); + _replayDayTimer?.cancel(); + _debounce?.cancel(); + final subscription = _replayHistorySubscription; + _replayHistorySubscription = null; + if (subscription != null) unawaited(subscription.cancel()); + } + + /// Refresh only Replay for history/day changes; ordinary progress pushes + /// don't query the library or republish an unchanged statistics snapshot. + Future refreshReplay() async { + try { + await _ensureInit(); + if (await _pushReplaySnapshot(_clock())) { + await HomeWidget.updateWidget(qualifiedAndroidName: replayProvider); + } + } catch (e, st) { + devLog('WidgetSyncService replay refresh failed: $e\n$st'); + } + } Future _ensureInit() async { if (_initialized) return; @@ -63,6 +166,7 @@ class WidgetSyncService { await HomeWidget.updateWidget(qualifiedAndroidName: miniPlayerProvider); await HomeWidget.updateWidget(qualifiedAndroidName: flagshipProvider); await HomeWidget.updateWidget(qualifiedAndroidName: compactProvider); + await HomeWidget.updateWidget(qualifiedAndroidName: replayProvider); } void schedulePush(PlayerState state) { @@ -70,8 +174,16 @@ class WidgetSyncService { final playingChanged = state.isPlaying != _lastPushedIsPlaying; final shuffleChanged = state.isShuffle != _lastPushedIsShuffle; final loopChanged = state.loopMode.index != _lastPushedLoopMode; + final upcomingChanged = !listEquals( + _upcomingSnapshot(state), + _lastPushedUpcoming, + ); - if (songChanged || playingChanged || shuffleChanged || loopChanged) { + if (songChanged || + playingChanged || + shuffleChanged || + loopChanged || + upcomingChanged) { _debounce?.cancel(); unawaited(_push(state)); } else if (_debounce?.isActive != true) { @@ -111,6 +223,33 @@ class WidgetSyncService { keyQueueCount, state.upNext.length, ); + final upcoming = _upcomingSnapshot(state); + final previewData = _upcomingSnapshot(state, resolveArt: true); + if (!listEquals(previewData, _lastPreviewData)) { + const keys = [ + keyNext1Id, + keyNext1Title, + keyNext1Art, + keyNext2Id, + keyNext2Title, + keyNext2Art, + ]; + for (var index = 0; index < keys.length; index++) { + await HomeWidget.saveWidgetData( + keys[index], + previewData[index], + ); + } + } + + final now = _clock(); + final replayDue = + !_hasPushedReplay || + song?.id != _lastPushedSongId || + _replayDayAtPush != WidgetReplaySnapshot.dayKey(now); + if (replayDue) { + await _pushReplaySnapshot(now); + } await _updateAll(); @@ -118,15 +257,122 @@ class WidgetSyncService { _lastPushedIsPlaying = state.isPlaying; _lastPushedIsShuffle = state.isShuffle; _lastPushedLoopMode = state.loopMode.index; + _lastPushedUpcoming = upcoming; + _lastPreviewData = previewData; } catch (e, st) { devLog('WidgetSyncService push failed: $e\n$st'); } } + /// Refresh the Flick Replay statistics snapshot. Skips writing and keeps + /// the retry flag set when the database is unavailable so the next push + /// tries again instead of publishing misleading zeros. + Future _pushReplaySnapshot(DateTime now) async { + final active = _replayRefresh; + if (active != null) { + _replayRefreshAgain = true; + return active; + } + final work = _refreshReplayUntilCurrent(now); + _replayRefresh = work; + try { + return await work; + } finally { + _replayRefresh = null; + } + } + + Future _refreshReplayUntilCurrent(DateTime now) async { + var changed = false; + do { + _replayRefreshAgain = false; + changed = await _writeReplaySnapshot(now) || changed; + now = _clock(); + } while (_replayRefreshAgain); + return changed; + } + + Future _writeReplaySnapshot(DateTime now) async { + final dayKey = WidgetReplaySnapshot.dayKey(now); + var timeMs = 0; + var plays = 0; + var timeDelta = WidgetReplaySnapshot.unknownDelta; + var playsDelta = WidgetReplaySnapshot.unknownDelta; + final covers = ['', '', '']; + try { + final repository = _replayRepository ??= _replayRepositoryFactory(); + _watchReplayHistory(); + final today = await repository.getListeningRecap( + ListeningRecapPeriod.daily, + now: now, + ); + final yesterday = await repository.getListeningRecap( + ListeningRecapPeriod.daily, + // Calendar days, not 24 hours: DST can make yesterday shorter/longer. + now: DateTime(now.year, now.month, now.day - 1), + ); + timeMs = today.totalListeningTime.inMilliseconds; + plays = today.totalPlays; + timeDelta = WidgetReplaySnapshot.deltaPercent( + current: timeMs, + previous: yesterday.totalListeningTime.inMilliseconds, + ); + playsDelta = WidgetReplaySnapshot.deltaPercent( + current: plays, + previous: yesterday.totalPlays, + ); + final albums = today.topAlbums.take(3).toList(); + for (var index = 0; index < covers.length; index++) { + if (index < albums.length) { + covers[index] = WidgetReplaySnapshot.localCover( + albums[index].representativeSong.albumArt, + ); + } + } + } catch (e, st) { + devLog('WidgetSyncService replay snapshot failed: $e\n$st'); + return false; + } + + final snapshot = [ + dayKey, + timeMs, + plays, + timeDelta, + playsDelta, + covers[0], + covers[1], + covers[2], + ]; + final changed = !listEquals(snapshot, _lastReplayData); + if (changed) { + const coverKeys = [keyReplayCover1, keyReplayCover2, keyReplayCover3]; + // Invalidate the old snapshot before writing, and publish its day last. + // A native refresh mid-write must not mix yesterday's covers with today. + await HomeWidget.saveWidgetData(keyReplayDay, ''); + await HomeWidget.saveWidgetData(keyReplayTimeMs, timeMs); + await HomeWidget.saveWidgetData(keyReplayPlays, plays); + await HomeWidget.saveWidgetData(keyReplayTimeDelta, timeDelta); + await HomeWidget.saveWidgetData(keyReplayPlaysDelta, playsDelta); + for (var index = 0; index < coverKeys.length; index++) { + await HomeWidget.saveWidgetData( + coverKeys[index], + covers[index], + ); + } + await HomeWidget.saveWidgetData(keyReplayDay, dayKey); + } + _lastReplayData = snapshot; + _replayDayAtPush = dayKey; + _hasPushedReplay = true; + return changed; + } + Future pushPaused() async { try { await _ensureInit(); await HomeWidget.saveWidgetData(keyIsPlaying, false); + await _pushReplaySnapshot(_clock()); await _updateAll(); } catch (e, st) { devLog('WidgetSyncService pushPaused failed: $e\n$st'); @@ -137,6 +383,7 @@ class WidgetSyncService { try { await _ensureInit(); await HomeWidget.saveWidgetData(keyIsPlaying, false); + await _pushReplaySnapshot(_clock()); await _updateAll(); } catch (e, st) { devLog('WidgetSyncService pushKilled failed: $e\n$st'); @@ -200,6 +447,12 @@ class WidgetSyncService { prefs.widgetCompactTextScale, ); + // Replay widget customization + await HomeWidget.saveWidgetData( + keyReplayLook, + prefs.widgetReplayLook, + ); + await _updateAll(); } catch (e, st) { devLog('WidgetSyncService pushCustomization failed: $e\n$st'); @@ -244,6 +497,22 @@ class WidgetSyncService { } } + List _upcomingSnapshot(PlayerState state, {bool resolveArt = false}) { + final songs = state.currentSong == null ? const [] : state.upNext; + return [ + for (var index = 0; index < 2; index++) ...[ + if (index < songs.length) songs[index].id else '', + if (index < songs.length) songs[index].title else '', + if (index < songs.length) + resolveArt + ? _resolveLocalArt(songs[index].albumArt) + : songs[index].albumArt ?? '' + else + '', + ], + ]; + } + String _resolveLocalArt(String? raw) { if (raw == null || raw.isEmpty) return ''; if (raw.startsWith('http://') || raw.startsWith('https://')) return ''; @@ -254,6 +523,7 @@ class WidgetSyncService { } ProviderSubscription installWidgetSync(WidgetRef ref) { + WidgetSyncService.instance.startReplaySync(); unawaited(WidgetSyncService.instance.pushInitialCustomization()); return ref.listenManual( playerProvider, diff --git a/test/providers/app_preferences_provider_test.dart b/test/providers/app_preferences_provider_test.dart index fb3d6245..f5d33c38 100644 --- a/test/providers/app_preferences_provider_test.dart +++ b/test/providers/app_preferences_provider_test.dart @@ -9,6 +9,44 @@ void main() { setUp(() => SharedPreferences.setMockInitialValues({})); + test('Replay widgets default to Acid when no look was chosen', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + expect(container.read(appPreferencesProvider).widgetReplayLook, 'acid'); + await pumpEventQueue(); + expect(container.read(appPreferencesProvider).widgetReplayLook, 'acid'); + expect( + await container.read(appPreferencesServiceProvider).getWidgetReplayLook(), + 'acid', + ); + }); + + for (final look in ['night', 'signal', 'artwork', 'acid']) { + test('Replay widgets preserve a saved $look selection', () async { + SharedPreferences.setMockInitialValues({'widget_replay_look': look}); + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(appPreferencesProvider); + await pumpEventQueue(); + expect(container.read(appPreferencesProvider).widgetReplayLook, look); + }); + } + + test('Replay Acid selection persists after changing from Night', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(appPreferencesProvider); + await pumpEventQueue(); + final notifier = container.read(appPreferencesProvider.notifier); + await notifier.setWidgetReplayLook('night'); + await notifier.setWidgetReplayLook('acid'); + final restored = ProviderContainer(); + addTearDown(restored.dispose); + restored.read(appPreferencesProvider); + await pumpEventQueue(); + expect(restored.read(appPreferencesProvider).widgetReplayLook, 'acid'); + }); + test( 'visualizer colors default to album art on existing installations', () async { diff --git a/test/services/widget_intent_handler_test.dart b/test/services/widget_intent_handler_test.dart new file mode 100644 index 00000000..97912a6b --- /dev/null +++ b/test/services/widget_intent_handler_test.dart @@ -0,0 +1,137 @@ +import 'dart:async'; + +import 'package:flick/services/widget_intent_handler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.mossapps.flick/widget'); + const homeChannel = MethodChannel('home_widget'); + late WidgetIntentHandler handler; + late int recapOpens; + late int queueOpens; + late int pluginLaunchReads; + String? pendingUri; + Completer? handshake; + + Future mount(WidgetTester tester) async { + late WidgetRef widgetRef; + await tester.pumpWidget( + ProviderScope( + child: Consumer( + builder: (_, ref, _) { + widgetRef = ref; + return const SizedBox(); + }, + ), + ), + ); + handler = WidgetIntentHandler( + widgetRef, + onOpenRecap: () => recapOpens++, + onOpenQueue: () => queueOpens++, + ); + addTearDown(handler.detach); + } + + Future dispatch( + WidgetTester tester, + String method, [ + String? uri, + ]) async { + final reply = Completer(); + tester.binding.channelBuffers.push( + channel.name, + const StandardMethodCodec().encodeMethodCall(MethodCall(method, uri)), + (_) => reply.complete(), + ); + await reply.future; + } + + setUp(() { + recapOpens = queueOpens = pluginLaunchReads = 0; + pendingUri = null; + handshake = null; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'widgetHandlerDetached') return null; + if (call.method == 'widgetHandlerReady') { + if (handshake != null) return handshake!.future; + final uri = pendingUri; + pendingUri = null; + return uri; + } + fail('Unexpected native widget call ${call.method}'); + }); + messenger.setMockMethodCallHandler(homeChannel, (call) async { + expect(call.method, 'initiallyLaunchedFromHomeWidget'); + pluginLaunchReads++; + return null; + }); + }); + + tearDown(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, null); + messenger.setMockMethodCallHandler(homeChannel, null); + }); + + testWidgets( + 'cold-start Replay taps drain once without relying on the plugin action', + (tester) async { + await mount(tester); + pendingUri = 'flickwidget://recap/open'; + await handler.attach(); + expect(recapOpens, 1); + expect(pluginLaunchReads, 0); + await dispatch(tester, 'channelReady'); + expect(recapOpens, 1); + }, + ); + + testWidgets( + 'warm clicks preserve Queue and Replay routing and reject unrelated URIs', + (tester) async { + await mount(tester); + await handler.attach(); + await dispatch(tester, 'dispatch', 'flickwidget://recap/open'); + await dispatch(tester, 'dispatch', 'flickwidget://queue/open'); + await dispatch(tester, 'dispatch', 'flickwidget://player/open'); + await dispatch(tester, 'dispatch', 'https://recap/open'); + await dispatch(tester, 'dispatch', 'flickwidget://recap/not-open'); + expect(recapOpens, 1); + expect(queueOpens, 1); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('cached engine reattachment drains the next tap only once', ( + tester, + ) async { + await mount(tester); + await handler.attach(); + pendingUri = 'flickwidget://recap/open'; + await dispatch(tester, 'channelReady'); + await dispatch(tester, 'channelReady'); + expect(recapOpens, 1); + }); + + testWidgets('detaching during a pending handshake prevents late navigation', ( + tester, + ) async { + await mount(tester); + handshake = Completer(); + final attaching = handler.attach(); + await tester.pump(); + await handler.detach(); + handshake!.complete('flickwidget://recap/open'); + await attaching; + expect(recapOpens, 0); + expect(pluginLaunchReads, 0); + }); +} diff --git a/test/services/widget_replay_snapshot_test.dart b/test/services/widget_replay_snapshot_test.dart new file mode 100644 index 00000000..308c15d9 --- /dev/null +++ b/test/services/widget_replay_snapshot_test.dart @@ -0,0 +1,60 @@ +import 'package:flick/services/widget_replay_snapshot.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('WidgetReplaySnapshot.dayKey', () { + test('zero-pads month and day', () { + expect(WidgetReplaySnapshot.dayKey(DateTime(2026, 1, 5)), '2026-01-05'); + expect(WidgetReplaySnapshot.dayKey(DateTime(2026, 10, 14)), '2026-10-14'); + }); + + test('ignores the time of day', () { + expect( + WidgetReplaySnapshot.dayKey(DateTime(2026, 10, 14, 23, 59, 59)), + WidgetReplaySnapshot.dayKey(DateTime(2026, 10, 14)), + ); + }); + }); + + group('WidgetReplaySnapshot.deltaPercent', () { + test('reports growth and decline against yesterday', () { + expect( + WidgetReplaySnapshot.deltaPercent(current: 120, previous: 100), + 20, + ); + expect(WidgetReplaySnapshot.deltaPercent(current: 95, previous: 100), -5); + expect(WidgetReplaySnapshot.deltaPercent(current: 100, previous: 100), 0); + }); + + test('rounds to the nearest whole percent', () { + expect( + WidgetReplaySnapshot.deltaPercent(current: 101, previous: 300), + -66, + ); + expect(WidgetReplaySnapshot.deltaPercent(current: 2, previous: 3), -33); + expect(WidgetReplaySnapshot.deltaPercent(current: 4, previous: 3), 33); + }); + + test('is unknown when either day has no data', () { + expect( + WidgetReplaySnapshot.deltaPercent(current: 10, previous: 0), + WidgetReplaySnapshot.unknownDelta, + ); + expect( + WidgetReplaySnapshot.deltaPercent(current: 0, previous: 10), + WidgetReplaySnapshot.unknownDelta, + ); + expect( + WidgetReplaySnapshot.deltaPercent(current: 0, previous: 0), + WidgetReplaySnapshot.unknownDelta, + ); + }); + + test('clamps extreme growth', () { + expect( + WidgetReplaySnapshot.deltaPercent(current: 1000000, previous: 1), + 999, + ); + }); + }); +} diff --git a/test/services/widget_replay_sync_test.dart b/test/services/widget_replay_sync_test.dart new file mode 100644 index 00000000..e0a16c08 --- /dev/null +++ b/test/services/widget_replay_sync_test.dart @@ -0,0 +1,333 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flick/data/repositories/recently_played_repository.dart'; +import 'package:flick/models/song.dart'; +import 'package:flick/providers/player_provider.dart'; +import 'package:flick/services/app_preferences_service.dart'; +import 'package:flick/services/widget_replay_snapshot.dart'; +import 'package:flick/services/widget_sync_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Repository implements RecentlyPlayedRepository { + final changes = StreamController.broadcast(); + final recaps = {}; + final requests = []; + Future Function(DateTime)? load; + bool unavailable = false; + + @override + Future getListeningRecap( + ListeningRecapPeriod period, { + DateTime? now, + }) async { + final date = now!; + requests.add(date); + if (unavailable) throw StateError('Library unavailable'); + return load != null + ? load!(date) + : recaps[WidgetReplaySnapshot.dayKey(date)] ?? _recap(date); + } + + @override + Stream watchHistory() => changes.stream; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +ListeningRecap _recap( + DateTime date, { + int plays = 0, + int minutes = 0, + List covers = const [], +}) { + final period = ListeningRecapPeriod.daily; + final range = period.rangeFor(date); + final albums = [ + for (var i = 0; i < covers.length; i++) + RankedRecapAlbum( + album: 'Album $i', + artist: 'Artist $i', + plays: plays, + uniqueSongs: 1, + listeningTime: Duration(minutes: minutes), + lastPlayedAt: date, + representativeSong: Song( + id: '$i', + title: 'Song $i', + artist: 'Artist $i', + albumArt: covers[i], + duration: const Duration(minutes: 3), + fileType: 'FLAC', + ), + ), + ]; + return ListeningRecap( + period: period, + start: range.start, + endExclusive: range.endExclusive, + totalPlays: plays, + totalListeningTime: Duration(minutes: minutes), + uniqueSongs: plays, + uniqueArtists: plays, + activeDays: plays == 0 ? 0 : 1, + peakHour: null, + topSong: null, + topArtist: null, + topAlbum: albums.firstOrNull, + topSongs: const [], + topArtists: const [], + topAlbums: albums, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('home_widget'); + late _Repository repository; + late WidgetSyncService service; + late DateTime now; + late Directory directory; + late Map saved; + late List written; + late List publishedDays; + late List updates; + + String cover(String name) { + final file = File('${directory.path}/$name.png')..writeAsBytesSync([0]); + return file.path; + } + + Future flush(WidgetTester tester) async { + for (var i = 0; i < 30; i++) { + await tester.pump(); + } + } + + setUp(() { + now = DateTime(2026, 10, 14, 12); + directory = Directory.systemTemp.createTempSync('flick-replay-test-'); + repository = _Repository(); + service = WidgetSyncService( + replayRepository: () => repository, + clock: () => now, + ); + saved = {}; + written = []; + publishedDays = []; + updates = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'setAppGroupId') return true; + final args = call.arguments as Map; + if (call.method == 'saveWidgetData') { + final key = args['id'] as String; + saved[key] = args['data']; + written.add(key); + if (key == WidgetSyncService.keyReplayDay) { + publishedDays.add(args['data']); + } + return true; + } + if (call.method == 'updateWidget') { + updates.add(args['qualifiedAndroidName'] as String); + return true; + } + fail('Unexpected widget call ${call.method}'); + }); + }); + + tearDown(() async { + service.stopReplaySync(); + await repository.changes.close(); + directory.deleteSync(recursive: true); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test( + 'publishes local statistics, comparable deltas and only three local covers', + () async { + final first = cover('first'); + final second = cover('second with spaces'); + repository.recaps['2026-10-14'] = _recap( + now, + plays: 20, + minutes: 48, + covers: [ + first, + Uri.file(second).toString(), + 'https://example.com/cover', + cover('fourth'), + ], + ); + repository.recaps['2026-10-13'] = _recap( + DateTime(2026, 10, 13), + plays: 10, + minutes: 40, + ); + await service.refreshReplay(); + + expect(saved[WidgetSyncService.keyReplayTimeMs], 48 * 60000); + expect(saved[WidgetSyncService.keyReplayPlays], 20); + expect(saved[WidgetSyncService.keyReplayTimeDelta], 20); + expect(saved[WidgetSyncService.keyReplayPlaysDelta], 100); + expect(saved[WidgetSyncService.keyReplayCover1], first); + expect(saved[WidgetSyncService.keyReplayCover2], second); + expect(saved[WidgetSyncService.keyReplayCover3], ''); + expect(publishedDays, ['', '2026-10-14']); + expect(written.last, WidgetSyncService.keyReplayDay); + expect(repository.requests.last, DateTime(2026, 10, 13)); + expect(updates, [WidgetSyncService.replayProvider]); + expect(WidgetReplaySnapshot.localCover(directory.path), ''); + expect(WidgetReplaySnapshot.localCover('content://library/cover'), ''); + }, + ); + + test('unchanged snapshots are neither rewritten nor redrawn', () async { + repository.recaps['2026-10-14'] = _recap(now, plays: 1, minutes: 3); + await service.refreshReplay(); + written.clear(); + updates.clear(); + await service.refreshReplay(); + expect(written, isEmpty); + expect(updates, isEmpty); + expect( + saved[WidgetSyncService.keyReplayPlaysDelta], + WidgetReplaySnapshot.unknownDelta, + ); + }); + + testWidgets( + 'committed repeat plays and history clearing refresh without a song change', + (tester) async { + service.startReplaySync(); + await service.refreshReplay(); + updates.clear(); + repository.recaps['2026-10-14'] = _recap( + now, + plays: 2, + minutes: 6, + covers: [cover('repeat')], + ); + repository.changes.add(null); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + await flush(tester); + expect(saved[WidgetSyncService.keyReplayPlays], 2); + expect(updates, [WidgetSyncService.replayProvider]); + + repository.recaps.clear(); + repository.changes.add(null); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + await flush(tester); + expect(saved[WidgetSyncService.keyReplayPlays], 0); + expect(saved[WidgetSyncService.keyReplayTimeMs], 0); + expect(saved[WidgetSyncService.keyReplayCover1], ''); + expect( + saved[WidgetSyncService.keyReplayTimeDelta], + WidgetReplaySnapshot.unknownDelta, + ); + service.stopReplaySync(); + }, + ); + + testWidgets('position updates never poll listening history', (tester) async { + final state = PlayerState(); + service.schedulePush(state); + await flush(tester); + expect(repository.requests.length, 2); + now = now.add(const Duration(minutes: 5)); + service.schedulePush(state.copyWith(position: const Duration(seconds: 2))); + await tester.pump(const Duration(seconds: 2)); + await flush(tester); + expect(repository.requests.length, 2); + service.stopReplaySync(); + }); + + testWidgets('midnight publishes the new local day even while paused', ( + tester, + ) async { + now = DateTime(2026, 10, 14, 23, 59, 59); + repository.recaps['2026-10-14'] = _recap(now, plays: 1, minutes: 3); + service.startReplaySync(); + await service.refreshReplay(); + now = DateTime(2026, 10, 15); + await tester.pump(const Duration(seconds: 1)); + await flush(tester); + expect(saved[WidgetSyncService.keyReplayDay], '2026-10-15'); + expect(saved[WidgetSyncService.keyReplayPlays], 0); + expect(repository.requests.last, DateTime(2026, 10, 14)); + service.stopReplaySync(); + final requestCount = repository.requests.length; + repository.changes.add(null); + await tester.pump(const Duration(seconds: 1)); + await flush(tester); + expect(repository.requests.length, requestCount); + }); + + test( + 'overlapping refreshes serialize writes and finish on the latest history', + () async { + final firstToday = Completer(); + var waiting = true; + repository.recaps['2026-10-14'] = _recap(now, plays: 3, minutes: 9); + repository.load = (date) { + if (date.day == 14 && waiting) { + waiting = false; + return firstToday.future; + } + return Future.value( + repository.recaps[WidgetReplaySnapshot.dayKey(date)] ?? _recap(date), + ); + }; + final first = service.refreshReplay(); + await Future.delayed(Duration.zero); + final second = service.refreshReplay(); + await Future.delayed(Duration.zero); + firstToday.complete(_recap(now, plays: 1, minutes: 3)); + await Future.wait([first, second]); + expect(saved[WidgetSyncService.keyReplayPlays], 3); + expect(publishedDays, ['', '2026-10-14', '', '2026-10-14']); + }, + ); + + test( + 'temporary query failures preserve the last snapshot and retry', + () async { + repository.recaps['2026-10-14'] = _recap(now, plays: 1, minutes: 3); + await service.refreshReplay(); + repository.unavailable = true; + written.clear(); + updates.clear(); + await service.refreshReplay(); + expect(written, isEmpty); + expect(updates, isEmpty); + expect(saved[WidgetSyncService.keyReplayPlays], 1); + repository.unavailable = false; + repository.recaps['2026-10-14'] = _recap(now, plays: 2, minutes: 6); + await service.refreshReplay(); + expect(saved[WidgetSyncService.keyReplayPlays], 2); + }, + ); + + test( + 'the Replay look is pushed alongside the existing widget customization', + () async { + for (final look in ['acid', 'night', 'signal', 'artwork']) { + updates.clear(); + await service.pushCustomization(AppPreferences(widgetReplayLook: look)); + expect(saved[WidgetSyncService.keyReplayLook], look); + expect(updates, [ + WidgetSyncService.miniPlayerProvider, + WidgetSyncService.flagshipProvider, + WidgetSyncService.compactProvider, + WidgetSyncService.replayProvider, + ]); + } + }, + ); +} diff --git a/test/services/widget_sync_service_test.dart b/test/services/widget_sync_service_test.dart new file mode 100644 index 00000000..a7bc6c11 --- /dev/null +++ b/test/services/widget_sync_service_test.dart @@ -0,0 +1,241 @@ +import 'dart:io'; + +import 'package:flick/models/song.dart'; +import 'package:flick/providers/player_provider.dart'; +import 'package:flick/services/widget_sync_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('home_widget'); + const previewKeys = [ + WidgetSyncService.keyNext1Id, + WidgetSyncService.keyNext1Title, + WidgetSyncService.keyNext1Art, + WidgetSyncService.keyNext2Id, + WidgetSyncService.keyNext2Title, + WidgetSyncService.keyNext2Art, + ]; + final service = WidgetSyncService.instance; + late Directory directory; + late Map saved; + late List writtenKeys; + late List updatedProviders; + + Song song(String id, {String? art}) => Song( + id: id, + title: 'Title $id', + artist: 'Artist $id', + albumArt: art, + duration: const Duration(seconds: 122), + fileType: 'FLAC', + ); + + String localArt(String name) { + final file = File('${directory.path}/$name.png'); + file.writeAsBytesSync([0]); + return file.path; + } + + Future send( + WidgetTester tester, + PlayerState state, { + Duration delay = Duration.zero, + }) async { + final before = updatedProviders.length; + service.schedulePush(state); + await tester.pump(delay); + for (var attempt = 0; attempt < 30; attempt++) { + if (updatedProviders.length == before + 4) { + expect(updatedProviders.skip(before), [ + WidgetSyncService.miniPlayerProvider, + WidgetSyncService.flagshipProvider, + WidgetSyncService.compactProvider, + WidgetSyncService.replayProvider, + ]); + // Flush the continuation that records the successfully pushed snapshot. + await tester.pump(); + return; + } + await tester.pump(); + } + fail('Expected all four widgets to refresh after a state change'); + } + + setUp(() { + directory = Directory.systemTemp.createTempSync('flick-widget-sync-'); + saved = {}; + writtenKeys = []; + updatedProviders = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'setAppGroupId': + return true; + case 'saveWidgetData': + final args = call.arguments as Map; + final key = args['id'] as String; + saved[key] = args['data']; + writtenKeys.add(key); + return true; + case 'updateWidget': + final args = call.arguments as Map; + updatedProviders.add(args['qualifiedAndroidName'] as String); + return true; + default: + fail('Unexpected widget platform call: ${call.method}'); + } + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + directory.deleteSync(recursive: true); + }); + + testWidgets('publishes the actual first two upcoming covers', (tester) async { + final currentArt = localArt('current'); + final firstArt = localArt('first'); + final secondArt = localArt('second'); + await send( + tester, + PlayerState( + currentSong: song('current', art: currentArt), + upNext: [ + song('first', art: firstArt), + song('second', art: 'file://$secondArt'), + song('third', art: localArt('third')), + ], + ), + ); + + expect(saved[WidgetSyncService.keyAlbumArt], currentArt); + expect(saved[WidgetSyncService.keyNext1Id], 'first'); + expect(saved[WidgetSyncService.keyNext1Title], 'Title first'); + expect(saved[WidgetSyncService.keyNext1Art], firstArt); + expect(saved[WidgetSyncService.keyNext2Id], 'second'); + expect(saved[WidgetSyncService.keyNext2Title], 'Title second'); + expect(saved[WidgetSyncService.keyNext2Art], secondArt); + expect(saved[WidgetSyncService.keyQueueCount], 3); + }); + + testWidgets('a queue-only reorder refreshes without waiting for progress', ( + tester, + ) async { + final first = song('reorder-first', art: localArt('reorder-first')); + final second = song('reorder-second', art: localArt('reorder-second')); + final state = PlayerState( + currentSong: song('reorder-current'), + upNext: [first, second], + ); + await send(tester, state); + await send(tester, state.copyWith(upNext: [second, first])); + + expect(saved[WidgetSyncService.keyNext1Id], second.id); + expect(saved[WidgetSyncService.keyNext1Art], second.albumArt); + expect(saved[WidgetSyncService.keyNext2Id], first.id); + expect(saved[WidgetSyncService.keyNext2Art], first.albumArt); + }); + + testWidgets('remote and missing artwork are never sent to native widgets', ( + tester, + ) async { + await send( + tester, + PlayerState( + currentSong: song( + 'remote-current', + art: 'https://example.com/cover.png', + ), + upNext: [ + song('remote-next', art: 'http://example.com/next.png'), + song('missing-next', art: '${directory.path}/missing.png'), + ], + ), + ); + + expect(saved[WidgetSyncService.keyAlbumArt], ''); + expect(saved[WidgetSyncService.keyNext1Art], ''); + expect(saved[WidgetSyncService.keyNext2Art], ''); + expect(saved[WidgetSyncService.keyNext1Id], 'remote-next'); + }); + + testWidgets('unchanged previews are not rewritten on playback updates', ( + tester, + ) async { + final state = PlayerState( + currentSong: song('cached-current'), + upNext: [song('cached-next', art: localArt('cached-next'))], + ); + await send(tester, state); + writtenKeys.clear(); + await send(tester, state.copyWith(isPlaying: true)); + + expect(writtenKeys.where(previewKeys.contains), isEmpty); + expect(saved[WidgetSyncService.keyIsPlaying], isTrue); + expect(saved[WidgetSyncService.keyNext1Id], 'cached-next'); + }); + + testWidgets('clears covers when the queue becomes empty', (tester) async { + final state = PlayerState( + currentSong: song('empty-current'), + upNext: [song('empty-next', art: localArt('empty-next'))], + ); + await send(tester, state); + await send(tester, state.copyWith(upNext: [])); + + for (final key in previewKeys) { + expect(saved[key], '', reason: key); + } + }); + + testWidgets('clears covers when no song is loaded, even with a stale queue', ( + tester, + ) async { + final state = PlayerState( + currentSong: song('stopped-current'), + upNext: [song('stopped-next', art: localArt('stopped-next'))], + ); + await send(tester, state); + await send(tester, state.copyWith(clearSong: true)); + + expect(saved[WidgetSyncService.keyHasSong], isFalse); + for (final key in previewKeys) { + expect(saved[key], '', reason: key); + } + }); + + testWidgets('picks up local artwork created after the first update', ( + tester, + ) async { + final path = '${directory.path}/late.png'; + final state = PlayerState( + currentSong: song('late-current'), + upNext: [song('late-next', art: path)], + ); + await send(tester, state); + expect(saved[WidgetSyncService.keyNext1Art], ''); + File(path).writeAsBytesSync([0]); + await send( + tester, + state.copyWith(position: const Duration(seconds: 2)), + delay: const Duration(seconds: 2), + ); + + expect(saved[WidgetSyncService.keyNext1Art], path); + }); + + testWidgets( + 'replay stats stay untouched when the library database is absent', + (tester) async { + await send(tester, PlayerState(currentSong: song('replay-offline'))); + + expect( + saved.keys.where((key) => key.startsWith('flick_widget_replay_')), + isEmpty, + ); + }, + ); +}