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