From 44bfb839db8a47c4b6dc840670587d546daa3f15 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 14 Oct 2026 16:15:11 +0800 Subject: [PATCH 1/8] Refactor widgets with shared responsive layout Extract WidgetViews, WidgetLayoutPolicy, WidgetPlaybackState, and WidgetUpcomingTrack helpers so all widget providers share layout, text, playback, and art logic. Add responsive layouts for compact, flagship, and mini widgets including a square compact fallback and gradient surfaces. Cache rendered bitmaps and parsed cover art. --- .../kotlin/com/mossapps/flick/MainActivity.kt | 19 +- .../flick/widgets/CompactWidgetProvider.kt | 166 ++++------- .../flick/widgets/FlagshipWidgetProvider.kt | 274 ++++++------------ .../flick/widgets/MiniPlayerWidgetProvider.kt | 173 +++-------- .../mossapps/flick/widgets/WidgetArtLoader.kt | 174 ++++++++++- .../mossapps/flick/widgets/WidgetIntents.kt | 3 + .../com/mossapps/flick/widgets/WidgetPrefs.kt | 24 +- .../flick/widgets/WidgetTextRenderer.kt | 79 ++++- 8 files changed, 457 insertions(+), 455 deletions(-) diff --git a/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt b/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt index a4a694be..cd825272 100644 --- a/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt +++ b/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt @@ -17,6 +17,7 @@ package com.mossapps.flick // (android_audio_device_service.dart isLikelyDap). Kotlin does NOT detect DAP brands. import android.Manifest +import com.mossapps.flick.widgets.WidgetLaunchInbox import android.app.ActivityManager import android.app.ApplicationExitInfo import android.app.PendingIntent @@ -168,6 +169,7 @@ class MainActivity: FlutterActivity() { private var visualizer: Visualizer? = null private var visualizerEventSink: EventChannel.EventSink? = null private var widgetChannel: MethodChannel? = null + private val widgetLaunchInbox = WidgetLaunchInbox() private val HOME_WIDGET_LAUNCH_ACTION = "com.mossapps.flick.WIDGET_LAUNCH" private var a2dpProxy: BluetoothA2dp? = null private var developerMode: Boolean = false @@ -1243,7 +1245,21 @@ class MainActivity: FlutterActivity() { registerVolumeContentObserver() widgetChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, WIDGET_CHANNEL) + widgetLaunchInbox.detach() dispatchWidgetIntent(intent) + widgetChannel?.setMethodCallHandler { call, result -> + when (call.method) { + "widgetHandlerReady" -> result.success(widgetLaunchInbox.attach()) + "widgetHandlerDetached" -> { + widgetLaunchInbox.detach() + result.success(null) + } + else -> result.notImplemented() + } + } + // A cached engine may already have a Dart handler from the old Activity. + // A fresh engine drains the queued URI when its handler attaches later. + widgetChannel?.invokeMethod("channelReady", null) } // private fun handleConversionResult(conversionResult: ConversionResult?, result: MethodChannel.Result) { @@ -1419,7 +1435,8 @@ class MainActivity: FlutterActivity() { private fun dispatchWidgetIntent(intent: Intent?) { if (intent?.action != HOME_WIDGET_LAUNCH_ACTION) return val uri = intent.data?.toString() ?: return - widgetChannel?.invokeMethod("dispatch", uri) + val dispatch = widgetLaunchInbox.offer(uri) ?: return + widgetChannel?.invokeMethod("dispatch", dispatch) } private fun emitCastEvent(event: Map) { diff --git a/android/app/src/main/kotlin/com/mossapps/flick/widgets/CompactWidgetProvider.kt b/android/app/src/main/kotlin/com/mossapps/flick/widgets/CompactWidgetProvider.kt index a1ad1fea..40ec53ee 100644 --- a/android/app/src/main/kotlin/com/mossapps/flick/widgets/CompactWidgetProvider.kt +++ b/android/app/src/main/kotlin/com/mossapps/flick/widgets/CompactWidgetProvider.kt @@ -1,131 +1,77 @@ package com.mossapps.flick.widgets import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider import android.content.Context import android.graphics.Color -import android.text.Layout +import android.os.Build import android.view.View import android.widget.RemoteViews import com.mossapps.flick.R -class CompactWidgetProvider : AppWidgetProvider() { - - companion object { - private const val MARGIN_PAD_DP = 40f - private const val ART_BG_PX = 512 - } - - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray, - ) { - val prefs = WidgetPrefs.get(context) - val hasSong = prefs.getBoolean(WidgetPrefs.KEY_HAS_SONG, false) - val isPlaying = prefs.getBoolean(WidgetPrefs.KEY_IS_PLAYING, false) - val title = prefs.getString(WidgetPrefs.KEY_TITLE, "")?.takeIf { it.isNotEmpty() } - ?: context.getString(R.string.widget_nothing_playing) - val artist = prefs.getString(WidgetPrefs.KEY_ARTIST, "") ?: "" - val artPath = prefs.getString(WidgetPrefs.KEY_ALBUM_ART, "") ?: "" +class CompactWidgetProvider : ResponsiveWidgetProvider() { + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + val state = WidgetPlaybackState.read(context) val showArt = WidgetPrefs.getCompactShowAlbumArt(context) val showArtist = WidgetPrefs.getCompactShowArtist(context) - val accentColor = WidgetPrefs.getCompactAccentColor(context) + val accent = WidgetPrefs.getCompactAccentColor(context) val textScale = WidgetPrefs.getCompactTextScale(context) - val dm = context.resources.displayMetrics - for (id in appWidgetIds) { - val views = RemoteViews(context.packageName, R.layout.widget_compact) - views.applyBackground(context) - views.applyArt(context, hasSong, showArt, artPath) - - val options = appWidgetManager.getAppWidgetOptions(id) - val widgetWidthDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0) - val widgetWidthPx = - if (widgetWidthDp > 0) (widgetWidthDp * dm.density).toInt() else dm.widthPixels - val textWidthPx = (widgetWidthPx - MARGIN_PAD_DP * dm.density).toInt().coerceAtLeast(100) - val titleSp = WidgetPrefs.scaledSp(15, widgetWidthDp, 200f, textScale) - val artistSp = WidgetPrefs.scaledSp(12, widgetWidthDp, 200f, textScale) - - if (hasSong) { - views.setImageViewBitmap( - R.id.compact_title, - WidgetTextRenderer.createTextBitmap( - context, title, R.font.product_sans_bold, titleSp, Color.WHITE, - textWidthPx, Layout.Alignment.ALIGN_CENTER, - ), - ) - views.setContentDescription(R.id.compact_title, title) + val views = responsiveViews(appWidgetManager.getAppWidgetOptions(id), WidgetLayoutPolicy.compactDefault) { hostSize -> + val size = WidgetLayoutPolicy.compactCardSize(hostSize, + legacySizing = Build.VERSION.SDK_INT < Build.VERSION_CODES.S) + val layout = WidgetLayoutPolicy.compactLayout(size) + val layoutId = when (layout) { + CompactLayout.REGULAR -> R.layout.widget_compact + CompactLayout.SHORT -> R.layout.widget_compact_short + CompactLayout.NARROW -> R.layout.widget_compact_narrow + } + val view = RemoteViews(context.packageName, layoutId) + view.setInt(R.id.compact_root, "setBackgroundResource", WidgetPrefs.getCompactBackgroundDrawableRes(context)) + view.setOnClickPendingIntent(R.id.compact_root, WidgetIntents.openApp(context, 50)) + view.setViewVisibility(R.id.compact_art_bg, if (showArt) View.VISIBLE else View.GONE) + val foreground = Color.WHITE + val shadow = 0x80000000.toInt() + if (showArt) { + // One uniform scrim keeps white metadata readable on bright artwork. + view.setImageViewBitmap(R.id.compact_art_bg, WidgetArtLoader.rounded(context, + state.artPath, size, 384, 24, overlayColor = 0x4D000000)) + view.setOnClickPendingIntent(R.id.compact_art_bg, WidgetIntents.openApp(context, 53)) + } + if (layout == CompactLayout.REGULAR) { + view.setInt(R.id.compact_brand, "setColorFilter", foreground) + } - if (showArtist && artist.isNotEmpty()) { - views.setImageViewBitmap( - R.id.compact_artist, - WidgetTextRenderer.createTextBitmap( - context, artist, R.font.product_sans_regular, artistSp, accentColor, - textWidthPx, Layout.Alignment.ALIGN_CENTER, - ), - ) - views.setContentDescription(R.id.compact_artist, artist) - views.setViewVisibility(R.id.compact_artist, View.VISIBLE) + val width = WidgetLayoutPolicy.compactTextWidth(size, state.hasSong) + val artistVisible = state.hasSong && showArtist && state.artist.isNotBlank() + val titleHeight = if (artistVisible) 22 else if (layout == CompactLayout.NARROW) 42 else 44 + WidgetViews.text(context, view, R.id.compact_title, state.title, + WidgetPrefs.scaledSp(12, 168, 168f, textScale), width, titleHeight, + true, color = foreground, shadowColor = shadow) + view.setViewVisibility(R.id.compact_artist, if (artistVisible) View.VISIBLE else View.GONE) + if (artistVisible) WidgetViews.text(context, view, R.id.compact_artist, state.artist, + WidgetPrefs.scaledSp(10, 168, 168f, textScale), width, 18, + false, color = WidgetViews.SECONDARY_TEXT, fakeBold = true, shadowColor = shadow) + view.setViewVisibility(R.id.compact_controls, if (state.hasSong) View.VISIBLE else View.GONE) + view.setOnClickPendingIntent(R.id.compact_title, WidgetIntents.openApp(context, 51)) + view.setOnClickPendingIntent(R.id.compact_artist, WidgetIntents.openApp(context, 52)) + WidgetViews.playButton(context, view, state, R.id.compact_play_pause, + WidgetViews.playBackground(accent, compact = true), 0xFF111111.toInt()) + WidgetViews.skipButtons(context, view, state, R.id.compact_prev, R.id.compact_next, foreground) + if (WidgetLayoutPolicy.compactShowsProgress(size)) { + WidgetViews.progress(view, R.id.compact_progress, state, + if (accent == Color.WHITE) foreground else accent) } else { - views.setViewVisibility(R.id.compact_artist, View.GONE) + view.setViewVisibility(R.id.compact_progress, View.GONE) } - - views.setImageViewResource( - R.id.compact_play_pause, - if (isPlaying) R.drawable.widget_ic_lucide_pause else R.drawable.widget_ic_lucide_play, - ) - views.setViewVisibility(R.id.compact_controls, View.VISIBLE) - - views.setOnClickPendingIntent(R.id.compact_title, WidgetIntents.openApp(context, 50)) - views.setOnClickPendingIntent(R.id.compact_artist, WidgetIntents.openApp(context, 51)) - views.setOnClickPendingIntent(R.id.compact_play_pause, WidgetIntents.playerPlayPause(context)) - views.setOnClickPendingIntent(R.id.compact_next, WidgetIntents.playerNext(context)) - views.setOnClickPendingIntent(R.id.compact_prev, WidgetIntents.playerPrevious(context)) - } else { - val tapText = context.getString(R.string.widget_tap_to_open) - views.setImageViewBitmap( - R.id.compact_title, - WidgetTextRenderer.createTextBitmap( - context, tapText, R.font.product_sans_bold, titleSp, Color.WHITE, - textWidthPx, Layout.Alignment.ALIGN_CENTER, - ), - ) - views.setContentDescription(R.id.compact_title, tapText) - views.setViewVisibility(R.id.compact_artist, View.GONE) - views.setViewVisibility(R.id.compact_controls, View.GONE) - views.setOnClickPendingIntent(R.id.compact_title, WidgetIntents.openApp(context, 60)) + if (size != hostSize) { + RemoteViews(context.packageName, R.layout.widget_compact_square).apply { + removeAllViews(R.id.compact_square_content) + addView(R.id.compact_square_content, view) + } + } else view } - appWidgetManager.updateAppWidget(id, views) } } - - private fun RemoteViews.applyBackground(context: Context) { - val bgRes = WidgetPrefs.getCompactBackgroundDrawableRes(context) - setInt(R.id.compact_root, "setBackgroundResource", bgRes) - } - - private fun RemoteViews.applyArt( - context: Context, - hasSong: Boolean, - showArt: Boolean, - artPath: String, - ) { - if (!showArt) { - setViewVisibility(R.id.compact_art_bg, View.GONE) - setViewVisibility(R.id.compact_scrim, View.GONE) - return - } - val art = if (hasSong) WidgetArtLoader.load(artPath, ART_BG_PX) else null - if (art != null) { - setImageViewBitmap(R.id.compact_art_bg, art) - } else { - setImageViewResource(R.id.compact_art_bg, R.drawable.widget_default_art) - } - setViewVisibility(R.id.compact_art_bg, View.VISIBLE) - setViewVisibility(R.id.compact_scrim, View.VISIBLE) - setOnClickPendingIntent(R.id.compact_art_bg, WidgetIntents.openApp(context, 52)) - } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/com/mossapps/flick/widgets/FlagshipWidgetProvider.kt b/android/app/src/main/kotlin/com/mossapps/flick/widgets/FlagshipWidgetProvider.kt index 491a977c..9c094388 100644 --- a/android/app/src/main/kotlin/com/mossapps/flick/widgets/FlagshipWidgetProvider.kt +++ b/android/app/src/main/kotlin/com/mossapps/flick/widgets/FlagshipWidgetProvider.kt @@ -1,205 +1,103 @@ package com.mossapps.flick.widgets import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider import android.content.Context import android.graphics.Color import android.text.Layout -import android.util.Log import android.view.View import android.widget.RemoteViews import com.mossapps.flick.R -class FlagshipWidgetProvider : AppWidgetProvider() { +class FlagshipWidgetProvider : ResponsiveWidgetProvider() { + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + val state = WidgetPlaybackState.read(context) + val showArtist = WidgetPrefs.getFlagshipShowArtist(context) + val accent = WidgetPrefs.getFlagshipAccentColor(context) + val textScale = WidgetPrefs.getFlagshipTextScale(context) + val upcoming = WidgetUpcomingTrack.read(context) - companion object { - private const val TAG = "FlagshipWidget" - private const val ART_MAX_PX = 1024 - } - - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray, - ) { - Log.d(TAG, "onUpdate called for ids=${appWidgetIds.contentToString()}") - try { - val prefs = WidgetPrefs.get(context) - val hasSong = prefs.getBoolean(WidgetPrefs.KEY_HAS_SONG, false) - val isPlaying = prefs.getBoolean(WidgetPrefs.KEY_IS_PLAYING, false) - val title = prefs.getString(WidgetPrefs.KEY_TITLE, "")?.takeIf { it.isNotEmpty() } - ?: context.getString(R.string.widget_nothing_playing) - val artist = prefs.getString(WidgetPrefs.KEY_ARTIST, "") ?: "" - val artPath = prefs.getString(WidgetPrefs.KEY_ALBUM_ART, "") ?: "" - val showArtist = WidgetPrefs.getFlagshipShowArtist(context) - val accentColor = WidgetPrefs.getFlagshipAccentColor(context) - val textScale = WidgetPrefs.getFlagshipTextScale(context) - val isShuffle = prefs.getBoolean(WidgetPrefs.KEY_IS_SHUFFLE, false) - val loopMode = prefs.getInt(WidgetPrefs.KEY_LOOP_MODE, 0) - Log.d(TAG, "hasSong=$hasSong, isPlaying=$isPlaying, title=$title, idsCount=${appWidgetIds.size}") - - val dm = context.resources.displayMetrics - val marginPadDp = 48f - - for (id in appWidgetIds) { - Log.d(TAG, "Updating widget id=$id") - val views = RemoteViews(context.packageName, R.layout.widget_flagship_art) - - val options = appWidgetManager.getAppWidgetOptions(id) - val widgetWidthDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0) - val widgetWidthPx = if (widgetWidthDp > 0) (widgetWidthDp * dm.density).toInt() else dm.widthPixels - val textWidthPx = (widgetWidthPx - marginPadDp * dm.density).toInt().coerceAtLeast(100) - val titleSp = WidgetPrefs.scaledSp(15, widgetWidthDp, 400f, textScale) - val artistSp = WidgetPrefs.scaledSp(12, widgetWidthDp, 400f, textScale) - - if (hasSong) { - val art = WidgetArtLoader.load(artPath, ART_MAX_PX) - if (art != null) { - views.setImageViewBitmap(R.id.flagship_art, art) - } else { - views.setImageViewResource(R.id.flagship_art, R.drawable.widget_default_art) - } - views.setViewVisibility(R.id.flagship_art, View.VISIBLE) - - views.setImageViewBitmap( - R.id.flagship_title, - WidgetTextRenderer.createTextBitmap( - context, - title, - R.font.product_sans_bold, - titleSp, - Color.WHITE, - textWidthPx, - Layout.Alignment.ALIGN_CENTER, - ), - ) - views.setContentDescription(R.id.flagship_title, title) - - if (showArtist && artist.isNotEmpty()) { - views.setImageViewBitmap( - R.id.flagship_artist, - WidgetTextRenderer.createTextBitmap( - context, - artist, - R.font.product_sans_regular, - artistSp, - accentColor, - textWidthPx, - Layout.Alignment.ALIGN_CENTER, - ), - ) - views.setContentDescription(R.id.flagship_artist, artist) - views.setViewVisibility(R.id.flagship_artist, View.VISIBLE) - } else { - views.setViewVisibility(R.id.flagship_artist, View.GONE) + for (id in appWidgetIds) { + val views = responsiveViews(appWidgetManager.getAppWidgetOptions(id), WidgetLayoutPolicy.flagshipDefault) { size -> + val geometry = WidgetLayoutPolicy.flagship(size) + val layout = when (geometry.gridWidthDp) { + 184 -> R.layout.widget_flagship_art_large + 144 -> R.layout.widget_flagship_art_wide + else -> R.layout.widget_flagship_art + } + val view = RemoteViews(context.packageName, layout) + view.setOnClickPendingIntent(R.id.flagship_root, WidgetIntents.openApp(context, 30)) + val padding = WidgetViews.pixels(context, geometry.paddingDp) + view.setViewPadding(R.id.flagship_root, padding, padding, padding, padding) + view.setImageViewBitmap(R.id.flagship_panel_art, WidgetArtLoader.rounded(context, + state.artPath, WidgetSize(geometry.panelWidthDp, size.heightDp - geometry.paddingDp * 2), + 512, 20, overlayColor = 0xBD0A0A0A.toInt())) + val panelPadding = WidgetViews.pixels(context, geometry.panelPaddingDp) + view.setViewPadding(R.id.flagship_panel_content, panelPadding, panelPadding, panelPadding, panelPadding) + WidgetViews.art(context, view, R.id.flagship_art, state, + WidgetSize(geometry.coverDp, geometry.coverDp), 352, 12) + for ((index, coverId) in listOf(R.id.flagship_next_art_1, R.id.flagship_next_art_2).withIndex()) { + val track = upcoming.getOrNull(index) + val visible = track != null && index < geometry.upcomingSlots + view.setViewVisibility(coverId, if (visible) View.VISIBLE else View.GONE) + if (visible) { + view.setImageViewBitmap(coverId, WidgetArtLoader.rounded(context, track.artPath, + WidgetSize(geometry.coverDp, geometry.coverDp), 352, 12)) + view.setInt(coverId, "setImageAlpha", 150) + view.setContentDescription(coverId, context.getString(R.string.widget_up_next, track.title)) + view.setOnClickPendingIntent(coverId, WidgetIntents.openApp(context, coverId)) } - - views.setImageViewResource( - R.id.flagship_play_pause, - if (isPlaying) R.drawable.widget_ic_lucide_pause else R.drawable.widget_ic_lucide_play, - ) - views.setViewVisibility(R.id.flagship_prev, View.VISIBLE) - views.setViewVisibility(R.id.flagship_play_pause, View.VISIBLE) - views.setViewVisibility(R.id.flagship_next, View.VISIBLE) - views.setViewVisibility(R.id.flagship_shuffle, View.VISIBLE) - views.setViewVisibility(R.id.flagship_repeat, View.VISIBLE) - - val inactiveTint = Color.argb(0x55, 0xFF, 0xFF, 0xFF) - val activeBg = R.drawable.widget_button_transport_active_bg - val inactiveBg = R.drawable.widget_button_transport_bg - views.setInt( - R.id.flagship_shuffle, - "setBackgroundResource", - if (isShuffle) activeBg else inactiveBg, - ) - views.setInt( - R.id.flagship_shuffle, - "setColorFilter", - if (isShuffle) accentColor else inactiveTint, - ) - val repeatActive = loopMode != 0 - views.setInt( - R.id.flagship_repeat, - "setBackgroundResource", - if (repeatActive) activeBg else inactiveBg, - ) - views.setImageViewResource( - R.id.flagship_repeat, - if (loopMode == 1) R.drawable.widget_ic_repeat1 - else R.drawable.widget_ic_lucide_repeat, - ) - views.setInt( - R.id.flagship_repeat, - "setColorFilter", - if (repeatActive) accentColor else inactiveTint, - ) - - views.setOnClickPendingIntent( - R.id.flagship_art, - WidgetIntents.openApp(context, 30), - ) - views.setOnClickPendingIntent( - R.id.flagship_title, - WidgetIntents.openApp(context, 31), - ) - views.setOnClickPendingIntent( - R.id.flagship_artist, - WidgetIntents.openApp(context, 32), - ) - views.setOnClickPendingIntent( - R.id.flagship_play_pause, - WidgetIntents.playerPlayPause(context), - ) - views.setOnClickPendingIntent( - R.id.flagship_next, - WidgetIntents.playerNext(context), - ) - views.setOnClickPendingIntent( - R.id.flagship_prev, - WidgetIntents.playerPrevious(context), - ) - views.setOnClickPendingIntent( - R.id.flagship_shuffle, - WidgetIntents.playerShuffle(context), - ) - views.setOnClickPendingIntent( - R.id.flagship_repeat, - WidgetIntents.playerRepeat(context), - ) - } else { - views.setImageViewResource(R.id.flagship_art, R.drawable.widget_default_art) - views.setViewVisibility(R.id.flagship_art, View.VISIBLE) - val tapText = context.getString(R.string.widget_tap_to_open) - views.setImageViewBitmap( - R.id.flagship_title, - WidgetTextRenderer.createTextBitmap( - context, - tapText, - R.font.product_sans_bold, - titleSp, - Color.WHITE, - textWidthPx, - Layout.Alignment.ALIGN_CENTER, - ), - ) - views.setContentDescription(R.id.flagship_title, tapText) - views.setViewVisibility(R.id.flagship_artist, View.GONE) - views.setViewVisibility(R.id.flagship_prev, View.GONE) - views.setViewVisibility(R.id.flagship_play_pause, View.GONE) - views.setViewVisibility(R.id.flagship_next, View.GONE) - views.setViewVisibility(R.id.flagship_shuffle, View.GONE) - views.setViewVisibility(R.id.flagship_repeat, View.GONE) - - val openIntent = WidgetIntents.openApp(context, 40) - views.setOnClickPendingIntent(R.id.flagship_art, openIntent) - views.setOnClickPendingIntent(R.id.flagship_title, openIntent) } - - appWidgetManager.updateAppWidget(id, views) - Log.d(TAG, "Widget id=$id updated successfully") + for (coverId in listOf(R.id.flagship_art, R.id.flagship_next_art_1, R.id.flagship_next_art_2)) { + view.setInt(coverId, "setMaxWidth", WidgetViews.pixels(context, geometry.coverDp)) + view.setInt(coverId, "setMaxHeight", WidgetViews.pixels(context, geometry.coverDp)) + } + view.setViewPadding(R.id.flagship_metadata_text, 0, 0, + WidgetViews.pixels(context, if (geometry.repeatInHeader || !state.hasSong) 0 else 56), 0) + val width = if (state.hasSong) geometry.textWidthDp else geometry.panelContentWidthDp + val artistVisible = state.hasSong && showArtist && state.artist.isNotBlank() + WidgetViews.text(context, view, R.id.flagship_title, state.title, + WidgetPrefs.scaledSp(16, size.widthDp, 360f, textScale), width, if (artistVisible) 26 else 44, true) + view.setViewVisibility(R.id.flagship_artist, if (artistVisible) View.VISIBLE else View.GONE) + if (artistVisible) WidgetViews.text(context, view, R.id.flagship_artist, state.artist, + WidgetPrefs.scaledSp(11, size.widthDp, 360f, textScale), width, 18, false) + view.setOnClickPendingIntent(R.id.flagship_title, WidgetIntents.openApp(context, 31)) + view.setOnClickPendingIntent(R.id.flagship_artist, WidgetIntents.openApp(context, 32)) + view.setViewVisibility(R.id.flagship_controls, if (state.hasSong) View.VISIBLE else View.INVISIBLE) + WidgetViews.playButton(context, view, state, R.id.flagship_play_pause, + R.drawable.widget_button_tile, Color.WHITE) + WidgetViews.skipButtons(context, view, state, R.id.flagship_prev, R.id.flagship_next, Color.WHITE) + view.setInt(R.id.flagship_shuffle, "setBackgroundResource", + if (state.shuffle) R.drawable.widget_button_tile_active else R.drawable.widget_button_tile) + view.setInt(R.id.flagship_shuffle, "setColorFilter", if (state.shuffle) accent else Color.WHITE) + for (repeatId in listOf(R.id.flagship_repeat, R.id.flagship_repeat_header)) { + val inHeader = repeatId == R.id.flagship_repeat_header + view.setViewVisibility(repeatId, + if (state.hasSong && geometry.repeatInHeader == inHeader) View.VISIBLE else View.GONE) + view.setInt(repeatId, "setColorFilter", if (state.loopMode != 0) accent else Color.WHITE) + view.setInt(repeatId, "setBackgroundResource", if (state.loopMode != 0) + R.drawable.widget_button_transport_active_bg else R.drawable.widget_button_transport_bg) + view.setImageViewResource(repeatId, if (state.loopMode == 1) + R.drawable.widget_ic_lucide_repeat_one else R.drawable.widget_ic_lucide_repeat) + view.setContentDescription(repeatId, context.getString(WidgetViews.repeatDescription(state.loopMode))) + view.setOnClickPendingIntent(repeatId, WidgetIntents.playerRepeat(context)) + } + view.setContentDescription(R.id.flagship_shuffle, context.getString( + if (state.shuffle) R.string.widget_shuffle_on else R.string.widget_shuffle_off)) + view.setOnClickPendingIntent(R.id.flagship_shuffle, WidgetIntents.playerShuffle(context)) + WidgetViews.progress(view, R.id.flagship_progress, state, accent) + val timingVisible = state.hasSong && state.durationMs > 0 + view.setViewVisibility(R.id.flagship_times, if (timingVisible) View.VISIBLE else View.GONE) + if (timingVisible) { + val timeWidth = geometry.panelContentWidthDp / 2 + WidgetViews.text(context, view, R.id.flagship_elapsed, + WidgetLayoutPolicy.time(state.positionMs.coerceIn(0, state.durationMs)), + 11, timeWidth, 18, false, color = Color.WHITE) + WidgetViews.text(context, view, R.id.flagship_duration, WidgetLayoutPolicy.time(state.durationMs), + 11, timeWidth, 18, false, color = Color.WHITE, alignment = Layout.Alignment.ALIGN_OPPOSITE) + } + view } - } catch (e: Exception) { - Log.e(TAG, "Error updating flagship widget", e) + appWidgetManager.updateAppWidget(id, views) } } } diff --git a/android/app/src/main/kotlin/com/mossapps/flick/widgets/MiniPlayerWidgetProvider.kt b/android/app/src/main/kotlin/com/mossapps/flick/widgets/MiniPlayerWidgetProvider.kt index 0f6c07d3..ed1eff9d 100644 --- a/android/app/src/main/kotlin/com/mossapps/flick/widgets/MiniPlayerWidgetProvider.kt +++ b/android/app/src/main/kotlin/com/mossapps/flick/widgets/MiniPlayerWidgetProvider.kt @@ -1,160 +1,57 @@ package com.mossapps.flick.widgets import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider import android.content.Context -import android.content.SharedPreferences -import android.content.res.ColorStateList import android.graphics.Color import android.view.View import android.widget.RemoteViews import com.mossapps.flick.R -class MiniPlayerWidgetProvider : AppWidgetProvider() { - - companion object { - private const val CHROME_DP = 148f - private const val ART_BG_PX = 512 - } - - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray, - ) { - val prefs = WidgetPrefs.get(context) - val hasSong = prefs.getBoolean(WidgetPrefs.KEY_HAS_SONG, false) - val isPlaying = prefs.getBoolean(WidgetPrefs.KEY_IS_PLAYING, false) - val title = prefs.getString(WidgetPrefs.KEY_TITLE, "")?.takeIf { it.isNotEmpty() } - ?: context.getString(R.string.widget_nothing_playing) - val artist = prefs.getString(WidgetPrefs.KEY_ARTIST, "") ?: "" - val artPath = prefs.getString(WidgetPrefs.KEY_ALBUM_ART, "") ?: "" - val positionMs = readMs(prefs, WidgetPrefs.KEY_POSITION_MS) - val durationMs = readMs(prefs, WidgetPrefs.KEY_DURATION_MS) +class MiniPlayerWidgetProvider : ResponsiveWidgetProvider() { + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + val state = WidgetPlaybackState.read(context) val showArt = WidgetPrefs.getShowAlbumArt(context) val showArtist = WidgetPrefs.getShowArtist(context) - val accentColor = WidgetPrefs.getAccentColor(context) + val accent = WidgetPrefs.getAccentColor(context) val textScale = WidgetPrefs.getMiniTextScale(context) - val dm = context.resources.displayMetrics - for (id in appWidgetIds) { - val views = RemoteViews(context.packageName, R.layout.widget_mini_player) - views.applyBackground(context) - - if (showArt) { - val art = if (hasSong) WidgetArtLoader.load(artPath, ART_BG_PX) else null - if (art != null) { - views.setImageViewBitmap(R.id.widget_art_bg, art) - } else { - views.setImageViewResource(R.id.widget_art_bg, R.drawable.widget_default_art) + val views = responsiveViews(appWidgetManager.getAppWidgetOptions(id), WidgetLayoutPolicy.miniDefault) { size -> + val view = RemoteViews(context.packageName, R.layout.widget_mini_player) + val background = when (WidgetPrefs.get(context).getInt(WidgetPrefs.KEY_BG_OPACITY, 3)) { + 0 -> R.drawable.widget_mini_background_0 + 1 -> R.drawable.widget_mini_background_1 + 2 -> R.drawable.widget_mini_background_2 + 4 -> R.drawable.widget_mini_background_4 + else -> R.drawable.widget_mini_background } - views.setViewVisibility(R.id.widget_art_bg, View.VISIBLE) - views.setViewVisibility(R.id.widget_scrim, View.VISIBLE) - } else { - views.setViewVisibility(R.id.widget_art_bg, View.GONE) - views.setViewVisibility(R.id.widget_scrim, View.GONE) - } - views.setOnClickPendingIntent(R.id.widget_art_bg, WidgetIntents.openApp(context, 10)) - - val options = appWidgetManager.getAppWidgetOptions(id) - val widgetWidthDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0) - val widgetWidthPx = - if (widgetWidthDp > 0) (widgetWidthDp * dm.density).toInt() else dm.widthPixels - val textWidthPx = (widgetWidthPx - CHROME_DP * dm.density).toInt().coerceAtLeast(80) - val titleSp = WidgetPrefs.scaledSp(13, widgetWidthDp, 360f, textScale) - val artistSp = WidgetPrefs.scaledSp(11, widgetWidthDp, 360f, textScale) - - if (hasSong) { - views.setImageViewBitmap( - R.id.widget_title, - WidgetTextRenderer.createTextBitmap( - context, title, R.font.product_sans_bold, titleSp, Color.WHITE, textWidthPx, - ), - ) - views.setContentDescription(R.id.widget_title, title) - - if (showArtist && artist.isNotEmpty()) { - views.setImageViewBitmap( - R.id.widget_artist, - WidgetTextRenderer.createTextBitmap( - context, artist, R.font.product_sans_regular, artistSp, accentColor, textWidthPx, - ), - ) - views.setContentDescription(R.id.widget_artist, artist) - views.setViewVisibility(R.id.widget_artist, View.VISIBLE) - } else { - views.setViewVisibility(R.id.widget_artist, View.GONE) - } - - views.setImageViewResource( - R.id.widget_play_pause, - if (isPlaying) R.drawable.widget_ic_lucide_pause else R.drawable.widget_ic_lucide_play, - ) - - if (durationMs > 0) { - views.setViewVisibility(R.id.widget_progress, View.VISIBLE) - views.setProgressBar( - R.id.widget_progress, 1000, - (positionMs * 1000L / durationMs).toInt().coerceIn(0, 1000), false, - ) - views.setColorStateList( - R.id.widget_progress, "setProgressTintList", - ColorStateList.valueOf(accentColor), - ) - views.setColorStateList( - R.id.widget_progress, "setProgressBackgroundTintList", - ColorStateList.valueOf(0x33FFFFFF), - ) - } else { - views.setViewVisibility(R.id.widget_progress, View.GONE) + view.setInt(R.id.widget_root, "setBackgroundResource", background) + view.setOnClickPendingIntent(R.id.widget_root, WidgetIntents.openApp(context, 10)) + view.setViewVisibility(R.id.widget_thumbnail, if (showArt) View.VISIBLE else View.GONE) + if (showArt) { + view.setImageViewBitmap(R.id.widget_thumbnail, WidgetArtLoader.rounded(context, + state.artPath, WidgetSize(56, 56), 224, 16, leftCornersOnly = true, + placeholder = R.drawable.widget_mini_placeholder)) + view.setOnClickPendingIntent(R.id.widget_thumbnail, WidgetIntents.openApp(context, 13)) } - views.setOnClickPendingIntent(R.id.widget_title, WidgetIntents.openApp(context, 11)) - views.setOnClickPendingIntent(R.id.widget_artist, WidgetIntents.openApp(context, 12)) - views.setOnClickPendingIntent(R.id.widget_play_pause, WidgetIntents.playerPlayPause(context)) - views.setOnClickPendingIntent(R.id.widget_next, WidgetIntents.playerNext(context)) - views.setOnClickPendingIntent(R.id.widget_prev, WidgetIntents.playerPrevious(context)) - - views.setViewVisibility(R.id.widget_prev, View.VISIBLE) - views.setViewVisibility(R.id.widget_play_pause, View.VISIBLE) - views.setViewVisibility(R.id.widget_next, View.VISIBLE) - } else { - val tapText = context.getString(R.string.widget_tap_to_open) - views.setImageViewBitmap( - R.id.widget_title, - WidgetTextRenderer.createTextBitmap( - context, tapText, R.font.product_sans_bold, titleSp, Color.WHITE, textWidthPx, - ), - ) - views.setContentDescription(R.id.widget_title, tapText) - views.setViewVisibility(R.id.widget_artist, View.GONE) - views.setViewVisibility(R.id.widget_progress, View.GONE) - views.setViewVisibility(R.id.widget_prev, View.GONE) - views.setViewVisibility(R.id.widget_play_pause, View.GONE) - views.setViewVisibility(R.id.widget_next, View.GONE) - - views.setOnClickPendingIntent(R.id.widget_title, WidgetIntents.openApp(context, 20)) + val width = WidgetLayoutPolicy.miniTextWidth(size, showArt, state.hasSong) + val artistVisible = state.hasSong && showArtist && state.artist.isNotBlank() + WidgetViews.text(context, view, R.id.widget_title, state.title, + WidgetPrefs.scaledSp(14, 320, 320f, textScale), width, if (artistVisible) 22 else 40, true) + view.setViewVisibility(R.id.widget_artist, if (artistVisible) View.VISIBLE else View.GONE) + if (artistVisible) WidgetViews.text(context, view, R.id.widget_artist, state.artist, + WidgetPrefs.scaledSp(12, 320, 320f, textScale), width, 18, false, weight = 500) + view.setOnClickPendingIntent(R.id.widget_title, WidgetIntents.openApp(context, 11)) + view.setOnClickPendingIntent(R.id.widget_artist, WidgetIntents.openApp(context, 12)) + WidgetViews.playButton(context, view, state, R.id.widget_play_pause, + R.drawable.widget_button_transport_bg, Color.WHITE) + WidgetViews.progress(view, R.id.widget_progress, state, + if (accent == Color.WHITE) 0xFFE0E0E0.toInt() else accent, trackColor = 0, + barSize = WidgetSize(size.widthDp, 56), radiusDp = 16) + view } - appWidgetManager.updateAppWidget(id, views) } } - - override fun onReceive(context: Context, intent: android.content.Intent) { - super.onReceive(context, intent) - } - - private fun readMs(prefs: SharedPreferences, key: String): Long { - return try { - prefs.getLong(key, 0L) - } catch (_: ClassCastException) { - prefs.getInt(key, 0).toLong() - } - } - - private fun RemoteViews.applyBackground(context: Context) { - val bgRes = WidgetPrefs.getBackgroundDrawableRes(context) - setInt(R.id.widget_root, "setBackgroundResource", bgRes) - } } diff --git a/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetArtLoader.kt b/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetArtLoader.kt index 5447ce59..8c6d999a 100644 --- a/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetArtLoader.kt +++ b/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetArtLoader.kt @@ -1,23 +1,191 @@ package com.mossapps.flick.widgets +import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.LinearGradient +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Shader +import android.util.LruCache +import androidx.core.graphics.ColorUtils +import com.mossapps.flick.R import java.io.File +import kotlin.math.max +import kotlin.math.roundToInt object WidgetArtLoader { private const val MAX_PX = 256 + private data class Key( + val path: String, val modified: Long, val length: Long, val maxPx: Int, + val width: Int = 0, val height: Int = 0, val radiusDp: Int = 0, val density: Int = 0, + val leftCornersOnly: Boolean = false, val overlayColor: Int = 0, + val placeholder: Int = R.drawable.widget_default_art, + ) + private val cache = object : LruCache(6 * 1024 * 1024) { + override fun sizeOf(key: Key, value: Bitmap): Int = value.allocationByteCount + } fun load(path: String?, maxPx: Int = MAX_PX): Bitmap? { - if (path.isNullOrEmpty() || !File(path).exists()) return null + if (path.isNullOrEmpty() || maxPx <= 0) return null + val file = File(path) + if (!file.isFile) return null + val key = Key(file.absolutePath, file.lastModified(), file.length(), maxPx) + cache.get(key)?.let { return it } val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(path, opts) if (opts.outWidth <= 0 || opts.outHeight <= 0) return null val sample = calculateSampleSize(opts.outWidth, opts.outHeight, maxPx) - return BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { + val bitmap = BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample inPreferredConfig = Bitmap.Config.RGB_565 - }) + }) ?: return null + cache.put(key, bitmap) + // Do not recycle evictions: a RemoteViews may still reference the bitmap. + return bitmap + } + + /** XML clipToOutline is API 31+: pre-crop and mask art for older launchers too. */ + internal fun rounded( + context: Context, path: String?, size: WidgetSize, maxPx: Int, radiusDp: Int, + leftCornersOnly: Boolean = false, overlayColor: Int = 0, + placeholder: Int = R.drawable.widget_default_art, + ): Bitmap { + val dm = context.resources.displayMetrics + val targetWidth = (size.widthDp * dm.density).roundToInt().coerceAtLeast(1) + val targetHeight = (size.heightDp * dm.density).roundToInt().coerceAtLeast(1) + val scale = (maxPx.toFloat() / max(targetWidth, targetHeight)).coerceAtMost(1f) + val width = (targetWidth * scale).roundToInt().coerceAtLeast(1) + val height = (targetHeight * scale).roundToInt().coerceAtLeast(1) + val file = path?.takeIf { it.isNotEmpty() }?.let { File(it) } + val key = Key(file?.absolutePath ?: "", file?.lastModified() ?: 0L, file?.length() ?: 0L, + maxPx, targetWidth, targetHeight, radiusDp, dm.densityDpi, + leftCornersOnly, overlayColor, placeholder) + cache.get(key)?.let { return it } + + val source = load(path, maxPx) ?: Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { + val canvas = Canvas(it) + canvas.scale(width.toFloat() / targetWidth, height.toFloat() / targetHeight) + context.getDrawable(placeholder)?.apply { + setBounds(0, 0, targetWidth, targetHeight) + draw(canvas) + } + } + val cropScale = max(width.toFloat() / source.width, height.toFloat() / source.height) + val matrix = Matrix().apply { + setScale(cropScale, cropScale) + postTranslate((width - source.width * cropScale) / 2f, (height - source.height * cropScale) / 2f) + } + val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { + shader = BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply { + setLocalMatrix(matrix) + } + } + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + // Keep wrap-content cover thumbnails at their requested dp size even + // when the binder-safe bitmap has been downsampled. + bitmap.density = (dm.densityDpi * scale).roundToInt().coerceAtLeast(1) + val radius = radiusDp * dm.density * scale + val mask = Path().apply { + val rightRadius = if (leftCornersOnly) 0f else radius + addRoundRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), + floatArrayOf(radius, radius, rightRadius, rightRadius, rightRadius, rightRadius, radius, radius), + Path.Direction.CW) + } + val canvas = Canvas(bitmap) + canvas.drawPath(mask, paint) + if (overlayColor != 0) { + paint.shader = null + paint.color = overlayColor + canvas.drawPath(mask, paint) + } + cache.put(key, bitmap) + return bitmap + } + + /** Average color of a cover for the artwork look, clamped into the Replay palette range. */ + internal fun dominantColor(path: String?, maxPx: Int = 96): Int? { + val bitmap = load(path, maxPx) ?: return null + val stepX = (bitmap.width / 10).coerceAtLeast(1) + val stepY = (bitmap.height / 10).coerceAtLeast(1) + var r = 0L + var g = 0L + var b = 0L + var count = 0 + var y = stepY / 2 + while (y < bitmap.height) { + var x = stepX / 2 + while (x < bitmap.width) { + val pixel = bitmap.getPixel(x, y) + r += pixel shr 16 and 0xFF + g += pixel shr 8 and 0xFF + b += pixel and 0xFF + count++ + x += stepX + } + y += stepY + } + if (count == 0) return null + val hsl = FloatArray(3) + ColorUtils.RGBToHSL((r / count).toInt(), (g / count).toInt(), (b / count).toInt(), hsl) + hsl[1] = hsl[1].coerceIn(0.35f, 0.75f) + hsl[2] = hsl[2].coerceIn(0.38f, 0.62f) + return ColorUtils.HSLToColor(hsl) + } + + private data class SurfaceKey( + val start: Int, val end: Int, val border: Int, + val width: Int, val height: Int, val radius: Int, val density: Int, + ) + private val surfaceCache = object : LruCache(2 * 1024 * 1024) { + override fun sizeOf(key: SurfaceKey, value: Bitmap): Int = value.allocationByteCount + } + + /** Rounded gradient surface for the artwork look; same pre-31 masking rule as [rounded]. */ + internal fun gradientSurface( + context: Context, size: WidgetSize, maxPx: Int, radiusDp: Int, + startColor: Int, endColor: Int, borderColor: Int, borderWidthDp: Int = 1, + ): Bitmap { + val dm = context.resources.displayMetrics + val targetWidth = (size.widthDp * dm.density).roundToInt().coerceAtLeast(1) + val targetHeight = (size.heightDp * dm.density).roundToInt().coerceAtLeast(1) + val scale = (maxPx.toFloat() / max(targetWidth, targetHeight)).coerceAtMost(1f) + val width = (targetWidth * scale).roundToInt().coerceAtLeast(1) + val height = (targetHeight * scale).roundToInt().coerceAtLeast(1) + val key = SurfaceKey(startColor, endColor, borderColor, width, height, radiusDp, dm.densityDpi) + surfaceCache.get(key)?.let { return it } + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.density = (dm.densityDpi * scale).roundToInt().coerceAtLeast(1) + val radius = radiusDp * dm.density * scale + val mask = Path().apply { + addRoundRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), radius, radius, Path.Direction.CW) + } + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.shader = LinearGradient( + 0f, 0f, width.toFloat(), height.toFloat(), startColor, endColor, Shader.TileMode.CLAMP, + ) + canvas.drawPath(mask, paint) + paint.shader = null + paint.style = Paint.Style.STROKE + paint.color = borderColor + val stroke = (borderWidthDp * dm.density * scale).coerceAtLeast(1f) + paint.strokeWidth = stroke + val inset = stroke / 2f + val borderMask = Path().apply { + addRoundRect( + RectF(inset, inset, width - inset, height - inset), + radius, radius, Path.Direction.CW, + ) + } + canvas.drawPath(borderMask, paint) + surfaceCache.put(key, bitmap) + return bitmap } private fun calculateSampleSize(w: Int, h: Int, maxPx: Int): Int { diff --git a/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetIntents.kt b/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetIntents.kt index 18220f23..8eeed73d 100644 --- a/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetIntents.kt +++ b/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetIntents.kt @@ -56,4 +56,7 @@ internal object WidgetIntents { fun openApp(context: Context, requestCode: Int): PendingIntent = activity(context, Uri.parse("flickwidget://player/open"), requestCode) + + fun openRecap(context: Context): PendingIntent = + activity(context, Uri.parse("flickwidget://recap/open"), 70) } diff --git a/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetPrefs.kt b/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetPrefs.kt index 832f260b..d7db5f22 100644 --- a/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetPrefs.kt +++ b/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetPrefs.kt @@ -28,6 +28,22 @@ internal object WidgetPrefs { const val KEY_POSITION_MS = "flick_widget_position_ms" const val KEY_DURATION_MS = "flick_widget_duration_ms" const val KEY_QUEUE_COUNT = "flick_widget_queue_count" + const val KEY_NEXT_1_ID = "flick_widget_next_1_id" + const val KEY_NEXT_1_TITLE = "flick_widget_next_1_title" + const val KEY_NEXT_1_ART = "flick_widget_next_1_art" + const val KEY_NEXT_2_ID = "flick_widget_next_2_id" + const val KEY_NEXT_2_TITLE = "flick_widget_next_2_title" + const val KEY_NEXT_2_ART = "flick_widget_next_2_art" + + const val KEY_REPLAY_LOOK = "flick_widget_replay_look" + const val KEY_REPLAY_DAY = "flick_widget_replay_day" + const val KEY_REPLAY_TIME_MS = "flick_widget_replay_time_ms" + const val KEY_REPLAY_PLAYS = "flick_widget_replay_plays" + const val KEY_REPLAY_TIME_DELTA = "flick_widget_replay_time_delta" + const val KEY_REPLAY_PLAYS_DELTA = "flick_widget_replay_plays_delta" + const val KEY_REPLAY_COVER_1 = "flick_widget_replay_cover_1" + const val KEY_REPLAY_COVER_2 = "flick_widget_replay_cover_2" + const val KEY_REPLAY_COVER_3 = "flick_widget_replay_cover_3" private val BG_OPACITY_MAP = mapOf( 0 to 0x00, 1 to 0x40, 2 to 0x80, 3 to 0xC0, 4 to 0xFF @@ -48,7 +64,7 @@ internal object WidgetPrefs { fun get(context: Context): SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - // ponytail: rebuild all three home widgets from current prefs; used when the + // ponytail: rebuild all home widgets from current prefs; used when the // app process is about to die so the play/pause state can't drift stale. fun updateAllWidgets(context: Context) { val mgr = AppWidgetManager.getInstance(context) @@ -56,6 +72,7 @@ internal object WidgetPrefs { MiniPlayerWidgetProvider::class.java, FlagshipWidgetProvider::class.java, CompactWidgetProvider::class.java, + ReplayWidgetProvider::class.java, ).forEach { cls -> val ids = mgr.getAppWidgetIds(ComponentName(context, cls)) if (ids.isNotEmpty()) { @@ -191,4 +208,9 @@ internal object WidgetPrefs { } } + // --- Replay widget preferences --- + + fun getReplayLook(context: Context): String = + get(context).getString(KEY_REPLAY_LOOK, "acid") ?: "acid" + } diff --git a/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetTextRenderer.kt b/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetTextRenderer.kt index 57780234..aa2e0072 100644 --- a/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetTextRenderer.kt +++ b/android/app/src/main/kotlin/com/mossapps/flick/widgets/WidgetTextRenderer.kt @@ -4,16 +4,40 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint +import android.graphics.Typeface import android.text.Layout import android.text.StaticLayout import android.text.TextPaint import android.text.TextUtils import android.util.TypedValue +import android.util.LruCache import androidx.annotation.FontRes import androidx.core.content.res.ResourcesCompat object WidgetTextRenderer { + private data class Key( + val text: String, val font: Int, val sizePx: Float, val color: Int, + val width: Int, val height: Int, val alignment: Layout.Alignment, val locale: String, + val weight: Int, val shadowColor: Int, val fakeBold: Boolean, val fitWidth: Boolean, + val fillHeight: Boolean, val baselineInset: Float, + ) + private val cache = object : LruCache(2 * 1024 * 1024) { + override fun sizeOf(key: Key, value: Bitmap): Int = value.allocationByteCount + } + private val fonts = HashMap() + + private fun font(context: Context, @FontRes fallback: Int, weight: Int): Typeface = + fonts.getOrPut(weight) { + try { + val asset = if (weight >= 600) "Bold" else "Regular" + Typeface.Builder(context.assets, "flutter_assets/assets/fonts/GoogleSansFlex-$asset.ttf") + .setWeight(weight).build() + ?: ResourcesCompat.getFont(context, fallback) ?: Typeface.DEFAULT + } catch (_: RuntimeException) { + ResourcesCompat.getFont(context, fallback) ?: Typeface.DEFAULT + } + } fun createTextBitmap( context: Context, @@ -23,36 +47,63 @@ object WidgetTextRenderer { textColor: Int, maxWidthPx: Int, alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, + maxHeightPx: Int = Int.MAX_VALUE, + fontWeight: Int = 400, + shadowColor: Int = 0, + fakeBold: Boolean = false, + fitWidth: Boolean = false, + fillHeight: Boolean = false, ): Bitmap { val dm = context.resources.displayMetrics val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { - typeface = ResourcesCompat.getFont(context, fontRes) + typeface = font(context, fontRes, fontWeight) textSize = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, textSizeSp.toFloat(), dm, ) color = textColor + textLocale = context.resources.configuration.locales[0] + if (shadowColor != 0) setShadowLayer(dm.density, 0f, dm.density * 0.5f, shadowColor) + // The bundled font ships Regular and Bold only; a small synthetic + // weight fills the gap for secondary metadata. + if (fakeBold) setFakeBoldText(true) } + val width = maxWidthPx.coerceAtLeast(1) + val height = maxHeightPx.coerceAtLeast(1) + // Honor SP (including system font scaling), fitting only when the finite + // launcher cell cannot hold a full line. The full label stays accessible. + if (paint.fontSpacing > height) paint.textSize *= height / paint.fontSpacing + val baselineInset = if (fillHeight) paint.fontMetrics.descent else 0f + val singleLine = text.replace('\n', ' ').replace('\r', ' ') + if (fitWidth) { + val measured = paint.measureText(singleLine) + if (measured > width) paint.textSize *= width / measured + } + val key = Key(text, fontRes, paint.textSize, textColor, width, height, alignment, + paint.textLocale.toLanguageTag(), fontWeight, shadowColor, fakeBold, fitWidth, fillHeight, baselineInset) + cache.get(key)?.let { return it } val ellipsized = TextUtils.ellipsize( - text, + singleLine, paint, - maxWidthPx.toFloat(), + width.toFloat(), TextUtils.TruncateAt.END, ) - @Suppress("DEPRECATION") - val layout = StaticLayout( - ellipsized, - paint, - maxWidthPx, - alignment, - 1f, - 0f, - false, - ) - val bitmap = Bitmap.createBitmap(maxWidthPx, layout.height, Bitmap.Config.ARGB_8888) + val layout = StaticLayout.Builder.obtain(ellipsized, 0, ellipsized.length, paint, width) + .setAlignment(alignment) + .setIncludePad(false) + .setMaxLines(1) + .build() + val bitmap = Bitmap.createBitmap(width, + if (fillHeight) height else layout.height.coerceIn(1, height), Bitmap.Config.ARGB_8888) + if (fillHeight) bitmap.density = dm.densityDpi val canvas = Canvas(bitmap) + // A fixed metric box keeps adjacent values on the same baseline even + // when a long duration or play count has to fit a narrower column. + if (fillHeight) canvas.translate(0f, + (height - baselineInset - layout.getLineBaseline(0)).coerceAtLeast(0f)) layout.draw(canvas) + cache.put(key, bitmap) return bitmap } } From fa8680ec70f22954256cafdc4d484e9e04d7fb3c Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 14 Oct 2026 16:15:21 +0800 Subject: [PATCH 2/8] Add ripple effects to widget buttons Replace static oval shapes with ripple drawables for play and transport buttons, add active transport background, and update widget layouts to use outline clipping. Add widget dimension resources for compact sizing. Enable resizing on the flagship widget and support vertical/horizontal resize. Restructure flagship and mini player layouts to use included sub-layouts and a thumbnail-based mini player design. Add accessibility strings for playback state and elapsed/duration labels. --- .../res/drawable/widget_button_play_bg.xml | 14 +- .../widget_button_transport_active_bg.xml | 11 +- .../drawable/widget_button_transport_bg.xml | 11 +- .../src/main/res/layout/widget_compact.xml | 148 +++++++++-------- .../main/res/layout/widget_flagship_art.xml | 120 +++----------- .../main/res/layout/widget_mini_player.xml | 150 +++++++----------- .../src/main/res/values/widget_strings.xml | 25 +++ .../src/main/res/xml/compact_widget_info.xml | 7 +- .../src/main/res/xml/flagship_widget_info.xml | 7 +- .../main/res/xml/mini_player_widget_info.xml | 3 +- 10 files changed, 214 insertions(+), 282 deletions(-) diff --git a/android/app/src/main/res/drawable/widget_button_play_bg.xml b/android/app/src/main/res/drawable/widget_button_play_bg.xml index d7d44cd5..b6cb69d1 100644 --- a/android/app/src/main/res/drawable/widget_button_play_bg.xml +++ b/android/app/src/main/res/drawable/widget_button_play_bg.xml @@ -1,8 +1,8 @@ - - - - + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_button_transport_active_bg.xml b/android/app/src/main/res/drawable/widget_button_transport_active_bg.xml index bc4f0561..cfd68913 100644 --- a/android/app/src/main/res/drawable/widget_button_transport_active_bg.xml +++ b/android/app/src/main/res/drawable/widget_button_transport_active_bg.xml @@ -1,5 +1,8 @@ - - - + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_button_transport_bg.xml b/android/app/src/main/res/drawable/widget_button_transport_bg.xml index 894282c9..342d35bb 100644 --- a/android/app/src/main/res/drawable/widget_button_transport_bg.xml +++ b/android/app/src/main/res/drawable/widget_button_transport_bg.xml @@ -1,5 +1,8 @@ - - - + + + + + + + diff --git a/android/app/src/main/res/layout/widget_compact.xml b/android/app/src/main/res/layout/widget_compact.xml index 06430fd3..79570152 100644 --- a/android/app/src/main/res/layout/widget_compact.xml +++ b/android/app/src/main/res/layout/widget_compact.xml @@ -3,7 +3,9 @@ android:id="@+id/compact_root" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@drawable/widget_bg_3"> + android:background="@drawable/widget_bg_3" + android:clipToOutline="true" + android:outlineProvider="background"> + android:src="@drawable/widget_default_art" /> + android:id="@+id/compact_brand" + android:layout_width="16dp" + android:layout_height="20dp" + android:layout_marginStart="16dp" + android:layout_marginTop="16dp" + android:importantForAccessibility="no" + android:src="@drawable/widget_flick_logo" /> - - - - + android:layout_height="48dp" + android:layout_gravity="center_vertical" + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" + android:layout_marginBottom="8dp" + android:gravity="center_vertical" + android:orientation="horizontal"> - - + android:layout_weight="1" + android:orientation="vertical"> - - - + + + + - \ No newline at end of file + + + + + + + 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_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/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/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" From 3c0371e619f5a966569b8d16a4604ec370855ffb Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 14 Oct 2026 16:15:41 +0800 Subject: [PATCH 3/8] Add replay widget provider to Android manifest --- android/app/src/main/AndroidManifest.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) 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_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_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_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/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) + } +} From d7e31c8cff409fc9d1b50151347faac100b8305a Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 14 Oct 2026 16:17:15 +0800 Subject: [PATCH 7/8] Add widget replay snapshot value helpers --- lib/services/widget_replay_snapshot.dart | 43 ++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 lib/services/widget_replay_snapshot.dart 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); + } +} From 8b6a293ce2d6bc17309847bf0e804fc04d7d7c42 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 14 Oct 2026 16:17:24 +0800 Subject: [PATCH 8/8] Add widget sync and replay tests --- test/services/widget_intent_handler_test.dart | 137 +++++++ .../services/widget_replay_snapshot_test.dart | 60 ++++ test/services/widget_replay_sync_test.dart | 333 ++++++++++++++++++ test/services/widget_sync_service_test.dart | 241 +++++++++++++ 4 files changed, 771 insertions(+) create mode 100644 test/services/widget_intent_handler_test.dart create mode 100644 test/services/widget_replay_snapshot_test.dart create mode 100644 test/services/widget_replay_sync_test.dart create mode 100644 test/services/widget_sync_service_test.dart 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, + ); + }, + ); +}