From 1a151339a91e7ac50a4ef70500f4e2de51afbb6f Mon Sep 17 00:00:00 2001 From: Aswin C Date: Wed, 11 Nov 2026 01:48:07 +0530 Subject: [PATCH 1/9] feat(widgets): redesign home screen widgets in Material 3 Expressive shapes --- app/README.md | 2 +- .../NavGraphSettingsDestinations.kt | 42 + .../boxlore/navigation/NavGraphWiring.kt | 1 + .../cx/aswin/boxlore/ui/BoxLoreAppRoot.kt | 4 + .../widgets/HomeScreenWidgetPreview.kt | 84 ++ .../widgets/HomeScreenWidgetsInstaller.kt | 8 +- .../NowPlayingWidgetPlaybackAdapter.kt | 6 + .../widgets/WidgetLibrarySourceAdapter.kt | 14 +- .../boxlore/navigation/AddableWidgetsTest.kt | 31 + .../widgets/HomeScreenWidgetPreviewTest.kt | 131 +++ .../designsystem/theme/BoxLoreChromeColors.kt | 8 + core/playback/README.md | 2 +- .../core/playback/PlaybackRepository.kt | 15 + core/prefs/README.md | 5 +- .../core/prefs/AppearancePreferences.kt | 2 + .../boxlore/core/prefs/UserPreferenceKeys.kt | 1 + .../core/prefs/UserPreferencesRepository.kt | 7 + .../boxlore/core/prefs/WidgetAppearance.kt | 24 + .../prefs/WidgetShowsArtworkPreferences.kt | 41 + .../prefs/UserPreferencesRepositoryTest.kt | 13 + .../core/prefs/WidgetAppearanceTest.kt | 10 + feature/settings/README.md | 9 +- .../settings/ProfileSettingsDestination.kt | 3 +- .../feature/settings/SettingsScreen.kt | 24 +- .../settings/pages/AppearanceSettingsPage.kt | 50 +- .../settings/pages/AppearanceThemePage.kt | 18 +- .../settings/pages/AppearanceWidgetsPage.kt | 223 +++++ .../settings/AppearanceActionsTrackedTest.kt | 28 + .../settings/SettingsBackNavigationTest.kt | 1 + .../settings/TabletSettingsCaptureTest.kt | 3 + .../settings/pages/AppearancePagesTest.kt | 7 + .../pages/AppearanceWidgetsPageTest.kt | 129 +++ feature/widgets/README.md | 64 +- .../feature/widgets/HomeScreenWidgets.kt | 67 ++ .../widgets/LibraryWidgetCoordinator.kt | 90 +- .../widgets/LibraryWidgetDependencies.kt | 22 +- .../LibraryWidgetRemoteViewsService.kt | 216 +++-- .../feature/widgets/LibraryWidgetRenderer.kt | 196 +++-- .../widgets/NowPlayingWidgetCoordinator.kt | 26 + .../widgets/NowPlayingWidgetDependencies.kt | 6 + .../widgets/NowPlayingWidgetRenderer.kt | 816 ++++++++---------- .../feature/widgets/WidgetBitmapCache.kt | 63 ++ .../boxlore/feature/widgets/WidgetChrome.kt | 118 ++- .../boxlore/feature/widgets/WidgetPalette.kt | 8 + .../feature/widgets/WidgetPickerPreviews.kt | 250 ++++++ .../boxlore/feature/widgets/WidgetSeekIcon.kt | 87 ++ .../boxlore/feature/widgets/WidgetShapes.kt | 207 +++++ .../widgets/WidgetTextBitmapRenderer.kt | 109 --- .../widgets/actions/WidgetActionHandler.kt | 40 +- .../widgets/logic/NowPlayingWidgetLogic.kt | 20 +- .../widgets/logic/WidgetPresentation.kt | 116 +++ .../feature/widgets/logic/WidgetProgress.kt | 38 + .../widgets/logic/WidgetProgressPoller.kt | 29 + .../feature/widgets/logic/WidgetSizes.kt | 32 + .../res/drawable/ic_widget_arrow_forward.xml | 13 + .../main/res/drawable/ic_widget_caught_up.xml | 13 + .../res/drawable/widget_art_placeholder.xml | 10 - .../res/drawable/widget_art_tile_shape.xml | 6 - ...idget_button_play.xml => widget_block.xml} | 2 +- .../main/res/drawable/widget_button_pause.xml | 6 - .../src/main/res/drawable/widget_circle.xml | 11 + ...ton_secondary.xml => widget_container.xml} | 2 +- .../src/main/res/drawable/widget_cookie.xml | 11 + .../main/res/drawable/widget_grid_surface.xml | 6 - .../res/drawable/widget_list_art_frame.xml | 6 - .../res/drawable/widget_list_footer_chip.xml | 6 - .../res/drawable/widget_list_row_chip.xml | 6 - .../src/main/res/drawable/widget_new_dot.xml | 5 - ...et_list_count_chip.xml => widget_pill.xml} | 1 + .../widget_preview_button_primary.xml | 6 - .../widget_preview_button_secondary.xml | 6 - .../res/drawable/widget_preview_cover_a.xml | 17 +- .../res/drawable/widget_preview_cover_b.xml | 17 +- .../res/drawable/widget_preview_cover_c.xml | 17 +- .../res/drawable/widget_preview_cover_d.xml | 17 + .../res/drawable/widget_preview_cover_e.xml | 14 + .../res/drawable/widget_preview_cover_f.xml | 17 + .../res/drawable/widget_preview_disc_a.xml | 17 + .../res/drawable/widget_preview_disc_b.xml | 17 + .../res/drawable/widget_preview_disc_c.xml | 17 + .../res/drawable/widget_preview_new_dot.xml | 6 +- .../res/drawable/widget_preview_pill_chip.xml | 6 - .../main/res/drawable/widget_preview_ring.xml | 19 + .../res/drawable/widget_preview_row_chip.xml | 6 - .../res/drawable/widget_preview_surface.xml | 7 - .../res/drawable/widget_preview_tile_a.xml | 17 + .../res/drawable/widget_preview_tile_b.xml | 17 + .../res/drawable/widget_preview_tile_c.xml | 17 + .../res/drawable/widget_preview_tile_d.xml | 20 + .../res/drawable/widget_preview_tile_e.xml | 17 + .../res/drawable/widget_preview_tile_f.xml | 20 + .../res/drawable/widget_sizer_bar_play.xml | 9 + .../main/res/drawable/widget_sizer_fill.xml | 9 + .../main/res/drawable/widget_sizer_frame.xml | 9 + .../res/drawable/widget_sizer_frame_bar.xml | 9 + .../res/drawable/widget_sizer_frame_tall.xml | 9 + .../main/res/drawable/widget_sizer_mini.xml | 9 + .../main/res/drawable/widget_sizer_square.xml | 9 + .../src/main/res/drawable/widget_surface.xml | 7 - .../library_widget_episode_list_item.xml | 74 +- .../main/res/layout/library_widget_grid.xml | 204 +++++ .../main/res/layout/library_widget_list.xml | 159 ++-- .../res/layout/library_widget_list_item.xml | 107 +-- .../res/layout/now_playing_widget_bar.xml | 576 +++++++++---- .../layout/now_playing_widget_standard.xml | 678 +++++++++------ .../now_playing_widget_standard_tall.xml | 482 +++++++++++ .../res/layout/playback_controls_widget.xml | 380 +++++--- .../res/layout/widget_preview_bar_4x1.xml | 194 +++-- .../res/layout/widget_preview_control_4x2.xml | 297 ++++--- .../res/layout/widget_preview_grid_2x2.xml | 188 ++-- .../layout/widget_preview_grid_next_2x2.xml | 188 ++-- .../layout/widget_preview_new_episodes.xml | 414 +++++---- .../layout/widget_preview_subscriptions.xml | 384 ++++----- .../src/main/res/values-night-v31/colors.xml | 4 + .../src/main/res/values-v31/colors.xml | 4 + .../widgets/src/main/res/values/colors.xml | 4 + .../widgets/src/main/res/values/strings.xml | 35 +- .../main/res/xml/now_playing_widget_info.xml | 2 +- .../feature/widgets/HomeScreenWidgetsTest.kt | 59 ++ .../widgets/LibraryWidgetRemoteViewsTest.kt | 64 +- .../NowPlayingWidgetCoordinatorTest.kt | 2 + .../NowPlayingWidgetRemoteViewsTest.kt | 219 ++++- .../feature/widgets/WidgetChromeTest.kt | 46 +- .../widgets/WidgetPickerPreviewsTest.kt | 104 +++ .../widgets/WidgetRenderGalleryTest.kt | 299 +++++++ .../feature/widgets/WidgetShapesTest.kt | 66 ++ .../widgets/WidgetTextBitmapRendererTest.kt | 38 - .../logic/NowPlayingWidgetLogicTest.kt | 51 ++ .../widgets/logic/WidgetPresentationTest.kt | 105 +++ .../widgets/logic/WidgetProgressPollerTest.kt | 56 ++ .../widgets/logic/WidgetProgressTest.kt | 44 + .../feature/widgets/logic/WidgetSizesTest.kt | 36 + release-notes/upcoming.md | 5 + 133 files changed, 7066 insertions(+), 2556 deletions(-) create mode 100644 app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreview.kt create mode 100644 app/src/test/java/cx/aswin/boxlore/navigation/AddableWidgetsTest.kt create mode 100644 app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreviewTest.kt create mode 100644 core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetShowsArtworkPreferences.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt create mode 100644 feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt create mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt create mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetBitmapCache.kt create mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt create mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetSeekIcon.kt create mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetShapes.kt delete mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetTextBitmapRenderer.kt create mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetPresentation.kt create mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgress.kt create mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressPoller.kt create mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetSizes.kt create mode 100644 feature/widgets/src/main/res/drawable/ic_widget_arrow_forward.xml create mode 100644 feature/widgets/src/main/res/drawable/ic_widget_caught_up.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_art_placeholder.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_art_tile_shape.xml rename feature/widgets/src/main/res/drawable/{widget_button_play.xml => widget_block.xml} (82%) delete mode 100644 feature/widgets/src/main/res/drawable/widget_button_pause.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_circle.xml rename feature/widgets/src/main/res/drawable/{widget_button_secondary.xml => widget_container.xml} (82%) create mode 100644 feature/widgets/src/main/res/drawable/widget_cookie.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_grid_surface.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_list_art_frame.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_list_footer_chip.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_list_row_chip.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_new_dot.xml rename feature/widgets/src/main/res/drawable/{widget_list_count_chip.xml => widget_pill.xml} (72%) delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_button_primary.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_button_secondary.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_cover_d.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_cover_e.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_cover_f.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_disc_a.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_disc_b.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_disc_c.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_pill_chip.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_ring.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_row_chip.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_surface.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_a.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_b.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_c.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_d.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_e.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_f.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_sizer_bar_play.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_sizer_fill.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_sizer_frame.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_sizer_frame_bar.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_sizer_frame_tall.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_sizer_mini.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_sizer_square.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_surface.xml create mode 100644 feature/widgets/src/main/res/layout/library_widget_grid.xml create mode 100644 feature/widgets/src/main/res/layout/now_playing_widget_standard_tall.xml create mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt create mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt create mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt create mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetShapesTest.kt delete mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetTextBitmapRendererTest.kt create mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetPresentationTest.kt create mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressPollerTest.kt create mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressTest.kt create mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetSizesTest.kt diff --git a/app/README.md b/app/README.md index 6b354c1bd..feebc6169 100644 --- a/app/README.md +++ b/app/README.md @@ -94,7 +94,7 @@ src/main/java/cx/aswin/boxlore/ AdaptivePlayerChrome, announcement/, libraryimport/, logic/ updates/ AppUpdates, UpdateChecker, direct and Play sources, AppUpdatesPanel util/ InstallSource - widgets/ HomeScreenWidgetsInstaller and widget adapters + widgets/ HomeScreenWidgetsInstaller, widget adapters and HomeScreenWidgetPreview (Settings' widget pictures) src/direct/ APK downloader and installer (debug, release, updateTest) src/playRelease/ Play stub installer src/debug/ App Check debug provider, ADB-only notification preview receiver diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt index 7a23d4d1a..b362cb608 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.navArgument @@ -24,11 +26,15 @@ import cx.aswin.boxlore.feature.settings.SettingsScreen import cx.aswin.boxlore.feature.settings.SettingsScreenConfig import cx.aswin.boxlore.feature.settings.downloads.AutoDownloadSettingsScreen import cx.aswin.boxlore.feature.settings.downloads.SmartDownloadsSettingsScreen +import cx.aswin.boxlore.feature.settings.pages.AddableWidget import cx.aswin.boxlore.feature.settings.pages.AppearanceActions import cx.aswin.boxlore.feature.settings.pages.AppearanceUiState +import cx.aswin.boxlore.feature.settings.pages.AppearanceWidgetPreview import cx.aswin.boxlore.feature.settings.pages.PlaybackActions import cx.aswin.boxlore.feature.settings.pages.PlaybackUiState +import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets import cx.aswin.boxlore.ui.libraryimport.OpmlImportState +import cx.aswin.boxlore.widgets.HomeScreenWidgetPreview import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged @@ -167,6 +173,34 @@ private fun resetSettingsAnalytics() { } } +/** Every boxlore widget under its picker name, for Appearance › Widgets. Reads strings only. */ +internal fun addableWidgets(context: android.content.Context): List = HomeScreenWidgets.all.map { widget -> + AddableWidget( + key = widget.key, + name = context.getString(widget.label), + description = context.getString(widget.description), + previewWidthDp = widget.previewWidthDp, + previewHeightDp = widget.previewHeightDp, + ) +} + +/** Appearance › Widgets draws the real widgets, again whenever a setting changes how they look. */ +private fun widgetPreview(w: NavGraphWiring): AppearanceWidgetPreview = { widget, modifier -> + val settings = w.settingsState + val look = + listOf( + settings.widgetAppearance, + settings.widgetShowsArtwork, + settings.themeConfig, + settings.themeBrand, + settings.surfaceStyle, + settings.useDynamicColor, + settings.fontRoundness, + settings.savedCustomTheme, + ) + HomeScreenWidgetPreview(widget.key, DpSize(widget.previewWidthDp.dp, widget.previewHeightDp.dp), look, modifier) +} + private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings { val scope = w.scope val userPrefs = w.userPrefs @@ -183,6 +217,8 @@ private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings { currentOpenAppTo = settingsState.openAppTo, homeShortcutsInLibrary = settingsState.homeShortcutsInLibrary, currentWidgetAppearance = settingsState.widgetAppearance, + currentWidgetShowsArtwork = settingsState.widgetShowsArtwork, + addableWidgets = addableWidgets(w.context), currentExploreDefaultTab = settingsState.exploreDefaultTab, currentSubscriptionsDefaultTab = settingsState.subscriptionsDefaultTab, currentSubscriptionsTabStyle = settingsState.subscriptionsTabStyle, @@ -225,6 +261,11 @@ private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings { onSetWidgetAppearance = { appearance -> scope.launch { userPrefs.setWidgetAppearance(appearance) } }, + onSetWidgetShowsArtwork = { style -> + scope.launch { userPrefs.setWidgetShowsArtwork(style) } + }, + canAddWidgets = { HomeScreenWidgets.canAdd(w.context) }, + onAddWidget = { key -> HomeScreenWidgets.requestAdd(w.context, key) }, onSetExploreDefaultTab = { tab -> scope.launch { userPrefs.setExploreDefaultTab(tab) } }, @@ -246,6 +287,7 @@ private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings { val sessions by w.playbackRepository.resumeSessions.collectAsState(initial = emptyList()) HeroStylePreview(style, coverShade, collection, sessions, modifier, tideProgress = settingsState.heroTideProgress) }, + widgetPreview = widgetPreview(w), ) } diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt index 5691f2a5e..2c6b28750 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt @@ -147,6 +147,7 @@ data class NavSettingsState( val sameShowQueueOnly: Boolean, val homeShortcutsInLibrary: Boolean, val widgetAppearance: String, + val widgetShowsArtwork: String, val exploreDefaultTab: String, val subscriptionsDefaultTab: String, val subscriptionsTabStyle: String = cx.aswin.boxlore.core.prefs.SubscriptionsTabStyle.TOP, diff --git a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt index 910cc9be6..d3233c679 100644 --- a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt +++ b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt @@ -433,6 +433,9 @@ fun BoxLoreAppRoot( val widgetAppearance by userPrefs.widgetAppearanceStream.collectAsState( initial = remember { userPrefs.cachedWidgetAppearance }, ) + val widgetShowsArtwork by userPrefs.widgetShowsArtworkStream.collectAsState( + initial = remember { userPrefs.cachedWidgetShowsArtwork }, + ) val exploreDefaultTab by userPrefs.exploreDefaultTabStream.collectAsState( initial = remember { userPrefs.cachedExploreDefaultTab }, ) @@ -710,6 +713,7 @@ fun BoxLoreAppRoot( homeShortcutsInLibrary = homeShortcutsInLibrary, discoverySections = discoverySections, widgetAppearance = widgetAppearance, + widgetShowsArtwork = widgetShowsArtwork, exploreDefaultTab = exploreDefaultTab, subscriptionsDefaultTab = subscriptionsDefaultTab, subscriptionsTabStyle = subscriptionsTabStyle, diff --git a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreview.kt b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreview.kt new file mode 100644 index 000000000..58c2090e6 --- /dev/null +++ b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreview.kt @@ -0,0 +1,84 @@ +package cx.aswin.boxlore.widgets + +import android.annotation.SuppressLint +import android.appwidget.AppWidgetHostView +import android.content.Context +import android.view.MotionEvent +import android.view.View +import android.widget.RemoteViews +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.viewinterop.AndroidView +import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * The home screen widget with this [key], drawn by its own renderer with sample content. The widget + * is laid out at its own [size] and that picture is scaled to fit the modifier's bounds, so it looks + * the same at every window width. It is drawn again when [look] changes: pass everything that changes + * how a widget looks (its colours, the lettering, the Your Shows cover style). It is a picture: + * nothing in it can be tapped, and screen readers skip it. + */ +@Composable +internal fun HomeScreenWidgetPreview(key: String, size: DpSize, look: Any, modifier: Modifier = Modifier) { + val context = LocalContext.current + // Light and dark, and the font scale, change what the widget's own layout resolves to. + val configuration = LocalConfiguration.current + val views by produceState(initialValue = null, key, look, configuration) { + value = withContext(Dispatchers.Default) { HomeScreenWidgets.preview(context, key) } + } + BoxWithConstraints(modifier, contentAlignment = Alignment.Center) { + val scale = widgetPictureScale(size, maxWidth, maxHeight) + AndroidView( + factory = ::WidgetPictureHost, + update = { host -> host.show(views) }, + modifier = + Modifier.requiredSize(size).graphicsLayer { + scaleX = scale + scaleY = scale + }, + ) + } +} + +/** How much a widget laid out at [size] is scaled to fit the space it has. It is shrunk to fit, never enlarged. */ +internal fun widgetPictureScale(size: DpSize, maxWidth: Dp, maxHeight: Dp): Float = minOf(1f, maxWidth / size.width, maxHeight / size.height) + +/** + * Holds one widget's views the way a home screen does: a list or grid a widget carries with it is + * only filled inside a widget host. Touches never reach the widget, so its buttons cannot control + * playback from Settings. + */ +@SuppressLint("ViewConstructor") +private class WidgetPictureHost(context: Context) : AppWidgetHostView(context) { + private var shown: RemoteViews? = null + + init { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + + fun show(views: RemoteViews?) { + if (views === shown) return + shown = views + removeAllViews() + if (views == null) return + // A preview that cannot be drawn leaves its frame empty; the widget's name still stands beside it. + runCatching { addView(views.apply(context, this), LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) } + } + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean = true + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean = false +} diff --git a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt index 4b649fa43..6112c72d0 100644 --- a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt +++ b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt @@ -9,6 +9,7 @@ import cx.aswin.boxlore.feature.widgets.LibraryWidgetCoordinator import cx.aswin.boxlore.feature.widgets.LibraryWidgetDependencies import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetCoordinator import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetDependencies +import cx.aswin.boxlore.feature.widgets.WidgetPickerPreviews import cx.aswin.boxlore.feature.widgets.configureLibraryWidgets import cx.aswin.boxlore.feature.widgets.configureNowPlayingWidget import kotlinx.coroutines.CoroutineScope @@ -55,7 +56,7 @@ object HomeScreenWidgetsInstaller { } }, ) - // Re-render when widget chrome source, Appearance theme, or lettering changes. + // Re-render when widget chrome source, Appearance theme, lettering, or the Your Shows artwork style changes. scope.launch { combine( combine( @@ -66,9 +67,12 @@ object HomeScreenWidgetsInstaller { userPreferencesRepository.surfaceStyleStream, ) { _, _, _, _, _ -> Unit }, userPreferencesRepository.fontRoundnessStream, - ) { _, _ -> Unit }.collect { + userPreferencesRepository.widgetShowsArtworkStream, + ) { _, _, _ -> Unit }.collect { NowPlayingWidgetCoordinator.requestRefresh(context) LibraryWidgetCoordinator.requestRefresh(context) + // The widget picker shows the same look the placed widgets now have. + WidgetPickerPreviews.refresh(context) } } } diff --git a/app/src/main/java/cx/aswin/boxlore/widgets/NowPlayingWidgetPlaybackAdapter.kt b/app/src/main/java/cx/aswin/boxlore/widgets/NowPlayingWidgetPlaybackAdapter.kt index fb4028aa2..542effab2 100644 --- a/app/src/main/java/cx/aswin/boxlore/widgets/NowPlayingWidgetPlaybackAdapter.kt +++ b/app/src/main/java/cx/aswin/boxlore/widgets/NowPlayingWidgetPlaybackAdapter.kt @@ -77,6 +77,12 @@ class NowPlayingWidgetPlaybackAdapter( } } + override suspend fun refreshPosition() { + withWidgetPlaybackDispatcher(playbackDispatcher) { + playbackRepository.refreshProgressOnce() + } + } + private suspend fun restoreIfNeeded() { withWidgetPlaybackDispatcher(playbackDispatcher) { if (playbackRepository.playerState.value.currentEpisode == null) { diff --git a/app/src/main/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapter.kt b/app/src/main/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapter.kt index fe734eb2b..4b273c1dc 100644 --- a/app/src/main/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapter.kt +++ b/app/src/main/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapter.kt @@ -16,6 +16,7 @@ import cx.aswin.boxlore.core.ranking.EpisodeRankingInput import cx.aswin.boxlore.core.ranking.RankingObjective import cx.aswin.boxlore.core.ranking.RankingSurface import cx.aswin.boxlore.feature.widgets.WidgetEpisodeRow +import cx.aswin.boxlore.feature.widgets.WidgetLibraryCounts import cx.aswin.boxlore.feature.widgets.WidgetLibrarySource import cx.aswin.boxlore.feature.widgets.WidgetShowRow import cx.aswin.boxlore.feature.widgets.logic.LibraryWidgetLogic @@ -43,7 +44,11 @@ class WidgetLibrarySourceAdapter( private val adaptiveScorer: AdaptiveCandidateScorer, scope: CoroutineScope, ) : WidgetLibrarySource { - private data class Projection(val subscriptions: List, val newEpisodes: List,) + private data class Projection( + val subscriptions: List, + val newEpisodes: List, + val counts: WidgetLibraryCounts = WidgetLibraryCounts(), + ) private val projection: Flow = combine( @@ -107,7 +112,11 @@ class WidgetLibrarySourceAdapter( ) }, ) - Projection(showRows, episodeRows) + Projection( + subscriptions = showRows, + newEpisodes = episodeRows, + counts = WidgetLibraryCounts(subscriptions = sortedShows.size, newEpisodes = sortedLatest.size), + ) }.stateIn( scope = scope, started = SharingStarted.Eagerly, @@ -117,6 +126,7 @@ class WidgetLibrarySourceAdapter( override val subscriptions: Flow> = projection.map { it.subscriptions } override val newEpisodes: Flow> = projection.map { it.newEpisodes } + override val counts: Flow = projection.map { it.counts } private data class Inputs( val podcasts: List, diff --git a/app/src/test/java/cx/aswin/boxlore/navigation/AddableWidgetsTest.kt b/app/src/test/java/cx/aswin/boxlore/navigation/AddableWidgetsTest.kt new file mode 100644 index 000000000..be9afd891 --- /dev/null +++ b/app/src/test/java/cx/aswin/boxlore/navigation/AddableWidgetsTest.kt @@ -0,0 +1,31 @@ +package cx.aswin.boxlore.navigation + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class AddableWidgetsTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun settingsOffersEveryWidgetUnderItsPickerName() { + val offered = addableWidgets(context) + + assertEquals(HomeScreenWidgets.all.map { it.key }, offered.map { it.key }) + assertEquals("Now Playing · Full Controls", offered.first().name) + assertEquals("Library · New Episodes", offered.last().name) + assertTrue(offered.all { it.name.isNotBlank() && it.description.isNotBlank() }) + assertEquals("every name is told apart", offered.size, offered.map { it.name }.toSet().size) + // The two round players are small enough to sit side by side in Settings. + assertEquals(listOf("round", "round_next"), offered.filter { it.previewWidthDp <= 200 }.map { it.key }) + } +} diff --git a/app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreviewTest.kt b/app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreviewTest.kt new file mode 100644 index 000000000..dc406b812 --- /dev/null +++ b/app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreviewTest.kt @@ -0,0 +1,131 @@ +package cx.aswin.boxlore.widgets + +import android.app.Application +import android.appwidget.AppWidgetHostView +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme +import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The widget pictures Settings shows. Set `BOXLORE_WIDGET_RENDERS=1` to save them under + * `build/reports/widget-previews/` for a look. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w412dp-h2000dp-420dpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class HomeScreenWidgetPreviewTest { + @get:Rule val compose = createAndroidComposeRule() + + /** How big each frame is, as a share of its widget's own size: a narrow Settings card is smaller. */ + private val fit = mutableFloatStateOf(1f) + + private fun render(dark: Boolean) { + compose.setContent { + BoxLoreTheme(darkTheme = dark, dynamicColor = false) { + Column(Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + for (widget in HomeScreenWidgets.all) { + val size = DpSize(widget.previewWidthDp.dp, widget.previewHeightDp.dp) + Box(Modifier.background(MaterialTheme.colorScheme.outlineVariant).padding(14.dp)) { + HomeScreenWidgetPreview(widget.key, size, look = dark, Modifier.size(size * fit.floatValue)) + } + } + } + } + } + compose.waitUntil(timeoutMillis = 10_000) { hosts().size == HomeScreenWidgets.all.size && hosts().all { it.childCount == 1 } } + compose.waitForIdle() + } + + private fun hosts(): List { + val found = mutableListOf() + + fun walk(view: View) { + if (view is AppWidgetHostView) { + found += view + } else if (view is ViewGroup) { + for (index in 0 until view.childCount) walk(view.getChildAt(index)) + } + } + walk(compose.activity.window.decorView) + return found + } + + @Test + fun everyWidgetIsLaidOutAtItsOwnSizeHoweverSmallItsFrame() { + render(dark = false) + for (share in listOf(1f, 0.8f)) { + fit.floatValue = share + compose.waitForIdle() + val density = compose.activity.resources.displayMetrics.density + hosts().zip(HomeScreenWidgets.all).forEach { (host, widget) -> + val root = host.getChildAt(0) + assertNotNull("${widget.key} holds its widget", root) + assertEquals("${widget.key} fills its host", host.width, root.width) + // A smaller frame scales the picture; the widget itself keeps its size, so nothing reflows. + assertEquals("${widget.key} is laid out as wide as it is", widget.previewWidthDp * density, host.width.toFloat(), 2f) + assertEquals("${widget.key} is laid out as tall as it is", widget.previewHeightDp * density, host.height.toFloat(), 2f) + } + save(if (share == 1f) "light" else "light-narrow") + } + } + + @Test + fun aPictureIsShrunkToFitItsFrameAndNeverEnlarged() { + val widget = DpSize(320.dp, 170.dp) + assertEquals(1f, widgetPictureScale(widget, maxWidth = 320.dp, maxHeight = 170.dp)) + assertEquals(1f, widgetPictureScale(widget, maxWidth = 700.dp, maxHeight = 400.dp)) + assertEquals(0.8f, widgetPictureScale(widget, maxWidth = 256.dp, maxHeight = 170.dp), 0.001f) + assertEquals(0.5f, widgetPictureScale(widget, maxWidth = 320.dp, maxHeight = 85.dp), 0.001f) + } + + @Test + fun aPictureNeverPassesATouchToTheWidgetsButtons() { + render(dark = true) + for (host in hosts()) { + val now = SystemClock.uptimeMillis() + val down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, host.width / 2f, host.height / 2f, 0) + assertFalse("a tap is not handled inside the picture", host.dispatchTouchEvent(down)) + down.recycle() + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, host.importantForAccessibility) + } + save("dark") + } + + private fun save(name: String) { + if (System.getenv("BOXLORE_WIDGET_RENDERS") != "1") return + val content = compose.activity.findViewById(android.R.id.content) + val bitmap = Bitmap.createBitmap(content.width, content.height, Bitmap.Config.ARGB_8888) + content.draw(Canvas(bitmap)) + val directory = File("build/reports/widget-previews").apply { mkdirs() } + File(directory, "previews-$name.png").outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + } +} diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/BoxLoreChromeColors.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/BoxLoreChromeColors.kt index d5d3a494e..03dea8a99 100644 --- a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/BoxLoreChromeColors.kt +++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/BoxLoreChromeColors.kt @@ -15,6 +15,10 @@ data class BoxLoreChromeColors( @ColorInt val onPrimaryContainer: Int, @ColorInt val secondaryContainer: Int, @ColorInt val onSecondaryContainer: Int, + @ColorInt val tertiary: Int, + @ColorInt val onTertiary: Int, + @ColorInt val tertiaryContainer: Int, + @ColorInt val onTertiaryContainer: Int, ) fun resolveBoxLoreChromeColors( @@ -42,5 +46,9 @@ fun resolveBoxLoreChromeColors( onPrimaryContainer = scheme.onPrimaryContainer.toArgb(), secondaryContainer = scheme.secondaryContainer.toArgb(), onSecondaryContainer = scheme.onSecondaryContainer.toArgb(), + tertiary = scheme.tertiary.toArgb(), + onTertiary = scheme.onTertiary.toArgb(), + tertiaryContainer = scheme.tertiaryContainer.toArgb(), + onTertiaryContainer = scheme.onTertiaryContainer.toArgb(), ) } diff --git a/core/playback/README.md b/core/playback/README.md index 680dbacf3..cee3bf3fb 100644 --- a/core/playback/README.md +++ b/core/playback/README.md @@ -121,7 +121,7 @@ src/main/java/cx/aswin/boxlore/core/data/service/ # stubs for legacy service n - `AppContainer` builds the graph once: DB → `PodcastRepository` → `QueueRepository` → `PlaybackRepository` → `QueueManager`. - Player and controller callbacks run on the main thread. `restoreLastSession` and transport calls hop to `PlaybackThreadPolicy.mainDispatcher`; database, artwork and recommendation work runs on background dispatchers. -- `PlaybackRepository` polls the controller position only while app UI is in the foreground. The service alone saves active-session progress and telemetry. +- `PlaybackRepository` polls the controller position only while app UI is in the foreground; `refreshProgressOnce()` publishes it once on request, which home-screen widgets use while an episode plays in the background. The service alone saves active-session progress and telemetry. - `PlaybackMediaControllerHandle.awaitController()` returns null on a rejected connection and reconnects with growing delays, at most `MediaControllerReconnectPolicy.MAX_ATTEMPTS` times. - `BoxLorePlaybackService` resolves shared dependencies lazily after app start; its players are service-scoped, so playback and transfers continue without an Activity. - Idle handoff: a local session paused in the background for `PlaybackPowerPolicy.PAUSED_IDLE_TIMEOUT_MS` saves progress, rechecks eligibility and stops the service. Playback, buffering intent, foreground UI and Cast skip that teardown. diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackRepository.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackRepository.kt index bdc71947a..8e3e5e8e0 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackRepository.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackRepository.kt @@ -540,6 +540,21 @@ class PlaybackRepository internal constructor( } } + /** + * Reads the player's position once and publishes it. Home-screen widgets call this while the app + * is in the background, where [startProgressTicker] is stopped to save power. + */ + fun refreshProgressOnce() { + runOnMainThread { + val controller = mediaHandle.controller ?: return@runOnMainThread + playerStateFlow.value = + PlaybackControllerStatePolicy.mergeProgress( + previous = playerStateFlow.value, + snapshot = controller.progressSnapshot(), + ) + } + } + private fun startProgressTicker() { stopProgressTicker() if (!PlaybackUiVisibility.isForeground.value) return diff --git a/core/prefs/README.md b/core/prefs/README.md index ffdebf079..ab6f8a894 100644 --- a/core/prefs/README.md +++ b/core/prefs/README.md @@ -15,6 +15,7 @@ It does not own analytics event storage, playback behaviour, ranking storage, ca - `ThemeSelection`: opaque brand, surface and wallpaper-colour inputs; `setThemeSelection` and `setThemePreset` write them in one update, DataStore and fast cache together. - `FontRoundnessAxis`: lettering preset keys and ROND axis values for non-Compose readers, plus the `boxlore_theme_fast_cache` file name. - `WidgetAppearance`: sanitizes widget chrome (`app` or `system`) and reads it from the fast cache for RemoteViews. +- `WidgetShowsArtwork`: how the Your Shows widget draws covers (`rounded`, the default, or `shapes`), read from the fast cache by the widget's list factory. - `FullPlayerStyle`, `NavigationBlurLevel`, `SubscriptionsTabStyle`: stored values and sanitizers for the full player, navigation blur and Subscriptions tab layout. - `HeroCardStyle`, `HeroCoverShade`, `HeroTideProgress`: Home hero card style, shared colour choice and Tide ring, with per-style defaults (`HeroCoverShade.forStyle`). - `ExploreDefaultTab`, `SubscriptionsDefaultTab`: sanitize default tabs and resolve the pager index when a route names no tab. @@ -36,7 +37,7 @@ src/main/java/cx/aswin/boxlore/core/prefs/ UserPreferencesRepository.kt # public repository, user_preferences delegate UserPreferenceKeys.kt # internal Keys object AppearancePreferences.kt # theme, lettering, navigation, landing; fast cache and hydration - ThemeSelection.kt, FontRoundnessAxis.kt, WidgetAppearance.kt + ThemeSelection.kt, FontRoundnessAxis.kt, WidgetAppearance.kt, WidgetShowsArtworkPreferences.kt FullPlayerStyle.kt, FullPlayerPreferences.kt NavigationBlurLevel.kt, NavigationBlurPreferences.kt HeroCardStyle.kt, HeroCoverShade.kt, HeroTideProgress.kt, HeroCardStylePreferences.kt @@ -89,7 +90,7 @@ Files: Contracts: - Legacy `boxcast_*` files are read through `PrefsFileMigrator`; the canonical `boxlore_*` file wins. -- The fast cache mirrors `theme_config`, `surface_style`, `theme_brand`, `use_dynamic_color`, `font_roundness`, `navigation_style`, `open_app_to`, `widget_appearance`, `explore_default_tab`, `subscriptions_default_tab`, plus the full player, navigation blur, hero, artwork-colour, custom theme and Picked for you choices. +- The fast cache mirrors `theme_config`, `surface_style`, `theme_brand`, `use_dynamic_color`, `font_roundness`, `navigation_style`, `open_app_to`, `widget_appearance`, `widget_shows_artwork`, `explore_default_tab`, `subscriptions_default_tab`, plus the full player, navigation blur, hero, artwork-colour, custom theme and Picked for you choices. - After an Android backup restore, `hydrateMissingDataStoreFromFastCache` copies cached values only into missing DataStore keys; an existing DataStore choice always wins. - Hero choices are cached and hydrated only once picked, so listeners who never picked follow the current default. - `theme_brand` holds a named palette, a `#RRGGBB` Material 3 seed, or `exact:#RRGGBB` (primary used as is). diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/AppearancePreferences.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/AppearancePreferences.kt index dbf78c12b..7caec672a 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/AppearancePreferences.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/AppearancePreferences.kt @@ -48,6 +48,7 @@ internal class AppearancePreferences( val fullPlayer = FullPlayerPreferences(dataStore, syncPrefs) val navigationBlur = NavigationBlurPreferences(dataStore, syncPrefs) val heroCard = HeroCardStylePreferences(dataStore, syncPrefs) + val widgetShowsArtwork = WidgetShowsArtworkPreferences(dataStore, syncPrefs) val cachedThemeBrand: String get() = syncPrefs.getString("theme_brand", null) ?: "violet" @@ -355,6 +356,7 @@ internal class AppearancePreferences( navigationBlur.hydrate(preferences) heroCard.hydrate(preferences) preferences[Keys.WIDGET_APPEARANCE] = preferences[Keys.WIDGET_APPEARANCE] ?: cachedWidgetAppearance + widgetShowsArtwork.hydrate(preferences) } } diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferenceKeys.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferenceKeys.kt index 23e4d13a9..cbaf514de 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferenceKeys.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferenceKeys.kt @@ -85,6 +85,7 @@ internal object Keys { androidx.datastore.preferences.core .booleanPreferencesKey("home_shortcuts_in_library") val WIDGET_APPEARANCE = stringPreferencesKey("widget_appearance") + val WIDGET_SHOWS_ARTWORK = stringPreferencesKey("widget_shows_artwork") val AUTO_ORGANIZE_FOLDERS_ENABLED = androidx.datastore.preferences.core .booleanPreferencesKey("auto_organize_folders_enabled") diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt index 09780f5d1..d189f669d 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt @@ -797,6 +797,13 @@ class UserPreferencesRepository(context: Context,) { suspend fun setWidgetAppearance(appearance: String) = this.appearance.setWidgetAppearance(appearance) + val cachedWidgetShowsArtwork: String + get() = appearance.widgetShowsArtwork.cachedStyle + + val widgetShowsArtworkStream: Flow = appearance.widgetShowsArtwork.style + + suspend fun setWidgetShowsArtwork(style: String) = appearance.widgetShowsArtwork.setStyle(style) + val overriddenRecPodcastIdStream: Flow = library.overriddenRecPodcastIdStream suspend fun setOverriddenRecPodcastId(podcastId: String?) = library.setOverriddenRecPodcastId(podcastId) diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetAppearance.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetAppearance.kt index 3f3ee10a7..22a469bed 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetAppearance.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetAppearance.kt @@ -24,3 +24,27 @@ object WidgetAppearance { return sanitize(prefs.getString(PREF_KEY, null)) } } + +/** + * How the Your Shows widget draws artwork: whole covers as rounded squares (default), or covers cut + * into mixed Material shapes. + */ +object WidgetShowsArtwork { + const val ROUNDED = "rounded" + const val SHAPES = "shapes" + + const val PREF_KEY = "widget_shows_artwork" + + fun sanitize(value: String?): String { + val normalized = value?.trim()?.lowercase() + return if (normalized == SHAPES) SHAPES else ROUNDED + } + + fun usesShapes(value: String?): Boolean = sanitize(value) == SHAPES + + fun cached(context: Context): String { + val prefs = + context.getSharedPreferences(FontRoundnessAxis.THEME_FAST_CACHE, Context.MODE_PRIVATE) + return sanitize(prefs.getString(PREF_KEY, null)) + } +} diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetShowsArtworkPreferences.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetShowsArtworkPreferences.kt new file mode 100644 index 000000000..235ccde58 --- /dev/null +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetShowsArtworkPreferences.kt @@ -0,0 +1,41 @@ +package cx.aswin.boxlore.core.prefs + +import android.content.SharedPreferences +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import java.io.IOException +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** + * How the Your Shows widget draws covers ([WidgetShowsArtwork]); whole covers by default. Mirrored in + * the theme fast-cache so the widget's list factory can read it without DataStore. + */ +internal class WidgetShowsArtworkPreferences(private val store: DataStore, private val cache: SharedPreferences) { + val cachedStyle: String + get() = WidgetShowsArtwork.sanitize(cache.getString(WidgetShowsArtwork.PREF_KEY, null)) + + val style = store.data + .catch { exception -> + if (exception is IOException) emit(emptyPreferences()) else throw exception + }.map { preferences -> + val style = preferences[Keys.WIDGET_SHOWS_ARTWORK]?.let(WidgetShowsArtwork::sanitize) ?: cachedStyle + cache.edit().putString(WidgetShowsArtwork.PREF_KEY, style).apply() + style + }.distinctUntilChanged() + + suspend fun setStyle(style: String) { + val sanitized = WidgetShowsArtwork.sanitize(style) + // The cache is written first: the widget refresh this triggers reads it straight away. + cache.edit().putString(WidgetShowsArtwork.PREF_KEY, sanitized).apply() + store.edit { it[Keys.WIDGET_SHOWS_ARTWORK] = sanitized } + } + + fun hydrate(preferences: MutablePreferences) { + preferences[Keys.WIDGET_SHOWS_ARTWORK] = preferences[Keys.WIDGET_SHOWS_ARTWORK] ?: cachedStyle + } +} diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt index 31b0414ac..c0074b9c6 100644 --- a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt @@ -604,6 +604,19 @@ class UserPreferencesRepositoryTest { assertEquals(WidgetAppearance.APP, repository.widgetAppearanceStream.first()) } + @Test + fun widgetShowsArtworkDefaultsToWholeCoversAndPersists() = runTest { + assertEquals(WidgetShowsArtwork.ROUNDED, repository.widgetShowsArtworkStream.first()) + assertEquals(WidgetShowsArtwork.ROUNDED, repository.cachedWidgetShowsArtwork) + repository.setWidgetShowsArtwork(WidgetShowsArtwork.SHAPES) + assertEquals(WidgetShowsArtwork.SHAPES, repository.widgetShowsArtworkStream.first()) + // The widget's list factory reads the cache, so it must change with the setting. + assertEquals(WidgetShowsArtwork.SHAPES, repository.cachedWidgetShowsArtwork) + repository.setWidgetShowsArtwork("unknown") + assertEquals(WidgetShowsArtwork.ROUNDED, repository.widgetShowsArtworkStream.first()) + assertEquals(WidgetShowsArtwork.ROUNDED, repository.cachedWidgetShowsArtwork) + } + @Test fun overriddenRecPodcastIdSetAndClear() = runTest { assertNull(repository.overriddenRecPodcastIdStream.first()) diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/WidgetAppearanceTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/WidgetAppearanceTest.kt index be39ea35a..c001d1a9c 100644 --- a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/WidgetAppearanceTest.kt +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/WidgetAppearanceTest.kt @@ -20,4 +20,14 @@ class WidgetAppearanceTest { assertTrue(WidgetAppearance.followsAppTheme(WidgetAppearance.APP)) assertFalse(WidgetAppearance.followsAppTheme(WidgetAppearance.SYSTEM)) } + + @Test + fun showsArtworkDefaultsToWholeCoversAndShapesAreOptIn() { + assertEquals(WidgetShowsArtwork.ROUNDED, WidgetShowsArtwork.sanitize(null)) + assertEquals(WidgetShowsArtwork.ROUNDED, WidgetShowsArtwork.sanitize("unknown")) + assertEquals(WidgetShowsArtwork.SHAPES, WidgetShowsArtwork.sanitize(" Shapes ")) + assertFalse(WidgetShowsArtwork.usesShapes(null)) + assertFalse(WidgetShowsArtwork.usesShapes(WidgetShowsArtwork.ROUNDED)) + assertTrue(WidgetShowsArtwork.usesShapes(WidgetShowsArtwork.SHAPES)) + } } diff --git a/feature/settings/README.md b/feature/settings/README.md index 2739592f0..891b4ce00 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -10,7 +10,7 @@ Owns the Settings hub and its pages, sign-in, the Smart and automatic download s - `SettingsScreenConfig.initialPage` opens a page directly (`account`, `sync`, `library`, `recommendations`, `appearance`, `theme`, `playback`, `downloads`, `privacy`, `about`, `support`); anything else opens the hub. `isOnboarding` makes Back leave Settings at once. - Shell callbacks on `SettingsScreenConfig`: `onSendFeedback`, `onCheckForUpdates` (the shell owns release checks and installation), and `onSupportPageVisibilityChanged` / `onAccountPageVisibilityChanged`, used to hide player chrome. - `SettingsViewModel`, built by `SettingsViewModelAssembler` (`create` / `factory`): the Add RSS flow and Reset recommendations. -- `AppearanceUiState`, `AppearanceActions` and `AppearanceHeroPreview`; `PlaybackUiState` and `PlaybackActions`; `LibraryDiscoveryPreferences` and `SavedShowsControl`. App wiring persists every value through `:core:prefs`. +- `AppearanceUiState`, `AppearanceActions`, `AddableWidget`, `AppearanceHeroPreview` and `AppearanceWidgetPreview`; `PlaybackUiState` and `PlaybackActions`; `LibraryDiscoveryPreferences` and `SavedShowsControl`. App wiring persists every value through `:core:prefs`. - `FeedbackScreen(supportRepository, boxcastPrefs, onBack, bottomContentPadding, openThreadId)` on the app's `feedback?thread=` route, plus `FeedbackViewModel`, `DiagnosticCollector`, `LogcatCollector` and `SharedPrefsRetryKeyStore`. - `AutoDownloadSettingsScreen` and `SmartDownloadsSettingsScreen`, each taking `UserPreferencesRepository` and `onBack`. - `LogsPreviewDialog` (in `feedback/FeedbackDialogs.kt`). @@ -23,9 +23,10 @@ Owns the Settings hub and its pages, sign-in, the Smart and automatic download s ## Appearance -- The overview shows Display mode, then one row per page with its current choice (`appearanceSummary`): **Theme & colors**, **Home**, **Player**, **Navigation** and **Text**. -- Theme & colors offers four looks (boxlore classic, Pure, Material 3, Custom), **Use artwork colors** (on by default) and **Widgets**. Legacy palettes show as Custom without changing stored values (`ThemeSettingsLogic.kt`). +- The overview shows Display mode, then one row per page with its current choice (`appearanceSummary`): **Theme & colors**, **Home**, **Player**, **Navigation**, **Text** and **Widgets**. +- Theme & colors offers four looks (boxlore classic, Pure, Material 3, Custom) and **Use artwork colors** (on by default). Legacy palettes show as Custom without changing stored values (`ThemeSettingsLogic.kt`). - The custom theme editor is a full page. Drafts change only its preview; Save applies the palette in one write, and Back discards it. +- Widgets holds the home screen widgets' colours (App theme or System), how the Your Shows widget cuts its covers (whole covers or shapes), and every widget (`AddableWidget`, supplied by the app) as a card: its real look drawn by the app through `AppearanceWidgetPreview`, scaled to fit and never enlarged, its name, and an Add button that asks the home screen app to add it. The two round players share a row. When the home screen app cannot add widgets (`AppearanceActions.canAddWidgets`), the cards stay without buttons, with the steps for adding one by hand. - Home holds the hero card style and its colour choice, with live previews drawn by the app through `AppearanceHeroPreview`. Hero styles are described in [`feature/home/README.md`](../home/README.md). - Player holds **Player style** (Standard or bunpod) and the miniplayer's **Seek buttons** (off by default). The bunpod option credits Kamran Bekirov's BunPod design. Video always uses Standard. - Navigation holds the bar style, **Blur behind the bar** (Floating bar only) and **Start page & tabs** (**Explore opens to** hides while Picked for you is off). Text holds the letter shape with a preview. @@ -127,7 +128,7 @@ Hermetic JVM tests live in `feature/settings/src/test`, using fakes from [`:core | :--- | :--- | | Hub, Back and tablet split | `SettingsBackNavigationTest`, `TabletSettingsLayoutTest`, `TabletSettingsDialogTest`, `TabletSettingsCaptureTest`, `SettingsHeaderTitleTest` | | RSS and reset dialogs | `SettingsViewModelTest`, `SettingsViewModelDialogTest`, `SettingsGoldenRoborazziTest` | -| Appearance | `AppearancePagesTest`, `AppearanceActionsTrackedTest`, `SettingsPictureRowTest`, `PlayerStyleSectionTest` | +| Appearance | `AppearancePagesTest`, `AppearanceActionsTrackedTest`, `SettingsPictureRowTest`, `PlayerStyleSectionTest`, `AppearanceWidgetsPageTest` | | Themes | `CustomThemeEditorTest`, `CustomThemeEditorOptionsTest`, `ThemeEditorPressFeedbackTest`, `ThemeSettingsLogicTest`, `ThemePresetResolutionTest`, `AccentSwatchLayoutTest` | | Account and sync | `AccountPagePresentationTest`, `AccountPageAdaptiveTest`, `AccountSettingsPageTest`, `AccountAuthHelpersTest`, `AccountLinkFailedContentTest`, `SyncAndBackupsPageTest` | | Library | `LibraryLanguagesAdaptiveTest`, `ClearSavedShowsAdaptiveTest` | diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt index 605cdeeea..8688b585b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt @@ -15,6 +15,7 @@ internal enum class ProfileSettingsDestination( AppearancePlayer("Player"), AppearanceNavigation("Navigation"), AppearanceText("Text"), + AppearanceWidgets("Widgets"), Playback("Playback"), Downloads("Downloads"), Privacy("Privacy"), @@ -28,7 +29,7 @@ internal enum class ProfileSettingsDestination( */ val parent: ProfileSettingsDestination? get() = when (this) { - Theme, AppearanceHome, AppearancePlayer, AppearanceNavigation, AppearanceText -> Appearance + Theme, AppearanceHome, AppearancePlayer, AppearanceNavigation, AppearanceText, AppearanceWidgets -> Appearance CustomTheme -> Theme Recommendations -> Library else -> null diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt index 1ed5a643b..0893ca355 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt @@ -55,6 +55,8 @@ import cx.aswin.boxlore.feature.settings.pages.AppearanceSettingsPage import cx.aswin.boxlore.feature.settings.pages.AppearanceTextPage import cx.aswin.boxlore.feature.settings.pages.AppearanceThemePage import cx.aswin.boxlore.feature.settings.pages.AppearanceUiState +import cx.aswin.boxlore.feature.settings.pages.AppearanceWidgetPreview +import cx.aswin.boxlore.feature.settings.pages.AppearanceWidgetsPage import cx.aswin.boxlore.feature.settings.pages.CustomThemeEditor import cx.aswin.boxlore.feature.settings.pages.DownloadsSettingsPage import cx.aswin.boxlore.feature.settings.pages.LibraryBackupActions @@ -144,6 +146,8 @@ data class AppearanceSettings( val actions: AppearanceActions = AppearanceActions({}, {}, {}, {}, {}), /** Home's hero cards for the Appearance previews; null shows the choices without previews. */ val heroPreview: AppearanceHeroPreview? = null, + /** The home screen widgets as they really look, for Appearance › Widgets; null lists them by name only. */ + val widgetPreview: AppearanceWidgetPreview? = null, ) /** Playback sub-page state paired with its actions, so [SettingsScreen] can pass both as one. */ @@ -484,6 +488,7 @@ private fun SettingsDestinationContent( ProfileSettingsDestination.AppearancePlayer, ProfileSettingsDestination.AppearanceNavigation, ProfileSettingsDestination.AppearanceText, + ProfileSettingsDestination.AppearanceWidgets, -> AppearanceDestinationContent(destination, contentBundle.appearanceSettings, actions) ProfileSettingsDestination.CustomTheme -> @@ -682,8 +687,12 @@ private fun trackedLibraryBackupActions( */ internal fun AppearanceActions.trackedForAnalytics(currentState: () -> AppearanceUiState? = { null }): AppearanceActions { fun report(key: String, value: T, current: (AppearanceUiState) -> T) { - val state = currentState() - if (state == null || current(state) != value) AnalyticsHelper.trackSettingsInteraction(key, value.toString()) + if (currentState()?.let(current) != value) AnalyticsHelper.trackSettingsInteraction(key, value.toString()) + } + + fun tracked(key: String, current: (AppearanceUiState) -> T, action: (T) -> Unit): (T) -> Unit = { + report(key, it, current) + action(it) } return AppearanceActions( onSetThemeConfig = { @@ -722,10 +731,11 @@ internal fun AppearanceActions.trackedForAnalytics(currentState: () -> Appearanc report("home_shortcuts_in_library_toggled", it) { state -> state.homeShortcutsInLibrary } onSetHomeShortcutsInLibrary(it) }, - onSetWidgetAppearance = { - report("widget_appearance_changed", it) { state -> state.currentWidgetAppearance } - onSetWidgetAppearance(it) - }, + onSetWidgetAppearance = tracked("widget_appearance_changed", { it.currentWidgetAppearance }, onSetWidgetAppearance), + onSetWidgetShowsArtwork = tracked("widget_shows_artwork_changed", { it.currentWidgetShowsArtwork }, onSetWidgetShowsArtwork), + canAddWidgets = canAddWidgets, + // Every request is reported: there is no current value a repeat could match. + onAddWidget = tracked("widget_add_requested", { "" }, onAddWidget), onSetExploreDefaultTab = { report("explore_default_tab_changed", it) { state -> state.currentExploreDefaultTab } onSetExploreDefaultTab(it) @@ -834,6 +844,7 @@ private fun AppearanceDestinationContent( ProfileSettingsDestination.AppearancePlayer -> AppearancePlayerPage(state, tracked, onBack = actions.onReturnToHub) ProfileSettingsDestination.AppearanceNavigation -> AppearanceNavigationPage(state, tracked, onBack = actions.onReturnToHub) ProfileSettingsDestination.AppearanceText -> AppearanceTextPage(state, tracked, onBack = actions.onReturnToHub) + ProfileSettingsDestination.AppearanceWidgets -> AppearanceWidgetsPage(state, tracked, onBack = actions.onReturnToHub, preview = appearance.widgetPreview) else -> AppearanceSettingsPage( state = state, @@ -847,6 +858,7 @@ private fun AppearanceDestinationContent( AppearanceSection.Player -> ProfileSettingsDestination.AppearancePlayer AppearanceSection.Navigation -> ProfileSettingsDestination.AppearanceNavigation AppearanceSection.Text -> ProfileSettingsDestination.AppearanceText + AppearanceSection.Widgets -> ProfileSettingsDestination.AppearanceWidgets }, ) }, diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt index 83f329cba..9271af12b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.PlayCircle import androidx.compose.material.icons.rounded.TextFields import androidx.compose.material.icons.rounded.ViewDay +import androidx.compose.material.icons.rounded.Widgets import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -43,6 +44,7 @@ import cx.aswin.boxlore.core.prefs.SubscriptionsDefaultTab import cx.aswin.boxlore.core.prefs.SubscriptionsTabStyle import cx.aswin.boxlore.core.prefs.ThemeSelection import cx.aswin.boxlore.core.prefs.WidgetAppearance +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork import cx.aswin.boxlore.feature.settings.components.SettingsContent import cx.aswin.boxlore.feature.settings.components.SettingsDivider import cx.aswin.boxlore.feature.settings.components.SettingsGroup @@ -50,6 +52,25 @@ import cx.aswin.boxlore.feature.settings.components.SettingsNavigationRow import cx.aswin.boxlore.feature.settings.components.SettingsPicture import cx.aswin.boxlore.feature.settings.components.SettingsScaffold +/** + * A home screen widget the listener can add from Appearance › Widgets, named as the widget picker + * names it, with the size its preview is drawn for. + */ +data class AddableWidget( + val key: String, + val name: String, + val description: String, + val previewWidthDp: Int = 320, + val previewHeightDp: Int = 170, +) + +/** + * Draws a home screen widget as it looks on the home screen, fitted into the modifier's bounds. The + * app supplies it from the widgets feature, so Appearance shows the real widgets without depending + * on it; without it the Widgets page lists them by name only. + */ +typealias AppearanceWidgetPreview = @Composable (widget: AddableWidget, modifier: Modifier) -> Unit + /** Current values shown on [AppearanceSettingsPage]. Also used by [cx.aswin.boxlore.feature.settings.SettingsScreen]. */ data class AppearanceUiState( val currentThemeConfig: String, @@ -61,6 +82,9 @@ data class AppearanceUiState( val currentOpenAppTo: String = OpenAppTo.HOME, val homeShortcutsInLibrary: Boolean = false, val currentWidgetAppearance: String = WidgetAppearance.APP, + val currentWidgetShowsArtwork: String = WidgetShowsArtwork.ROUNDED, + /** Every widget the app offers. Whether the home screen app can add them is asked when the page opens. */ + val addableWidgets: List = emptyList(), val currentExploreDefaultTab: String = ExploreDefaultTab.FOR_YOU, val currentSubscriptionsDefaultTab: String = SubscriptionsDefaultTab.SHOWS, val currentSubscriptionsTabStyle: String = SubscriptionsTabStyle.TOP, @@ -92,6 +116,11 @@ data class AppearanceActions( val onSetOpenAppTo: (String) -> Unit = {}, val onSetHomeShortcutsInLibrary: (Boolean) -> Unit = {}, val onSetWidgetAppearance: (String) -> Unit = {}, + val onSetWidgetShowsArtwork: (String) -> Unit = {}, + /** Whether the current home screen app adds a widget when asked. Asked once when the Widgets page opens. */ + val canAddWidgets: () -> Boolean = { false }, + /** Asks the home screen app to add the widget with this key; it confirms with the listener itself. */ + val onAddWidget: (String) -> Unit = {}, val onSetExploreDefaultTab: (String) -> Unit = {}, val onSetSubscriptionsDefaultTab: (String) -> Unit = {}, val onSetSubscriptionsTabStyle: (String) -> Unit = {}, @@ -116,7 +145,7 @@ data class AppearanceActions( typealias AppearanceHeroPreview = @Composable (style: String, coverShade: String, collection: Boolean, modifier: Modifier) -> Unit /** Where each Appearance row leads, so the page needs no navigation types of its own. */ -internal enum class AppearanceSection { Theme, Home, Player, Navigation, Text } +internal enum class AppearanceSection { Theme, Home, Player, Navigation, Text, Widgets } /** * The Appearance overview: Display mode (the setting people change most, also on Theme & colors), @@ -145,12 +174,21 @@ internal fun AppearanceSettingsPage( SettingsNavigationRow(title = "Navigation", supportingText = summary.navigation, icon = Icons.Rounded.ViewDay, onClick = { onOpenSection(AppearanceSection.Navigation) }) SettingsDivider() SettingsNavigationRow(title = "Text", supportingText = summary.text, icon = Icons.Rounded.TextFields, onClick = { onOpenSection(AppearanceSection.Text) }) + SettingsDivider() + SettingsNavigationRow(title = "Widgets", supportingText = summary.widgets, icon = Icons.Rounded.Widgets, onClick = { onOpenSection(AppearanceSection.Widgets) }) } } } /** What each Appearance row shows under its title. */ -internal data class AppearanceSummary(val theme: String, val home: String, val player: String, val navigation: String, val text: String) +internal data class AppearanceSummary( + val theme: String, + val home: String, + val player: String, + val navigation: String, + val text: String, + val widgets: String, +) internal fun appearanceSummary(state: AppearanceUiState): AppearanceSummary { val look = themeLookOptions().firstOrNull { it.key == state.selectedLookKey() }?.name ?: "Custom" @@ -167,9 +205,17 @@ internal fun appearanceSummary(state: AppearanceUiState): AppearanceSummary { player = if (state.miniPlayerSeekButtonsEnabled) "$player · seek buttons on" else player, navigation = "${NavigationStyle.fromKey(state.currentNavigationStyle).label} bar · starts on $start", text = "$letters letters", + widgets = widgetsSummary(state), ) } +/** The widgets' colour source, then how Your Shows cuts its covers. */ +private fun widgetsSummary(state: AppearanceUiState): String { + val colors = if (WidgetAppearance.sanitize(state.currentWidgetAppearance) == WidgetAppearance.SYSTEM) "System colors" else "App theme colors" + val covers = if (WidgetShowsArtwork.usesShapes(state.currentWidgetShowsArtwork)) "shapes" else "whole covers" + return "$colors · $covers" +} + /** The hero card style's name as Settings shows it. */ private fun heroStyleName(style: String): String = when (style) { HeroCardStyle.COVER -> "Cover" diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceThemePage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceThemePage.kt index 90d1fa896..ff094c726 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceThemePage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceThemePage.kt @@ -8,8 +8,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView -import cx.aswin.boxlore.core.designsystem.components.ConnectedOptionSelector -import cx.aswin.boxlore.core.prefs.WidgetAppearance import cx.aswin.boxlore.feature.settings.components.SettingsContent import cx.aswin.boxlore.feature.settings.components.SettingsDivider import cx.aswin.boxlore.feature.settings.components.SettingsGroup @@ -23,8 +21,8 @@ import cx.aswin.boxlore.feature.settings.components.ThemeLookPicture /** * Theme & colors: display mode, the four looks as picture tiles (each in light and dark), the - * custom theme editor, artwork colors for episode and podcast pages, and the widgets, which follow - * these colors. + * custom theme editor, artwork colors for episode and podcast pages, the widgets, which follow these + * colors, and how the Your Shows widget draws covers. */ @Composable internal fun AppearanceThemePage(state: AppearanceUiState, actions: AppearanceActions, onBack: () -> Unit, onEditCustomTheme: () -> Unit) { @@ -51,18 +49,6 @@ internal fun AppearanceThemePage(state: AppearanceUiState, actions: AppearanceAc icon = Icons.Rounded.AutoAwesome, ) } - SettingsGroup( - title = "Widgets", - footer = "App theme matches your chosen colors and mode. System follows your phone’s mode and wallpaper colors.", - ) { - SettingsContent { - ConnectedOptionSelector( - options = listOf(WidgetAppearance.APP to "App theme", WidgetAppearance.SYSTEM to "System"), - selected = WidgetAppearance.sanitize(state.currentWidgetAppearance), - onSelect = actions.onSetWidgetAppearance, - ) - } - } } } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt new file mode 100644 index 000000000..2ae1d5fea --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt @@ -0,0 +1,223 @@ +package cx.aswin.boxlore.feature.settings.pages + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView +import cx.aswin.boxlore.core.designsystem.components.ConnectedOptionSelector +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight +import cx.aswin.boxlore.core.prefs.WidgetAppearance +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork +import cx.aswin.boxlore.feature.settings.components.SettingsActionRow +import cx.aswin.boxlore.feature.settings.components.SettingsContent +import cx.aswin.boxlore.feature.settings.components.SettingsDivider +import cx.aswin.boxlore.feature.settings.components.SettingsGroup +import cx.aswin.boxlore.feature.settings.components.SettingsInfoTip +import cx.aswin.boxlore.feature.settings.components.SettingsPicture +import cx.aswin.boxlore.feature.settings.components.SettingsScaffold +import cx.aswin.boxlore.feature.settings.components.SettingsTileSection + +/** + * Widgets: the colours every home screen widget takes, how Your Shows cuts its covers, then every + * widget as it looks on the home screen ([preview]), each with a button that asks the home screen + * app to add it. + */ +@Composable +internal fun AppearanceWidgetsPage( + state: AppearanceUiState, + actions: AppearanceActions, + onBack: () -> Unit, + preview: AppearanceWidgetPreview? = null, +) { + TrackScreenView("appearance_widgets") + SettingsScaffold(title = "Widgets", onBack = onBack) { + SettingsGroup( + title = "Colors", + footer = "App theme matches your chosen colors and mode. System follows your phone’s mode and wallpaper colors.", + ) { + SettingsContent { + ConnectedOptionSelector( + options = listOf(WidgetAppearance.APP to "App theme", WidgetAppearance.SYSTEM to "System"), + selected = WidgetAppearance.sanitize(state.currentWidgetAppearance), + onSelect = actions.onSetWidgetAppearance, + ) + } + } + SettingsGroup( + title = "Your Shows covers", + footer = "Whole covers shows each cover uncropped. Shapes cuts covers into circles, arches and scallops.", + ) { + SettingsContent { + ConnectedOptionSelector( + options = listOf(WidgetShowsArtwork.ROUNDED to "Whole covers", WidgetShowsArtwork.SHAPES to "Shapes"), + selected = WidgetShowsArtwork.sanitize(state.currentWidgetShowsArtwork), + onSelect = actions.onSetWidgetShowsArtwork, + ) + } + } + if (state.addableWidgets.isNotEmpty()) { + // The home screen app is asked once, when the page opens: it only changes when the listener switches it. + val canAdd = remember(actions.canAddWidgets) { actions.canAddWidgets() } + when { + preview != null -> WidgetGallery(state.addableWidgets, canAdd, actions.onAddWidget, preview) + canAdd -> WidgetRows(state.addableWidgets, actions.onAddWidget) + else -> SettingsInfoTip(ADD_WIDGETS_BY_HAND) + } + } + } +} + +/** Every widget as a picture with its name. The Add buttons are there only when the home screen app can add. */ +@Composable +private fun WidgetGallery( + widgets: List, + canAdd: Boolean, + onAdd: (String) -> Unit, + preview: AppearanceWidgetPreview, +) { + SettingsTileSection( + title = if (canAdd) "Add to home screen" else "boxlore widgets", + footer = if (canAdd) ADD_WIDGETS_CONFIRM else null, + ) { + if (!canAdd) SettingsInfoTip(ADD_WIDGETS_BY_HAND) + for (row in widgetGalleryRows(widgets)) { + if (row.size == 1) { + WidgetCard(row.single(), sideBySide = false, onAdd = onAdd.takeIf { canAdd }, preview = preview) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + row.forEach { widget -> + WidgetCard(widget, sideBySide = true, onAdd = onAdd.takeIf { canAdd }, preview = preview, modifier = Modifier.weight(1f)) + } + } + } + } + } +} + +/** + * One widget: its picture on a tonal stage that stands in for the wallpaper, then its name, what it + * shows and the Add button. Wide widgets put the button beside the text; a small widget sharing its + * row with another puts it underneath, where it still has room at large text sizes. + */ +@Composable +private fun WidgetCard( + widget: AddableWidget, + sideBySide: Boolean, + onAdd: ((String) -> Unit)?, + preview: AppearanceWidgetPreview, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Box( + modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.large).background(MaterialTheme.colorScheme.outlineVariant).padding(14.dp), + contentAlignment = Alignment.Center, + ) { + SettingsPicture(Modifier.widthIn(max = widget.previewWidthDp.dp).fillMaxWidth().aspectRatio(widget.previewWidthDp.toFloat() / widget.previewHeightDp)) { + preview(widget, Modifier.matchParentSize()) + } + } + if (sideBySide) { + Column(Modifier.padding(horizontal = 4.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + WidgetCardText(widget) + if (onAdd != null) AddWidgetButton(widget, onAdd, Modifier.fillMaxWidth()) + } + } else { + Row(Modifier.padding(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + WidgetCardText(widget, Modifier.weight(1f)) + if (onAdd != null) AddWidgetButton(widget, onAdd) + } + } + } + } +} + +@Composable +private fun WidgetCardText(widget: AddableWidget, modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(widget.name, style = MaterialTheme.typography.titleSmall, fontWeight = GoogleSansWeight.bold, color = MaterialTheme.colorScheme.onSurface) + Text(widget.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun AddWidgetButton(widget: AddableWidget, onAdd: (String) -> Unit, modifier: Modifier = Modifier) { + val label = "Add ${widget.name} to home screen" + FilledTonalButton( + onClick = { onAdd(widget.key) }, + modifier = modifier.semantics { contentDescription = label }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + Text("Add", modifier = Modifier.padding(start = ButtonDefaults.IconSpacing)) + } +} + +/** Without pictures: one row per widget that asks the home screen app to add it. */ +@Composable +private fun WidgetRows(widgets: List, onAdd: (String) -> Unit) { + SettingsGroup(title = "Add to home screen", footer = ADD_WIDGETS_CONFIRM) { + widgets.forEachIndexed { index, widget -> + if (index > 0) SettingsDivider() + SettingsActionRow( + title = widget.name, + supportingText = widget.description, + icon = Icons.Rounded.Add, + onClick = { onAdd(widget.key) }, + ) + } + } +} + +/** + * The gallery's rows: a small widget shares its row with the small widget after it, so the two round + * players sit side by side; every other widget has a row to itself. + */ +internal fun widgetGalleryRows(widgets: List): List> { + val rows = mutableListOf>() + var index = 0 + while (index < widgets.size) { + val widget = widgets[index] + val next = widgets.getOrNull(index + 1) + val pair = widget.previewWidthDp <= SMALL_WIDGET_MAX_WIDTH_DP && next != null && next.previewWidthDp <= SMALL_WIDGET_MAX_WIDTH_DP + rows += if (pair) listOf(widget, next!!) else listOf(widget) + index += if (pair) 2 else 1 + } + return rows +} + +private const val SMALL_WIDGET_MAX_WIDTH_DP = 200 + +internal const val ADD_WIDGETS_CONFIRM = "Your home screen app asks you to confirm." + +internal const val ADD_WIDGETS_BY_HAND = + "To add a boxlore widget, touch and hold an empty spot on your home screen, then choose Widgets." diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt index 1d229f726..d9640623e 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt @@ -28,6 +28,34 @@ class AppearanceActionsTrackedTest { assertEquals(0, miniChanges) } + @Test + fun `Your Shows widget artwork choice survives the appearance wrapper`() { + val styles = mutableListOf() + var widgetColourChanges = 0 + val tracked = AppearanceActions( + {}, + {}, + {}, + {}, + onSetWidgetAppearance = { widgetColourChanges++ }, + onSetWidgetShowsArtwork = { styles.add(it) }, + ).trackedForAnalytics() + tracked.onSetWidgetShowsArtwork(cx.aswin.boxlore.core.prefs.WidgetShowsArtwork.SHAPES) + tracked.onSetWidgetShowsArtwork(cx.aswin.boxlore.core.prefs.WidgetShowsArtwork.ROUNDED) + assertEquals(listOf("shapes", "rounded"), styles) + assertEquals(0, widgetColourChanges) + } + + @Test + fun `adding a widget survives the appearance wrapper, every time it is asked`() { + val asked = mutableListOf() + val tracked = AppearanceActions({}, {}, {}, {}, canAddWidgets = { true }, onAddWidget = { asked.add(it) }).trackedForAnalytics() + tracked.onAddWidget("compact") + tracked.onAddWidget("compact") + assertEquals(listOf("compact", "compact"), asked) + assertEquals(true, tracked.canAddWidgets()) + } + @Test fun `LeaveMeAlone switches reach the setter with their section and value`() { val calls = mutableListOf>() diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt index ff4617c33..8e01642ab 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt @@ -112,6 +112,7 @@ class SettingsBackNavigationTest { ProfileSettingsDestination.AppearancePlayer, ProfileSettingsDestination.AppearanceNavigation, ProfileSettingsDestination.AppearanceText, + ProfileSettingsDestination.AppearanceWidgets, ).forEach { page -> assertEquals( SettingsBackAction.NavigateTo(ProfileSettingsDestination.Appearance), diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt index 972f7e760..90cd20c90 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt @@ -23,6 +23,7 @@ import cx.aswin.boxlore.feature.settings.pages.AppearanceNavigationPage import cx.aswin.boxlore.feature.settings.pages.AppearancePlayerPage import cx.aswin.boxlore.feature.settings.pages.AppearanceSettingsPage import cx.aswin.boxlore.feature.settings.pages.AppearanceTextPage +import cx.aswin.boxlore.feature.settings.pages.AppearanceWidgetsPage import cx.aswin.boxlore.feature.settings.pages.CustomThemeEditor import cx.aswin.boxlore.feature.settings.pages.DownloadsSettingsPage import cx.aswin.boxlore.feature.settings.pages.LibraryBackupActions @@ -111,6 +112,7 @@ private fun CaptureSettingsPage(destination: ProfileSettingsDestination) { ProfileSettingsDestination.AppearancePlayer, ProfileSettingsDestination.AppearanceNavigation, ProfileSettingsDestination.AppearanceText, + ProfileSettingsDestination.AppearanceWidgets, -> CaptureAppearanceSubpage(destination, appearance) ProfileSettingsDestination.CustomTheme -> CustomThemeEditor(appearance.state, {}, {}) ProfileSettingsDestination.Playback -> PlaybackSettingsPage(playback.state, playback.actions, {}) @@ -129,6 +131,7 @@ private fun CaptureAppearanceSubpage(destination: ProfileSettingsDestination, ap ProfileSettingsDestination.AppearanceHome -> AppearanceHomePage(appearance.state, appearance.actions, null, {}) ProfileSettingsDestination.AppearancePlayer -> AppearancePlayerPage(appearance.state, appearance.actions, {}) ProfileSettingsDestination.AppearanceNavigation -> AppearanceNavigationPage(appearance.state, appearance.actions, {}) + ProfileSettingsDestination.AppearanceWidgets -> AppearanceWidgetsPage(appearance.state, appearance.actions, {}) else -> AppearanceTextPage(appearance.state, appearance.actions, {}) } } diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearancePagesTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearancePagesTest.kt index b9616d0d0..2e43d6474 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearancePagesTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearancePagesTest.kt @@ -21,6 +21,8 @@ import cx.aswin.boxlore.core.prefs.HeroCardStyle import cx.aswin.boxlore.core.prefs.HeroCoverShade import cx.aswin.boxlore.core.prefs.HeroTideProgress import cx.aswin.boxlore.core.prefs.OpenAppTo +import cx.aswin.boxlore.core.prefs.WidgetAppearance +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -166,6 +168,11 @@ class AppearancePagesTest { assertEquals("Block hero cards, artwork color", appearanceSummary(baseState.copy(heroCoverShade = HeroCoverShade.ARTWORK)).home) assertEquals("bunpod · seek buttons on", summary.player) assertEquals("Crisp letters", summary.text) + assertEquals("App theme colors · whole covers", summary.widgets) + assertEquals( + "System colors · shapes", + appearanceSummary(baseState.copy(currentWidgetAppearance = WidgetAppearance.SYSTEM, currentWidgetShowsArtwork = WidgetShowsArtwork.SHAPES)).widgets, + ) assertEquals("boxlore classic · artwork colors on", appearanceSummary(baseState).theme) // Listeners who never picked see Cover on the Artwork shade. val unpicked = AppearanceUiState(currentThemeConfig = "system", isDynamicColorEnabled = false, currentThemeBrand = "violet", currentSurfaceStyle = "classic_dynamic") diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt new file mode 100644 index 000000000..8212510f5 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt @@ -0,0 +1,129 @@ +package cx.aswin.boxlore.feature.settings.pages + +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], qualifiers = "w360dp-h780dp-mdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class AppearanceWidgetsPageTest { + @get:Rule val compose = createComposeRule() + private val added = mutableListOf() + private val widgets = + listOf( + AddableWidget("full", "Now Playing · Full Controls", "The full player", 320, 170), + AddableWidget("round", "Now Playing · Round", "A round player", 160, 160), + AddableWidget("round_next", "Now Playing · Round with Next", "A round player with next", 160, 160), + AddableWidget("shows", "Library · Your Shows", "Your subscribed shows as a grid of artwork", 320, 250), + ) + private val state = + AppearanceUiState( + currentThemeConfig = "system", + isDynamicColorEnabled = false, + currentThemeBrand = "violet", + currentSurfaceStyle = "classic_dynamic", + addableWidgets = widgets, + ) + private val picture: AppearanceWidgetPreview = { widget, modifier -> Box(modifier.testTag("picture-${widget.key}")) } + + private fun render(canAdd: Boolean, state: AppearanceUiState = this.state, preview: AppearanceWidgetPreview? = picture) { + val actions = AppearanceActions({}, {}, {}, {}, canAddWidgets = { canAdd }, onAddWidget = { added.add(it) }) + compose.setContent { BoxLoreTheme { AppearanceWidgetsPage(state, actions, onBack = {}, preview = preview) } } + } + + @Test + fun `each widget shows its picture and a button that asks the home screen to add that widget`() { + render(canAdd = true) + compose.onNodeWithText("Add to home screen").performScrollTo().assertIsDisplayed() + for (widget in widgets) { + compose.onNodeWithTag("picture-${widget.key}", useUnmergedTree = true).assertExists() + compose.onNodeWithText(widget.name).performScrollTo().assertIsDisplayed() + } + compose.onNodeWithContentDescription("Add Library · Your Shows to home screen").performScrollTo().performClick() + compose.onNodeWithContentDescription("Add Now Playing · Round to home screen").performScrollTo().performClick() + assertEquals(listOf("shows", "round"), added) + compose.onNodeWithText(ADD_WIDGETS_BY_HAND).assertDoesNotExist() + } + + @Test + fun `a picture keeps its widget's shape and is never wider than the widget really is`() { + render(canAdd = true) + for (widget in widgets) { + val bounds = compose.onNodeWithTag("picture-${widget.key}", useUnmergedTree = true).getUnclippedBoundsInRoot() + val width = (bounds.right - bounds.left).value + val height = (bounds.bottom - bounds.top).value + assertTrue("${widget.key} is drawn", width > 80f && height > 40f) + assertTrue("${widget.key} is at most its real width", width <= widget.previewWidthDp + 0.5f) + assertEquals("${widget.key} keeps its shape", widget.previewWidthDp.toFloat() / widget.previewHeightDp, width / height, 0.03f) + } + // The two round players share a row. + val round = compose.onNodeWithTag("picture-round", useUnmergedTree = true).getUnclippedBoundsInRoot() + val roundNext = compose.onNodeWithTag("picture-round_next", useUnmergedTree = true).getUnclippedBoundsInRoot() + assertEquals(round.top, roundNext.top) + assertTrue(roundNext.left > round.right) + } + + @Test + @Config(sdk = [33], qualifiers = "w1280dp-h800dp-mdpi") + fun `on a tablet the pictures stay at their real size instead of stretching`() { + render(canAdd = true) + val full = compose.onNodeWithTag("picture-full", useUnmergedTree = true).getUnclippedBoundsInRoot() + assertEquals(320.dp, full.right - full.left) + compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").performScrollTo().performClick() + assertEquals(listOf("full"), added) + } + + @Test + fun `a home screen that cannot add widgets still shows them, with the steps to add one by hand`() { + render(canAdd = false) + compose.onNodeWithText(ADD_WIDGETS_BY_HAND).performScrollTo().assertIsDisplayed() + compose.onNodeWithText("boxlore widgets").assertExists() + compose.onNodeWithText("Add to home screen").assertDoesNotExist() + compose.onNodeWithTag("picture-full", useUnmergedTree = true).assertExists() + compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").assertDoesNotExist() + // The widget options stay either way. + compose.onNodeWithText("Your Shows covers").assertExists() + compose.onNodeWithText("Colors").assertExists() + } + + @Test + fun `without pictures each widget is a row that adds it, or the steps when the home screen cannot`() { + render(canAdd = true, preview = null) + compose.onNodeWithText("Library · Your Shows").performScrollTo().performClick() + assertEquals(listOf("shows"), added) + } + + @Test + fun `with no widgets to offer the page is only the options`() { + render(canAdd = true, state = state.copy(addableWidgets = emptyList())) + compose.onNodeWithText("Add to home screen").assertDoesNotExist() + compose.onNodeWithText(ADD_WIDGETS_BY_HAND).assertDoesNotExist() + compose.onNodeWithText("Whole covers").assertExists() + } + + @Test + fun `small widgets pair up and every other widget has a row to itself`() { + assertEquals(listOf(listOf("full"), listOf("round", "round_next"), listOf("shows")), widgetGalleryRows(widgets).map { row -> row.map { it.key } }) + // A lone small widget, or a third one, is not stretched into a pair. + val three = widgets.filter { it.previewWidthDp <= 200 } + AddableWidget("extra", "Extra", "Another small one", 160, 160) + assertEquals(listOf(listOf("round", "round_next"), listOf("extra")), widgetGalleryRows(three).map { row -> row.map { it.key } }) + } +} diff --git a/feature/widgets/README.md b/feature/widgets/README.md index a8780d011..29a0aadf6 100644 --- a/feature/widgets/README.md +++ b/feature/widgets/README.md @@ -2,45 +2,52 @@ ## Purpose -Owns boxlore's home-screen widgets, built with `AppWidgetProvider` and `RemoteViews` (no Glance): four playback widgets and two library list widgets. It renders snapshots supplied through narrow ports and does not construct `PlaybackRepository` or `SubscriptionRepository`, or talk to Media3 or Room directly; `:app` installs the ports. +Owns boxlore's home-screen widgets, built with `AppWidgetProvider` and `RemoteViews` (no Glance): four playback widgets and two library widgets, drawn as Material 3 Expressive shapes (pills, circles and a scalloped "cookie") on a transparent root. It renders snapshots supplied through narrow ports and does not construct `PlaybackRepository` or `SubscriptionRepository`, or talk to Media3 or Room directly; `:app` installs the ports. ## Widgets | Picker label | Receiver | Contents | | :--- | :--- | :--- | -| Now Playing · Full Controls | `NowPlayingWidgetReceiver` | Artwork, titles, previous, seek back, play-pause, seek forward, next | -| Now Playing · Compact | `NowPlayingBarWidgetReceiver` | One-row bar with artwork, titles and seek controls | -| Playback Controls · Seek | `PlaybackControlsWidgetReceiver` | Square grid: artwork, play-pause, seek back and forward | -| Playback Controls · Next | `PlaybackNextControlsWidgetReceiver` | Square grid: artwork, play-pause, next episode and seek forward | -| Your Shows | `SubscriptionsWidgetReceiver` | Scrollable list of subscribed shows, in Library's Shows order | -| New Episodes | `NewEpisodesWidgetReceiver` | Scrollable list of latest episodes, with Library's New Episodes filters | - -- There is no shuffle or repeat on widgets. Library list widgets never play audio; rows open the app. -- When playback is dismissed, every playback widget becomes a branded launch surface ("Ready when you are") that opens boxlore, never disabled transport. -- Library lists are capped at `LibraryWidgetLogic.MAX_ROWS`. Row taps open the deep link `:app` supplies on each row; the footer opens `boxlore://library/subscriptions?tab=0` or `?tab=1`. -- Missing or unreadable artwork falls back to show or episode candidates, then to the shared boxlore mark. +| Now Playing · Full Controls | `NowPlayingWidgetReceiver` | Info pill (round artwork in a progress ring, title, time left and show) over a row of previous and seek back, play-pause, seek forward and next | +| Now Playing · Compact | `NowPlayingBarWidgetReceiver` | One pill with artwork in its progress ring, the episode title and time left (the artwork names the show), seek back, play-pause and seek forward | +| Now Playing · Round | `PlaybackControlsWidgetReceiver` | Round widget: artwork inside a progress ring, with seek back, play-pause and seek forward along the bottom | +| Now Playing · Round with Next | `PlaybackNextControlsWidgetReceiver` | Round widget: artwork inside a progress ring, with seek forward, play-pause and next episode | +| Library · Your Shows | `SubscriptionsWidgetReceiver` | Scrollable grid of subscribed shows' artwork, in Library's Shows order, with a dot on shows that have a new episode | +| Library · New Episodes | `NewEpisodesWidgetReceiver` | Scrollable rows of latest episodes, with Library's New Episodes filters | + +- There is no shuffle or repeat on widgets. Library widgets never play audio; rows and tiles open the app. +- The play button is a circle at rest and the scalloped cookie while playing. Seek icons show the listener's own seek lengths. +- When playback is dismissed, every playback widget becomes a launch surface ("Ready when you are") that opens boxlore, never disabled transport. +- Library lists are capped at `LibraryWidgetLogic.MAX_ROWS`; the header badge counts the whole library through `WidgetLibraryCounts`. Row and tile taps open the deep link `:app` supplies; the header and its arrow open `boxlore://library/subscriptions?tab=0` or `?tab=1`. +- Your Shows draws whole covers as rounded squares, or cuts them into mixed shapes when the listener chooses that in Appearance › Widgets (`WidgetShowsArtwork`). +- Missing or unreadable artwork falls back to show or episode candidates, then to a tinted placeholder with the boxlore mark, cut to the same shape. ## Public API - The six receivers above: picker-visible providers. +- `HomeScreenWidgets`: the six widgets under their picker names, each one's live preview as `RemoteViews` with the size it is drawn for (`preview`), whether the current home screen app can add a widget on request (`canAdd`), and the request itself (`requestAdd`). Settings shows and offers them through `:app`. - `WidgetControlReceiver`: one explicit, non-exported action endpoint shared by the playback widgets. - `WidgetActionIntents`: builds broadcast, open-app and deep-link `PendingIntent`s with `SecurityException` guards. `cancelAll(context, appWidgetId)` and `LibraryWidgetRenderer.cancelAll` cancel a deleted widget's intents so the app never exhausts the system's `PendingIntent` quota. -- `NowPlayingWidgetDependencies`, `configureNowPlayingWidget(...)` and `WidgetPlaybackSource`: the playback port. -- `LibraryWidgetDependencies`, `configureLibraryWidgets(...)` and `WidgetLibrarySource`: the library port. +- `NowPlayingWidgetDependencies`, `configureNowPlayingWidget(...)` and `WidgetPlaybackSource`: the playback port. `refreshPosition()` asks the player for its position, which the app only tracks while it is on screen. +- `LibraryWidgetDependencies`, `configureLibraryWidgets(...)` and `WidgetLibrarySource`: the library port, with capped lists and uncapped `counts`. - `NowPlayingWidgetSnapshotStore` and `LibraryWidgetSnapshotStore`: persisted snapshots each widget renders from. ## Rendering and theming - Coordinators collect the ports' flows, persist snapshots, render `RemoteViews` and load artwork without blocking metadata; cancellation keeps an outdated artwork result from replacing newer metadata. +- Playback layouts scale themselves: rows share the height by weight, squares take their width from their height, icons are fractions of their button and text shrinks to fit, inside a frame that keeps the widget's proportions and centres it in the cell. Nothing is sized from what the launcher reports, so a widget fits phones, tablets, foldables and any resize. The round widgets stay a circle as wide as the cell's shorter side, and Your Shows picks its own column count. +- What a playback widget shows (title lines, the time left, the show name, Full Controls' taller shape) changes at the size steps in `WidgetPresentation`. Each update carries one layout per step and the launcher draws the largest that fits the space it really has, also while resizing. Reported sizes (`WidgetSizes`) only decide how sharp the pictures are. +- While an episode plays and a playback widget is placed, `WidgetProgressPoller` asks the port for the position every `PROGRESS_POLL_MS`. `WidgetUpdatePolicy` repaints only when the ring or the time-left label would change, about once a minute. - Both renderers push through `pushWidgetUpdate`. From Android 17, an update over the launcher's bitmap cap throws; the guard catches it, retries once with a lighter update where one exists, and otherwise waits for the next update, so the player service never crashes. -- Artwork is decoded to fit the widget, and text bitmaps (`WidgetTextBitmapRenderer`) to the label's own box, keeping updates under that cap. -- Widget chrome follows the in-app Appearance theme by default (`WidgetChrome` bakes colours from the theme cache). Appearance › Widgets can switch to System, which uses the system's dynamic colour resources. -- `WidgetThemeSync` re-pushes widgets on configuration changes; `:app` refreshes them when the theme preferences change. -- Picker previews (`widget_preview_*.xml`) mirror each live variant on themed surfaces. +- Artwork is decoded to fit the widget and cut to its shape (`WidgetShapes`), keeping updates under that cap. `WidgetBitmapCache` keeps cut artwork, rings, masks and seek icons in memory, so a tap redraws without decoding anything again. +- Library covers load a few at a time and each batch is shown as it lands. The list service also fetches a missing cover itself, so covers appear while the app is closed; a tile shows its placeholder until then. +- Every colour is a tint, never baked into a bitmap. Widget chrome follows the in-app Appearance theme by default: `WidgetChrome` sends one ARGB value for a fixed Light or Dark display mode, and a light and dark pair when the display mode follows the system, so the launcher switches with the phone while the app is not running. Appearance › Widgets can switch to System, which uses the system's dynamic colour resources. +- `WidgetThemeSync` re-pushes widgets on configuration changes; `:app` refreshes them when the theme or widget preferences change. +- Picker previews: from Android 15, `WidgetPickerPreviews` publishes previews drawn by the live renderers with sample content, in the listener's own widget colours, again whenever that look changes. Older versions show the static `widget_preview_*.xml` layouts in resource colours. ## Design provenance -The playback widgets are an independent `RemoteViews` implementation of the widget hierarchy measured from PixelPlayerOSS. No GPL source, app assets, names or resources are copied into boxlore. Transport icons come independently from Google Material Symbols under Apache-2.0. boxlore keeps its own PolyForm Strict licensing and implementation. +The widgets are boxlore's own design in Material 3 Expressive shapes, drawn in code and vector drawables written for this module. Transport icons come from Google Material Symbols under Apache-2.0. boxlore keeps its own PolyForm Strict licensing and implementation. ## Internal structure @@ -49,10 +56,12 @@ src/main/java/cx/aswin/boxlore/feature/widgets/ NowPlayingWidget*.kt # receivers, dependencies, coordinator, renderer, models, snapshot store LibraryWidget*.kt # receivers, dependencies, coordinator, renderer, snapshot store, # RemoteViewsService for the lists - WidgetArtworkLoader.kt, WidgetChrome.kt, WidgetPalette.kt, WidgetRemoteViewsColors.kt, - WidgetTextBitmapRenderer.kt, WidgetThemeSync.kt, WidgetHostPresence.kt, WidgetUpdates.kt + WidgetArtworkLoader.kt, WidgetBitmapCache.kt, WidgetChrome.kt, WidgetPalette.kt, + WidgetRemoteViewsColors.kt, WidgetShapes.kt, WidgetSeekIcon.kt, WidgetPickerPreviews.kt, + WidgetThemeSync.kt, WidgetHostPresence.kt, WidgetUpdates.kt, HomeScreenWidgets.kt actions/ # WidgetActionIntents, WidgetActionHandler, WidgetControlReceiver - logic/ # NowPlayingWidgetLogic, LibraryWidgetLogic + logic/ # NowPlayingWidgetLogic, LibraryWidgetLogic, WidgetPresentation, WidgetSizes, + # WidgetProgress, WidgetProgressPoller src/main/res/ layout/ # live layouts and picker previews xml/*_widget_info.xml # provider info @@ -60,7 +69,7 @@ src/main/res/ ## Dependencies -- → `:core:model`, `:core:designsystem` (font and theme colours), `:core:prefs` (`FontRoundnessAxis`, `WidgetAppearance`). +- → `:core:model`, `:core:designsystem` (font and theme colours), `:core:prefs` (`FontRoundnessAxis`, `WidgetAppearance`, `WidgetShowsArtwork`). - Libraries: AndroidX Core, Material, Coil (artwork disk cache), Kotlin coroutines, kotlinx.serialization JSON; tests add Robolectric and coroutines-test. - Feature modules never depend on other features. This module never constructs repositories; `:app`'s `NowPlayingWidgetPlaybackAdapter` and `WidgetLibrarySourceAdapter` implement the ports. @@ -69,7 +78,8 @@ src/main/res/ - `:app` calls both `configure*` helpers once from `BoxLoreApplication` after `AppContainer` is ready. On the application IO scope they check for installed widgets (`hasInstalledWidgets`) and start collection only for an installed family. - Adding a widget later starts its coordinator through the enabled broadcast. The app's adapters are lazy, so an unused family never restores playback or ranks the library. - Coordinator collection and artwork IO run on the application `CoroutineScope` from the dependencies. -- Control broadcasts use `goAsync()` with `Dispatchers.Main.immediate`: an optimistic snapshot renders first, and a failed action rolls back to the real playback state. +- Control broadcasts use `goAsync()` with `Dispatchers.Main.immediate`: an optimistic snapshot renders first from the player's last published position, the action then asks for the real position, and a failed action rolls back to the real playback state. +- Progress polling runs on the application scope only while an episode plays and a playback widget is placed; it stops when playback pauses. - `:app`'s playback adapter runs every transport action on the main thread before touching `MediaController`. - `onUpdate` reads snapshots on a background scope, then renders once. - Dependency holders expose `instance` with an `internal` setter; only `configure*` installs them. @@ -78,7 +88,7 @@ src/main/res/ | Concern | Contract | | :--- | :--- | -| SharedPreferences files | `boxlore_now_playing_widget`, `boxlore_library_widget` | +| SharedPreferences files | `boxlore_now_playing_widget`, `boxlore_library_widget`, `boxlore_widget_picker_previews` (the look each published preview was drawn for) | | Snapshot key | `snapshot` (JSON) | | Artwork cache | `{cacheDir}/widget_artwork/` | | Receiver FQCNs | the six receivers above, under `cx.aswin.boxlore.feature.widgets` | @@ -93,10 +103,12 @@ Hermetic JVM tests live in `feature/widgets/src/test`, using Robolectric and fak | Area | Tests | | :--- | :--- | | Playback widgets | `NowPlayingWidgetRemoteViewsTest`, `NowPlayingWidgetCoordinatorTest`, `NowPlayingWidgetLogicTest`, `WidgetOptimisticActionTest` | +| Sizing, resizing and progress | `WidgetPresentationTest`, `WidgetSizesTest`, `WidgetProgressTest`, `WidgetProgressPollerTest`, `WidgetShapesTest` | +| Every widget, state and size applied and drawn | `WidgetRenderGalleryTest` (set `BOXLORE_WIDGET_RENDERS=1` to save the drawings under `build/reports/widget-renders/`) | | Library widgets | `LibraryWidgetRemoteViewsTest`, `LibraryWidgetLogicTest` | | Snapshots and dependency holders | `NowPlayingWidgetSnapshotStoreTest`, `NowPlayingWidgetDependenciesHolderTest` | | Startup and updates | `WidgetStartupTest`, `WidgetUpdatesTest` | -| Theming and text | `WidgetChromeTest`, `WidgetRemoteViewsColorsTest`, `WidgetTextBitmapRendererTest` | +| Theming, picker previews and adding from the app | `WidgetChromeTest`, `WidgetRemoteViewsColorsTest`, `WidgetPickerPreviewsTest`, `HomeScreenWidgetsTest` | | Intents | `WidgetActionIntentsTest` | ```bash diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt new file mode 100644 index 000000000..5fd0eeda6 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt @@ -0,0 +1,67 @@ +package cx.aswin.boxlore.feature.widgets + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.widget.RemoteViews +import androidx.annotation.StringRes + +/** + * The widgets a listener can see and add to the home screen from inside the app. The home screen app + * does the adding and confirms it with the listener itself; not every home screen app can. + */ +object HomeScreenWidgets { + /** One widget, named as the widget picker names it, with the size its preview is drawn for. */ + class Entry internal constructor( + val key: String, + @param:StringRes val label: Int, + @param:StringRes val description: Int, + val previewWidthDp: Int, + val previewHeightDp: Int, + internal val receiver: Class, + ) + + val all: List = + listOf( + entry("full", R.string.now_playing_widget_label, R.string.now_playing_widget_description), + entry("compact", R.string.now_playing_bar_widget_label, R.string.now_playing_bar_widget_description), + entry("round", R.string.playback_controls_widget_label, R.string.playback_controls_widget_description), + entry("round_next", R.string.playback_next_controls_widget_label, R.string.playback_next_controls_widget_description), + entry("shows", R.string.subscriptions_widget_label, R.string.subscriptions_widget_description), + entry("new_episodes", R.string.new_episodes_widget_label, R.string.new_episodes_widget_description), + ) + + /** Whether the current home screen app adds a widget when an app asks it to. */ + fun canAdd(context: Context): Boolean = runCatching { manager(context)?.isRequestPinAppWidgetSupported == true }.getOrDefault(false) + + /** + * Asks the home screen app to add the widget with this [key]. Returns false when the key is + * unknown or the home screen app turned the request down; it never throws. + */ + fun requestAdd(context: Context, key: String): Boolean { + val entry = all.firstOrNull { it.key == key } ?: return false + return runCatching { + manager(context)?.requestPinAppWidget(ComponentName(context.applicationContext, entry.receiver), null, null) == true + }.getOrDefault(false) + } + + /** + * The widget with this [key] as its own renderer draws it, with sample content and in the + * listener's widget colours. Lay it out at its entry's preview size, then scale that picture to + * fit: at that size every row is whole. It is a picture: show it where its buttons cannot be + * tapped. Cuts and caches artwork, so call it off the main thread. Null when the key is unknown + * or the preview cannot be drawn. + */ + fun preview(context: Context, key: String): RemoteViews? { + val preview = WidgetPickerPreviews.previews().firstOrNull { it.key == key } ?: return null + return runCatching { preview.build(context.applicationContext) }.getOrNull() + } + + private fun entry(key: String, @StringRes label: Int, @StringRes description: Int): Entry { + val preview = WidgetPickerPreviews.previews().first { it.key == key } + return Entry(key, label, description, preview.size.widthDp, preview.size.heightDp, preview.receiver) + } + + private fun manager(context: Context): AppWidgetManager? = context.applicationContext.getSystemService(AppWidgetManager::class.java) +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetCoordinator.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetCoordinator.kt index 1682e662c..dfd5298c3 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetCoordinator.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetCoordinator.kt @@ -36,15 +36,15 @@ object LibraryWidgetCoordinator { renderAll(context, cached) } - combine(deps.library.subscriptions, deps.library.newEpisodes) { shows, episodes -> - shows to episodes - }.collectLatest { (shows, episodes) -> + combine(deps.library.subscriptions, deps.library.newEpisodes, deps.library.counts) { shows, episodes, counts -> + Triple(shows, episodes, counts) + }.collectLatest { (shows, episodes, counts) -> val previous = store.read() val withDiskCacheShows = shows.map { row -> row.copy( artworkCachePath = - row.artworkCachePath + row.artworkCachePath.ifOnDisk() ?: artworkLoader.resolveCachedPath(row.artworkUrl), ) } @@ -52,7 +52,7 @@ object LibraryWidgetCoordinator { episodes.map { row -> row.copy( artworkCachePath = - row.artworkCachePath + row.artworkCachePath.ifOnDisk() ?: artworkLoader.resolveCachedPath(row.artworkUrl), ) } @@ -71,6 +71,7 @@ object LibraryWidgetCoordinator { subscriptions = mergedShows, newEpisodes = mergedEpisodes, updatedAtMs = System.currentTimeMillis(), + counts = counts, ) store.write(snapshot) renderAll(context, snapshot) @@ -108,6 +109,11 @@ object LibraryWidgetCoordinator { } } + /** + * Fetches covers the widgets do not have yet and shows them as they arrive, a few at a time. + * Waiting for the whole library first left a widget without covers whenever the app was closed + * before the last one came in. + */ private fun loadMissingArtwork( context: Context, snapshot: LibraryWidgetSnapshot, @@ -118,41 +124,49 @@ object LibraryWidgetCoordinator { artworkJob.getAndSet(null)?.cancel() artworkJob.set( deps.scope.launch { - var shows = snapshot.subscriptions - var episodes = snapshot.newEpisodes - var changed = false - - shows = - shows.map { row -> - if (!row.artworkCachePath.isNullOrBlank() || cx.aswin.boxlore.core.model.artworkUrls(row.artworkUrl, *row.fallbackArtworkUrls.toTypedArray()).isEmpty()) { - row - } else { - val path = artworkLoader.load(row.artworkUrl, row.fallbackArtworkUrls) ?: return@map row - changed = true - row.copy(artworkCachePath = path) - } - } - episodes = - episodes.map { row -> - if (!row.artworkCachePath.isNullOrBlank() || cx.aswin.boxlore.core.model.artworkUrls(row.artworkUrl, *row.fallbackArtworkUrls.toTypedArray()).isEmpty()) { - row - } else { - val path = artworkLoader.load(row.artworkUrl, row.fallbackArtworkUrls) ?: return@map row - changed = true - row.copy(artworkCachePath = path) - } - } + var current = snapshot + var unpublished = 0 + + fun publish() { + store.write(current.copy(updatedAtMs = System.currentTimeMillis())) + notifyRowsChanged(context.applicationContext) + unpublished = 0 + } - if (!changed) return@launch - val withArt = - LibraryWidgetSnapshot( - subscriptions = shows, - newEpisodes = episodes, - updatedAtMs = System.currentTimeMillis(), - ) - store.write(withArt) - renderAll(context.applicationContext, withArt) + snapshot.subscriptions.forEachIndexed { index, row -> + if (!row.needsArtwork()) return@forEachIndexed + val path = artworkLoader.load(row.artworkUrl, row.fallbackArtworkUrls) ?: return@forEachIndexed + current = current.copy(subscriptions = current.subscriptions.toMutableList().also { it[index] = row.copy(artworkCachePath = path) }) + if (++unpublished >= PUBLISH_EVERY) publish() + } + snapshot.newEpisodes.forEachIndexed { index, row -> + if (!row.needsArtwork()) return@forEachIndexed + val path = artworkLoader.load(row.artworkUrl, row.fallbackArtworkUrls) ?: return@forEachIndexed + current = current.copy(newEpisodes = current.newEpisodes.toMutableList().also { it[index] = row.copy(artworkCachePath = path) }) + if (++unpublished >= PUBLISH_EVERY) publish() + } + if (unpublished > 0) publish() }, ) } + + /** Tells the placed library widgets to redraw their rows; the frame around them stays as it is. */ + private fun notifyRowsChanged(context: Context) { + val manager = context.getSystemService(AppWidgetManager::class.java) ?: return + LibraryWidgetProviders.all.forEach { provider -> + val ids = manager.getAppWidgetIds(ComponentName(context, provider.receiverClass)) + if (ids.isNotEmpty()) manager.notifyAppWidgetViewDataChanged(ids, R.id.widget_list_view) + } + } + + private fun WidgetShowRow.needsArtwork(): Boolean = artworkCachePath.isNullOrBlank() && + cx.aswin.boxlore.core.model.artworkUrls(artworkUrl, *fallbackArtworkUrls.toTypedArray()).isNotEmpty() + + private fun WidgetEpisodeRow.needsArtwork(): Boolean = artworkCachePath.isNullOrBlank() && + cx.aswin.boxlore.core.model.artworkUrls(artworkUrl, *fallbackArtworkUrls.toTypedArray()).isNotEmpty() + + /** The system may clear the artwork cache, so a remembered path only counts while its file is there. */ + private fun String?.ifOnDisk(): String? = this?.takeIf { it.isNotBlank() && java.io.File(it).exists() } + + private const val PUBLISH_EVERY = 4 } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetDependencies.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetDependencies.kt index 0e21bf310..3d0aadec2 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetDependencies.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetDependencies.kt @@ -30,13 +30,30 @@ data class WidgetEpisodeRow( val deepLinkUri: String, ) +/** + * How many shows and latest episodes the library holds in all. The lists a widget scrolls are capped, + * so the header badge counts from here to match what the app shows. + */ +@Serializable +data class WidgetLibraryCounts( + val subscriptions: Int = 0, + val newEpisodes: Int = 0, +) + /** Combined library-widget snapshot persisted for cold updates. */ @Serializable data class LibraryWidgetSnapshot( val subscriptions: List = emptyList(), val newEpisodes: List = emptyList(), val updatedAtMs: Long = 0L, -) + val counts: WidgetLibraryCounts = WidgetLibraryCounts(), +) { + /** The badge number: the library's total, never less than the rows the widget holds. */ + fun count(kind: LibraryWidgetKind): Int = when (kind) { + LibraryWidgetKind.SUBSCRIPTIONS -> maxOf(counts.subscriptions, subscriptions.size) + LibraryWidgetKind.NEW_EPISODES -> maxOf(counts.newEpisodes, newEpisodes.size) + } +} enum class LibraryWidgetKind { SUBSCRIPTIONS, @@ -51,6 +68,9 @@ interface WidgetLibrarySource { val subscriptions: Flow> val newEpisodes: Flow> + + /** Totals before the lists were capped. */ + val counts: Flow } interface LibraryWidgetDependencies { diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt index 26882abb4..ac38cd331 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt @@ -3,17 +3,19 @@ package cx.aswin.boxlore.feature.widgets import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Rect import android.net.Uri import android.view.View import android.widget.RemoteViews import android.widget.RemoteViewsService +import androidx.core.net.toUri +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork +import java.io.File +import kotlin.math.roundToInt +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull /** - * Supplies scrollable rows for Subscriptions / New Episodes [ListView] widgets. + * Supplies the scrolling New Episodes rows and Your Shows tiles. * Reads the latest [LibraryWidgetSnapshot] from prefs (written by the coordinator). */ class LibraryWidgetRemoteViewsService : RemoteViewsService() { @@ -45,18 +47,23 @@ class LibraryWidgetRemoteViewsService : RemoteViewsService() { internal class LibraryWidgetRemoteViewsFactory( private val context: Context, private val kind: LibraryWidgetKind, + /** Rows to show instead of the saved snapshot: the sample content of a picker preview. */ + private val preset: LibraryWidgetSnapshot? = null, ) : RemoteViewsService.RemoteViewsFactory { private var shows: List = emptyList() private var episodes: List = emptyList() private var chrome: WidgetChrome = WidgetChrome.resolve(context) + private var mixedShapes: Boolean = WidgetShowsArtwork.usesShapes(WidgetShowsArtwork.cached(context)) + private val artworkLoader by lazy { WidgetArtworkLoader(context) } override fun onCreate() = Unit override fun onDataSetChanged() { - val snapshot = LibraryWidgetSnapshotStore(context).read() ?: LibraryWidgetSnapshot() + val snapshot = preset ?: LibraryWidgetSnapshotStore(context).read() ?: LibraryWidgetSnapshot() shows = snapshot.subscriptions episodes = snapshot.newEpisodes chrome = WidgetChrome.resolve(context) + mixedShapes = WidgetShowsArtwork.usesShapes(WidgetShowsArtwork.cached(context)) } override fun onDestroy() { @@ -69,84 +76,64 @@ internal class LibraryWidgetRemoteViewsFactory( LibraryWidgetKind.NEW_EPISODES -> episodes.size } - override fun getViewAt(position: Int): RemoteViews { - val layoutId = - when (kind) { - LibraryWidgetKind.SUBSCRIPTIONS -> R.layout.library_widget_list_item - LibraryWidgetKind.NEW_EPISODES -> R.layout.library_widget_episode_list_item - } - val views = RemoteViews(context.packageName, layoutId) - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_row_bg, - WidgetPalette.secondaryContainer, - chrome, - ) - - val deepLink: String - when (kind) { - LibraryWidgetKind.SUBSCRIPTIONS -> { - val row = shows.getOrNull(position) ?: return views - views.setTextViewText(R.id.widget_row_title, row.title) - views.setTextViewText(R.id.widget_row_subtitle, row.subtitle) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_row_title, - WidgetPalette.onSecondaryContainer, - chrome, - ) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_row_subtitle, - WidgetPalette.onSurfaceVariant, - chrome, - ) - views.setViewVisibility( - R.id.widget_row_new_dot, - if (row.isNew) View.VISIBLE else View.GONE, - ) - if (row.isNew) { - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_row_new_dot, - WidgetPalette.primary, - chrome, - ) - } - bindArt(views, row.artworkCachePath) - deepLink = row.deepLinkUri - } - LibraryWidgetKind.NEW_EPISODES -> { - val row = episodes.getOrNull(position) ?: return views - views.setTextViewText(R.id.widget_row_title, row.episodeTitle) - views.setTextViewText(R.id.widget_row_subtitle, row.podcastTitle) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_row_title, - WidgetPalette.onSecondaryContainer, - chrome, - ) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_row_subtitle, - WidgetPalette.onSurfaceVariant, - chrome, - ) - views.setViewVisibility(R.id.widget_row_new_dot, View.GONE) - bindArt(views, row.artworkCachePath) - deepLink = row.deepLinkUri - } + override fun getViewAt(position: Int): RemoteViews = when (kind) { + LibraryWidgetKind.SUBSCRIPTIONS -> showTile(position) + LibraryWidgetKind.NEW_EPISODES -> episodeRow(position) + } + + /** One show: its cover, whole or cut into this tile's shape, with a dot when it has a new episode. */ + private fun showTile(position: Int): RemoteViews { + val views = RemoteViews(context.packageName, R.layout.library_widget_list_item) + val row = shows.getOrNull(position) ?: return views + val shape = tileShape(mixedShapes, position) + val sizePx = px(TILE_ART_DP) + bindArt(views, artworkPath(row.artworkCachePath, row.artworkUrl, row.fallbackArtworkUrls), sizePx, shape) + // The placeholder is cut to the same shape, so a tile keeps its outline when it has no cover. + views.setImageViewBitmap(R.id.widget_row_art_placeholder, WidgetBitmapCache.mask(shape, sizePx)) + views.setContentDescription(R.id.widget_row_root, row.title) + + val dot = if (row.isNew) View.VISIBLE else View.GONE + views.setViewVisibility(R.id.widget_row_new_dot_ring, dot) + views.setViewVisibility(R.id.widget_row_new_dot, dot) + if (row.isNew) { + // The ring is the widget's own colour, so the dot reads as cut out of the cover. + chrome.setColorFilter(views, R.id.widget_row_new_dot_ring, LibraryWidgetRenderer.palette(kind).container) + chrome.setColorFilter(views, R.id.widget_row_new_dot, WidgetPalette.tertiary) } + views.setOnClickFillInIntent(R.id.widget_row_root, Intent().apply { data = row.deepLinkUri.toUri() }) + return views + } - val fillIn = - Intent().apply { - data = Uri.parse(deepLink) - } - views.setOnClickFillInIntent(R.id.widget_row_root, fillIn) + /** One latest episode: a pill with round artwork, the episode and its show. */ + private fun episodeRow(position: Int): RemoteViews { + val views = RemoteViews(context.packageName, R.layout.library_widget_episode_list_item) + val row = episodes.getOrNull(position) ?: return views + chrome.setColorFilter(views, R.id.widget_row_bg, WidgetPalette.surface) + views.setTextViewText(R.id.widget_row_title, row.episodeTitle) + views.setTextViewText(R.id.widget_row_subtitle, row.podcastTitle) + chrome.setTextColor(views, R.id.widget_row_title, WidgetPalette.onSurface) + chrome.setTextColor(views, R.id.widget_row_subtitle, WidgetPalette.onSurfaceVariant) + bindArt(views, artworkPath(row.artworkCachePath, row.artworkUrl, row.fallbackArtworkUrls), px(ROW_ART_DP), WidgetArtShape.CIRCLE) + views.setOnClickFillInIntent(R.id.widget_row_root, Intent().apply { data = row.deepLinkUri.toUri() }) return views } - override fun getLoadingView(): RemoteViews? = null + /** + * What a row or tile looks like while its cover is fetched: the placeholder it would show without + * one, in place of the launcher's plain "Loading" text. + */ + override fun getLoadingView(): RemoteViews = when (kind) { + LibraryWidgetKind.SUBSCRIPTIONS -> + RemoteViews(context.packageName, R.layout.library_widget_list_item).also { views -> + bindArt(views, null, px(TILE_ART_DP), WidgetArtShape.ROUNDED) + views.setImageViewBitmap(R.id.widget_row_art_placeholder, WidgetBitmapCache.mask(WidgetArtShape.ROUNDED, px(TILE_ART_DP))) + } + LibraryWidgetKind.NEW_EPISODES -> + RemoteViews(context.packageName, R.layout.library_widget_episode_list_item).also { views -> + chrome.setColorFilter(views, R.id.widget_row_bg, WidgetPalette.surface) + bindArt(views, null, px(ROW_ART_DP), WidgetArtShape.CIRCLE) + } + } override fun getViewTypeCount(): Int = 1 @@ -172,43 +159,48 @@ internal class LibraryWidgetRemoteViewsFactory( private fun bindArt( views: RemoteViews, cachePath: String?, + sizePx: Int, + shape: WidgetArtShape, ) { - val bitmap = cachePath?.let { decodeArt(it) } - if (bitmap == null) { - views.setImageViewResource(R.id.widget_row_art, R.drawable.widget_art_placeholder) - } else { + val bitmap = cachePath?.let { WidgetBitmapCache.artwork(it, sizePx, shape) } + if (bitmap != null) { views.setImageViewBitmap(R.id.widget_row_art, bitmap) + views.setViewVisibility(R.id.widget_row_art, View.VISIBLE) + views.setViewVisibility(R.id.widget_row_art_placeholder, View.GONE) + views.setViewVisibility(R.id.widget_row_art_mark, View.GONE) + return } + views.setViewVisibility(R.id.widget_row_art, View.GONE) + views.setViewVisibility(R.id.widget_row_art_placeholder, View.VISIBLE) + views.setViewVisibility(R.id.widget_row_art_mark, View.VISIBLE) + chrome.setColorFilter(views, R.id.widget_row_art_placeholder, WidgetPalette.secondaryContainer) + chrome.setColorFilter(views, R.id.widget_row_art_mark, WidgetPalette.onSecondaryContainer) } - private fun decodeArt(path: String): Bitmap? { - val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeFile(path, bounds) - if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null - val target = 80 - var sample = 1 - while (bounds.outWidth / (sample * 2) >= target && bounds.outHeight / (sample * 2) >= target) { - sample *= 2 - } - val source = - BitmapFactory.decodeFile( - path, - BitmapFactory.Options().apply { inSampleSize = sample }, - ) ?: return null - val ratio = source.width.toFloat() / source.height - val crop = - if (ratio > 1f) { - val cropWidth = source.height - val left = (source.width - cropWidth) / 2 - Rect(left, 0, left + cropWidth, source.height) - } else { - val cropHeight = source.width - val top = (source.height - cropHeight) / 2 - Rect(0, top, source.width, top + cropHeight) - } - return Bitmap.createBitmap(target, target, Bitmap.Config.ARGB_8888).also { out -> - Canvas(out).drawBitmap(source, crop, Rect(0, 0, target, target), null) - source.recycle() - } + /** + * Where this row's cover is on disk, fetching it first if the widget has never had it. The app only + * fetches covers while it is running; a widget placed on the home screen cannot wait for that. + * The launcher calls this off the main thread and shows the loading tile meanwhile. + */ + private fun artworkPath(cachePath: String?, url: String?, fallbackUrls: List): String? { + cachePath?.takeIf { it.isNotBlank() && File(it).exists() }?.let { return it } + // Sample rows of a picker preview have no address to fetch from. + if (preset != null || cx.aswin.boxlore.core.model.artworkUrls(url, *fallbackUrls.toTypedArray()).isEmpty()) return null + artworkLoader.resolveCachedPath(url)?.let { return it } + return runCatching { + runBlocking { withTimeoutOrNull(ARTWORK_TIMEOUT_MS) { artworkLoader.load(url, fallbackUrls) } } + }.getOrNull() + } + + private fun px(dp: Int): Int = (dp * context.resources.displayMetrics.density).roundToInt().coerceIn(1, MAX_ART_PX) + + internal companion object { + /** Whole covers as rounded squares, or the mixed shapes when the listener chose them. */ + fun tileShape(mixedShapes: Boolean, position: Int): WidgetArtShape = if (mixedShapes) WidgetShapes.mixedShapeAt(position) else WidgetArtShape.ROUNDED + + private const val TILE_ART_DP = 84 + private const val ROW_ART_DP = 46 + private const val MAX_ART_PX = 288 + private const val ARTWORK_TIMEOUT_MS = 6_000L } } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt index 98e9aec4a..bfedcdc28 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt @@ -6,10 +6,16 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build +import android.util.TypedValue import android.view.View import android.widget.RemoteViews +import androidx.annotation.ColorRes import cx.aswin.boxlore.feature.widgets.actions.WidgetActionIntents +/** + * Draws the frame of the two library widgets: the tinted block, the count badge, the header and the + * resting state. [LibraryWidgetRemoteViewsFactory] supplies the rows and tiles. + */ object LibraryWidgetRenderer { fun updateAll( context: Context, @@ -36,15 +42,55 @@ object LibraryWidgetRenderer { } } + /** The roles one library widget is painted with. */ + internal class Palette( + @ColorRes val container: Int, + @ColorRes val onContainer: Int, + @ColorRes val accent: Int, + @ColorRes val onAccent: Int, + @ColorRes val button: Int, + @ColorRes val onButton: Int, + ) + + internal fun palette(kind: LibraryWidgetKind): Palette = when (kind) { + // New Episodes is the seafoam block with white pills; Your Shows stays neutral so covers stand out. + LibraryWidgetKind.NEW_EPISODES -> + Palette( + container = WidgetPalette.tertiaryContainer, + onContainer = WidgetPalette.onTertiaryContainer, + accent = WidgetPalette.tertiary, + onAccent = WidgetPalette.onTertiary, + button = WidgetPalette.surface, + onButton = WidgetPalette.onSurface, + ) + LibraryWidgetKind.SUBSCRIPTIONS -> + Palette( + container = WidgetPalette.surface, + onContainer = WidgetPalette.onSurface, + accent = WidgetPalette.primary, + onAccent = WidgetPalette.onPrimary, + button = WidgetPalette.primaryContainer, + onButton = WidgetPalette.onPrimaryContainer, + ) + } + fun build( context: Context, appWidgetId: Int, snapshot: LibraryWidgetSnapshot, kind: LibraryWidgetKind, + collection: RemoteViews.RemoteCollectionItems? = null, ): RemoteViews { - val views = RemoteViews(context.packageName, R.layout.library_widget_list) + val layout = + when (kind) { + LibraryWidgetKind.SUBSCRIPTIONS -> R.layout.library_widget_grid + LibraryWidgetKind.NEW_EPISODES -> R.layout.library_widget_list + } + val views = RemoteViews(context.packageName, layout) val chrome = WidgetChrome.resolve(context) - applyChrome(views, chrome) + val palette = palette(kind) + chrome.setColorFilter(views, R.id.widget_surface_background, palette.container) + chrome.setTextColor(views, R.id.widget_list_header, palette.onContainer) val headerRes = when (kind) { @@ -75,16 +121,20 @@ object LibraryWidgetRenderer { ), ) - val count = + val rows = when (kind) { LibraryWidgetKind.SUBSCRIPTIONS -> snapshot.subscriptions.size LibraryWidgetKind.NEW_EPISODES -> snapshot.newEpisodes.size } - - views.setRemoteAdapter( - R.id.widget_list_view, - LibraryWidgetRemoteViewsService.adapterIntent(context, appWidgetId, kind), - ) + if (collection != null) { + // A picker preview carries its rows with it; a placed widget binds the list service. + views.setRemoteAdapter(R.id.widget_list_view, collection) + } else { + views.setRemoteAdapter( + R.id.widget_list_view, + LibraryWidgetRemoteViewsService.adapterIntent(context, appWidgetId, kind), + ) + } views.setEmptyView(R.id.widget_list_view, R.id.widget_empty_container) val template = collectionClickTemplate(context, appWidgetId) if (template != null) { @@ -94,74 +144,47 @@ object LibraryWidgetRenderer { ) } - if (count == 0) { - bindEmpty( - context, - views, - chrome, - appWidgetId, - when (kind) { - LibraryWidgetKind.SUBSCRIPTIONS -> R.string.subscriptions_widget_empty_title - LibraryWidgetKind.NEW_EPISODES -> R.string.new_episodes_widget_empty_title - }, - when (kind) { - LibraryWidgetKind.SUBSCRIPTIONS -> R.string.subscriptions_widget_empty_cta - LibraryWidgetKind.NEW_EPISODES -> R.string.new_episodes_widget_empty_cta - }, - openListUri, - ) + if (rows == 0) { + bindEmpty(context, views, chrome, appWidgetId, kind, palette, openListUri) } else { - bindPopulated(context, views, chrome, appWidgetId, count, openListUri) + bindPopulated(context, views, chrome, appWidgetId, snapshot.count(kind), palette, openListUri) } - return views } + @Suppress("LongParameterList") private fun bindPopulated( context: Context, views: RemoteViews, chrome: WidgetChrome, appWidgetId: Int, count: Int, + palette: Palette, openListUri: String, ) { + views.setViewVisibility(R.id.widget_empty_container, View.GONE) views.setViewVisibility(R.id.widget_count_chip, View.VISIBLE) - views.setViewVisibility(R.id.widget_footer, View.VISIBLE) views.setTextViewText(R.id.widget_count_chip_text, count.toString()) - WidgetRemoteViewsColors.setTextColor( - views, + views.setTextViewTextSize( R.id.widget_count_chip_text, - WidgetPalette.onSecondaryContainer, - chrome, - ) - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_count_chip_bg, - WidgetPalette.secondaryContainer, - chrome, - ) - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_footer_bg, - WidgetPalette.secondaryContainer, - chrome, - ) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_footer_text, - WidgetPalette.onSecondaryContainer, - chrome, + TypedValue.COMPLEX_UNIT_SP, + if (count >= THREE_DIGITS) COUNT_SP_LONG else COUNT_SP, ) + chrome.setColorFilter(views, R.id.widget_count_chip_bg, palette.accent) + chrome.setTextColor(views, R.id.widget_count_chip_text, palette.onAccent) + + // The arrow replaces the old "View all" footer, so the whole body is content. + views.setViewVisibility(R.id.widget_open_list, View.VISIBLE) + chrome.setColorFilter(views, R.id.widget_open_list_background, palette.button) + chrome.setColorFilter(views, R.id.widget_open_list_icon, palette.onButton) val open = WidgetActionIntents.openDeepLink( context, openListUri, requestCode(appWidgetId, FOOTER_REQUEST), ) - views.setOnClickPendingIntent(R.id.widget_footer, open) - views.setOnClickPendingIntent(R.id.widget_footer_text, open) + views.setOnClickPendingIntent(R.id.widget_open_list, open) views.setOnClickPendingIntent(R.id.widget_count_chip, open) - views.setViewVisibility(R.id.widget_empty_container, View.GONE) } internal fun collectionClickTemplate( @@ -197,38 +220,43 @@ object LibraryWidgetRenderer { } } + @Suppress("LongParameterList") private fun bindEmpty( context: Context, views: RemoteViews, chrome: WidgetChrome, appWidgetId: Int, - titleRes: Int, - ctaRes: Int, + kind: LibraryWidgetKind, + palette: Palette, uri: String, ) { - views.setViewVisibility(R.id.widget_footer, View.GONE) views.setViewVisibility(R.id.widget_count_chip, View.GONE) + views.setViewVisibility(R.id.widget_open_list, View.GONE) views.setViewVisibility(R.id.widget_empty_container, View.VISIBLE) + val titleRes = + when (kind) { + LibraryWidgetKind.SUBSCRIPTIONS -> R.string.subscriptions_widget_empty_title + LibraryWidgetKind.NEW_EPISODES -> R.string.new_episodes_widget_empty_title + } + val ctaRes = + when (kind) { + LibraryWidgetKind.SUBSCRIPTIONS -> R.string.subscriptions_widget_empty_cta + LibraryWidgetKind.NEW_EPISODES -> R.string.new_episodes_widget_empty_cta + } views.setTextViewText(R.id.widget_empty_title, context.getString(titleRes)) views.setTextViewText(R.id.widget_empty_cta, context.getString(ctaRes)) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_empty_title, - WidgetPalette.onSurface, - chrome, - ) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_empty_cta, - WidgetPalette.onSecondaryContainer, - chrome, - ) - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_empty_cta_bg, - WidgetPalette.secondaryContainer, - chrome, + views.setImageViewResource( + R.id.widget_empty_icon, + when (kind) { + LibraryWidgetKind.SUBSCRIPTIONS -> cx.aswin.boxlore.core.designsystem.R.drawable.ic_boxlore_app_mark + LibraryWidgetKind.NEW_EPISODES -> R.drawable.ic_widget_caught_up + }, ) + chrome.setColorFilter(views, R.id.widget_empty_mark_background, palette.accent) + chrome.setColorFilter(views, R.id.widget_empty_icon, palette.onAccent) + chrome.setTextColor(views, R.id.widget_empty_title, palette.onContainer) + chrome.setColorFilter(views, R.id.widget_empty_cta_bg, palette.button) + chrome.setTextColor(views, R.id.widget_empty_cta, palette.onButton) val open = WidgetActionIntents.openDeepLink( context, @@ -240,24 +268,6 @@ object LibraryWidgetRenderer { views.setOnClickPendingIntent(R.id.widget_empty_cta, open) } - private fun applyChrome( - views: RemoteViews, - chrome: WidgetChrome, - ) { - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_surface_background, - WidgetPalette.surface, - chrome, - ) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_list_header, - WidgetPalette.onSurface, - chrome, - ) - } - fun cancelAll(context: Context, appWidgetId: Int) { val tabFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -329,8 +339,14 @@ object LibraryWidgetRenderer { internal const val HEADER_REQUEST = 10 internal const val ROOT_REQUEST = 11 internal const val EMPTY_REQUEST = 12 + + /** The header arrow. It keeps the slot of the footer it replaced, so old pending intents still cancel. */ internal const val FOOTER_REQUEST = 13 internal const val TEMPLATE_REQUEST = 14 + private const val SUBSCRIPTIONS_TAB_URI = "boxlore://library/subscriptions?tab=0" private const val NEW_EPISODES_TAB_URI = "boxlore://library/subscriptions?tab=1" + private const val THREE_DIGITS = 100 + private const val COUNT_SP = 19f + private const val COUNT_SP_LONG = 15f } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinator.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinator.kt index ffc71f7fb..974475ee3 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinator.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinator.kt @@ -4,15 +4,21 @@ import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context import cx.aswin.boxlore.feature.widgets.logic.NowPlayingWidgetMapper +import cx.aswin.boxlore.feature.widgets.logic.WidgetProgressPoller import cx.aswin.boxlore.feature.widgets.logic.WidgetUpdatePolicy import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch object NowPlayingWidgetCoordinator { private val collectJob = AtomicReference(null) private val artworkJob = AtomicReference(null) + private val progressJob = AtomicReference(null) + + /** How often a playing widget asks the player where it is. Repaints are rarer: see [WidgetUpdatePolicy]. */ + internal const val PROGRESS_POLL_MS = 20_000L @Volatile private var artworkLoadingUrl: String? = null @@ -30,6 +36,7 @@ object NowPlayingWidgetCoordinator { artworkLoadingUrl = null val context = deps.context.applicationContext + progressJob.getAndSet(pollProgressWhilePlaying(context, deps))?.cancel() val store = NowPlayingWidgetSnapshotStore(context) val artworkLoader = WidgetArtworkLoader(context) @@ -43,11 +50,13 @@ object NowPlayingWidgetCoordinator { deps.playback.state.collectLatest { playback -> val previous = store.read() + // The system may clear the artwork cache, so a remembered path only counts while its file is there. val cachedPath = artworkLoader.resolveCachedPath(playback.artworkUrl) ?: previous ?.takeIf { it.episodeId == playback.episodeId } ?.artworkCachePath + ?.takeIf { java.io.File(it).exists() } val snapshot = NowPlayingWidgetMapper.fromPlayback( state = playback, @@ -64,6 +73,23 @@ object NowPlayingWidgetCoordinator { } } + /** + * The app stops tracking the position once it leaves the screen, which is when widgets are looked + * at. While an episode plays and a playback widget is placed, ask the player for it now and then + * so the progress ring and the time left keep moving. + */ + private fun pollProgressWhilePlaying( + context: Context, + deps: NowPlayingWidgetDependencies, + ): Job = deps.scope.launch { + WidgetProgressPoller.run( + playing = deps.playback.state.map { it.isPlaying && it.hasEpisode }, + intervalMs = PROGRESS_POLL_MS, + hasWidgets = { hasInstalledWidgets(context, WidgetProviders.all.map { it.receiverClass }) }, + refresh = { deps.playback.refreshPosition() }, + ) + } + fun requestRefresh(context: Context) { val store = NowPlayingWidgetSnapshotStore(context) val snapshot = store.read() ?: return diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetDependencies.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetDependencies.kt index 2bea48e08..a6791ea44 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetDependencies.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetDependencies.kt @@ -24,6 +24,12 @@ interface WidgetPlaybackSource { suspend fun skipForward() suspend fun skipBackward() + + /** + * Publishes the player's current position through [state]. The app only tracks the position while + * it is on screen, so widgets ask for it while an episode plays in the background. + */ + suspend fun refreshPosition() } interface NowPlayingWidgetDependencies { diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRenderer.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRenderer.kt index 48848ba5a..d4c715d68 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRenderer.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRenderer.kt @@ -3,18 +3,26 @@ package cx.aswin.boxlore.feature.widgets import android.appwidget.AppWidgetManager import android.content.Context import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Rect -import android.os.Build -import android.text.Layout +import android.os.Bundle import android.util.SizeF -import android.util.TypedValue +import android.view.Gravity import android.view.View import android.widget.RemoteViews +import androidx.annotation.ColorRes import cx.aswin.boxlore.feature.widgets.actions.WidgetActionIntents +import cx.aswin.boxlore.feature.widgets.logic.WidgetPresentation +import cx.aswin.boxlore.feature.widgets.logic.WidgetProgress import cx.aswin.boxlore.feature.widgets.logic.WidgetSemantics +import cx.aswin.boxlore.feature.widgets.logic.WidgetSize +import cx.aswin.boxlore.feature.widgets.logic.WidgetSizes +import kotlin.math.roundToInt +/** + * Draws the four playback widgets. The layouts scale themselves to the space the launcher gives them, + * so this only decides what to show ([WidgetPresentation]), supplies the pictures and tints every part + * through [WidgetChrome]. The size a launcher reports is used for those choices and for how sharp to + * draw the pictures, never to lay anything out. + */ @Suppress("TooManyFunctions") object NowPlayingWidgetRenderer { fun updateAll( @@ -22,7 +30,7 @@ object NowPlayingWidgetRenderer { appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, snapshot: NowPlayingWidgetSnapshot, - optionsById: Map = emptyMap(), + optionsById: Map = emptyMap(), variant: WidgetVariant = WidgetVariant.NOW_PLAYING, ) { appWidgetIds.forEach { id -> @@ -36,7 +44,7 @@ object NowPlayingWidgetRenderer { appWidgetManager: AppWidgetManager, appWidgetId: Int, snapshot: NowPlayingWidgetSnapshot, - options: android.os.Bundle = appWidgetManager.getAppWidgetOptions(appWidgetId), + options: Bundle = appWidgetManager.getAppWidgetOptions(appWidgetId), variant: WidgetVariant = WidgetVariant.NOW_PLAYING, ) { val remoteViews = buildRemoteViews(context, appWidgetId, snapshot, options, variant) @@ -46,528 +54,408 @@ object NowPlayingWidgetRenderer { } } + /** + * What a widget shows depends on its size, and launchers report sizes late, loosely or not at all. + * So the widget is built once for every size at which what it shows changes, and the launcher + * itself draws the one that fits the space it really has, also while the widget is being resized. + * The pictures are the same objects in each, so they are sent once. + */ internal fun buildRemoteViews( context: Context, appWidgetId: Int, snapshot: NowPlayingWidgetSnapshot, - options: android.os.Bundle, + options: Bundle, variant: WidgetVariant = WidgetVariant.NOW_PLAYING, ): RemoteViews { - val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 250) - val maxWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, minWidth) - val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 110) - val maxHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, minHeight) - var widthDp = maxOf(minWidth, maxWidth) - var heightDp = maxOf(minHeight, maxHeight) - - // Prefer the widest reported size. A SizeF→RemoteViews map often left the host - // displaying a narrow title layout inside a much wider bar. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - @Suppress("DEPRECATION") - val sizes = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - options.getParcelableArrayList( - AppWidgetManager.OPTION_APPWIDGET_SIZES, - SizeF::class.java, - ) - } else { - options.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES) - } - val widest = sizes?.maxByOrNull { it.width } - if (widest != null) { - widthDp = widest.width.toInt().coerceAtLeast(widthDp) - heightDp = widest.height.toInt().coerceAtLeast(1) - } - } - - return buildForSize(context, appWidgetId, snapshot, variant, widthDp, heightDp) + val steps = stepsFor(variant) + val update = Update(context, appWidgetId, snapshot, variant, WidgetChrome.resolve(context), reportedSizes(options, variant).largest) + if (steps.size == 1) return buildStep(update, steps.single()) + return RemoteViews(steps.associate { step -> SizeF(step.widthDp.toFloat(), step.heightDp.toFloat()) to buildStep(update, step) }) } - private fun buildForSize( + /** The one layout a launcher shows for a widget really drawn at [drawn]: for previews and tests. */ + internal fun buildRemoteViewsAt( context: Context, appWidgetId: Int, snapshot: NowPlayingWidgetSnapshot, - variant: WidgetVariant, - widthDp: Int, - heightDp: Int, + options: Bundle, + variant: WidgetVariant = WidgetVariant.NOW_PLAYING, + drawn: WidgetSize = reportedSizes(options, variant).portrait, ): RemoteViews { - val layoutId = - when (variant) { - WidgetVariant.NOW_PLAYING -> R.layout.now_playing_widget_standard - WidgetVariant.BAR -> R.layout.now_playing_widget_bar - WidgetVariant.CONTROLS -> R.layout.playback_controls_widget - WidgetVariant.CONTROLS_NEXT -> R.layout.playback_controls_widget + val update = Update(context, appWidgetId, snapshot, variant, WidgetChrome.resolve(context), reportedSizes(options, variant).largest) + return buildStep(update, WidgetPresentation.stepFor(stepsFor(variant), drawn)) + } + + /** The sizes at which what [variant] shows changes, smallest first. The round widget never changes. */ + internal fun stepsFor(variant: WidgetVariant): List = when (variant) { + WidgetVariant.NOW_PLAYING -> WidgetPresentation.FULL_CONTROLS_STEPS + WidgetVariant.BAR -> WidgetPresentation.BAR_STEPS + WidgetVariant.CONTROLS, + WidgetVariant.CONTROLS_NEXT, + -> listOf(WidgetSize(0, 0)) + } + + private fun buildStep(update: Update, shown: WidgetSize): RemoteViews { + val frame = Frame(update, shown) + return RemoteViews(update.context.packageName, layoutFor(update.variant, shown)).also { views -> + when (update.variant) { + WidgetVariant.NOW_PLAYING -> bindFullControls(frame, views) + WidgetVariant.BAR -> bindBar(frame, views) + WidgetVariant.CONTROLS, + WidgetVariant.CONTROLS_NEXT, + -> bindRound(frame, views) } - return RemoteViews(context.packageName, layoutId).also { views -> - bind( - context = context, - views = views, - appWidgetId = appWidgetId, - snapshot = snapshot, - variant = variant, - widthDp = widthDp, - heightDp = heightDp, - ) } } - private fun bind( - context: Context, - views: RemoteViews, - appWidgetId: Int, - snapshot: NowPlayingWidgetSnapshot, - variant: WidgetVariant, - widthDp: Int, - heightDp: Int, + internal fun layoutFor(variant: WidgetVariant, shown: WidgetSize): Int = when (variant) { + WidgetVariant.NOW_PLAYING -> + if (WidgetPresentation.fullControls(shown).tall) { + R.layout.now_playing_widget_standard_tall + } else { + R.layout.now_playing_widget_standard + } + WidgetVariant.BAR -> R.layout.now_playing_widget_bar + WidgetVariant.CONTROLS, + WidgetVariant.CONTROLS_NEXT, + -> R.layout.playback_controls_widget + } + + internal fun reportedSizes(options: Bundle, variant: WidgetVariant): WidgetSizes = WidgetSizes.of( + minWidthDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0), + maxWidthDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0), + minHeightDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0), + maxHeightDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0), + fallback = + when (variant) { + WidgetVariant.NOW_PLAYING -> WidgetSize(FALLBACK_WIDE_DP, FALLBACK_TWO_ROWS_DP) + WidgetVariant.BAR -> WidgetSize(FALLBACK_WIDE_DP, FALLBACK_ONE_ROW_DP) + WidgetVariant.CONTROLS, + WidgetVariant.CONTROLS_NEXT, + -> WidgetSize(FALLBACK_TWO_ROWS_DP, FALLBACK_TWO_ROWS_DP) + }, + ) + + /** Everything one widget update needs, whatever size it is drawn at. */ + private class Update( + val context: Context, + val appWidgetId: Int, + val snapshot: NowPlayingWidgetSnapshot, + val variant: WidgetVariant, + val chrome: WidgetChrome, + /** The most space the launcher says the widget may get, so pictures stay sharp at any size. */ + val largest: WidgetSize, + ) + + /** One update drawn for one size step. */ + private class Frame( + update: Update, + /** The size what is shown is chosen for. */ + val shown: WidgetSize, ) { - views.setContentDescription(R.id.widget_root, WidgetSemantics.contentDescription(snapshot)) - configureGridCardSize(views, variant, widthDp, heightDp) - configureCompactCardHeight(views, variant) + val context: Context = update.context + val appWidgetId: Int = update.appWidgetId + val snapshot: NowPlayingWidgetSnapshot = update.snapshot + val variant: WidgetVariant = update.variant + val chrome: WidgetChrome = update.chrome + val largest: WidgetSize = update.largest + private val density: Float = context.resources.displayMetrics.density - val chrome = WidgetChrome.resolve(context) - val artwork = - snapshot.artworkCachePath - ?.takeIf(String::isNotBlank) - ?.let { path -> decodeArtwork(path, context, variant, widthDp, heightDp) } - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_surface_background, - WidgetPalette.surface, - chrome, - ) + /** Pixels for a picture about [dp] across, drawn sharp enough for the largest size reported. */ + fun px(dp: Number): Int = (dp.toFloat() * density).roundToInt().coerceIn(MIN_PICTURE_PX, MAX_PICTURE_PX) + } + + // region Now Playing · Full Controls + + private fun bindFullControls(frame: Frame, views: RemoteViews) { + val show = WidgetPresentation.fullControls(frame.shown) + bindRoot(frame, views) - if (!snapshot.hasEpisode) { - bindEmptyState(context, views, widthDp, variant, chrome) + if (!frame.snapshot.hasEpisode) { + bindRest(frame, views, showSubtitle = true) return } + showPlaying(views) + frame.chrome.setColorFilter(views, R.id.widget_surface_background, WidgetPalette.primaryContainer) + val tallestDp = frame.largest.heightDp + bindDisc(frame, views, frame.px(if (show.tall) tallestDp * TALL_DISC_SHARE else tallestDp / 2f), DISC_ART_INSET, DISC_RING_STROKE) + bindMetadata(frame, views, show.titleLines, show.meta) - views.setViewVisibility(R.id.widget_empty_container, View.GONE) - views.setViewVisibility(R.id.widget_playing_container, View.VISIBLE) - if (artwork == null) { - views.setImageViewResource(R.id.widget_artwork, R.drawable.widget_art_placeholder) - } else { - views.setImageViewBitmap(R.id.widget_artwork, artwork) + frame.chrome.setColorFilter(views, R.id.widget_controls_start_background, WidgetPalette.surface) + frame.chrome.setColorFilter(views, R.id.widget_controls_end_background, WidgetPalette.surface) + bindPlayPause(frame, views) + val seekColors = ControlColors(WidgetPalette.primaryContainer, WidgetPalette.onPrimaryContainer) + bindControl(frame, views, Slot.LEADING, WidgetControl.SKIP_BACK, seekColors) + bindControl(frame, views, Slot.TRAILING, WidgetControl.SKIP_FORWARD, seekColors) + val edgeColors = ControlColors(background = null, icon = WidgetPalette.onSurface) + bindControl(frame, views, Slot.PREVIOUS, WidgetControl.PREVIOUS, edgeColors) + bindControl(frame, views, Slot.NEXT, WidgetControl.NEXT, edgeColors) + } + + // endregion + + // region Now Playing · Compact + + private fun bindBar(frame: Frame, views: RemoteViews) { + val show = WidgetPresentation.bar(frame.shown) + bindRoot(frame, views) + + if (!frame.snapshot.hasEpisode) { + bindRest(frame, views, showSubtitle = show.showTimeLeft) + return } + showPlaying(views) + frame.chrome.setColorFilter(views, R.id.widget_surface_background, WidgetPalette.primaryContainer) + bindDisc(frame, views, frame.px(frame.largest.heightDp.coerceAtMost(BAR_MAX_DISC_DP)), DISC_ART_INSET, DISC_RING_STROKE) + // The artwork already says which show this is, so the narrow title area keeps the episode name. + bindMetadata(frame, views, show.titleLines, if (show.showTimeLeft) WidgetPresentation.Meta.TIME else WidgetPresentation.Meta.NONE) + + bindPlayPause(frame, views) + val miniColors = ControlColors(WidgetPalette.surface, WidgetPalette.onSurface) + bindControl(frame, views, Slot.LEADING, WidgetControl.SKIP_BACK, miniColors) + bindControl(frame, views, Slot.TRAILING, WidgetControl.SKIP_FORWARD, miniColors) + } + + // endregion - if (!variant.isControlsGrid()) { - bindMetadata(views, snapshot, chrome) + // region Now Playing · Round + + private fun bindRound(frame: Frame, views: RemoteViews) { + bindRoot(frame, views) + frame.chrome.setColorFilter(views, R.id.widget_surface_background, WidgetPalette.primaryContainer) + if (!frame.snapshot.hasEpisode) { + bindRest(frame, views, showSubtitle = false) + return } - bindTransport(context, views, appWidgetId, snapshot, variant, chrome) - views.setOnClickPendingIntent(R.id.widget_root, WidgetActionIntents.openApp(context)) + showPlaying(views) + bindDisc(frame, views, frame.px(frame.largest.shortSide), ROUND_ART_INSET, ROUND_RING_STROKE) + bindPlayPause(frame, views) + val miniColors = ControlColors(WidgetPalette.surface, WidgetPalette.onSurface) + // Seek has back and forward. Next keeps forward on the left and puts next episode on the right. + val next = frame.variant == WidgetVariant.CONTROLS_NEXT + bindControl(frame, views, Slot.LEADING, if (next) WidgetControl.SKIP_FORWARD else WidgetControl.SKIP_BACK, miniColors) + bindControl(frame, views, Slot.TRAILING, if (next) WidgetControl.NEXT else WidgetControl.SKIP_FORWARD, miniColors) } - private fun bindEmptyState( - context: Context, - views: RemoteViews, - widthDp: Int, - variant: WidgetVariant, - chrome: WidgetChrome, - ) { + // endregion + + // region Shared parts + + private fun bindRoot(frame: Frame, views: RemoteViews) { + views.setContentDescription(R.id.widget_root, WidgetSemantics.contentDescription(frame.snapshot)) + views.setOnClickPendingIntent(R.id.widget_root, WidgetActionIntents.openApp(frame.context)) + } + + private fun showPlaying(views: RemoteViews) { + views.setViewVisibility(R.id.widget_empty_container, View.GONE) + views.setViewVisibility(R.id.widget_playing_container, View.VISIBLE) + } + + /** Nothing playing: the scalloped mark and a line that opens boxlore. */ + private fun bindRest(frame: Frame, views: RemoteViews, showSubtitle: Boolean) { views.setViewVisibility(R.id.widget_playing_container, View.GONE) views.setViewVisibility(R.id.widget_empty_container, View.VISIBLE) - if (!variant.isControlsGrid()) { - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_empty_surface_background, - WidgetPalette.surface, - chrome, + // The round widget rests on its own circle; the others have a pill of their own. + if (frame.variant == WidgetVariant.NOW_PLAYING || frame.variant == WidgetVariant.BAR) { + frame.chrome.setColorFilter(views, R.id.widget_empty_surface_background, WidgetPalette.primaryContainer) + views.setViewVisibility(R.id.widget_empty_subtitle, if (showSubtitle) View.VISIBLE else View.GONE) + views.setTextViewText(R.id.widget_empty_subtitle, frame.context.getString(R.string.widget_empty_subtitle)) + frame.chrome.setTextColor(views, R.id.widget_empty_subtitle, WidgetPalette.onPrimaryContainer) + views.setInt( + R.id.widget_empty_title, + METHOD_GRAVITY, + if (showSubtitle) Gravity.BOTTOM or Gravity.START else Gravity.CENTER_VERTICAL or Gravity.START, ) } - val appIcon = context.applicationInfo.icon - if (appIcon != 0) { - views.setImageViewResource(R.id.widget_empty_icon, appIcon) - } - views.setOnClickPendingIntent(R.id.widget_empty_container, WidgetActionIntents.openApp(context)) + frame.chrome.setColorFilter(views, R.id.widget_empty_mark_background, WidgetPalette.primary) + frame.chrome.setColorFilter(views, R.id.widget_empty_icon, WidgetPalette.onPrimary) + views.setTextViewText(R.id.widget_empty_title, frame.context.getString(R.string.widget_empty_title)) + frame.chrome.setTextColor(views, R.id.widget_empty_title, WidgetPalette.onPrimaryContainer) + views.setOnClickPendingIntent(R.id.widget_empty_container, WidgetActionIntents.openApp(frame.context)) + } - when (variant) { - WidgetVariant.NOW_PLAYING -> bindStandardEmptyState(context, views, widthDp, chrome) - WidgetVariant.BAR -> bindBarEmptyState(context, views, widthDp, chrome) - WidgetVariant.CONTROLS, - WidgetVariant.CONTROLS_NEXT, - -> bindControlsEmptyState(context, views, widthDp, chrome) + /** Round artwork inside the progress ring: what has played, a gap, then what is left. */ + private fun bindDisc(frame: Frame, views: RemoteViews, discPx: Int, artInset: Float, ringStroke: Float) { + val snapshot = frame.snapshot + val ring = WidgetBitmapCache.progressRing(discPx, WidgetProgress.ringStep(snapshot.positionMs, snapshot.durationMs), ringStroke) + views.setMaskOrHide(R.id.widget_progress, ring.played) + frame.chrome.setColorFilter(views, R.id.widget_progress, WidgetPalette.primary) + views.setMaskOrHide(R.id.widget_progress_track, ring.remaining) + frame.chrome.setColorFilter(views, R.id.widget_progress_track, WidgetPalette.onPrimaryContainer) + views.setInt(R.id.widget_progress_track, "setImageAlpha", TRACK_ALPHA) + + val artwork = + snapshot.artworkCachePath + ?.takeIf(String::isNotBlank) + ?.let { path -> WidgetBitmapCache.artwork(path, discPx, WidgetArtShape.CIRCLE, artInset) } + if (artwork != null) { + views.setImageViewBitmap(R.id.widget_artwork, artwork) + views.setViewVisibility(R.id.widget_artwork, View.VISIBLE) + views.setViewVisibility(R.id.widget_artwork_placeholder, View.GONE) + views.setViewVisibility(R.id.widget_artwork_mark, View.GONE) + return } + // No artwork yet: a tinted circle with the boxlore mark, in the same place. + views.setViewVisibility(R.id.widget_artwork, View.GONE) + views.setImageViewBitmap(R.id.widget_artwork_placeholder, WidgetBitmapCache.mask(WidgetArtShape.CIRCLE, discPx, artInset)) + views.setViewVisibility(R.id.widget_artwork_placeholder, View.VISIBLE) + frame.chrome.setColorFilter(views, R.id.widget_artwork_placeholder, WidgetPalette.secondaryContainer) + views.setViewVisibility(R.id.widget_artwork_mark, View.VISIBLE) + frame.chrome.setColorFilter(views, R.id.widget_artwork_mark, WidgetPalette.onSecondaryContainer) } - private fun bindStandardEmptyState( - context: Context, - views: RemoteViews, - widthDp: Int, - chrome: WidgetChrome, - ) { - val metadataWidth = (widthDp - STANDARD_EMPTY_METADATA_INSETS_DP).coerceAtLeast(MIN_TEXT_WIDTH_DP) - views.setImageViewBitmap( - R.id.widget_empty_title, - WidgetTextBitmapRenderer.renderColor( - context = context, - spec = - WidgetTextBitmapRenderer.Spec( - text = context.getString(R.string.widget_empty_title), - widthDp = metadataWidth, - heightDp = 40, - preferredSizeSp = 17f, - minSizeSp = 13f, - weight = TITLE_WEIGHT, - maxLines = 2, - alignment = Layout.Alignment.ALIGN_CENTER, - ), - color = chrome.argb(WidgetPalette.onSurface), - ), - ) - views.setImageViewBitmap( - R.id.widget_empty_subtitle, - WidgetTextBitmapRenderer.renderColor( - context = context, - spec = - WidgetTextBitmapRenderer.Spec( - text = context.getString(R.string.widget_empty_subtitle), - widthDp = metadataWidth, - heightDp = 18, - preferredSizeSp = 11f, - minSizeSp = 9f, - weight = BODY_WEIGHT, - maxLines = 1, - alignment = Layout.Alignment.ALIGN_CENTER, - ), - color = chrome.argb(WidgetPalette.onSurfaceVariant), - ), + /** + * The episode title, then whatever there is room for under it. The title always comes first: when + * the line under it goes, the title is centred in the space instead. + */ + private fun bindMetadata(frame: Frame, views: RemoteViews, titleLines: Int, meta: WidgetPresentation.Meta) { + val line = metaLine(frame.context, frame.snapshot, meta) + views.setTextViewText(R.id.widget_episode_title, frame.snapshot.episodeTitle) + views.setInt(R.id.widget_episode_title, "setMaxLines", titleLines) + views.setInt( + R.id.widget_episode_title, + METHOD_GRAVITY, + if (line.isEmpty()) Gravity.CENTER_VERTICAL or Gravity.START else Gravity.BOTTOM or Gravity.START, ) + frame.chrome.setTextColor(views, R.id.widget_episode_title, WidgetPalette.onPrimaryContainer) + views.setViewVisibility(R.id.widget_podcast_title, if (line.isEmpty()) View.GONE else View.VISIBLE) + views.setTextViewText(R.id.widget_podcast_title, line) + frame.chrome.setTextColor(views, R.id.widget_podcast_title, WidgetPalette.onPrimaryContainer) } - private fun bindBarEmptyState( - context: Context, - views: RemoteViews, - widthDp: Int, - chrome: WidgetChrome, - ) { - val textWidth = (widthDp - BAR_EMPTY_CONTENT_INSETS_DP).coerceAtLeast(MIN_TEXT_WIDTH_DP) - views.setImageViewBitmap( - R.id.widget_empty_title, - WidgetTextBitmapRenderer.renderColor( - context = context, - spec = - WidgetTextBitmapRenderer.Spec( - text = context.getString(R.string.widget_empty_bar_title), - widthDp = textWidth, - heightDp = 24, - preferredSizeSp = 14f, - minSizeSp = 11f, - weight = TITLE_WEIGHT, - maxLines = 1, - alignment = Layout.Alignment.ALIGN_CENTER, - ), - color = chrome.argb(WidgetPalette.onSurface), - ), - ) + /** + * "36m left · The Daily Lore": the time first, so a long show name is what gets cut off. With no + * time to show yet, the show name stands alone wherever there is room for it. + */ + internal fun metaLine(context: Context, snapshot: NowPlayingWidgetSnapshot, meta: WidgetPresentation.Meta): String { + val timeLeft = timeLeftLabel(context, snapshot) + val show = snapshot.podcastTitle + return when (meta) { + WidgetPresentation.Meta.NONE -> "" + WidgetPresentation.Meta.TIME -> timeLeft.orEmpty() + WidgetPresentation.Meta.TIME_AND_SHOW -> + when { + timeLeft == null -> show + show.isBlank() -> timeLeft + else -> context.getString(R.string.widget_meta_time_and_show, timeLeft, show) + } + } } - private fun bindControlsEmptyState( - context: Context, - views: RemoteViews, - widthDp: Int, - chrome: WidgetChrome, - ) { - val textWidth = - (widthDp - GRID_EMPTY_HORIZONTAL_PADDING_DP) - .coerceAtLeast(GRID_MIN_TEXT_WIDTH_DP) - views.setImageViewBitmap( - R.id.widget_empty_title, - WidgetTextBitmapRenderer.renderColor( - context = context, - spec = - WidgetTextBitmapRenderer.Spec( - text = context.getString(R.string.widget_empty_title), - widthDp = textWidth, - heightDp = 28, - preferredSizeSp = 11f, - minSizeSp = 9f, - weight = TITLE_WEIGHT, - maxLines = 2, - alignment = Layout.Alignment.ALIGN_CENTER, - ), - color = chrome.argb(WidgetPalette.onSurface), - ), - ) + internal fun timeLeftLabel(context: Context, snapshot: NowPlayingWidgetSnapshot): String? { + val left = WidgetProgress.timeLeft(snapshot.positionMs, snapshot.durationMs) ?: return null + return when { + left.hours == 0 -> context.getString(R.string.widget_time_left_minutes, left.minutes) + left.minutes == 0 -> context.getString(R.string.widget_time_left_hours, left.hours) + else -> context.getString(R.string.widget_time_left_hours_minutes, left.hours, left.minutes) + } } - private fun bindMetadata( - views: RemoteViews, - snapshot: NowPlayingWidgetSnapshot, - chrome: WidgetChrome, - ) { - views.setTextViewText(R.id.widget_episode_title, snapshot.episodeTitle) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_episode_title, - WidgetPalette.onSurface, - chrome, - ) - views.setTextViewText(R.id.widget_podcast_title, snapshot.podcastTitle) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_podcast_title, - WidgetPalette.onSurfaceVariant, - chrome, + private fun bindPlayPause(frame: Frame, views: RemoteViews) { + bindPlayPauseState(frame.context, views, frame.chrome, frame.snapshot.isPlaying) + views.setOnClickPendingIntent( + R.id.widget_play_pause, + WidgetActionIntents.broadcast(frame.context, frame.appWidgetId, WidgetControl.TOGGLE), ) } - private fun bindTransport( - context: Context, - views: RemoteViews, - appWidgetId: Int, - snapshot: NowPlayingWidgetSnapshot, - variant: WidgetVariant, - chrome: WidgetChrome, - ) { - val playBackground = - if (variant.isControlsGrid() || snapshot.isPlaying) { - R.drawable.widget_button_pause - } else { - R.drawable.widget_button_play - } - views.setImageViewResource(R.id.widget_play_pause_background, playBackground) - WidgetRemoteViewsColors.setColorFilter( - views, + /** The play button: a circle at rest, a scalloped cookie while playing. */ + private fun bindPlayPauseState(context: Context, views: RemoteViews, chrome: WidgetChrome, playing: Boolean) { + views.setImageViewResource( R.id.widget_play_pause_background, - WidgetPalette.primary, - chrome, + if (playing) R.drawable.widget_cookie else R.drawable.widget_circle, ) + chrome.setColorFilter(views, R.id.widget_play_pause_background, WidgetPalette.primary) views.setImageViewResource( R.id.widget_play_pause_icon, - if (snapshot.isPlaying) R.drawable.ic_widget_pause else R.drawable.ic_widget_play, - ) - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_play_pause_icon, - WidgetPalette.onPrimary, - chrome, + if (playing) R.drawable.ic_widget_pause else R.drawable.ic_widget_play, ) + chrome.setColorFilter(views, R.id.widget_play_pause_icon, WidgetPalette.onPrimary) views.setContentDescription( R.id.widget_play_pause_icon, - context.getString(if (snapshot.isPlaying) R.string.widget_pause else R.string.widget_play), - ) - views.setOnClickPendingIntent( - R.id.widget_play_pause, - WidgetActionIntents.broadcast(context, appWidgetId, WidgetControl.TOGGLE), - ) - - val leadingControl = - if (variant == WidgetVariant.CONTROLS_NEXT) { - WidgetControl.NEXT - } else { - WidgetControl.SKIP_BACK - } - views.setImageViewResource( - R.id.widget_skip_back_icon, - if (leadingControl == WidgetControl.NEXT) { - R.drawable.ic_widget_next - } else { - R.drawable.ic_widget_skip_back - }, - ) - views.setContentDescription( - R.id.widget_skip_back_icon, - context.getString( - if (leadingControl == WidgetControl.NEXT) { - R.string.widget_next - } else { - R.string.widget_skip_back - }, - ), - ) - - // Compact surfaces use seek-back by default; the alternate grid uses next. - bindSecondaryButton( - context = context, - views = views, - appWidgetId = appWidgetId, - containerId = R.id.widget_skip_back, - backgroundId = R.id.widget_skip_back_background, - iconId = R.id.widget_skip_back_icon, - control = leadingControl, - chrome = chrome, + context.getString(if (playing) R.string.widget_pause else R.string.widget_play), ) - bindSecondaryButton( - context = context, - views = views, - appWidgetId = appWidgetId, - containerId = R.id.widget_skip_forward, - backgroundId = R.id.widget_skip_forward_background, - iconId = R.id.widget_skip_forward_icon, - control = WidgetControl.SKIP_FORWARD, - chrome = chrome, - ) - - if (variant == WidgetVariant.NOW_PLAYING) { - bindSecondaryButton( - context = context, - views = views, - appWidgetId = appWidgetId, - containerId = R.id.widget_previous, - backgroundId = R.id.widget_previous_background, - iconId = R.id.widget_previous_icon, - control = WidgetControl.PREVIOUS, - chrome = chrome, - ) - bindSecondaryButton( - context = context, - views = views, - appWidgetId = appWidgetId, - containerId = R.id.widget_next, - backgroundId = R.id.widget_next_background, - iconId = R.id.widget_next_icon, - control = WidgetControl.NEXT, - chrome = chrome, - ) - } } - private fun bindSecondaryButton( - context: Context, - views: RemoteViews, - appWidgetId: Int, - containerId: Int, - backgroundId: Int, - iconId: Int, - control: WidgetControl, - chrome: WidgetChrome, - ) { - WidgetRemoteViewsColors.setColorFilter( - views, - backgroundId, - WidgetPalette.secondaryContainer, - chrome, - ) - WidgetRemoteViewsColors.setColorFilter( - views, - iconId, - WidgetPalette.onSecondaryContainer, - chrome, - ) - views.setOnClickPendingIntent( - containerId, - WidgetActionIntents.broadcast(context, appWidgetId, control), - ) + /** The views of one control. Leading and trailing hold whichever control the widget puts there. */ + private enum class Slot(val container: Int, val background: Int?, val icon: Int) { + LEADING(R.id.widget_skip_back, R.id.widget_skip_back_background, R.id.widget_skip_back_icon), + TRAILING(R.id.widget_skip_forward, R.id.widget_skip_forward_background, R.id.widget_skip_forward_icon), + PREVIOUS(R.id.widget_previous, null, R.id.widget_previous_icon), + NEXT(R.id.widget_next, null, R.id.widget_next_icon), } - private fun configureGridCardSize( - views: RemoteViews, - variant: WidgetVariant, - widthDp: Int, - heightDp: Int, - ) { - if (!variant.isControlsGrid()) return - val sideDp = minOf(widthDp, heightDp).coerceAtLeast(GRID_MIN_SIDE_DP) - views.setViewLayoutWidth(R.id.widget_grid_card, sideDp.toFloat(), TypedValue.COMPLEX_UNIT_DIP) - views.setViewLayoutHeight(R.id.widget_grid_card, sideDp.toFloat(), TypedValue.COMPLEX_UNIT_DIP) - setSquareSize(views, R.id.widget_skip_back_icon, sideDp * GRID_CONTROL_ICON_RATIO) - setSquareSize(views, R.id.widget_skip_forward_icon, sideDp * GRID_CONTROL_ICON_RATIO) - setSquareSize(views, R.id.widget_play_pause_icon, sideDp * GRID_PLAY_ICON_RATIO) - } + private class ControlColors(@ColorRes val background: Int?, @ColorRes val icon: Int) - /** - * Pin the beige card to content height so launcher hosts cannot stretch a - * match_parent surface into tall empty bands inside a 2-row cell. - */ - private fun configureCompactCardHeight( + private fun bindControl( + frame: Frame, views: RemoteViews, - variant: WidgetVariant, + slot: Slot, + control: WidgetControl, + colors: ControlColors, ) { - val heightDp = - when (variant) { - // 48 metadata + 4 gap + 38 controls + 10 vertical padding - WidgetVariant.NOW_PLAYING -> STANDARD_CARD_HEIGHT_DP - // 40 art + 8 vertical padding - WidgetVariant.BAR -> BAR_CARD_HEIGHT_DP - WidgetVariant.CONTROLS, - WidgetVariant.CONTROLS_NEXT, - -> return + val context = frame.context + if (slot.background != null && colors.background != null) { + frame.chrome.setColorFilter(views, slot.background, colors.background) + } + val description: Int + when (control) { + WidgetControl.SKIP_BACK -> { + views.setImageViewBitmap(slot.icon, WidgetBitmapCache.seekIcon(context, seconds(frame.snapshot.seekBackwardMs), forward = false)) + description = R.string.widget_skip_back } - views.setViewLayoutHeight( - R.id.widget_playing_container, - heightDp.toFloat(), - TypedValue.COMPLEX_UNIT_DIP, + WidgetControl.SKIP_FORWARD -> { + views.setImageViewBitmap(slot.icon, WidgetBitmapCache.seekIcon(context, seconds(frame.snapshot.seekForwardMs), forward = true)) + description = R.string.widget_skip_forward + } + WidgetControl.NEXT -> { + views.setImageViewResource(slot.icon, R.drawable.ic_widget_next) + description = R.string.widget_next + } + WidgetControl.PREVIOUS -> { + views.setImageViewResource(slot.icon, R.drawable.ic_widget_previous) + description = R.string.widget_previous + } + WidgetControl.TOGGLE, + WidgetControl.OPEN_APP, + -> return + } + frame.chrome.setColorFilter(views, slot.icon, colors.icon) + views.setContentDescription(slot.icon, context.getString(description)) + views.setOnClickPendingIntent( + slot.container, + WidgetActionIntents.broadcast(context, frame.appWidgetId, control), ) } - private fun setSquareSize( - views: RemoteViews, - viewId: Int, - sizeDp: Float, - ) { - views.setViewLayoutWidth(viewId, sizeDp, TypedValue.COMPLEX_UNIT_DIP) - views.setViewLayoutHeight(viewId, sizeDp, TypedValue.COMPLEX_UNIT_DIP) - } + private fun seconds(ms: Long): Int = (ms / MS_PER_SECOND).toInt().coerceAtLeast(1) - private fun decodeArtwork( - path: String, - context: Context, - variant: WidgetVariant, - widthDp: Int, - heightDp: Int, - ): Bitmap? { - val density = context.resources.displayMetrics.density - val targetSizeDp = - when (variant) { - WidgetVariant.NOW_PLAYING -> 48 - WidgetVariant.BAR -> 40 - WidgetVariant.CONTROLS, - WidgetVariant.CONTROLS_NEXT, - -> - ((minOf(widthDp, heightDp) - GRID_INSETS_DP) / 2) - .coerceAtLeast(GRID_MIN_ART_DP) - } - val targetSizePx = (targetSizeDp * density).toInt().coerceAtLeast(1) - val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeFile(path, bounds) - if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null - - var sampleSize = 1 - while (bounds.outWidth / (sampleSize * 2) >= targetSizePx && - bounds.outHeight / (sampleSize * 2) >= targetSizePx - ) { - sampleSize *= 2 - } - val source = - BitmapFactory.decodeFile( - path, - BitmapFactory.Options().apply { inSampleSize = sampleSize }, - ) ?: return null - val sourceRatio = source.width.toFloat() / source.height - val crop = - if (sourceRatio > 1f) { - val cropWidth = source.height - val left = (source.width - cropWidth) / 2 - Rect(left, 0, left + cropWidth, source.height) - } else { - val cropHeight = source.width - val top = (source.height - cropHeight) / 2 - Rect(0, top, source.width, top + cropHeight) - } - return Bitmap.createBitmap(targetSizePx, targetSizePx, Bitmap.Config.ARGB_8888).also { output -> - Canvas(output).drawBitmap( - source, - crop, - Rect(0, 0, targetSizePx, targetSizePx), - null, - ) - source.recycle() + // endregion + + private fun RemoteViews.setMaskOrHide(viewId: Int, mask: Bitmap?) { + if (mask == null) { + setViewVisibility(viewId, View.GONE) + } else { + setImageViewBitmap(viewId, mask) + setViewVisibility(viewId, View.VISIBLE) } } - private const val TITLE_WEIGHT = 600 - private const val BODY_WEIGHT = 400 - private const val MIN_TEXT_WIDTH_DP = 72 - private const val GRID_MIN_TEXT_WIDTH_DP = 48 - private const val STANDARD_EMPTY_METADATA_INSETS_DP = 104 - private const val BAR_EMPTY_CONTENT_INSETS_DP = 70 - private const val GRID_EMPTY_HORIZONTAL_PADDING_DP = 20 - private const val GRID_MIN_SIDE_DP = 110 - private const val GRID_INSETS_DP = 38 - private const val GRID_MIN_ART_DP = 36 - private const val GRID_CONTROL_ICON_RATIO = 0.14f - private const val GRID_PLAY_ICON_RATIO = 0.16f - private const val STANDARD_CARD_HEIGHT_DP = 100 - private const val BAR_CARD_HEIGHT_DP = 48 - - private fun WidgetVariant.isControlsGrid(): Boolean = this == WidgetVariant.CONTROLS || this == WidgetVariant.CONTROLS_NEXT + private const val FALLBACK_WIDE_DP = 250 + private const val FALLBACK_TWO_ROWS_DP = 110 + private const val FALLBACK_ONE_ROW_DP = 48 + + private const val METHOD_GRAVITY = "setGravity" + private const val MIN_PICTURE_PX = 96 + private const val MAX_PICTURE_PX = 512 + private const val MS_PER_SECOND = 1000L + private const val TRACK_ALPHA = 56 + private const val BAR_MAX_DISC_DP = 110 + private const val TALL_DISC_SHARE = 0.66f + + /** The artwork sits inside the ring: 8dp clear on a 78dp disc, 10dp on the 170dp round widget. */ + private const val DISC_ART_INSET = 0.105f + private const val DISC_RING_STROKE = 3.4f / 78f + private const val ROUND_ART_INSET = 10f / 170f + private const val ROUND_RING_STROKE = 5f / 170f } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetBitmapCache.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetBitmapCache.kt new file mode 100644 index 000000000..618555d30 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetBitmapCache.kt @@ -0,0 +1,63 @@ +package cx.aswin.boxlore.feature.widgets + +import android.content.Context +import android.graphics.Bitmap +import android.util.LruCache +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansTypefaces +import cx.aswin.boxlore.feature.widgets.logic.WidgetProgress +import java.io.File + +/** + * Keeps the pictures a widget is drawn from, so a redraw after a tap or a progress step reuses them + * instead of decoding the cover and drawing every shape again. That is what makes a widget answer a + * tap at once. Entries are keyed by everything that changes how they look, and the oldest are dropped + * once the cache is full. + */ +internal object WidgetBitmapCache { + private val bitmaps = + object : LruCache(MAX_BYTES) { + override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount + } + + /** The cover at [path] cut to [shape], or null when the file cannot be read. */ + fun artwork(path: String, sizePx: Int, shape: WidgetArtShape, insetFraction: Float = 0f): Bitmap? { + val file = File(path) + if (!file.exists()) return null + // A cover that is replaced on disk keeps its path, so its change time is part of the key. + val key = "art|$path|${file.lastModified()}|$sizePx|$shape|$insetFraction" + bitmaps.get(key)?.let { return it } + val source = WidgetShapes.decodeSampled(path, sizePx) ?: return null + return WidgetShapes.shapeArtwork(source, sizePx, shape, insetFraction).also { + bitmaps.put(key, it) + source.recycle() + } + } + + fun mask(shape: WidgetArtShape, sizePx: Int, insetFraction: Float = 0f): Bitmap = cached("mask|$sizePx|$shape|$insetFraction") { WidgetShapes.mask(shape, sizePx, insetFraction) } + + /** The ring at one of its [WidgetProgress.RING_STEPS] steps. Either arc is null when it has nothing to draw. */ + fun progressRing(sizePx: Int, step: Int, strokeFraction: Float): WidgetShapes.ProgressRing { + val key = "ring|$sizePx|$step|$strokeFraction" + val played = bitmaps.get("$key|played") + val remaining = bitmaps.get("$key|remaining") + // A step with only one arc is cheap to draw again, so only complete pairs are served from here. + if (played != null && remaining != null) return WidgetShapes.ProgressRing(played, remaining) + return WidgetShapes.progressRing(sizePx, step.toFloat() / WidgetProgress.RING_STEPS, strokeFraction).also { ring -> + ring.played?.let { bitmaps.put("$key|played", it) } + ring.remaining?.let { bitmaps.put("$key|remaining", it) } + } + } + + fun seekIcon(context: Context, seconds: Int, forward: Boolean): Bitmap { + // The number is drawn in the listener's lettering, so a change of lettering draws it again. + val roundness = GoogleSansTypefaces.cachedRoundness(context) + return cached("seek|$seconds|$forward|$roundness") { WidgetSeekIcon.mask(context, seconds, forward, SEEK_ICON_PX) } + } + + private inline fun cached(key: String, draw: () -> Bitmap): Bitmap = bitmaps.get(key) ?: draw().also { bitmaps.put(key, it) } + + private const val MAX_BYTES = 12 * 1024 * 1024 + + /** Sharp enough for the largest seek button; the layout scales it down. */ + private const val SEEK_ICON_PX = 120 +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetChrome.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetChrome.kt index 945280881..2e373b4ad 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetChrome.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetChrome.kt @@ -14,65 +14,75 @@ import cx.aswin.boxlore.core.prefs.WidgetAppearance /** * Resolves widget Material roles from either the in-app Appearance theme (default) * or system Material You resources. + * + * Every colour reaches the launcher as a tint, never baked into a bitmap, in one of three forms: + * - System appearance: a colour resource, which the launcher resolves with its own light/dark mode + * and wallpaper palette. + * - App theme with a fixed Light or Dark display mode: one ARGB value. + * - App theme whose display mode follows the system: a light and a dark ARGB value, so the launcher + * switches with the phone even while the app's process is not running. */ internal class WidgetChrome private constructor( private val context: Context, private val followAppTheme: Boolean, private val appColors: BoxLoreChromeColors?, + private val dayNight: DayNight?, ) { + /** The app theme's roles for the phone's light and dark modes. */ + internal class DayNight(val day: BoxLoreChromeColors, val night: BoxLoreChromeColors) + val usesAppTheme: Boolean = followAppTheme + /** True when the launcher, not this process, picks between the light and dark app colours. */ + val followsSystemDayNight: Boolean = dayNight != null + fun setColorFilter( views: RemoteViews, viewId: Int, @ColorRes colorRes: Int, - ) { - if (followAppTheme) { - views.setInt(viewId, "setColorFilter", argb(colorRes)) - } else { - views.setColor(viewId, "setColorFilter", colorRes) - } - } + ) = applyColor(views, viewId, METHOD_COLOR_FILTER, colorRes) fun setTextColor( views: RemoteViews, viewId: Int, @ColorRes colorRes: Int, + ) = applyColor(views, viewId, METHOD_TEXT_COLOR, colorRes) + + private fun applyColor( + views: RemoteViews, + viewId: Int, + method: String, + @ColorRes colorRes: Int, ) { - if (followAppTheme) { - views.setTextColor(viewId, argb(colorRes)) - } else { - views.setColor(viewId, "setTextColor", colorRes) + val pair = dayNight + when { + !followAppTheme -> views.setColor(viewId, method, colorRes) + pair != null -> + views.setColorInt( + viewId, + method, + pair.day.role(colorRes) ?: argb(colorRes), + pair.night.role(colorRes) ?: argb(colorRes), + ) + else -> views.setInt(viewId, method, argb(colorRes)) } } @ColorInt fun argb( @ColorRes colorRes: Int, - ): Int { - val colors = appColors - if (colors != null) { - return when (colorRes) { - R.color.widget_surface -> colors.surface - R.color.widget_on_surface -> colors.onSurface - R.color.widget_on_surface_variant -> colors.onSurfaceVariant - R.color.widget_primary -> colors.primary - R.color.widget_on_primary -> colors.onPrimary - R.color.widget_primary_container -> colors.primaryContainer - R.color.widget_on_primary_container -> colors.onPrimaryContainer - R.color.widget_secondary_container -> colors.secondaryContainer - R.color.widget_on_secondary_container -> colors.onSecondaryContainer - else -> ContextCompat.getColor(context, colorRes) - } - } - return ContextCompat.getColor(context, colorRes) - } + ): Int = appColors?.role(colorRes) ?: ContextCompat.getColor(context, colorRes) companion object { + private const val METHOD_COLOR_FILTER = "setColorFilter" + private const val METHOD_TEXT_COLOR = "setTextColor" + private const val THEME_CONFIG_LIGHT = "light" + private const val THEME_CONFIG_DARK = "dark" + fun resolve(context: Context): WidgetChrome { val app = context.applicationContext if (!WidgetAppearance.followsAppTheme(WidgetAppearance.cached(app))) { - return WidgetChrome(app, followAppTheme = false, appColors = null) + return WidgetChrome(app, followAppTheme = false, appColors = null, dayNight = null) } val prefs = app.getSharedPreferences(FontRoundnessAxis.THEME_FAST_CACHE, Context.MODE_PRIVATE) @@ -80,15 +90,45 @@ internal class WidgetChrome private constructor( val surfaceStyle = prefs.getString("surface_style", "classic_dynamic") ?: "classic_dynamic" val themeBrand = prefs.getString("theme_brand", "violet") ?: "violet" val dynamicColor = prefs.getBoolean("use_dynamic_color", false) - val colors = - resolveBoxLoreChromeColors( - context = app, - darkTheme = darkThemeFromConfig(app, themeConfig), - dynamicColor = dynamicColor, - themeBrand = themeBrand, - surfaceStyle = surfaceStyle, - ) - return WidgetChrome(app, followAppTheme = true, appColors = colors) + + fun colors(dark: Boolean): BoxLoreChromeColors = resolveBoxLoreChromeColors( + context = app, + darkTheme = dark, + dynamicColor = dynamicColor, + themeBrand = themeBrand, + surfaceStyle = surfaceStyle, + ) + + val followsSystem = themeConfig != THEME_CONFIG_LIGHT && themeConfig != THEME_CONFIG_DARK + return WidgetChrome( + context = app, + followAppTheme = true, + appColors = colors(darkThemeFromConfig(app, themeConfig)), + dayNight = if (followsSystem) DayNight(day = colors(false), night = colors(true)) else null, + ) } } } + +/** Each widget colour resource and the app-theme role that stands in for it. */ +private val APP_THEME_ROLES: Map Int> = + mapOf( + R.color.widget_surface to BoxLoreChromeColors::surface, + R.color.widget_on_surface to BoxLoreChromeColors::onSurface, + R.color.widget_on_surface_variant to BoxLoreChromeColors::onSurfaceVariant, + R.color.widget_primary to BoxLoreChromeColors::primary, + R.color.widget_on_primary to BoxLoreChromeColors::onPrimary, + R.color.widget_primary_container to BoxLoreChromeColors::primaryContainer, + R.color.widget_on_primary_container to BoxLoreChromeColors::onPrimaryContainer, + R.color.widget_secondary_container to BoxLoreChromeColors::secondaryContainer, + R.color.widget_on_secondary_container to BoxLoreChromeColors::onSecondaryContainer, + R.color.widget_tertiary to BoxLoreChromeColors::tertiary, + R.color.widget_on_tertiary to BoxLoreChromeColors::onTertiary, + R.color.widget_tertiary_container to BoxLoreChromeColors::tertiaryContainer, + R.color.widget_on_tertiary_container to BoxLoreChromeColors::onTertiaryContainer, + ) + +@ColorInt +private fun BoxLoreChromeColors.role( + @ColorRes colorRes: Int, +): Int? = APP_THEME_ROLES[colorRes]?.invoke(this) diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPalette.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPalette.kt index 098ac95ea..d60c49f97 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPalette.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPalette.kt @@ -24,4 +24,12 @@ internal object WidgetPalette { @ColorRes val primaryContainer: Int = R.color.widget_primary_container @ColorRes val onPrimaryContainer: Int = R.color.widget_on_primary_container + + @ColorRes val tertiary: Int = R.color.widget_tertiary + + @ColorRes val onTertiary: Int = R.color.widget_on_tertiary + + @ColorRes val tertiaryContainer: Int = R.color.widget_tertiary_container + + @ColorRes val onTertiaryContainer: Int = R.color.widget_on_tertiary_container } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt new file mode 100644 index 000000000..56bcfa5c7 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt @@ -0,0 +1,250 @@ +package cx.aswin.boxlore.feature.widgets + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.appwidget.AppWidgetProviderInfo +import android.content.ComponentName +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import android.os.Bundle +import android.widget.RemoteViews +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.core.graphics.createBitmap +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansTypefaces +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork +import cx.aswin.boxlore.feature.widgets.logic.WidgetSize +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Gives the launcher's widget picker previews drawn by the live renderers, in the listener's own widget + * colours and lettering. Android 15 and later show them. Older versions, and any preview the system + * turns down, fall back to the static `widget_preview_*` layouts, which can only use system colours. + */ +object WidgetPickerPreviews { + /** One picker entry: the widget it belongs to, the size it is drawn for, and how to draw it. */ + internal class Preview( + val key: String, + val receiver: Class, + /** + * A little under the widget's usual size, so a preview never outgrows the picker's frame. At + * this size the library widgets show whole rows only. + */ + val size: WidgetSize, + val build: (Context) -> RemoteViews, + ) + + /** + * Publishes every preview whose look changed since it was last accepted. Cheap to call often: an + * unchanged look publishes nothing, and a preview the system rate-limits is tried again next time. + */ + suspend fun refresh(context: Context) = withContext(Dispatchers.Default) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return@withContext + val app = context.applicationContext + val manager = app.getSystemService(AppWidgetManager::class.java) ?: return@withContext + val look = lookSignature(app) + val published = app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + for (preview in previews()) { + if (published.getString(preview.key, null) == look) continue + val accepted = + runCatching { + manager.setWidgetPreview( + ComponentName(app, preview.receiver), + AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN, + preview.build(app), + ) + }.getOrDefault(false) + if (accepted) published.edit { putString(preview.key, look) } + } + } + + internal fun previews(): List = listOf( + playback("full", NowPlayingWidgetReceiver::class.java, WidgetVariant.NOW_PLAYING, FULL_WIDTH_DP, FULL_HEIGHT_DP), + playback("compact", NowPlayingBarWidgetReceiver::class.java, WidgetVariant.BAR, FULL_WIDTH_DP, BAR_HEIGHT_DP), + playback("round", PlaybackControlsWidgetReceiver::class.java, WidgetVariant.CONTROLS, ROUND_SIDE_DP, ROUND_SIDE_DP), + playback("round_next", PlaybackNextControlsWidgetReceiver::class.java, WidgetVariant.CONTROLS_NEXT, ROUND_SIDE_DP, ROUND_SIDE_DP), + library("shows", SubscriptionsWidgetReceiver::class.java, LibraryWidgetKind.SUBSCRIPTIONS, SHOWS_HEIGHT_DP), + library("new_episodes", NewEpisodesWidgetReceiver::class.java, LibraryWidgetKind.NEW_EPISODES, EPISODES_HEIGHT_DP), + ) + + private fun playback( + key: String, + receiver: Class, + variant: WidgetVariant, + widthDp: Int, + heightDp: Int, + ) = Preview(key, receiver, WidgetSize(widthDp, heightDp)) { context -> + NowPlayingWidgetRenderer.buildRemoteViewsAt( + context = context, + appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID, + snapshot = samplePlayback(context), + options = + Bundle().apply { + putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, widthDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, widthDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, heightDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, heightDp) + }, + variant = variant, + ) + } + + private fun library( + key: String, + receiver: Class, + kind: LibraryWidgetKind, + heightDp: Int, + ) = Preview(key, receiver, WidgetSize(FULL_WIDTH_DP, heightDp)) { context -> + val sample = sampleLibrary(context) + // The picker cannot bind the widget's list service, so the preview carries its rows with it. + val factory = LibraryWidgetRemoteViewsFactory(context, kind, preset = sample).apply { onDataSetChanged() } + val rows = + RemoteViews.RemoteCollectionItems + .Builder() + .setHasStableIds(true) + .setViewTypeCount(1) + .apply { for (position in 0 until factory.count) addItem(factory.getItemId(position), factory.getViewAt(position)) } + .build() + LibraryWidgetRenderer.build( + context = context, + appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID, + snapshot = sample, + kind = kind, + collection = rows, + ) + } + + /** + * Everything that changes how a preview looks: the widget colours, the lettering, the Your Shows + * cover style and the installed build. A preview is published again only when this changes. + */ + internal fun lookSignature(context: Context): String { + val chrome = WidgetChrome.resolve(context) + val colours = + listOf( + WidgetPalette.surface, + WidgetPalette.onSurface, + WidgetPalette.primary, + WidgetPalette.primaryContainer, + WidgetPalette.tertiary, + WidgetPalette.tertiaryContainer, + ).joinToString(",") { role -> Integer.toHexString(chrome.argb(role)) } + val updatedAt = runCatching { context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime }.getOrDefault(0L) + return listOf( + PREVIEW_VERSION, + updatedAt, + chrome.usesAppTheme, + chrome.followsSystemDayNight, + colours, + GoogleSansTypefaces.cachedRoundness(context), + WidgetShowsArtwork.cached(context), + ).joinToString("|") + } + + internal fun samplePlayback(context: Context) = NowPlayingWidgetSnapshot( + episodeId = SAMPLE_ID, + episodeTitle = context.getString(R.string.widget_preview_episode), + podcastTitle = context.getString(R.string.widget_preview_podcast), + artworkCachePath = sampleCover(context, 0), + isPlaying = true, + positionMs = SAMPLE_POSITION_MS, + durationMs = SAMPLE_DURATION_MS, + seekForwardMs = SAMPLE_SEEK_FORWARD_MS, + seekBackwardMs = SAMPLE_SEEK_BACK_MS, + ) + + internal fun sampleLibrary(context: Context): LibraryWidgetSnapshot { + val episodes = + listOf( + R.string.widget_preview_episode to R.string.widget_preview_podcast, + R.string.widget_preview_episode_2 to R.string.widget_preview_episode_2_show, + R.string.widget_preview_episode_3 to R.string.widget_preview_episode_3_show, + ) + return LibraryWidgetSnapshot( + subscriptions = + List(SAMPLE_SHOWS) { index -> + WidgetShowRow( + podcastId = "$SAMPLE_ID-show-$index", + title = context.getString(episodes[index % episodes.size].second), + artworkCachePath = sampleCover(context, index), + deepLinkUri = SAMPLE_LINK, + isNew = index in SAMPLE_NEW_SHOWS, + ) + }, + newEpisodes = + episodes.mapIndexed { index, (episode, show) -> + WidgetEpisodeRow( + episodeId = "$SAMPLE_ID-episode-$index", + episodeTitle = context.getString(episode), + podcastId = "$SAMPLE_ID-show-$index", + podcastTitle = context.getString(show), + artworkCachePath = sampleCover(context, index), + deepLinkUri = SAMPLE_LINK, + ) + }, + counts = WidgetLibraryCounts(subscriptions = SAMPLE_SHOW_COUNT, newEpisodes = SAMPLE_EPISODE_COUNT), + ) + } + + /** + * The path of sample cover [index], drawn from the same pictures the static previews use and kept + * in the widget artwork cache. Null when it cannot be written; the widget then shows its placeholder. + */ + private fun sampleCover(context: Context, index: Int): String? { + val drawableRes = SAMPLE_COVERS[index.mod(SAMPLE_COVERS.size)] + val directory = File(context.cacheDir, WidgetArtworkLoader.CACHE_DIR_NAME) + val file = File(directory, "preview_cover_${PREVIEW_VERSION}_${index.mod(SAMPLE_COVERS.size)}.png") + if (file.exists() && file.length() > 0L) return file.absolutePath + return runCatching { + directory.mkdirs() + val drawable = ContextCompat.getDrawable(context, drawableRes) ?: return null + val bitmap = createBitmap(SAMPLE_COVER_PX, SAMPLE_COVER_PX) + drawable.setBounds(0, 0, SAMPLE_COVER_PX, SAMPLE_COVER_PX) + drawable.draw(Canvas(bitmap)) + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, it) } + file.absolutePath + }.getOrNull() + } + + private val SAMPLE_COVERS = + listOf( + R.drawable.widget_preview_cover_a, + R.drawable.widget_preview_cover_b, + R.drawable.widget_preview_cover_c, + R.drawable.widget_preview_cover_d, + R.drawable.widget_preview_cover_e, + R.drawable.widget_preview_cover_f, + ) + + internal const val PREFS_NAME = "boxlore_widget_picker_previews" + + /** Bump when the sample content or the way previews are built changes. */ + private const val PREVIEW_VERSION = 1 + + // A little under a typical 4-column grid, so a preview never outgrows the picker's frame. + private const val FULL_WIDTH_DP = 320 + private const val FULL_HEIGHT_DP = 170 + private const val BAR_HEIGHT_DP = 80 + + // Two whole rows of covers and three whole episode rows at the preview's width. + private const val SHOWS_HEIGHT_DP = 290 + private const val EPISODES_HEIGHT_DP = 296 + private const val ROUND_SIDE_DP = 160 + + private const val SAMPLE_ID = "widget-preview" + private const val SAMPLE_LINK = "boxlore://home" + private const val SAMPLE_POSITION_MS = 24 * 60_000L + private const val SAMPLE_DURATION_MS = 60 * 60_000L + private const val SAMPLE_SEEK_FORWARD_MS = 30_000L + private const val SAMPLE_SEEK_BACK_MS = 10_000L + private const val SAMPLE_SHOWS = 8 + private val SAMPLE_NEW_SHOWS = setOf(0, 2, 5) + private const val SAMPLE_SHOW_COUNT = 24 + private const val SAMPLE_EPISODE_COUNT = 12 + private const val SAMPLE_COVER_PX = 256 + private const val PNG_QUALITY = 100 +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetSeekIcon.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetSeekIcon.kt new file mode 100644 index 000000000..3355ab68c --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetSeekIcon.kt @@ -0,0 +1,87 @@ +package cx.aswin.boxlore.feature.widgets + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import androidx.core.graphics.createBitmap +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansTypefaces +import kotlin.math.cos +import kotlin.math.sin + +/** + * The seek icon the player uses: a circular arrow around the number of seconds it jumps. Drawn as a + * white mask so the launcher tints it, and redrawn when the listener changes the seek length. + */ +internal object WidgetSeekIcon { + fun mask(context: Context, seconds: Int, forward: Boolean, sizePx: Int): Bitmap { + val size = sizePx.coerceAtLeast(1).toFloat() + val stroke = size * STROKE + val inset = stroke * INSET_STROKES + val startAngle = if (forward) FORWARD_START else BACK_START + val sweepAngle = if (forward) SWEEP else -SWEEP + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE } + return createBitmap(size.toInt(), size.toInt()).also { bitmap -> + val canvas = Canvas(bitmap) + paint.style = Paint.Style.STROKE + paint.strokeWidth = stroke + paint.strokeCap = Paint.Cap.ROUND + canvas.drawArc(RectF(inset, inset, size - inset, size - inset), startAngle, sweepAngle, false, paint) + + paint.style = Paint.Style.FILL + canvas.drawPath(arrowHead(size, stroke, inset, startAngle + sweepAngle, forward), paint) + + paint.typeface = + GoogleSansTypefaces.create( + context = context, + weight = NUMBER_WEIGHT, + roundness = GoogleSansTypefaces.cachedRoundness(context), + ) + paint.textAlign = Paint.Align.CENTER + paint.textSize = size * if (seconds >= THREE_DIGITS) NUMBER_SIZE_LONG else NUMBER_SIZE + val metrics = paint.fontMetrics + canvas.drawText(seconds.toString(), size / 2f, size / 2f - (metrics.ascent + metrics.descent) / 2f, paint) + } + } + + private fun arrowHead(size: Float, stroke: Float, inset: Float, endAngle: Float, forward: Boolean): Path { + val endRadians = Math.toRadians(endAngle.toDouble()) + val radius = (size - inset * 2f) / 2f + val center = size / 2f + val endX = center + radius * cos(endRadians).toFloat() + val endY = center + radius * sin(endRadians).toFloat() + val direction = if (forward) 1f else -1f + val tangentX = -sin(endRadians).toFloat() * direction + val tangentY = cos(endRadians).toFloat() * direction + val head = stroke * HEAD_STROKES + val tipX = endX + tangentX * head * HEAD_TIP + val tipY = endY + tangentY * head * HEAD_TIP + val baseX = endX - tangentX * head * HEAD_BASE + val baseY = endY - tangentY * head * HEAD_BASE + // Perpendicular to the tangent. + val sideX = -tangentY * head * HEAD_TIP + val sideY = tangentX * head * HEAD_TIP + return Path().apply { + moveTo(tipX, tipY) + lineTo(baseX + sideX, baseY + sideY) + lineTo(baseX - sideX, baseY - sideY) + close() + } + } + + private const val STROKE = 0.085f + private const val INSET_STROKES = 1.35f + private const val FORWARD_START = -55f + private const val BACK_START = 235f + private const val SWEEP = 290f + private const val HEAD_STROKES = 2.25f + private const val HEAD_TIP = 0.55f + private const val HEAD_BASE = 0.45f + private const val NUMBER_WEIGHT = 700 + private const val NUMBER_SIZE = 0.3f + private const val NUMBER_SIZE_LONG = 0.24f + private const val THREE_DIGITS = 100 +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetShapes.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetShapes.kt new file mode 100644 index 000000000..f33133e7c --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetShapes.kt @@ -0,0 +1,207 @@ +package cx.aswin.boxlore.feature.widgets + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Shader +import androidx.core.graphics.createBitmap +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin + +/** Outlines the widgets cut artwork and placeholders into. */ +internal enum class WidgetArtShape { + CIRCLE, + COOKIE, + ARCH, + FLOWER, + ROUNDED, +} + +/** + * Shape outlines and the bitmaps cut from them. Masks are white, so the launcher tints them with a + * [WidgetChrome] colour; artwork keeps its own colours. + */ +internal object WidgetShapes { + /** Tile shapes for the Your Shows grid when the listener chose mixed shapes, in reading order. */ + private val MIXED = + listOf( + WidgetArtShape.COOKIE, WidgetArtShape.CIRCLE, WidgetArtShape.ARCH, WidgetArtShape.FLOWER, + WidgetArtShape.CIRCLE, WidgetArtShape.FLOWER, WidgetArtShape.COOKIE, WidgetArtShape.ARCH, + WidgetArtShape.FLOWER, WidgetArtShape.COOKIE, WidgetArtShape.ARCH, WidgetArtShape.CIRCLE, + ) + + fun mixedShapeAt(position: Int): WidgetArtShape = MIXED[position.mod(MIXED.size)] + + /** The outline of [shape] filling a [size] × [size] square. */ + fun path(shape: WidgetArtShape, size: Float): Path = when (shape) { + WidgetArtShape.CIRCLE -> Path().apply { addOval(RectF(0f, 0f, size, size), Path.Direction.CW) } + WidgetArtShape.COOKIE -> scalloped(size, lobes = COOKIE_LOBES, depth = COOKIE_DEPTH) + WidgetArtShape.FLOWER -> scalloped(size, lobes = FLOWER_LOBES, depth = FLOWER_DEPTH) + WidgetArtShape.ARCH -> { + val foot = size * ARCH_FOOT_RADIUS + val crown = size / 2f + Path().apply { + addRoundRect( + RectF(0f, 0f, size, size), + floatArrayOf(crown, crown, crown, crown, foot, foot, foot, foot), + Path.Direction.CW, + ) + } + } + WidgetArtShape.ROUNDED -> { + val radius = size * ROUNDED_RADIUS + Path().apply { addRoundRect(RectF(0f, 0f, size, size), radius, radius, Path.Direction.CW) } + } + } + + /** A soft-scalloped circle: the radius swells and dips [lobes] times around the edge. */ + private fun scalloped(size: Float, lobes: Int, depth: Float): Path { + val center = size / 2f + val steps = lobes * STEPS_PER_LOBE + return Path().apply { + for (step in 0 until steps) { + val angle = 2.0 * PI * step / steps + val radius = center * ((1f - depth) + depth * cos(lobes * angle).toFloat()) + val x = center + radius * sin(angle).toFloat() + val y = center - radius * cos(angle).toFloat() + if (step == 0) moveTo(x, y) else lineTo(x, y) + } + close() + } + } + + /** + * A white [shape] on a clear ground, for placeholders the launcher tints. [insetFraction] matches + * [shapeArtwork], so a placeholder sits exactly where the artwork would. + */ + fun mask(shape: WidgetArtShape, sizePx: Int, insetFraction: Float = 0f): Bitmap { + val size = sizePx.coerceAtLeast(1) + val inset = size * insetFraction.coerceIn(0f, MAX_INSET) + return createBitmap(size, size).also { bitmap -> + Canvas(bitmap).apply { + translate(inset, inset) + drawPath(path(shape, (size - inset * 2f).coerceAtLeast(1f)), fillPaint()) + } + } + } + + /** + * [source] centre-cropped into [shape]. [insetFraction] leaves a clear band around the artwork so + * a progress ring of the same size can sit outside it. + */ + fun shapeArtwork(source: Bitmap, sizePx: Int, shape: WidgetArtShape, insetFraction: Float = 0f): Bitmap { + val size = sizePx.coerceAtLeast(1) + val inset = size * insetFraction.coerceIn(0f, MAX_INSET) + val inner = (size - inset * 2f).coerceAtLeast(1f) + val crop = minOf(source.width, source.height).toFloat() + val matrix = + Matrix().apply { + postTranslate(-(source.width - crop) / 2f, -(source.height - crop) / 2f) + postScale(inner / crop, inner / crop) + } + 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) } + } + return createBitmap(size, size).also { bitmap -> + Canvas(bitmap).apply { + translate(inset, inset) + drawPath(path(shape, inner), paint) + } + } + } + + /** + * The two arcs of a progress ring as white masks: what has played, then a gap, then what is left. + * Either is null when it has nothing to draw. + */ + fun progressRing(sizePx: Int, fraction: Float, strokeFraction: Float): ProgressRing { + val size = sizePx.coerceAtLeast(1) + val stroke = max(size * strokeFraction, 1f) + val radius = (size - stroke) / 2f + val arcs = WidgetRingArcs.of(fraction, gapDegrees = Math.toDegrees((stroke * GAP_STROKES / radius).toDouble()).toFloat()) + val bounds = RectF(stroke / 2f, stroke / 2f, size - stroke / 2f, size - stroke / 2f) + val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = stroke + strokeCap = Paint.Cap.ROUND + } + + fun draw(arc: WidgetRingArcs.Arc?): Bitmap? = arc?.let { + createBitmap(size, size).also { bitmap -> + Canvas(bitmap).drawArc(bounds, RING_START_DEGREES + it.startDegrees, it.sweepDegrees, false, paint) + } + } + return ProgressRing(played = draw(arcs.played), remaining = draw(arcs.remaining)) + } + + class ProgressRing(val played: Bitmap?, val remaining: Bitmap?) + + /** Decodes [path] downsampled to roughly [targetPx] on its short side, or null when unreadable. */ + fun decodeSampled(path: String, targetPx: Int): Bitmap? { + val target = targetPx.coerceAtLeast(1) + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + var sampleSize = 1 + while (bounds.outWidth / (sampleSize * 2) >= target && bounds.outHeight / (sampleSize * 2) >= target) { + sampleSize *= 2 + } + return BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sampleSize }) + } + + private fun fillPaint(): Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + } + + private const val COOKIE_LOBES = 12 + private const val COOKIE_DEPTH = 0.07f + private const val FLOWER_LOBES = 8 + private const val FLOWER_DEPTH = 0.1f + private const val STEPS_PER_LOBE = 12 + private const val ARCH_FOOT_RADIUS = 0.22f + private const val ROUNDED_RADIUS = 0.24f + private const val MAX_INSET = 0.4f + private const val GAP_STROKES = 1.6f + private const val RING_START_DEGREES = -90f +} + +/** + * Where a progress ring's arcs start and how far they sweep, in degrees clockwise from the top. + * Pure geometry so it can be tested without a canvas. + */ +internal data class WidgetRingArcs(val played: Arc?, val remaining: Arc?) { + data class Arc(val startDegrees: Float, val sweepDegrees: Float) + + companion object { + private const val FULL = 360f + + /** A sliver of progress still shows as a visible dot. */ + private const val MIN_PLAYED_SWEEP = 2f + + fun of(fraction: Float, gapDegrees: Float): WidgetRingArcs { + val clamped = if (fraction.isNaN()) 0f else fraction.coerceIn(0f, 1f) + val gap = gapDegrees.coerceIn(0f, FULL / 4f) + if (clamped <= 0f) return WidgetRingArcs(played = null, remaining = Arc(0f, FULL)) + val playedSweep = (FULL * clamped).coerceAtLeast(MIN_PLAYED_SWEEP) + val remainingSweep = FULL - playedSweep - gap * 2f + // Too little left for a separate arc: the played arc closes the ring. + if (remainingSweep <= gap) return WidgetRingArcs(played = Arc(0f, FULL), remaining = null) + return WidgetRingArcs( + played = Arc(0f, playedSweep), + remaining = Arc(playedSweep + gap, remainingSweep), + ) + } + } +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetTextBitmapRenderer.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetTextBitmapRenderer.kt deleted file mode 100644 index f575a6b7d..000000000 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetTextBitmapRenderer.kt +++ /dev/null @@ -1,109 +0,0 @@ -package cx.aswin.boxlore.feature.widgets - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Paint -import android.text.Layout -import android.text.StaticLayout -import android.text.TextPaint -import android.text.TextUtils -import androidx.annotation.ColorInt -import androidx.annotation.ColorRes -import androidx.core.content.ContextCompat -import cx.aswin.boxlore.core.designsystem.theme.GoogleSansTypefaces - -/** - * Rasterizes widget labels with the exact Google Sans Flex ROND selected in Appearance. - * - * Launcher hosts are inconsistent about applying variable-font settings from RemoteViews. - * A bitmap makes the typeface, line count, and clipping deterministic across launchers. - */ -object WidgetTextBitmapRenderer { - data class Spec( - val text: String, - val widthDp: Int, - val heightDp: Int, - val preferredSizeSp: Float, - val minSizeSp: Float, - val weight: Int, - val maxLines: Int, - val alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, - ) - - fun render( - context: Context, - spec: Spec, - @ColorRes colorRes: Int, - ): Bitmap = renderColor( - context = context, - spec = spec, - color = ContextCompat.getColor(context, colorRes), - ) - - fun renderColor( - context: Context, - spec: Spec, - @ColorInt color: Int, - ): Bitmap { - val density = context.resources.displayMetrics.density - val scaledDensity = density * context.resources.configuration.fontScale - val widthPx = (spec.widthDp * density).toInt().coerceAtLeast(1) - val heightPx = (spec.heightDp * density).toInt().coerceAtLeast(1) - val paint = - TextPaint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply { - this.color = color - typeface = - GoogleSansTypefaces.create( - context = context, - weight = spec.weight, - roundness = GoogleSansTypefaces.cachedRoundness(context), - ) - } - - var low = spec.minSizeSp - var high = spec.preferredSizeSp.coerceAtLeast(spec.minSizeSp) - var best = spec.minSizeSp - repeat(FIT_ITERATIONS) { - val candidate = (low + high) / 2f - paint.textSize = candidate * scaledDensity - val layout = buildLayout(spec.text, paint, widthPx, spec.maxLines, spec.alignment) - if (layout.height <= heightPx && !isEllipsized(layout)) { - best = candidate - low = candidate - } else { - high = candidate - } - } - - paint.textSize = best * scaledDensity - val layout = buildLayout(spec.text, paint, widthPx, spec.maxLines, spec.alignment) - return Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888).also { bitmap -> - val canvas = Canvas(bitmap) - canvas.translate(0f, ((heightPx - layout.height) / 2f).coerceAtLeast(0f)) - layout.draw(canvas) - } - } - - private fun buildLayout( - text: String, - paint: TextPaint, - widthPx: Int, - maxLines: Int, - alignment: Layout.Alignment, - ): StaticLayout = StaticLayout - .Builder - .obtain(text, 0, text.length, paint, widthPx) - .setAlignment(alignment) - .setIncludePad(false) - .setLineSpacing(0f, 1f) - .setMaxLines(maxLines) - .setEllipsize(TextUtils.TruncateAt.END) - .build() - - private fun isEllipsized(layout: StaticLayout): Boolean = (0 until layout.lineCount).any { line -> - layout.getEllipsisCount(line) > 0 - } - - private const val FIT_ITERATIONS = 8 -} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/actions/WidgetActionHandler.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/actions/WidgetActionHandler.kt index bc83a1ced..2218d8a10 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/actions/WidgetActionHandler.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/actions/WidgetActionHandler.kt @@ -4,8 +4,10 @@ import android.content.Context import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetCoordinator import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetDependencies import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetDependenciesHolder +import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetSnapshot import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetSnapshotStore import cx.aswin.boxlore.feature.widgets.WidgetControl +import cx.aswin.boxlore.feature.widgets.WidgetPlaybackSource import cx.aswin.boxlore.feature.widgets.logic.NowPlayingWidgetMapper import cx.aswin.boxlore.feature.widgets.logic.WidgetOptimisticAction @@ -20,7 +22,8 @@ object WidgetActionHandler { val current = store.read() var wroteOptimistic = false if (current != null && control != WidgetControl.OPEN_APP) { - val optimistic = WidgetOptimisticAction.apply(current, control) + val optimistic = WidgetOptimisticAction.apply(withLivePosition(current, deps), control) + // Redrawn straight away from the expected result, so the widget never waits for the player. store.write(optimistic) NowPlayingWidgetCoordinator.requestRefresh(deps.context) wroteOptimistic = true @@ -30,14 +33,7 @@ object WidgetActionHandler { if (control != WidgetControl.OPEN_APP) { deps.playback.restoreBeforeAction() } - when (control) { - WidgetControl.TOGGLE -> deps.playback.togglePlayPause() - WidgetControl.PREVIOUS -> deps.playback.previous() - WidgetControl.NEXT -> deps.playback.next() - WidgetControl.SKIP_BACK -> deps.playback.skipBackward() - WidgetControl.SKIP_FORWARD -> deps.playback.skipForward() - WidgetControl.OPEN_APP -> Unit - } + perform(control, deps.playback) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e if (wroteOptimistic) { @@ -46,6 +42,32 @@ object WidgetActionHandler { } } + private suspend fun perform(control: WidgetControl, playback: WidgetPlaybackSource) { + when (control) { + WidgetControl.TOGGLE -> playback.togglePlayPause() + WidgetControl.PREVIOUS -> playback.previous() + WidgetControl.NEXT -> playback.next() + WidgetControl.SKIP_BACK -> playback.skipBackward() + WidgetControl.SKIP_FORWARD -> playback.skipForward() + WidgetControl.OPEN_APP -> return + } + // Replace the optimistic position with the player's real one, also while the app is in the background. + playback.refreshPosition() + } + + /** + * The saved snapshot only moves when the widget repaints, so a seek is previewed from the position + * the player last published instead. + */ + private fun withLivePosition( + snapshot: NowPlayingWidgetSnapshot, + deps: NowPlayingWidgetDependencies, + ): NowPlayingWidgetSnapshot { + val live = deps.playback.state.value + if (live.episodeId != snapshot.episodeId) return snapshot + return snapshot.copy(positionMs = live.positionMs, durationMs = live.durationMs) + } + private fun refreshFromAuthoritativePlayback( context: Context, store: NowPlayingWidgetSnapshotStore, diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogic.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogic.kt index 77ed738e3..e2cfe372e 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogic.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogic.kt @@ -40,7 +40,22 @@ object WidgetUpdatePolicy { previous.isLoading != next.isLoading || previous.artworkCachePath != next.artworkCachePath || previous.episodeTitle != next.episodeTitle || - previous.podcastTitle != next.podcastTitle + previous.podcastTitle != next.podcastTitle || + hasProgressChange(previous, next) || + previous.seekBackwardMs != next.seekBackwardMs || + previous.seekForwardMs != next.seekForwardMs + + /** + * The position moves many times a second, but the ring and the time-left label only change now and + * then. Repainting on those steps keeps a playing widget to about one update a minute. + */ + private fun hasProgressChange( + previous: NowPlayingWidgetSnapshot, + next: NowPlayingWidgetSnapshot, + ): Boolean = WidgetProgress.ringStep(previous.positionMs, previous.durationMs) != + WidgetProgress.ringStep(next.positionMs, next.durationMs) || + WidgetProgress.minutesLeft(previous.positionMs, previous.durationMs) != + WidgetProgress.minutesLeft(next.positionMs, next.durationMs) } object WidgetOptimisticAction { @@ -85,6 +100,9 @@ object WidgetSemantics { append(snapshot.podcastTitle) } append(if (snapshot.isPlaying) ", playing" else ", paused") + WidgetProgress.minutesLeft(snapshot.positionMs, snapshot.durationMs) + ?.takeIf { it > 0 } + ?.let { minutes -> append(if (minutes == 1) ", 1 minute left" else ", $minutes minutes left") } } } } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetPresentation.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetPresentation.kt new file mode 100644 index 000000000..0a9686a1a --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetPresentation.kt @@ -0,0 +1,116 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import kotlin.math.ceil + +/** + * What a playback widget shows at the size the launcher reports: how many title lines, whether there + * is room for the time left and the show name, and which shape of layout to use. + * + * The layouts scale themselves to the space they really get and keep their own proportions, so + * nothing here can clip a widget: a late or wrong report only makes these choices a little too + * generous or too cautious. The shape limits below repeat the ones the layouts' frames enforce. + */ +object WidgetPresentation { + /** What sits under the episode title. The show name goes first when space runs out. */ + enum class Meta { + TIME_AND_SHOW, + TIME, + NONE, + } + + /** Now Playing · Full Controls. */ + data class FullControls( + /** The taller shape: a large info block over the controls. */ + val tall: Boolean, + val titleLines: Int, + val meta: Meta, + ) + + fun fullControls(size: WidgetSize): FullControls { + val tall = size.heightDp >= FULL_TALL_FROM && size.widthDp >= FULL_TALL_MIN_WIDTH + // The frame stops growing once it is this tall for its width; the rest of the cell stays empty. + val content = minOf(size.heightDp.toFloat(), size.widthDp * if (tall) FULL_TALL_MAX_SHAPE else FULL_MAX_SHAPE) + val infoRow = if (tall) (content - ROW_GAP) * TALL_INFO_SHARE else (content - ROW_GAP) / 2f + return FullControls( + tall = tall, + titleLines = + when { + tall -> TALL_TITLE_LINES + infoRow >= TWO_LINE_ROW -> 2 + else -> 1 + }, + meta = + when { + infoRow >= SHOW_NAME_ROW -> Meta.TIME_AND_SHOW + infoRow >= TIME_ROW -> Meta.TIME + else -> Meta.NONE + }, + ) + } + + /** Now Playing · Compact. */ + data class Bar( + val titleLines: Int, + /** The artwork already names the show, so the second line is the time left, when there is room. */ + val showTimeLeft: Boolean, + ) + + fun bar(size: WidgetSize): Bar { + // The pill fills the cell until it is a quarter as tall as it is wide, then stays that slim. + val pill = minOf(size.heightDp.toFloat(), size.widthDp * BAR_MAX_SHAPE) + return Bar( + titleLines = if (pill >= BAR_TWO_LINE_PILL) 2 else 1, + showTimeLeft = pill >= BAR_TIME_PILL, + ) + } + + /** + * The sizes at which Full Controls changes what it shows, smallest first: the time left appears, + * then the show name, then a second title line, then the taller shape. Each is the smallest + * widget whose frame has room for that step, and each fits inside the next. + */ + val FULL_CONTROLS_STEPS: List = + listOf( + WidgetSize(0, 0), + frameWith(infoRow = TIME_ROW), + frameWith(infoRow = SHOW_NAME_ROW), + frameWith(infoRow = TWO_LINE_ROW), + WidgetSize(FULL_TALL_MIN_WIDTH, FULL_TALL_FROM), + ) + + /** The sizes at which Compact changes what it shows: a second title line, then the time left. */ + val BAR_STEPS: List = listOf(WidgetSize(0, 0), pillOf(BAR_TWO_LINE_PILL), pillOf(BAR_TIME_PILL)) + + /** + * The step a launcher draws for a widget really [drawn] this big: the largest one that fits, as + * `RemoteViews` chooses among sized layouts. The first step fits everything. + */ + fun stepFor(steps: List, drawn: WidgetSize): WidgetSize = + steps.lastOrNull { it.widthDp <= drawn.widthDp && it.heightDp <= drawn.heightDp } ?: steps.first() + + /** The smallest regular Full Controls widget whose info row is [infoRow] tall. */ + private fun frameWith(infoRow: Float): WidgetSize { + val frame = infoRow * 2 + ROW_GAP + return WidgetSize(ceil(frame / FULL_MAX_SHAPE).toInt(), ceil(frame).toInt()) + } + + /** The smallest Compact widget whose pill is [pill] tall. */ + private fun pillOf(pill: Float): WidgetSize = WidgetSize(ceil(pill / BAR_MAX_SHAPE).toInt(), ceil(pill).toInt()) + + private const val FULL_TALL_FROM = 236 + + // As wide as the widest regular step, so every step fits inside the next. + private const val FULL_TALL_MIN_WIDTH = 267 + private const val FULL_MAX_SHAPE = 0.6f + private const val FULL_TALL_MAX_SHAPE = 0.8f + private const val ROW_GAP = 8f + private const val TALL_INFO_SHARE = 21f / 31f + private const val TALL_TITLE_LINES = 3 + private const val TWO_LINE_ROW = 76f + private const val SHOW_NAME_ROW = 60f + private const val TIME_ROW = 46f + + private const val BAR_MAX_SHAPE = 0.25f + private const val BAR_TWO_LINE_PILL = 60f + private const val BAR_TIME_PILL = 68f +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgress.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgress.kt new file mode 100644 index 000000000..154b28594 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgress.kt @@ -0,0 +1,38 @@ +package cx.aswin.boxlore.feature.widgets.logic + +/** What the progress ring and the time-left label show, derived from a playback position. */ +object WidgetProgress { + /** How many visible steps the ring has. A position change inside one step is not worth a repaint. */ + const val RING_STEPS = 120 + + private const val MS_PER_MINUTE = 60_000L + private const val MINUTES_PER_HOUR = 60 + + /** Played share of the episode, or 0 while the length is unknown. */ + fun fraction(positionMs: Long, durationMs: Long): Float { + if (durationMs <= 0L) return 0f + return (positionMs.coerceIn(0L, durationMs).toDouble() / durationMs).toFloat() + } + + /** The ring step [positionMs] falls in, from 0 to [RING_STEPS]. */ + fun ringStep(positionMs: Long, durationMs: Long): Int = (fraction(positionMs, durationMs) * RING_STEPS).toInt() + + /** + * Whole minutes left, rounded up so the label reaches zero only when the episode ends, or null while + * the length is unknown. + */ + fun minutesLeft(positionMs: Long, durationMs: Long): Int? { + if (durationMs <= 0L) return null + val remainingMs = (durationMs - positionMs.coerceIn(0L, durationMs)) + return ((remainingMs + MS_PER_MINUTE - 1) / MS_PER_MINUTE).toInt() + } + + /** Hours and minutes of a [minutesLeft] value, for the label. */ + data class TimeLeft(val hours: Int, val minutes: Int) + + fun timeLeft(positionMs: Long, durationMs: Long): TimeLeft? { + val minutes = minutesLeft(positionMs, durationMs) ?: return null + if (minutes <= 0) return null + return TimeLeft(hours = minutes / MINUTES_PER_HOUR, minutes = minutes % MINUTES_PER_HOUR) + } +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressPoller.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressPoller.kt new file mode 100644 index 000000000..724bcca52 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressPoller.kt @@ -0,0 +1,29 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.isActive + +/** + * Asks the player for its position at a steady pace while an episode plays, and not at all while it is + * paused or no playback widget is placed. The app only tracks the position while it is on screen, which + * is exactly when a home-screen widget is not being looked at. + */ +object WidgetProgressPoller { + suspend fun run( + playing: Flow, + intervalMs: Long, + hasWidgets: () -> Boolean, + refresh: suspend () -> Unit, + ) { + playing.distinctUntilChanged().collectLatest { isPlaying -> + while (isPlaying && currentCoroutineContext().isActive) { + delay(intervalMs) + if (hasWidgets()) refresh() + } + } + } +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetSizes.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetSizes.kt new file mode 100644 index 000000000..31774c4f9 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetSizes.kt @@ -0,0 +1,32 @@ +package cx.aswin.boxlore.feature.widgets.logic + +/** A widget's size in dp, as the launcher reports it. */ +data class WidgetSize(val widthDp: Int, val heightDp: Int) { + val shortSide: Int get() = minOf(widthDp, heightDp) +} + +/** + * The two sizes a launcher reports for a widget: its narrowest width with its tallest height (portrait) + * and its widest width with its shortest height (landscape). Launchers report these late, loosely or + * not at all, so they only guide what a widget shows; the layouts scale themselves to the real space. + */ +data class WidgetSizes(val portrait: WidgetSize, val landscape: WidgetSize) { + /** The most space the widget may have: pictures drawn for this stay sharp either way round. */ + val largest: WidgetSize get() = WidgetSize(maxOf(portrait.widthDp, landscape.widthDp), maxOf(portrait.heightDp, landscape.heightDp)) + + companion object { + /** Values at or below zero are missing: some launchers leave them out until the first resize. */ + fun of(minWidthDp: Int, maxWidthDp: Int, minHeightDp: Int, maxHeightDp: Int, fallback: WidgetSize): WidgetSizes { + val narrow = firstPositive(minWidthDp, maxWidthDp, fallback.widthDp) + val wide = firstPositive(maxWidthDp, minWidthDp, fallback.widthDp) + val short = firstPositive(minHeightDp, maxHeightDp, fallback.heightDp) + val tall = firstPositive(maxHeightDp, minHeightDp, fallback.heightDp) + return WidgetSizes( + portrait = WidgetSize(minOf(narrow, wide), maxOf(short, tall)), + landscape = WidgetSize(maxOf(narrow, wide), minOf(short, tall)), + ) + } + + private fun firstPositive(vararg values: Int): Int = values.firstOrNull { it > 0 } ?: 1 + } +} diff --git a/feature/widgets/src/main/res/drawable/ic_widget_arrow_forward.xml b/feature/widgets/src/main/res/drawable/ic_widget_arrow_forward.xml new file mode 100644 index 000000000..37b2ac385 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/ic_widget_arrow_forward.xml @@ -0,0 +1,13 @@ + + + + diff --git a/feature/widgets/src/main/res/drawable/ic_widget_caught_up.xml b/feature/widgets/src/main/res/drawable/ic_widget_caught_up.xml new file mode 100644 index 000000000..58a292aa0 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/ic_widget_caught_up.xml @@ -0,0 +1,13 @@ + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_art_placeholder.xml b/feature/widgets/src/main/res/drawable/widget_art_placeholder.xml deleted file mode 100644 index 667891a34..000000000 --- a/feature/widgets/src/main/res/drawable/widget_art_placeholder.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_art_tile_shape.xml b/feature/widgets/src/main/res/drawable/widget_art_tile_shape.xml deleted file mode 100644 index 3279bf657..000000000 --- a/feature/widgets/src/main/res/drawable/widget_art_tile_shape.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_button_play.xml b/feature/widgets/src/main/res/drawable/widget_block.xml similarity index 82% rename from feature/widgets/src/main/res/drawable/widget_button_play.xml rename to feature/widgets/src/main/res/drawable/widget_block.xml index 59941f517..8cc8d5081 100644 --- a/feature/widgets/src/main/res/drawable/widget_button_play.xml +++ b/feature/widgets/src/main/res/drawable/widget_block.xml @@ -1,6 +1,6 @@ - + diff --git a/feature/widgets/src/main/res/drawable/widget_button_pause.xml b/feature/widgets/src/main/res/drawable/widget_button_pause.xml deleted file mode 100644 index f9b58d51d..000000000 --- a/feature/widgets/src/main/res/drawable/widget_button_pause.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_circle.xml b/feature/widgets/src/main/res/drawable/widget_circle.xml new file mode 100644 index 000000000..fd235a65e --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_circle.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_button_secondary.xml b/feature/widgets/src/main/res/drawable/widget_container.xml similarity index 82% rename from feature/widgets/src/main/res/drawable/widget_button_secondary.xml rename to feature/widgets/src/main/res/drawable/widget_container.xml index f9b58d51d..a982b8306 100644 --- a/feature/widgets/src/main/res/drawable/widget_button_secondary.xml +++ b/feature/widgets/src/main/res/drawable/widget_container.xml @@ -1,6 +1,6 @@ - + diff --git a/feature/widgets/src/main/res/drawable/widget_cookie.xml b/feature/widgets/src/main/res/drawable/widget_cookie.xml new file mode 100644 index 000000000..2e68c3eb5 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_cookie.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_grid_surface.xml b/feature/widgets/src/main/res/drawable/widget_grid_surface.xml deleted file mode 100644 index 46bf5ad72..000000000 --- a/feature/widgets/src/main/res/drawable/widget_grid_surface.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_list_art_frame.xml b/feature/widgets/src/main/res/drawable/widget_list_art_frame.xml deleted file mode 100644 index ea89223fc..000000000 --- a/feature/widgets/src/main/res/drawable/widget_list_art_frame.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_list_footer_chip.xml b/feature/widgets/src/main/res/drawable/widget_list_footer_chip.xml deleted file mode 100644 index 7e1621e83..000000000 --- a/feature/widgets/src/main/res/drawable/widget_list_footer_chip.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_list_row_chip.xml b/feature/widgets/src/main/res/drawable/widget_list_row_chip.xml deleted file mode 100644 index 3ab1628d6..000000000 --- a/feature/widgets/src/main/res/drawable/widget_list_row_chip.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_new_dot.xml b/feature/widgets/src/main/res/drawable/widget_new_dot.xml deleted file mode 100644 index 4d6a435b1..000000000 --- a/feature/widgets/src/main/res/drawable/widget_new_dot.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_list_count_chip.xml b/feature/widgets/src/main/res/drawable/widget_pill.xml similarity index 72% rename from feature/widgets/src/main/res/drawable/widget_list_count_chip.xml rename to feature/widgets/src/main/res/drawable/widget_pill.xml index 7e1621e83..2349793b3 100644 --- a/feature/widgets/src/main/res/drawable/widget_list_count_chip.xml +++ b/feature/widgets/src/main/res/drawable/widget_pill.xml @@ -1,4 +1,5 @@ + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_button_primary.xml b/feature/widgets/src/main/res/drawable/widget_preview_button_primary.xml deleted file mode 100644 index 7b3539fc9..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_button_primary.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_button_secondary.xml b/feature/widgets/src/main/res/drawable/widget_preview_button_secondary.xml deleted file mode 100644 index 3279bf657..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_button_secondary.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_a.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_a.xml index 46d7b5435..c7db751ae 100644 --- a/feature/widgets/src/main/res/drawable/widget_preview_cover_a.xml +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_a.xml @@ -1,5 +1,14 @@ - - - + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_b.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_b.xml index 89c146a7d..4439bd98b 100644 --- a/feature/widgets/src/main/res/drawable/widget_preview_cover_b.xml +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_b.xml @@ -1,5 +1,14 @@ - - - + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_c.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_c.xml index 07c72c36f..b490e1c62 100644 --- a/feature/widgets/src/main/res/drawable/widget_preview_cover_c.xml +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_c.xml @@ -1,5 +1,14 @@ - - - + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_d.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_d.xml new file mode 100644 index 000000000..f7b36051f --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_d.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_e.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_e.xml new file mode 100644 index 000000000..6bd6cf884 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_e.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_f.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_f.xml new file mode 100644 index 000000000..6310bd1ce --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_f.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_disc_a.xml b/feature/widgets/src/main/res/drawable/widget_preview_disc_a.xml new file mode 100644 index 000000000..f72d6f0f8 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_disc_a.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_disc_b.xml b/feature/widgets/src/main/res/drawable/widget_preview_disc_b.xml new file mode 100644 index 000000000..de004ec79 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_disc_b.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_disc_c.xml b/feature/widgets/src/main/res/drawable/widget_preview_disc_c.xml new file mode 100644 index 000000000..b1d912816 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_disc_c.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_new_dot.xml b/feature/widgets/src/main/res/drawable/widget_preview_new_dot.xml index 4d6a435b1..84bc89b75 100644 --- a/feature/widgets/src/main/res/drawable/widget_preview_new_dot.xml +++ b/feature/widgets/src/main/res/drawable/widget_preview_new_dot.xml @@ -1,5 +1,9 @@ + - + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_pill_chip.xml b/feature/widgets/src/main/res/drawable/widget_preview_pill_chip.xml deleted file mode 100644 index ad3b3f93e..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_pill_chip.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_ring.xml b/feature/widgets/src/main/res/drawable/widget_preview_ring.xml new file mode 100644 index 000000000..5ecc94d7e --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_ring.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_row_chip.xml b/feature/widgets/src/main/res/drawable/widget_preview_row_chip.xml deleted file mode 100644 index a75f77455..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_row_chip.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_surface.xml b/feature/widgets/src/main/res/drawable/widget_preview_surface.xml deleted file mode 100644 index f02e9fc59..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_surface.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_a.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_a.xml new file mode 100644 index 000000000..500a233c4 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_tile_a.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_b.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_b.xml new file mode 100644 index 000000000..153b956b0 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_tile_b.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_c.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_c.xml new file mode 100644 index 000000000..f85d42ad9 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_tile_c.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_d.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_d.xml new file mode 100644 index 000000000..3bada6353 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_tile_d.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_e.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_e.xml new file mode 100644 index 000000000..637d98141 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_tile_e.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_f.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_f.xml new file mode 100644 index 000000000..d50f0c7e6 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_tile_f.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_sizer_bar_play.xml b/feature/widgets/src/main/res/drawable/widget_sizer_bar_play.xml new file mode 100644 index 000000000..11f6a50a7 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_sizer_bar_play.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_sizer_fill.xml b/feature/widgets/src/main/res/drawable/widget_sizer_fill.xml new file mode 100644 index 000000000..03cb7adcf --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_sizer_fill.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_sizer_frame.xml b/feature/widgets/src/main/res/drawable/widget_sizer_frame.xml new file mode 100644 index 000000000..9647f60cb --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_sizer_frame.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_sizer_frame_bar.xml b/feature/widgets/src/main/res/drawable/widget_sizer_frame_bar.xml new file mode 100644 index 000000000..97487ea97 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_sizer_frame_bar.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_sizer_frame_tall.xml b/feature/widgets/src/main/res/drawable/widget_sizer_frame_tall.xml new file mode 100644 index 000000000..8ff5cd298 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_sizer_frame_tall.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_sizer_mini.xml b/feature/widgets/src/main/res/drawable/widget_sizer_mini.xml new file mode 100644 index 000000000..ec1fc90a8 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_sizer_mini.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_sizer_square.xml b/feature/widgets/src/main/res/drawable/widget_sizer_square.xml new file mode 100644 index 000000000..765ee4cca --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_sizer_square.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_surface.xml b/feature/widgets/src/main/res/drawable/widget_surface.xml deleted file mode 100644 index 23f7ac5f4..000000000 --- a/feature/widgets/src/main/res/drawable/widget_surface.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - diff --git a/feature/widgets/src/main/res/layout/library_widget_episode_list_item.xml b/feature/widgets/src/main/res/layout/library_widget_episode_list_item.xml index f6fed60fe..215f6cf97 100644 --- a/feature/widgets/src/main/res/layout/library_widget_episode_list_item.xml +++ b/feature/widgets/src/main/res/layout/library_widget_episode_list_item.xml @@ -1,4 +1,5 @@ + + android:src="@drawable/widget_pill" /> + android:paddingStart="6dp" + android:paddingEnd="22dp"> + android:layout_width="46dp" + android:layout_height="46dp"> + + + + + android:scaleType="fitXY" /> + android:orientation="vertical" + android:paddingVertical="7dp"> + android:textColor="@color/widget_on_surface" /> + android:textColor="@color/widget_on_surface_variant" /> - - diff --git a/feature/widgets/src/main/res/layout/library_widget_grid.xml b/feature/widgets/src/main/res/layout/library_widget_grid.xml new file mode 100644 index 000000000..686016bc5 --- /dev/null +++ b/feature/widgets/src/main/res/layout/library_widget_grid.xml @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/widgets/src/main/res/layout/library_widget_list.xml b/feature/widgets/src/main/res/layout/library_widget_list.xml index c1b1b354e..0e0c614ec 100644 --- a/feature/widgets/src/main/res/layout/library_widget_list.xml +++ b/feature/widgets/src/main/res/layout/library_widget_list.xml @@ -1,4 +1,8 @@ + + android:layout_height="match_parent"> + android:src="@drawable/widget_container" /> + android:paddingTop="14dp" + android:paddingBottom="14dp"> + android:baselineAligned="false" + android:orientation="horizontal"> + + + + + + + + android:textColor="@color/widget_on_surface" /> + android:src="@drawable/widget_circle" /> - + android:autoMirrored="true" + android:contentDescription="@string/library_widget_view_all" + android:scaleType="fitCenter" + android:src="@drawable/ic_widget_arrow_forward" /> @@ -88,10 +119,10 @@ android:id="@+id/widget_list_view" android:layout_width="match_parent" android:layout_height="match_parent" + android:layout_marginHorizontal="14dp" android:divider="@null" android:dividerHeight="0dp" - android:fadeScrollbars="true" - android:scrollbarStyle="outsideOverlay" /> + android:scrollbars="none" /> + + + + + + + + android:textSize="17sp" /> @@ -126,7 +180,7 @@ android:layout_height="match_parent" android:contentDescription="@null" android:scaleType="fitXY" - android:src="@drawable/widget_list_footer_chip" /> + android:src="@drawable/widget_pill" /> + android:paddingHorizontal="22dp" + android:textColor="@color/widget_on_surface" + android:textSize="14sp" /> - - - - - - - diff --git a/feature/widgets/src/main/res/layout/library_widget_list_item.xml b/feature/widgets/src/main/res/layout/library_widget_list_item.xml index 75e7ca386..9a3a90d2d 100644 --- a/feature/widgets/src/main/res/layout/library_widget_list_item.xml +++ b/feature/widgets/src/main/res/layout/library_widget_list_item.xml @@ -1,78 +1,63 @@ + + android:layout_height="wrap_content" + android:padding="4dp"> - + android:layout_height="wrap_content"> - - - + - - + - - - + android:adjustViewBounds="true" + android:contentDescription="@null" + android:scaleType="fitXY" /> - - + - + diff --git a/feature/widgets/src/main/res/layout/now_playing_widget_bar.xml b/feature/widgets/src/main/res/layout/now_playing_widget_bar.xml index 0d9268aa7..ca291fe98 100644 --- a/feature/widgets/src/main/res/layout/now_playing_widget_bar.xml +++ b/feature/widgets/src/main/res/layout/now_playing_widget_bar.xml @@ -1,221 +1,423 @@ + android:theme="@android:style/Theme.DeviceDefault.DayNight" + tools:ignore="RtlHardcoded,NestedWeights,UselessLeaf"> - + android:layout_height="match_parent"> + android:src="@drawable/widget_sizer_frame_bar" /> - + + android:id="@+id/widget_playing_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:visibility="gone"> - + android:scaleType="fitXY" + android:src="@drawable/widget_pill" /> - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - + android:baselineAligned="false" + android:layoutDirection="ltr" + android:orientation="horizontal"> - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:id="@+id/widget_empty_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:visibility="visible"> + android:scaleType="fitXY" + android:src="@drawable/widget_pill" /> + + + + + + + + + + + + + + + + + + + + + - - + + diff --git a/feature/widgets/src/main/res/layout/now_playing_widget_standard.xml b/feature/widgets/src/main/res/layout/now_playing_widget_standard.xml index c41768927..314587e91 100644 --- a/feature/widgets/src/main/res/layout/now_playing_widget_standard.xml +++ b/feature/widgets/src/main/res/layout/now_playing_widget_standard.xml @@ -1,286 +1,482 @@ + android:theme="@android:style/Theme.DeviceDefault.DayNight" + tools:ignore="RtlHardcoded,NestedWeights,UselessLeaf"> - + android:layout_height="match_parent"> - - - - - - - - - - - + android:layout_centerVertical="true" + android:adjustViewBounds="true" + android:contentDescription="@null" + android:src="@drawable/widget_sizer_frame" /> - - - - - + - - - - - - - + android:layout_height="match_parent" + android:orientation="vertical" + android:visibility="gone"> - - - + android:src="@drawable/widget_pill" /> - - - - - + android:baselineAligned="false" + android:orientation="horizontal" + android:paddingStart="4dp" + android:paddingTop="4dp" + android:paddingEnd="22dp" + android:paddingBottom="4dp"> + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + android:layout_marginLeft="8dp" + android:layout_weight="1"> + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + android:visibility="visible"> - + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_pill" /> - - - + + + + + + + + + + + + + + + + + + + - - + + diff --git a/feature/widgets/src/main/res/layout/now_playing_widget_standard_tall.xml b/feature/widgets/src/main/res/layout/now_playing_widget_standard_tall.xml new file mode 100644 index 000000000..3526b66c4 --- /dev/null +++ b/feature/widgets/src/main/res/layout/now_playing_widget_standard_tall.xml @@ -0,0 +1,482 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/widgets/src/main/res/layout/playback_controls_widget.xml b/feature/widgets/src/main/res/layout/playback_controls_widget.xml index c78360991..b54e86585 100644 --- a/feature/widgets/src/main/res/layout/playback_controls_widget.xml +++ b/feature/widgets/src/main/res/layout/playback_controls_widget.xml @@ -1,174 +1,308 @@ + + android:theme="@android:style/Theme.DeviceDefault.DayNight" + tools:ignore="NestedWeights,UselessLeaf"> - + + + android:src="@drawable/widget_circle" /> - + + + + + + + + + + + android:layout_height="match_parent" + android:orientation="vertical"> + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="79" /> - + + - + android:layout_weight="13" /> - + + + - + + + + + - - - + android:layout_weight="42"> + + + + + + + + + + + android:layout_height="match_parent" + android:orientation="vertical"> + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="92" /> + + - - - - + android:layout_weight="53" /> - + + + - + + + - - - + android:layout_weight="53" /> + + + - + - - + android:layout_height="0dp" + android:layout_weight="17" /> + + + + + - - - - - - - + + + + + diff --git a/feature/widgets/src/main/res/layout/widget_preview_bar_4x1.xml b/feature/widgets/src/main/res/layout/widget_preview_bar_4x1.xml index 1b014b3cf..af37aa611 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_bar_4x1.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_bar_4x1.xml @@ -1,138 +1,154 @@ - + + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="350dp" + android:layout_height="86dp" + android:theme="@android:style/Theme.DeviceDefault.DayNight" + tools:ignore="UseAppTint"> - - - - - - + android:layout_height="match_parent"> + + + android:layoutDirection="ltr" + android:orientation="horizontal" + android:paddingStart="4dp" + android:paddingEnd="14dp"> + android:layout_width="78dp" + android:layout_height="78dp"> + android:src="@drawable/widget_preview_ring" /> + android:scaleType="fitXY" + android:src="@drawable/widget_preview_disc_a" /> + + + + + + + + android:layout_width="38dp" + android:layout_height="38dp"> + android:src="@drawable/widget_circle" + android:tint="@color/widget_surface" /> + android:scaleType="fitCenter" + android:src="@drawable/ic_widget_skip_back" + android:tint="@color/widget_on_surface" /> + android:layout_width="56dp" + android:layout_height="56dp" + android:layout_marginHorizontal="3dp"> + android:src="@drawable/widget_cookie" + android:tint="@color/widget_primary" /> + android:scaleType="fitCenter" + android:src="@drawable/ic_widget_pause" + android:tint="@color/widget_on_primary" /> - - + - - - + + + + - + diff --git a/feature/widgets/src/main/res/layout/widget_preview_control_4x2.xml b/feature/widgets/src/main/res/layout/widget_preview_control_4x2.xml index 2aad65b68..36a841d22 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_control_4x2.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_control_4x2.xml @@ -1,182 +1,233 @@ - + + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="350dp" + android:layout_height="182dp" + android:theme="@android:style/Theme.DeviceDefault.DayNight" + tools:ignore="UseAppTint,RtlHardcoded,NestedWeights"> + android:layout_height="match_parent" + android:orientation="vertical"> - - - + android:layout_height="0dp" + android:layout_weight="1"> - - + - - - - + + + + + + + + + + android:layout_marginStart="10dp" + android:layout_weight="1" + android:orientation="vertical"> + + + + + - + + android:layout_height="match_parent" + android:layout_weight="1" + android:layout_marginRight="8dp"> - - - - - + android:src="@drawable/widget_pill" + android:tint="@color/widget_surface" /> - - - + android:baselineAligned="false" + android:gravity="center" + android:orientation="horizontal"> + + + + + + + + + + + android:layout_width="86dp" + android:layout_height="86dp"> + android:src="@drawable/widget_cookie" + android:tint="@color/widget_primary" /> + android:scaleType="fitCenter" + android:src="@drawable/ic_widget_pause" + android:tint="@color/widget_on_primary" /> + android:layout_height="match_parent" + android:layout_weight="1" + android:layout_marginLeft="8dp"> - - - + android:src="@drawable/widget_pill" + android:tint="@color/widget_surface" /> - - - - - + android:baselineAligned="false" + android:gravity="center" + android:orientation="horizontal"> + + + + + + + + + + diff --git a/feature/widgets/src/main/res/layout/widget_preview_grid_2x2.xml b/feature/widgets/src/main/res/layout/widget_preview_grid_2x2.xml index 8efeab8b6..833334568 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_grid_2x2.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_grid_2x2.xml @@ -1,109 +1,117 @@ - + + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="170dp" + android:layout_height="170dp" + android:theme="@android:style/Theme.DeviceDefault.DayNight" + tools:ignore="UseAppTint,RtlHardcoded"> - + - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_circle" + android:tint="@color/widget_primary_container" /> - + - - + - + - + - - - + + - + - + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_cookie" + android:tint="@color/widget_primary" /> - + + - - + - - - + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_circle" + android:tint="@color/widget_surface" /> - - - - + + + diff --git a/feature/widgets/src/main/res/layout/widget_preview_grid_next_2x2.xml b/feature/widgets/src/main/res/layout/widget_preview_grid_next_2x2.xml index 91eaa9701..4c44f2199 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_grid_next_2x2.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_grid_next_2x2.xml @@ -1,109 +1,117 @@ - + + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="170dp" + android:layout_height="170dp" + android:theme="@android:style/Theme.DeviceDefault.DayNight" + tools:ignore="UseAppTint,RtlHardcoded"> - + - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_circle" + android:tint="@color/widget_primary_container" /> - + - - + - + - + - - - + + - + - + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_cookie" + android:tint="@color/widget_primary" /> - + + - - + - - - + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_circle" + android:tint="@color/widget_surface" /> - - - - + + + diff --git a/feature/widgets/src/main/res/layout/widget_preview_new_episodes.xml b/feature/widgets/src/main/res/layout/widget_preview_new_episodes.xml index a48c9b5ba..5e2884204 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_new_episodes.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_new_episodes.xml @@ -1,192 +1,298 @@ - - + + - - - - - - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_container" + android:tint="@color/widget_tertiary_container" /> - - - - + android:layout_height="match_parent" + android:orientation="vertical" + android:padding="14dp"> + android:orientation="horizontal"> + android:layout_width="52dp" + android:layout_height="52dp" + android:layout_marginEnd="10dp"> - - - + android:scaleType="fitXY" + android:src="@drawable/widget_cookie" + android:tint="@color/widget_tertiary" /> - - - - - - - - - - + android:text="@string/widget_preview_count" + android:textColor="@color/widget_on_tertiary" + android:textSize="19sp" /> + - + + android:layout_width="40dp" + android:layout_height="40dp"> + android:scaleType="fitXY" + android:src="@drawable/widget_circle" + android:tint="@color/widget_surface" /> + + + + + + + + android:layout_width="match_parent" + android:layout_height="match_parent" + android:baselineAligned="false" + android:gravity="center_vertical" + android:layoutDirection="ltr" + android:orientation="horizontal" + android:paddingStart="6dp" + android:paddingEnd="22dp"> - + + + + + + android:layout_marginStart="12dp" + android:layout_weight="1" + android:orientation="vertical"> - + + + + + + + + + + + + + + + + + + + android:layout_marginStart="12dp" + android:layout_weight="1" + android:orientation="vertical"> + + + + + - - + - - + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/widgets/src/main/res/layout/widget_preview_subscriptions.xml b/feature/widgets/src/main/res/layout/widget_preview_subscriptions.xml index 52a8a84b7..525ba5c4f 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_subscriptions.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_subscriptions.xml @@ -1,265 +1,243 @@ - - - - + + + + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_container" + android:tint="@color/widget_surface" /> - - - - - - - - - + android:layout_height="match_parent" + android:orientation="vertical" + android:padding="14dp"> + android:orientation="horizontal"> + android:layout_width="52dp" + android:layout_height="52dp" + android:layout_marginEnd="10dp"> + android:scaleType="fitXY" + android:src="@drawable/widget_cookie" + android:tint="@color/widget_primary" /> + + - + android:fontFamily="sans-serif-medium" + android:includeFontPadding="false" + android:maxLines="1" + android:text="@string/subscriptions_widget_header" + android:textColor="@color/widget_on_surface" + android:textSize="20sp" /> - + - - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_circle" + android:tint="@color/widget_primary_container" /> - + + - - - - - - + android:layout_height="82dp" + android:layout_marginHorizontal="-4dp" + android:baselineAligned="false" + android:orientation="horizontal"> + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_margin="4dp" + android:layout_weight="1"> + android:scaleType="fitXY" + android:src="@drawable/widget_preview_tile_a" /> + + - + android:layout_height="match_parent" + android:layout_margin="4dp" + android:layout_weight="1"> - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_preview_tile_b" /> + - + + - - - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_preview_tile_c" /> - - + + - + + + + + + android:layout_height="82dp" + android:layout_marginHorizontal="-4dp" + android:baselineAligned="false" + android:orientation="horizontal"> + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_margin="4dp" + android:layout_weight="1"> + android:scaleType="fitXY" + android:src="@drawable/widget_preview_tile_e" /> - + android:layout_height="match_parent" + android:layout_margin="4dp" + android:layout_weight="1"> - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_preview_tile_f" /> - + + + + + - - - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_preview_tile_c" /> + - - + + + + + + + diff --git a/feature/widgets/src/main/res/values-night-v31/colors.xml b/feature/widgets/src/main/res/values-night-v31/colors.xml index a1fb8d9cb..7980a987e 100644 --- a/feature/widgets/src/main/res/values-night-v31/colors.xml +++ b/feature/widgets/src/main/res/values-night-v31/colors.xml @@ -10,4 +10,8 @@ @android:color/system_accent1_100 @android:color/system_neutral2_700 @android:color/system_neutral2_100 + @android:color/system_accent3_200 + @android:color/system_accent3_800 + @android:color/system_accent3_700 + @android:color/system_accent3_100 diff --git a/feature/widgets/src/main/res/values-v31/colors.xml b/feature/widgets/src/main/res/values-v31/colors.xml index ecdc91dd5..a5e562ba7 100644 --- a/feature/widgets/src/main/res/values-v31/colors.xml +++ b/feature/widgets/src/main/res/values-v31/colors.xml @@ -10,4 +10,8 @@ @android:color/system_accent1_900 @android:color/system_neutral2_100 @android:color/system_neutral2_900 + @android:color/system_accent3_600 + @android:color/system_accent3_0 + @android:color/system_accent3_100 + @android:color/system_accent3_900 diff --git a/feature/widgets/src/main/res/values/colors.xml b/feature/widgets/src/main/res/values/colors.xml index f6035c5ee..a04af2dad 100644 --- a/feature/widgets/src/main/res/values/colors.xml +++ b/feature/widgets/src/main/res/values/colors.xml @@ -9,4 +9,8 @@ #FFEADDFF #FF4A4458 #FFE8DEF8 + #FFEFB8C8 + #FF492532 + #FF633B48 + #FFFFD8E4 diff --git a/feature/widgets/src/main/res/values/strings.xml b/feature/widgets/src/main/res/values/strings.xml index 85f208f42..862c53a39 100644 --- a/feature/widgets/src/main/res/values/strings.xml +++ b/feature/widgets/src/main/res/values/strings.xml @@ -1,39 +1,46 @@ + Now Playing · Full Controls Now Playing · Compact - Playback Controls · Seek - Playback Controls · Next - A slim now-playing bar with quick seek controls - Artwork with play and seek controls - Artwork with play, next episode, and seek-forward controls + Now Playing · Round + Now Playing · Round with Next + + The full player: artwork, time left and all five controls + A slim one-row player with back, play and forward + A round player with back, play and forward + A round player with forward, play and next episode Ready when you are Tap to open boxlore - Ready when you are Previous episode Next episode Seek backward Seek forward Play Pause + + %1$dm left + %1$dh left + %1$dh %2$dm left + + %1$s · %2$s Why curiosity compounds The Daily Lore - Field Notes - 12 new episodes - Soft Power - Updated yesterday A quiet week in Kyoto Field Notes How maps shape memory Soft Power 12 - Your Shows - Your subscribed shows + 24 + 36m left · The Daily Lore + 36m left + Library · Your Shows + Your subscribed shows as a grid of artwork Subscriptions No subscriptions yet Open boxlore - New Episodes - Latest episodes from your shows + Library · New Episodes + The latest episodes from your shows New episodes No new episodes Open subscriptions diff --git a/feature/widgets/src/main/res/xml/now_playing_widget_info.xml b/feature/widgets/src/main/res/xml/now_playing_widget_info.xml index 9733beab0..dc2743526 100644 --- a/feature/widgets/src/main/res/xml/now_playing_widget_info.xml +++ b/feature/widgets/src/main/res/xml/now_playing_widget_info.xml @@ -1,6 +1,6 @@ 0 && entry.previewHeightDp > 0) + val views = HomeScreenWidgets.preview(context, entry.key) + assertTrue("${entry.key} draws", views != null) + views!!.apply(context, android.appwidget.AppWidgetHostView(context)) + } + assertEquals(null, HomeScreenWidgets.preview(context, "no-such-widget")) + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsTest.kt index c093431f8..217465380 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsTest.kt @@ -81,7 +81,7 @@ class LibraryWidgetRemoteViewsTest { private val context: Context = ApplicationProvider.getApplicationContext() @Test - fun subscriptionsEmptyUsesListLayout() { + fun subscriptionsEmptyUsesGridLayout() { val views = LibraryWidgetRenderer.build( context = context, @@ -89,7 +89,7 @@ class LibraryWidgetRemoteViewsTest { snapshot = LibraryWidgetSnapshot(), kind = LibraryWidgetKind.SUBSCRIPTIONS, ) - assertEquals(R.layout.library_widget_list, views.layoutId) + assertEquals(R.layout.library_widget_grid, views.layoutId) views.apply(context, FrameLayout(context)) } @@ -118,6 +118,58 @@ class LibraryWidgetRemoteViewsTest { views.apply(context, FrameLayout(context)) } + @Test + fun badgeCountsTheWholeLibraryNotJustTheRowsTheWidgetHolds() { + val snapshot = + LibraryWidgetSnapshot( + subscriptions = listOf(WidgetShowRow(podcastId = "p1", title = "Show", deepLinkUri = "boxlore://podcast/p1")), + counts = WidgetLibraryCounts(subscriptions = 84, newEpisodes = 0), + ) + assertEquals(84, snapshot.count(LibraryWidgetKind.SUBSCRIPTIONS)) + // An older saved snapshot has no totals: fall back to the rows it holds. + assertEquals(1, snapshot.copy(counts = WidgetLibraryCounts()).count(LibraryWidgetKind.SUBSCRIPTIONS)) + + val root = LibraryWidgetRenderer.build(context, 5, snapshot, LibraryWidgetKind.SUBSCRIPTIONS).apply(context, FrameLayout(context)) + assertEquals("84", root.findViewById(R.id.widget_count_chip_text).text.toString()) + assertEquals(View.VISIBLE, root.findViewById(R.id.widget_open_list).visibility) + } + + @Test + fun restingLibraryWidgetHidesTheBadgeAndArrow() { + val root = LibraryWidgetRenderer.build(context, 6, LibraryWidgetSnapshot(), LibraryWidgetKind.NEW_EPISODES).apply(context, FrameLayout(context)) + assertEquals(View.GONE, root.findViewById(R.id.widget_count_chip).visibility) + assertEquals(View.GONE, root.findViewById(R.id.widget_open_list).visibility) + assertEquals(View.VISIBLE, root.findViewById(R.id.widget_empty_container).visibility) + assertEquals( + context.getString(R.string.new_episodes_widget_empty_title), + root.findViewById(R.id.widget_empty_title).text.toString(), + ) + } + + @Test + fun headerBadgeAndArrowStayInsideANarrowWidget() { + val snapshot = + LibraryWidgetSnapshot( + subscriptions = listOf(WidgetShowRow(podcastId = "p1", title = "Show", deepLinkUri = "boxlore://podcast/p1")), + counts = WidgetLibraryCounts(subscriptions = 128), + ) + val root = LibraryWidgetRenderer.build(context, 7, snapshot, LibraryWidgetKind.SUBSCRIPTIONS).apply(context, FrameLayout(context)) + val density = context.resources.displayMetrics.density + root.measure( + View.MeasureSpec.makeMeasureSpec((200 * density).toInt(), View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec((180 * density).toInt(), View.MeasureSpec.EXACTLY), + ) + root.layout(0, 0, root.measuredWidth, root.measuredHeight) + + val header = root.findViewById(R.id.widget_list_header) + val arrow = root.findViewById(R.id.widget_open_list) + val row = root.findViewById(R.id.widget_header_row) + // The title shrinks to the room it has rather than pushing the arrow out or being cut off. + assertTrue("header keeps some width", header.width > 0) + assertTrue("arrow stays inside the header row", arrow.right <= row.width) + assertTrue("title is not ellipsized", header.layout.getEllipsisCount(0) == 0) + } + @Test fun populatedListResizeFloorLeavesRoomForACompleteRow() { val views = @@ -155,7 +207,7 @@ class LibraryWidgetRemoteViewsTest { } @Test - fun newEpisodeRowsAllowTwoTitleLinesWithoutChangingSubscriptionRows() { + fun newEpisodeRowsAllowTwoTitleLinesAndShowTilesNameTheirShow() { val store = LibraryWidgetSnapshotStore(context) store.write( LibraryWidgetSnapshot( @@ -211,7 +263,11 @@ class LibraryWidgetRemoteViewsTest { val subscriptionViews = subscriptionFactory.getViewAt(0) val subscriptionRoot = subscriptionViews.apply(context, FrameLayout(context)) assertEquals(R.layout.library_widget_list_item, subscriptionViews.layoutId) - assertEquals(1, subscriptionRoot.findViewById(R.id.widget_row_title).maxLines) + // A show tile is artwork only, so its name is what a screen reader announces. + assertEquals( + "A subscription title that remains on one line", + subscriptionRoot.findViewById(R.id.widget_row_root).contentDescription, + ) episodeFactory.onDestroy() subscriptionFactory.onDestroy() diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinatorTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinatorTest.kt index 2cc4b2129..2e2ce2501 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinatorTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinatorTest.kt @@ -46,6 +46,8 @@ class NowPlayingWidgetCoordinatorTest { override suspend fun skipForward() = Unit override suspend fun skipBackward() = Unit + + override suspend fun refreshPosition() = Unit } val deps = object : NowPlayingWidgetDependencies { diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRemoteViewsTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRemoteViewsTest.kt index 563d9183e..5a0565bff 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRemoteViewsTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRemoteViewsTest.kt @@ -6,14 +6,18 @@ import android.os.Bundle import android.util.SizeF import android.view.View import android.widget.FrameLayout +import android.widget.RemoteViews import android.widget.TextView import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.feature.widgets.logic.WidgetPresentation +import cx.aswin.boxlore.feature.widgets.logic.WidgetSize import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode @RunWith(RobolectricTestRunner::class) @Config(sdk = [31]) @@ -23,7 +27,7 @@ class NowPlayingWidgetRemoteViewsTest { @Test fun emptySnapshotUsesStandardLayout() { val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 1, snapshot = NowPlayingWidgetSnapshot(), @@ -51,7 +55,7 @@ class NowPlayingWidgetRemoteViewsTest { @Test fun playingSnapshotUsesStandardLayoutForDefaultOptions() { val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 2, snapshot = @@ -70,7 +74,7 @@ class NowPlayingWidgetRemoteViewsTest { @Test fun standardLayoutCanBeInflatedByRemoteViews() { val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 2, snapshot = playingSnapshot(), @@ -153,7 +157,7 @@ class NowPlayingWidgetRemoteViewsTest { ) } - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 2, snapshot = playingSnapshot(), @@ -164,7 +168,7 @@ class NowPlayingWidgetRemoteViewsTest { @Test fun barVariantUsesDedicatedHorizontalLayout() { val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 3, snapshot = playingSnapshot(), @@ -186,7 +190,7 @@ class NowPlayingWidgetRemoteViewsTest { } val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 5, snapshot = playingSnapshot(), @@ -201,7 +205,7 @@ class NowPlayingWidgetRemoteViewsTest { @Test fun controlsVariantUsesDedicatedGridLayout() { val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 4, snapshot = playingSnapshot(), @@ -227,9 +231,9 @@ class NowPlayingWidgetRemoteViewsTest { } @Test - fun nextControlsVariantBindsNextAndSeekForwardGrid() { + fun nextControlsVariantPutsSeekForwardLeftAndNextEpisodeRight() { val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 7, snapshot = playingSnapshot(), @@ -240,11 +244,11 @@ class NowPlayingWidgetRemoteViewsTest { assertEquals(R.layout.playback_controls_widget, views.layoutId) assertEquals( - context.getString(R.string.widget_next), + context.getString(R.string.widget_skip_forward), root.findViewById(R.id.widget_skip_back_icon).contentDescription, ) assertEquals( - context.getString(R.string.widget_skip_forward), + context.getString(R.string.widget_next), root.findViewById(R.id.widget_skip_forward_icon).contentDescription, ) assertTrue( @@ -255,6 +259,186 @@ class NowPlayingWidgetRemoteViewsTest { ) } + @Test + fun metaLinePutsTheTimeLeftFirstSoALongShowNameIsWhatGetsCut() { + val minute = 60_000L + val snapshot = playingSnapshot().copy(positionMs = 24 * minute, durationMs = 60 * minute) + val both = WidgetPresentation.Meta.TIME_AND_SHOW + + assertEquals("36m left · ${snapshot.podcastTitle}", NowPlayingWidgetRenderer.metaLine(context, snapshot, both)) + assertEquals("36m left", NowPlayingWidgetRenderer.metaLine(context, snapshot, WidgetPresentation.Meta.TIME)) + assertEquals("", NowPlayingWidgetRenderer.metaLine(context, snapshot, WidgetPresentation.Meta.NONE)) + assertEquals("36m left", NowPlayingWidgetRenderer.metaLine(context, snapshot.copy(podcastTitle = ""), both)) + // No length yet: just the show, never "0m left". + assertEquals(snapshot.podcastTitle, NowPlayingWidgetRenderer.metaLine(context, snapshot.copy(durationMs = 0L), both)) + assertEquals("", NowPlayingWidgetRenderer.metaLine(context, snapshot.copy(durationMs = 0L), WidgetPresentation.Meta.TIME)) + } + + @Test + fun timeLeftReadsInHoursAndMinutes() { + val minute = 60_000L + val snapshot = playingSnapshot() + assertEquals("1h 12m left", NowPlayingWidgetRenderer.timeLeftLabel(context, snapshot.copy(positionMs = 14 * minute, durationMs = 86 * minute))) + assertEquals("2h left", NowPlayingWidgetRenderer.timeLeftLabel(context, snapshot.copy(positionMs = 0L, durationMs = 120 * minute))) + assertEquals("1m left", NowPlayingWidgetRenderer.timeLeftLabel(context, snapshot.copy(positionMs = 59 * minute + 30_000L, durationMs = 60 * minute))) + assertEquals(null, NowPlayingWidgetRenderer.timeLeftLabel(context, snapshot.copy(positionMs = 60 * minute, durationMs = 60 * minute))) + } + + @Test + fun fullControlsGivesTheTitleMoreLinesAsItGrowsAndFewerAsItShrinks() { + val minute = 60_000L + val snapshot = playingSnapshot().copy(positionMs = 24 * minute, durationMs = 60 * minute) + + fun titleAt(heightDp: Int): TextView = renderAtMinimumSize(WidgetVariant.NOW_PLAYING, widthDp = 350, heightDp = heightDp, snapshot = snapshot) + .findViewById(R.id.widget_episode_title) + + val regular = renderAtMinimumSize(WidgetVariant.NOW_PLAYING, widthDp = 350, heightDp = 182, snapshot = snapshot) + assertEquals("36m left · ${snapshot.podcastTitle}", regular.findViewById(R.id.widget_podcast_title).text.toString()) + assertEquals(2, titleAt(182).maxLines) + assertEquals(3, titleAt(278).maxLines) + assertEquals(1, titleAt(100).maxLines) + assertEquals(2, titleAt(182).maxLines) + } + + @Test + fun compactPlayerKeepsTheEpisodeNameAndLeavesTheShowToTheArtwork() { + val minute = 60_000L + val snapshot = playingSnapshot().copy(positionMs = 24 * minute, durationMs = 60 * minute) + + val roomy = renderAtMinimumSize(WidgetVariant.BAR, widthDp = 350, heightDp = 86, snapshot = snapshot) + assertEquals(snapshot.episodeTitle, roomy.findViewById(R.id.widget_episode_title).text.toString()) + assertEquals("36m left", roomy.findViewById(R.id.widget_podcast_title).text.toString()) + + // A slim bar has room for the episode name only. + val slim = renderAtMinimumSize(WidgetVariant.BAR, widthDp = 350, heightDp = 52, snapshot = snapshot) + assertEquals(View.GONE, slim.findViewById(R.id.widget_podcast_title).visibility) + assertEquals(1, slim.findViewById(R.id.widget_episode_title).maxLines) + } + + @Test + fun aWidgetIsNeverClippedWhenTheLauncherReportsTheWrongSize() { + val long = playingSnapshot().copy(episodeTitle = "An exceptionally long episode title that would occupy several lines if it could") + // What the launcher said, then what the widget really got: smaller, larger, and turned on its side. + val reported = mapOf(WidgetVariant.NOW_PLAYING to (350 to 182), WidgetVariant.BAR to (350 to 86), WidgetVariant.CONTROLS to (170 to 182)) + val actual = listOf(200 to 96, 245 to 100, 300 to 60, 320 to 240, 140 to 140, 420 to 120, 120 to 260) + val playing = listOf(R.id.widget_play_pause, R.id.widget_skip_back, R.id.widget_skip_forward, R.id.widget_previous, R.id.widget_next, R.id.widget_episode_title, R.id.widget_artwork) + val resting = listOf(R.id.widget_empty_mark, R.id.widget_empty_title) + val cases = reported.flatMap { (variant, size) -> actual.flatMap { drawn -> listOf(long, NowPlayingWidgetSnapshot()).map { Triple(variant, size, drawn) to it } } } + for ((case, snapshot) in cases) { + val (variant, size, drawn) = case + val root = renderAt(variant, reportedDp = size, actualDp = drawn, snapshot = snapshot) + val where = "$variant reported ${size.first}x${size.second}, drawn ${drawn.first}x${drawn.second}" + val shown = (if (snapshot.hasEpisode) playing else resting).filter { root.findViewById(it)?.visibility == View.VISIBLE } + for (id in shown) { + val view = root.findViewById(id) + assertFullyInside(root, id, where) + assertTrue("$where: ${context.resources.getResourceEntryName(id)} has no size", view.width > 0 && view.height > 0) + } + } + } + + @Test + fun theRoundWidgetIsACircleThatFitsAnyCell() { + for ((widthDp, heightDp) in listOf(110 to 110, 170 to 182, 182 to 170, 350 to 182, 120 to 300)) { + val root = renderAtMinimumSize(WidgetVariant.CONTROLS, widthDp = widthDp, heightDp = heightDp, snapshot = playingSnapshot()) + val card = root.findViewById(R.id.widget_grid_card) + assertEquals("a circle at ${widthDp}x$heightDp", card.width, card.height) + assertEquals("as wide as the shorter side", minOf(root.width, root.height), card.width) + assertFullyInside(root, R.id.widget_grid_card) + // The play button never covers the smaller buttons beside it. + val play = root.findViewById(R.id.widget_play_pause) + val back = root.findViewById(R.id.widget_skip_back) + assertTrue("play sits below and between the side buttons", play.width > back.width && play.height > back.height) + } + } + + @Test + fun theLauncherIsGivenOneLayoutForEverySizeStepAndPicksTheSameOneWeDo() { + val pick = RemoteViews::class.java.getMethod("getRemoteViewsToApply", Context::class.java, SizeF::class.java) + val snapshot = playingSnapshot().copy(positionMs = 60_000L, durationMs = 3_600_000L) + for (variant in listOf(WidgetVariant.NOW_PLAYING, WidgetVariant.BAR)) { + // The report is deliberately wrong: what is shown must follow the space the launcher really has. + val sent = NowPlayingWidgetRenderer.buildRemoteViews(context, 4, snapshot, sizeOptions(350, 182), variant) + for (widthDp in 160..640 step 40) { + for (heightDp in 40..320 step 20) { + val launchers = (pick.invoke(sent, context, SizeF(widthDp.toFloat(), heightDp.toFloat())) as RemoteViews).apply(context, FrameLayout(context)) + val ours = + NowPlayingWidgetRenderer + .buildRemoteViewsAt(context, 4, snapshot, sizeOptions(350, 182), variant, drawn = WidgetSize(widthDp, heightDp)) + .apply(context, FrameLayout(context)) + val where = "$variant drawn ${widthDp}x$heightDp" + assertEquals(where, ours.sourceLayoutResId, launchers.sourceLayoutResId) + assertEquals(where, ours.findViewById(R.id.widget_episode_title).maxLines, launchers.findViewById(R.id.widget_episode_title).maxLines) + assertEquals(where, ours.findViewById(R.id.widget_podcast_title).text.toString(), launchers.findViewById(R.id.widget_podcast_title).text.toString()) + } + } + } + } + + @Test + fun whatAWidgetShowsFollowsTheSpaceItReallyHasNotTheReport() { + val long = playingSnapshot().copy(episodeTitle = "An exceptionally long episode title that would occupy several lines", positionMs = 60_000L, durationMs = 3_600_000L) + // Reported as a roomy 4x2, really drawn short: one title line and no show name, all of it readable. + val short = renderAt(WidgetVariant.NOW_PLAYING, reportedDp = 350 to 182, actualDp = 300 to 110, snapshot = long, pickedByLauncher = true) + assertEquals(1, short.findViewById(R.id.widget_episode_title).maxLines) + assertEquals("59m left", short.findViewById(R.id.widget_podcast_title).text.toString()) + // Reported short, really drawn roomy: the second line and the show name come back. + val roomy = renderAt(WidgetVariant.NOW_PLAYING, reportedDp = 300 to 110, actualDp = 350 to 182, snapshot = long, pickedByLauncher = true) + assertEquals(2, roomy.findViewById(R.id.widget_episode_title).maxLines) + assertEquals("59m left · Podcast", roomy.findViewById(R.id.widget_podcast_title).text.toString()) + // The round widget is one layout: it shows the same thing at every size. + assertEquals(1, NowPlayingWidgetRenderer.stepsFor(WidgetVariant.CONTROLS).size) + } + + // Real text measuring: the default test fonts do not have real line heights. + @GraphicsMode(GraphicsMode.Mode.NATIVE) + @Test + fun textIsNeverCutThroughALineAtAnySizeTheLauncherCanDraw() { + val long = playingSnapshot().copy(episodeTitle = "An exceptionally long episode title that would occupy several lines if it could", positionMs = 60_000L, durationMs = 3_600_000L) + val heights = mapOf(WidgetVariant.NOW_PLAYING to (100..320 step 10), WidgetVariant.BAR to (48..200 step 8)) + val sizes = heights.flatMap { (variant, range) -> listOf(245, 300, 350, 420, 620).flatMap { widthDp -> range.map { heightDp -> Triple(variant, widthDp, heightDp) } } } + for ((variant, widthDp, heightDp) in sizes) { + val root = renderAt(variant, reportedDp = 350 to 182, actualDp = widthDp to heightDp, snapshot = long, pickedByLauncher = true) + val texts = listOf(R.id.widget_episode_title, R.id.widget_podcast_title).map { root.findViewById(it) }.filter { it.visibility == View.VISIBLE } + for (text in texts) { + val shownLines = minOf(text.layout.lineCount, text.maxLines) + val needed = text.layout.getLineBottom(shownLines - 1) + text.paddingTop + text.paddingBottom + assertTrue( + "$variant ${widthDp}x$heightDp: ${context.resources.getResourceEntryName(text.id)} needs ${needed}px for $shownLines lines, has ${text.height}px", + needed <= text.height + 1, + ) + } + } + } + + /** Builds for one size and lays out at another, the way a launcher with a stale report would. */ + private fun renderAt( + variant: WidgetVariant, + reportedDp: Pair, + actualDp: Pair, + snapshot: NowPlayingWidgetSnapshot, + pickedByLauncher: Boolean = false, + ): View { + // A launcher picks the layout for the space it has. Left to the report, this draws the wrong pick. + val drawn = if (pickedByLauncher) WidgetSize(actualDp.first, actualDp.second) else WidgetSize(reportedDp.first, reportedDp.second) + val views = NowPlayingWidgetRenderer.buildRemoteViewsAt(context, 8, snapshot, sizeOptions(reportedDp.first, reportedDp.second), variant, drawn) + return views.apply(context, FrameLayout(context)).also { root -> + val density = context.resources.displayMetrics.density + root.measure( + View.MeasureSpec.makeMeasureSpec((actualDp.first * density).toInt(), View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec((actualDp.second * density).toInt(), View.MeasureSpec.EXACTLY), + ) + root.layout(0, 0, root.measuredWidth, root.measuredHeight) + } + } + + private fun sizeOptions(widthDp: Int, heightDp: Int): Bundle = Bundle().apply { + putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, widthDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, widthDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, heightDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, heightDp) + } + private fun playingSnapshot() = NowPlayingWidgetSnapshot( episodeId = "ep-1", episodeTitle = "A long episode title that needs careful fitting", @@ -269,7 +453,7 @@ class NowPlayingWidgetRemoteViewsTest { snapshot: NowPlayingWidgetSnapshot, ): View { val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 6, snapshot = snapshot, @@ -292,19 +476,26 @@ class NowPlayingWidgetRemoteViewsTest { } } + /** Fails when any edge of the view lies outside [root], on either axis. */ private fun assertFullyInside( root: View, viewId: Int, + where: String = "", ) { val view = root.findViewById(viewId) + val name = "$where ${context.resources.getResourceEntryName(viewId)}".trim() var top = view.top + var left = view.left var parent = view.parent while (parent is View && parent !== root) { top += parent.top + left += parent.left parent = parent.parent } - assertTrue("view $viewId starts above its widget", top >= 0) - assertTrue("view $viewId ends below its widget", top + view.height <= root.height) + assertTrue("$name starts above its widget", top >= 0) + assertTrue("$name ends below its widget", top + view.height <= root.height) + assertTrue("$name starts before its widget", left >= 0) + assertTrue("$name ends past its widget", left + view.width <= root.width) } private fun defaultOptions(): Bundle = Bundle().apply { diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetChromeTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetChromeTest.kt index 82c72295c..c8feff5ba 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetChromeTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetChromeTest.kt @@ -43,16 +43,60 @@ class WidgetChromeTest { assertEquals(0xFF000000.toInt(), chrome.argb(WidgetPalette.primary) and 0xFF000000.toInt()) } + @Test + fun fixedDisplayModeSendsOneColourAndSystemModeSendsLightAndDark() { + writeThemeCache(brand = "violet", appearance = WidgetAppearance.APP, themeConfig = "dark") + val fixed = WidgetChrome.resolve(context) + assertFalse(fixed.followsSystemDayNight) + + // Display mode "System": the launcher gets both colours, so widgets switch with the phone even + // when the app is not running. + writeThemeCache(brand = "violet", appearance = WidgetAppearance.APP, themeConfig = "system") + val following = WidgetChrome.resolve(context) + assertTrue(following.usesAppTheme) + assertTrue(following.followsSystemDayNight) + + // System appearance leaves light and dark to the launcher's own resources. + writeThemeCache(brand = "violet", appearance = WidgetAppearance.SYSTEM, themeConfig = "system") + assertFalse(WidgetChrome.resolve(context).followsSystemDayNight) + } + + @Test + fun lightAndDarkAppColoursDifferSoTheLauncherHasARealChoice() { + writeThemeCache(brand = "violet", appearance = WidgetAppearance.APP, themeConfig = "light") + val light = WidgetChrome.resolve(context) + writeThemeCache(brand = "violet", appearance = WidgetAppearance.APP, themeConfig = "dark") + val dark = WidgetChrome.resolve(context) + + for (role in listOf(WidgetPalette.surface, WidgetPalette.primaryContainer, WidgetPalette.tertiaryContainer, WidgetPalette.onSurface)) { + assertNotEquals(light.argb(role), dark.argb(role)) + } + } + + @Test + fun everyRoleTheWidgetsPaintWithComesFromTheAppTheme() { + writeThemeCache(brand = "violet", appearance = WidgetAppearance.APP) + val violet = WidgetChrome.resolve(context) + writeThemeCache(brand = "emerald", appearance = WidgetAppearance.APP) + val emerald = WidgetChrome.resolve(context) + + // Tertiary paints New Episodes; if it fell back to the system resource it would not follow the brand. + assertNotEquals(violet.argb(WidgetPalette.tertiaryContainer), emerald.argb(WidgetPalette.tertiaryContainer)) + assertNotEquals(violet.argb(WidgetPalette.tertiary), emerald.argb(WidgetPalette.tertiary)) + assertNotEquals(violet.argb(WidgetPalette.primaryContainer), emerald.argb(WidgetPalette.primaryContainer)) + } + private fun writeThemeCache( brand: String, appearance: String?, + themeConfig: String = "dark", ) { val editor = context .getSharedPreferences(FontRoundnessAxis.THEME_FAST_CACHE, Context.MODE_PRIVATE) .edit() .putString("theme_brand", brand) - .putString("theme_config", "dark") + .putString("theme_config", themeConfig) .putString("surface_style", "classic_dynamic") .putBoolean("use_dynamic_color", false) if (appearance == null) { diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt new file mode 100644 index 000000000..decac7cc8 --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt @@ -0,0 +1,104 @@ +package cx.aswin.boxlore.feature.widgets + +import android.content.Context +import android.widget.FrameLayout +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.prefs.FontRoundnessAxis +import cx.aswin.boxlore.core.prefs.WidgetAppearance +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork +import java.io.File +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class WidgetPickerPreviewsTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun everyWidgetInThePickerHasItsOwnLivePreview() { + val previews = WidgetPickerPreviews.previews() + val receivers = WidgetProviders.all.map { it.receiverClass } + LibraryWidgetProviders.all.map { it.receiverClass } + + assertEquals(receivers.toSet(), previews.map { it.receiver }.toSet()) + assertEquals(previews.size, previews.map { it.key }.toSet().size) + } + + @Test + fun everyLivePreviewBuildsAndAppliesLikeAPlacedWidget() { + useTheme(brand = "violet") + for (preview in WidgetPickerPreviews.previews()) { + preview.build(context).apply(context, FrameLayout(context)) + } + } + + @Test + fun sampleContentHasCoversSoPreviewsNeverShowPlaceholders() { + val playback = WidgetPickerPreviews.samplePlayback(context) + val library = WidgetPickerPreviews.sampleLibrary(context) + val covers = listOfNotNull(playback.artworkCachePath) + library.subscriptions.mapNotNull { it.artworkCachePath } + library.newEpisodes.mapNotNull { it.artworkCachePath } + + assertEquals(1 + library.subscriptions.size + library.newEpisodes.size, covers.size) + assertTrue(covers.all { File(it).length() > 0L }) + assertTrue("several different covers", covers.toSet().size >= 4) + assertTrue(library.subscriptions.any { it.isNew } && library.subscriptions.any { !it.isNew }) + // The badge shows the library's size, not the handful of sample tiles. + assertTrue(library.count(LibraryWidgetKind.SUBSCRIPTIONS) > library.subscriptions.size) + } + + @Test + fun thePreviewLookChangesWithTheColoursAndTheCoverStyleAndOnlyThen() { + useTheme(brand = "violet") + val violet = WidgetPickerPreviews.lookSignature(context) + assertEquals("an unchanged look publishes nothing new", violet, WidgetPickerPreviews.lookSignature(context)) + + useTheme(brand = "emerald") + val emerald = WidgetPickerPreviews.lookSignature(context) + assertNotEquals(violet, emerald) + + useTheme(brand = "emerald", appearance = WidgetAppearance.SYSTEM) + assertNotEquals(emerald, WidgetPickerPreviews.lookSignature(context)) + + useTheme(brand = "emerald", showsArtwork = WidgetShowsArtwork.SHAPES) + assertNotEquals(emerald, WidgetPickerPreviews.lookSignature(context)) + } + + @Test + fun beforeAndroid15NothingIsPublishedAndTheStaticPreviewsStay() = runTest { + WidgetPickerPreviews.refresh(context) + assertTrue(context.getSharedPreferences(WidgetPickerPreviews.PREFS_NAME, Context.MODE_PRIVATE).all.isEmpty()) + } + + @Test + @Config(sdk = [35]) + fun onAndroid15PublishingNeverThrowsAndARejectedPreviewIsTriedAgain() = runTest { + useTheme(brand = "violet") + WidgetPickerPreviews.refresh(context) + val published = context.getSharedPreferences(WidgetPickerPreviews.PREFS_NAME, Context.MODE_PRIVATE).all + // Only previews the system accepted are remembered; the rest stay due for the next call. + assertTrue(published.values.all { it == WidgetPickerPreviews.lookSignature(context) }) + } + + private fun useTheme( + brand: String, + appearance: String = WidgetAppearance.APP, + showsArtwork: String = WidgetShowsArtwork.ROUNDED, + ) { + context + .getSharedPreferences(FontRoundnessAxis.THEME_FAST_CACHE, Context.MODE_PRIVATE) + .edit() + .putString("theme_brand", brand) + .putString("theme_config", "dark") + .putString("surface_style", "classic_dynamic") + .putBoolean("use_dynamic_color", false) + .putString(WidgetAppearance.PREF_KEY, appearance) + .putString(WidgetShowsArtwork.PREF_KEY, showsArtwork) + .commit() + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt new file mode 100644 index 000000000..a9a550d21 --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt @@ -0,0 +1,299 @@ +package cx.aswin.boxlore.feature.widgets + +import android.appwidget.AppWidgetHostView +import android.appwidget.AppWidgetManager +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.AbsListView +import android.widget.BaseAdapter +import android.widget.FrameLayout +import android.widget.RemoteViews +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.prefs.FontRoundnessAxis +import cx.aswin.boxlore.core.prefs.WidgetAppearance +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork +import cx.aswin.boxlore.feature.widgets.logic.WidgetSize +import java.io.File +import kotlin.math.roundToInt +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Applies every widget in every state and size the way a launcher would, and draws it. A view id or a + * remote call that does not match the layout fails here instead of leaving a blank widget on a phone. + * + * Set `BOXLORE_WIDGET_RENDERS=1` to also save each drawing under `build/reports/widget-renders/` for + * a visual check against the design. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w412dp-h915dp-420dpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class WidgetRenderGalleryTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val density: Float get() = context.resources.displayMetrics.density + + private class Size(val name: String, val widthDp: Int, val heightDp: Int) + + @Test + fun playbackWidgetsDrawInEveryStateSizeAndTheme() { + val art = artworkFile() + val states = + listOf( + "playing" to playing(art, isPlaying = true, positionMs = 24 * MINUTE, durationMs = 60 * MINUTE), + "paused" to + playing(art, isPlaying = false, positionMs = 14 * MINUTE, durationMs = 86 * MINUTE).copy( + episodeTitle = "The surprisingly long history of the humble paperclip, part two", + podcastTitle = "Paper Trails", + ), + "no-art" to playing(null, isPlaying = true, positionMs = 0L, durationMs = 0L), + "resting" to NowPlayingWidgetSnapshot(), + ) + val sizes = + mapOf( + WidgetVariant.NOW_PLAYING to + listOf(Size("4x2", 350, 182), Size("min", 245, 100), Size("roomy", 380, 220), Size("tall", 350, 278), Size("wide", 530, 182)), + WidgetVariant.BAR to listOf(Size("4x1", 350, 86), Size("min", 245, 48), Size("tallcell", 380, 112), Size("wide", 530, 86)), + WidgetVariant.CONTROLS to listOf(Size("2x2", 170, 182), Size("min", 110, 110), Size("wide", 350, 182)), + WidgetVariant.CONTROLS_NEXT to listOf(Size("2x2", 170, 182)), + ) + val cases = + sizes.flatMap { (variant, variantSizes) -> + variantSizes.flatMap { size -> states.map { (stateName, snapshot) -> PlaybackCase(variant, size, stateName, snapshot) } } + } + for (dark in listOf(false, true)) { + useAppTheme(dark) + cases.forEach { case -> drawPlayback(case, dark) } + } + } + + @Test + fun playbackWidgetsStillFitWhenTheLauncherReportsAStaleSize() { + val art = artworkFile() + val snapshot = + playing(art, isPlaying = true, positionMs = 24 * MINUTE, durationMs = 60 * MINUTE) + .copy(episodeTitle = "The surprisingly long history of the humble paperclip, part two") + useAppTheme(dark = false) + // Reported as a regular size but drawn at another: the launcher picks the layout for the space it has. + val reported = mapOf(WidgetVariant.NOW_PLAYING to Size("r", 350, 182), WidgetVariant.BAR to Size("r", 350, 86), WidgetVariant.CONTROLS to Size("r", 170, 182)) + val drawn = listOf(Size("small", 245, 110), Size("squat", 320, 70), Size("big", 400, 250)) + for ((variant, said) in reported) { + for (actual in drawn) { + val views = NowPlayingWidgetRenderer.buildRemoteViewsAt(context, 7, snapshot, options(said), variant, drawn = WidgetSize(actual.widthDp, actual.heightDp)) + save(draw(views, actual, dark = false), "stale-${variant.name.lowercase()}-${actual.name}") + // And the worst case: a launcher that draws the layout picked for the report anyway. + val wrongPick = NowPlayingWidgetRenderer.buildRemoteViewsAt(context, 7, snapshot, options(said), variant) + save(draw(wrongPick, actual, dark = false), "wrongpick-${variant.name.lowercase()}-${actual.name}") + } + } + } + + private class PlaybackCase(val variant: WidgetVariant, val size: Size, val state: String, val snapshot: NowPlayingWidgetSnapshot) + + private fun drawPlayback(case: PlaybackCase, dark: Boolean) { + val views = + NowPlayingWidgetRenderer.buildRemoteViewsAt( + context = context, + appWidgetId = 7, + snapshot = case.snapshot, + options = options(case.size), + variant = case.variant, + ) + save(draw(views, case.size, dark), "${case.variant.name.lowercase()}-${case.size.name}-${case.state}-${themeName(dark)}") + } + + @Test + fun libraryWidgetsDrawWithRowsTilesAndRestingStates() { + val art = artworkFile().absolutePath + val full = + LibraryWidgetSnapshot( + subscriptions = + (1..14).map { index -> + WidgetShowRow( + podcastId = "show-$index", + title = "Show $index", + // Every third show has no cover yet, so placeholders are drawn too. + artworkCachePath = art.takeIf { index % 3 != 0 }, + deepLinkUri = "boxlore://podcast/show-$index", + isNew = index % 4 == 1, + ) + }, + newEpisodes = + listOf("Why curiosity compounds" to "The Daily Lore", "A quiet week in Kyoto" to "Field Notes", "How maps shape memory" to "Soft Power", "The last night bus" to "Night Shift Radio", "Sourdough, slowly" to "Small Batch") + .mapIndexed { index, (episode, show) -> + WidgetEpisodeRow( + episodeId = "episode-$index", + episodeTitle = episode, + podcastId = "show-$index", + podcastTitle = show, + artworkCachePath = art.takeIf { index != 2 }, + deepLinkUri = "boxlore://episode/episode-$index", + ) + }, + counts = WidgetLibraryCounts(subscriptions = 24, newEpisodes = 12), + ) + val sizes = listOf(Size("4x3", 350, 278), Size("narrow", 260, 278), Size("tall", 350, 374)) + // New Episodes has one look; Your Shows is drawn as whole covers and as mixed shapes. + val looks = + listOf( + LibraryLook(LibraryWidgetKind.NEW_EPISODES, WidgetShowsArtwork.ROUNDED, "new_episodes"), + LibraryLook(LibraryWidgetKind.SUBSCRIPTIONS, WidgetShowsArtwork.ROUNDED, "subscriptions-covers"), + LibraryLook(LibraryWidgetKind.SUBSCRIPTIONS, WidgetShowsArtwork.SHAPES, "subscriptions-shapes"), + ) + for (dark in listOf(false, true)) { + useAppTheme(dark) + for (look in looks) { + setShowsArtwork(look.artwork) + sizes.forEach { size -> save(drawLibrary(look.kind, full, size, dark), "${look.name}-${size.name}-${themeName(dark)}") } + } + LibraryWidgetKind.entries.forEach { kind -> + save(drawLibrary(kind, LibraryWidgetSnapshot(), sizes.first(), dark), "${kind.name.lowercase()}-resting-${themeName(dark)}") + } + } + } + + private class LibraryLook(val kind: LibraryWidgetKind, val artwork: String, val name: String) + + @Test + fun pickerPreviewsDrawBothLiveAndStatic() { + val picker = + mapOf( + "full" to (R.layout.widget_preview_control_4x2 to Size("picker", 350, 182)), + "compact" to (R.layout.widget_preview_bar_4x1 to Size("picker", 350, 86)), + "round" to (R.layout.widget_preview_grid_2x2 to Size("picker", 170, 170)), + "round_next" to (R.layout.widget_preview_grid_next_2x2 to Size("picker", 170, 170)), + "shows" to (R.layout.widget_preview_subscriptions to Size("picker", 350, 278)), + "new_episodes" to (R.layout.widget_preview_new_episodes to Size("picker", 350, 278)), + ) + for (dark in listOf(false, true)) { + useAppTheme(dark) + setShowsArtwork(WidgetShowsArtwork.ROUNDED) + for (preview in WidgetPickerPreviews.previews()) { + val (staticLayout, size) = picker.getValue(preview.key) + // Live: what Android 15 and later show, drawn by the widget's own renderer. + // Drawn in a real widget host: rows a preview carries with it are only shown inside one. + save(draw(preview.build(context), size, dark, host = AppWidgetHostView(context)), "picker-live-${preview.key}-${themeName(dark)}") + // Static: the fallback layout older versions inflate. + save(draw(RemoteViews(context.packageName, staticLayout), size, dark), "picker-static-${preview.key}-${themeName(dark)}") + } + } + } + + private fun drawLibrary(kind: LibraryWidgetKind, snapshot: LibraryWidgetSnapshot, size: Size, dark: Boolean): Bitmap { + LibraryWidgetSnapshotStore(context).write(snapshot) + val views = LibraryWidgetRenderer.build(context, appWidgetId = 9, snapshot = snapshot, kind = kind) + val factory = LibraryWidgetRemoteViewsFactory(context, kind).apply { onDataSetChanged() } + return draw(views, size, dark) { root -> + // A launcher binds the collection through the remote service; here the factory feeds it directly. + val collection = root.findViewById(R.id.widget_list_view) + collection.emptyView = root.findViewById(R.id.widget_empty_container) + collection.adapter = + object : BaseAdapter() { + override fun getCount(): Int = factory.count + + override fun getItem(position: Int): Any = position + + override fun getItemId(position: Int): Long = factory.getItemId(position) + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View = factory.getViewAt(position).apply(context, parent) + } + } + } + + private fun draw( + views: RemoteViews, + size: Size, + dark: Boolean, + host: FrameLayout = FrameLayout(context), + prepare: (View) -> Unit = {}, + ): Bitmap { + val width = (size.widthDp * density).roundToInt() + val height = (size.heightDp * density).roundToInt() + val root = views.apply(context, host) + host.addView(root, FrameLayout.LayoutParams(width, height)) + prepare(root) + host.measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY), + ) + host.layout(0, 0, width, height) + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + Canvas(bitmap).apply { + // A stand-in wallpaper, so the gaps between shapes read as on a home screen. + drawColor(if (dark) Color.rgb(12, 13, 19) else Color.rgb(205, 208, 232)) + host.draw(this) + } + assertTrue("the widget drew something", bitmap.width > 0 && bitmap.height > 0) + return bitmap + } + + private fun save(bitmap: Bitmap, name: String) { + if (System.getenv("BOXLORE_WIDGET_RENDERS") != "1") return + val directory = File("build/reports/widget-renders").apply { mkdirs() } + File(directory, "$name.png").outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + } + + private fun options(size: Size): Bundle = Bundle().apply { + putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, size.widthDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, size.widthDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, size.heightDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, size.heightDp) + } + + private fun playing(art: File?, isPlaying: Boolean, positionMs: Long, durationMs: Long) = NowPlayingWidgetSnapshot( + episodeId = "episode-1", + episodeTitle = "Why curiosity compounds", + podcastTitle = "The Daily Lore", + artworkCachePath = art?.absolutePath, + isPlaying = isPlaying, + positionMs = positionMs, + durationMs = durationMs, + seekForwardMs = 30_000L, + seekBackwardMs = 10_000L, + ) + + /** A flat cover with a sun, like the stand-in art on the design boards. */ + private fun artworkFile(): File { + val file = File(context.cacheDir, "gallery-cover.png") + val bitmap = Bitmap.createBitmap(COVER_PX, COVER_PX, Bitmap.Config.ARGB_8888) + Canvas(bitmap).apply { + drawColor(Color.rgb(216, 73, 42)) + drawCircle(COVER_PX * 0.66f, COVER_PX * 0.36f, COVER_PX * 0.22f, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(255, 226, 168) }) + } + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + return file + } + + private fun themeName(dark: Boolean): String = if (dark) "dark" else "light" + + private fun useAppTheme(dark: Boolean) { + themeCache() + .edit() + .putString("theme_brand", "violet") + .putString("theme_config", if (dark) "dark" else "light") + .putString("surface_style", "classic_dynamic") + .putBoolean("use_dynamic_color", false) + .putString(WidgetAppearance.PREF_KEY, WidgetAppearance.APP) + .commit() + } + + private fun setShowsArtwork(style: String) { + themeCache().edit().putString(WidgetShowsArtwork.PREF_KEY, style).commit() + } + + private fun themeCache() = context.getSharedPreferences(FontRoundnessAxis.THEME_FAST_CACHE, Context.MODE_PRIVATE) + + private companion object { + const val MINUTE = 60_000L + const val COVER_PX = 256 + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetShapesTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetShapesTest.kt new file mode 100644 index 000000000..08a71032c --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetShapesTest.kt @@ -0,0 +1,66 @@ +package cx.aswin.boxlore.feature.widgets + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WidgetShapesTest { + @Test + fun anUnstartedEpisodeShowsOnlyTheFullTrack() { + val arcs = WidgetRingArcs.of(fraction = 0f, gapDegrees = 9f) + assertNull(arcs.played) + assertEquals(WidgetRingArcs.Arc(0f, 360f), arcs.remaining) + } + + @Test + fun thePlayedArcAndTheRestAreSeparatedByAGapAtBothEnds() { + val gap = 9f + val arcs = WidgetRingArcs.of(fraction = 0.4f, gapDegrees = gap) + val played = arcs.played!! + val remaining = arcs.remaining!! + + assertEquals(144f, played.sweepDegrees, 0.01f) + assertEquals(played.sweepDegrees + gap, remaining.startDegrees, 0.01f) + // The rest stops one gap short of where the played arc began. + assertEquals(360f - gap, remaining.startDegrees + remaining.sweepDegrees, 0.01f) + } + + @Test + fun aSliverOfProgressStillShowsAndTheEndClosesTheRing() { + val started = WidgetRingArcs.of(fraction = 0.0005f, gapDegrees = 9f) + assertTrue(started.played!!.sweepDegrees >= 2f) + assertNotNull(started.remaining) + + val nearlyDone = WidgetRingArcs.of(fraction = 0.99f, gapDegrees = 9f) + assertEquals(WidgetRingArcs.Arc(0f, 360f), nearlyDone.played) + assertNull(nearlyDone.remaining) + } + + @Test + fun badInputNeverDrawsOutsideTheRing() { + assertNull(WidgetRingArcs.of(Float.NaN, 9f).played) + assertEquals(WidgetRingArcs.Arc(0f, 360f), WidgetRingArcs.of(7f, 9f).played) + assertNull(WidgetRingArcs.of(-3f, 9f).played) + } + + @Test + fun mixedShapesCycleAndNeverPutTheSameShapeSideBySide() { + val firstRow = (0 until 4).map(WidgetShapes::mixedShapeAt) + assertEquals(4, firstRow.toSet().size) + for (position in 0 until 48) { + assertTrue( + "tiles $position and ${position + 1} share a shape", + WidgetShapes.mixedShapeAt(position) != WidgetShapes.mixedShapeAt(position + 1), + ) + } + assertEquals(WidgetShapes.mixedShapeAt(0), WidgetShapes.mixedShapeAt(12)) + } + + @Test + fun wholeCoversAreTheDefaultTileAndShapesAreOptIn() { + assertEquals(WidgetArtShape.ROUNDED, LibraryWidgetRemoteViewsFactory.tileShape(mixedShapes = false, position = 3)) + assertEquals(WidgetShapes.mixedShapeAt(3), LibraryWidgetRemoteViewsFactory.tileShape(mixedShapes = true, position = 3)) + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetTextBitmapRendererTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetTextBitmapRendererTest.kt deleted file mode 100644 index 0a15e761f..000000000 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetTextBitmapRendererTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -package cx.aswin.boxlore.feature.widgets - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [31]) -class WidgetTextBitmapRendererTest { - private val context: Context = ApplicationProvider.getApplicationContext() - - @Test - fun renderedLabelHasExactBoundsAndVisiblePixels() { - val bitmap = - WidgetTextBitmapRenderer.render( - context = context, - spec = - WidgetTextBitmapRenderer.Spec( - text = "A rounded episode title", - widthDp = 180, - heightDp = 48, - preferredSizeSp = 22f, - minSizeSp = 14f, - weight = 600, - maxLines = 2, - ), - colorRes = R.color.widget_on_surface, - ) - val density = context.resources.displayMetrics.density - - assertEquals((180 * density).toInt(), bitmap.width) - assertEquals((48 * density).toInt(), bitmap.height) - } -} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogicTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogicTest.kt index 609b398f2..304221493 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogicTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogicTest.kt @@ -97,4 +97,55 @@ class NowPlayingWidgetLogicTest { assertEquals(25_000L, back.positionMs) assertEquals(70_000L, forward.positionMs) } + + @Test + fun `update policy repaints when the ring or the time left moves, not on every tick`() { + val minute = 60_000L + val base = + NowPlayingWidgetSnapshot( + episodeId = "ep-1", + episodeTitle = "Episode", + podcastTitle = "Show", + isPlaying = true, + positionMs = 10 * minute, + durationMs = 60 * minute, + ) + + // Half a second later nothing a listener can see has changed. + assertFalse(WidgetUpdatePolicy.shouldRender(base, base.copy(positionMs = base.positionMs + 500L))) + // A minute later the label counts down. + assertTrue(WidgetUpdatePolicy.shouldRender(base, base.copy(positionMs = base.positionMs + minute))) + // Seeking back repaints too. + assertTrue(WidgetUpdatePolicy.shouldRender(base, base.copy(positionMs = base.positionMs - 2 * minute))) + // The length arriving late turns the ring on. + assertTrue(WidgetUpdatePolicy.shouldRender(base.copy(durationMs = 0L), base)) + } + + @Test + fun `update policy repaints when the seek lengths change, since the icons show them`() { + val base = NowPlayingWidgetSnapshot(episodeId = "ep-1", seekForwardMs = 30_000L, seekBackwardMs = 10_000L) + assertTrue(WidgetUpdatePolicy.shouldRender(base, base.copy(seekForwardMs = 45_000L))) + assertTrue(WidgetUpdatePolicy.shouldRender(base, base.copy(seekBackwardMs = 15_000L))) + assertFalse(WidgetUpdatePolicy.shouldRender(base, base.copy())) + } + + @Test + fun `content description says how long is left`() { + val minute = 60_000L + val snapshot = + NowPlayingWidgetSnapshot( + episodeId = "ep-1", + episodeTitle = "Episode", + podcastTitle = "Show", + isPlaying = true, + positionMs = 24 * minute, + durationMs = 60 * minute, + ) + assertEquals("Episode from Show, playing, 36 minutes left", WidgetSemantics.contentDescription(snapshot)) + assertEquals( + "Episode from Show, paused, 1 minute left", + WidgetSemantics.contentDescription(snapshot.copy(isPlaying = false, positionMs = 59 * minute + 30_000L)), + ) + assertEquals("Episode from Show, playing", WidgetSemantics.contentDescription(snapshot.copy(durationMs = 0L))) + } } diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetPresentationTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetPresentationTest.kt new file mode 100644 index 000000000..ca2d1c195 --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetPresentationTest.kt @@ -0,0 +1,105 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WidgetPresentationTest { + private fun full(heightDp: Int, widthDp: Int = 350) = WidgetPresentation.fullControls(WidgetSize(widthDp, heightDp)) + + private fun bar(heightDp: Int, widthDp: Int = 350) = WidgetPresentation.bar(WidgetSize(widthDp, heightDp)) + + @Test + fun fullControlsDropsTheShowNameFirstThenTheTimeAndNeverTheTitle() { + // A roomy widget shows everything. + assertEquals(WidgetPresentation.Meta.TIME_AND_SHOW, full(182).meta) + assertEquals(2, full(182).titleLines) + // A shorter one keeps the title and the time left. + assertEquals(WidgetPresentation.Meta.TIME, full(110).meta) + assertEquals(1, full(110).titleLines) + // The shortest keeps only the episode title. + assertEquals(WidgetPresentation.Meta.NONE, full(80).meta) + assertEquals(1, full(80).titleLines) + } + + @Test + fun fullControlsNeverGainsLinesAsItShrinksAndNeverLosesThemAsItGrows() { + for (width in listOf(245, 350, 620)) { + var lines = Int.MAX_VALUE + var meta = WidgetPresentation.Meta.TIME_AND_SHOW.ordinal + for (height in 420 downTo 60) { + val show = full(height, width) + assertTrue("title lines grew while shrinking to ${width}x$height", show.titleLines <= lines) + assertTrue("the line under the title grew while shrinking to ${width}x$height", show.meta.ordinal >= meta) + assertTrue(show.titleLines >= 1) + lines = show.titleLines + meta = show.meta.ordinal + } + } + } + + @Test + fun fullControlsChangesShapeOnTheWayUpAndBackDown() { + val regular = full(182) + val tall = full(278) + + assertFalse(regular.tall) + assertTrue(tall.tall) + assertEquals(3, tall.titleLines) + // Resizing back gives the same answer as before. + assertEquals(regular, full(182)) + } + + @Test + fun aNarrowFullControlsWidgetOnlyShowsWhatItsSmallerFrameHasRoomFor() { + // In a narrow, tall cell the frame stops at 60% of the width, so the cell's height adds nothing. + assertEquals(full(147, widthDp = 245), full(220, widthDp = 245)) + assertEquals(1, full(220, widthDp = 245).titleLines) + assertEquals(2, full(220, widthDp = 350).titleLines) + } + + @Test + fun compactPlayerShowsTheTimeLeftOnlyWithRoom() { + val slim = bar(52) + val regular = bar(86) + + assertEquals(1, slim.titleLines) + assertFalse(slim.showTimeLeft) + assertEquals(2, regular.titleLines) + assertTrue(regular.showTimeLeft) + assertEquals(regular, bar(86)) + } + + @Test + fun compactPlayerStaysSlimInTallAndNarrowCells() { + // The pill is never taller than a quarter of its width, however tall the cell is. + assertEquals(bar(87), bar(160)) + assertEquals(bar(61, widthDp = 245), bar(160, widthDp = 245)) + assertFalse("a narrow pill has no room for the time left", bar(160, widthDp = 245).showTimeLeft) + } + + @Test + fun everySizeStepShowsExactlyWhatAWidgetOfAnySizeInItsRangeShows() { + // The launcher draws the largest step that fits, so that step must stand for every size it covers. + for (width in 100..700 step 7) { + for (height in 40..420 step 5) { + val drawn = WidgetSize(width, height) + val fullStep = WidgetPresentation.stepFor(WidgetPresentation.FULL_CONTROLS_STEPS, drawn) + assertEquals("full controls at ${width}x$height", WidgetPresentation.fullControls(drawn), WidgetPresentation.fullControls(fullStep)) + val barStep = WidgetPresentation.stepFor(WidgetPresentation.BAR_STEPS, drawn) + assertEquals("compact at ${width}x$height", WidgetPresentation.bar(drawn), WidgetPresentation.bar(barStep)) + } + } + } + + @Test + fun sizeStepsGrowOneInsideTheNextSoTheLargestThatFitsIsAlwaysTheClosest() { + for (steps in listOf(WidgetPresentation.FULL_CONTROLS_STEPS, WidgetPresentation.BAR_STEPS)) { + assertEquals(WidgetSize(0, 0), steps.first()) + steps.zipWithNext { smaller, larger -> + assertTrue("$smaller fits inside $larger", smaller.widthDp <= larger.widthDp && smaller.heightDp <= larger.heightDp && smaller != larger) + } + } + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressPollerTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressPollerTest.kt new file mode 100644 index 000000000..8a1d72b3f --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressPollerTest.kt @@ -0,0 +1,56 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class WidgetProgressPollerTest { + private val interval = 20_000L + + @Test + fun asksForThePositionAtEachIntervalOnlyWhilePlaying() = runTest { + val playing = MutableStateFlow(false) + var refreshes = 0 + val job = launch { WidgetProgressPoller.run(playing, interval, hasWidgets = { true }) { refreshes++ } } + + advanceTimeBy(interval * 3) + assertEquals("paused: nothing to refresh", 0, refreshes) + + playing.value = true + runCurrent() + advanceTimeBy(interval * 3 + 1) + assertEquals(3, refreshes) + + // Pausing stops the polling straight away, and playing again starts a fresh interval. + playing.value = false + advanceTimeBy(interval * 5) + assertEquals(3, refreshes) + playing.value = true + runCurrent() + advanceTimeBy(interval + 1) + assertEquals(4, refreshes) + job.cancel() + } + + @Test + fun doesNotTouchThePlayerWhileNoPlaybackWidgetIsPlaced() = runTest { + var placed = false + var refreshes = 0 + val job = launch { WidgetProgressPoller.run(MutableStateFlow(true), interval, hasWidgets = { placed }) { refreshes++ } } + + advanceTimeBy(interval * 4 + 1) + assertEquals(0, refreshes) + + // Placing a widget is picked up at the next interval, with no restart needed. + placed = true + advanceTimeBy(interval) + assertEquals(1, refreshes) + job.cancel() + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressTest.kt new file mode 100644 index 000000000..5c550829f --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressTest.kt @@ -0,0 +1,44 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class WidgetProgressTest { + private val minute = 60_000L + + @Test + fun fractionIsZeroUntilTheLengthIsKnownAndNeverLeavesItsRange() { + assertEquals(0f, WidgetProgress.fraction(positionMs = 5 * minute, durationMs = 0L), 0f) + assertEquals(0.25f, WidgetProgress.fraction(15 * minute, 60 * minute), 0.0001f) + assertEquals(1f, WidgetProgress.fraction(90 * minute, 60 * minute), 0f) + assertEquals(0f, WidgetProgress.fraction(-1L, 60 * minute), 0f) + } + + @Test + fun minutesLeftRoundsUpSoItOnlyReachesZeroAtTheEnd() { + assertNull(WidgetProgress.minutesLeft(0L, 0L)) + assertEquals(60, WidgetProgress.minutesLeft(0L, 60 * minute)) + assertEquals(36, WidgetProgress.minutesLeft(24 * minute, 60 * minute)) + assertEquals(1, WidgetProgress.minutesLeft(60 * minute - 1_000L, 60 * minute)) + assertEquals(0, WidgetProgress.minutesLeft(60 * minute, 60 * minute)) + } + + @Test + fun timeLeftSplitsIntoHoursAndMinutesAndDisappearsWhenNothingIsLeft() { + assertEquals(WidgetProgress.TimeLeft(hours = 0, minutes = 36), WidgetProgress.timeLeft(24 * minute, 60 * minute)) + assertEquals(WidgetProgress.TimeLeft(hours = 1, minutes = 12), WidgetProgress.timeLeft(14 * minute, 86 * minute)) + assertEquals(WidgetProgress.TimeLeft(hours = 2, minutes = 0), WidgetProgress.timeLeft(0L, 120 * minute)) + assertNull(WidgetProgress.timeLeft(60 * minute, 60 * minute)) + assertNull(WidgetProgress.timeLeft(10 * minute, 0L)) + } + + @Test + fun ringStepsAreCoarseEnoughToIgnoreSecondBySecondTicks() { + val duration = 60 * minute + val step = WidgetProgress.ringStep(10 * minute, duration) + assertEquals(step, WidgetProgress.ringStep(10 * minute + 500L, duration)) + assertEquals(step + 1, WidgetProgress.ringStep(10 * minute + 30_000L, duration)) + assertEquals(WidgetProgress.RING_STEPS, WidgetProgress.ringStep(duration, duration)) + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetSizesTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetSizesTest.kt new file mode 100644 index 000000000..9881bbbef --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetSizesTest.kt @@ -0,0 +1,36 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WidgetSizesTest { + private val fallback = WidgetSize(250, 110) + + @Test + fun portraitIsNarrowAndTallLandscapeIsWideAndShort() { + val sizes = WidgetSizes.of(minWidthDp = 350, maxWidthDp = 720, minHeightDp = 120, maxHeightDp = 182, fallback = fallback) + assertEquals(WidgetSize(350, 182), sizes.portrait) + assertEquals(WidgetSize(720, 120), sizes.landscape) + // Pictures are drawn for the most space the widget may get. + assertEquals(WidgetSize(720, 182), sizes.largest) + assertEquals(182, sizes.largest.shortSide) + } + + @Test + fun missingValuesFallBackInsteadOfCollapsingTheWidget() { + val nothing = WidgetSizes.of(0, 0, 0, 0, fallback) + assertEquals(fallback, nothing.portrait) + assertEquals(fallback, nothing.landscape) + + val onlyMinimums = WidgetSizes.of(minWidthDp = 300, maxWidthDp = 0, minHeightDp = 90, maxHeightDp = 0, fallback = fallback) + assertEquals(WidgetSize(300, 90), onlyMinimums.portrait) + assertEquals(WidgetSize(300, 90), onlyMinimums.landscape) + } + + @Test + fun swappedReportsAreStillReadTheRightWayRound() { + val sizes = WidgetSizes.of(minWidthDp = 720, maxWidthDp = 350, minHeightDp = 182, maxHeightDp = 120, fallback = fallback) + assertEquals(WidgetSize(350, 182), sizes.portrait) + assertEquals(WidgetSize(720, 120), sizes.landscape) + } +} diff --git a/release-notes/upcoming.md b/release-notes/upcoming.md index 32984030e..0d0e68842 100644 --- a/release-notes/upcoming.md +++ b/release-notes/upcoming.md @@ -26,8 +26,13 @@ Announcement title: ## Improved +- Home screen widgets have a new look with rounder shapes. The players now show how far you are and how much time is left, and the skip buttons show your own skip lengths. +- The Your Shows widget is now a grid of artwork. Choose whole covers or shapes in Settings › Appearance › Widgets. +- See every boxlore widget and add it to your home screen from Settings › Appearance › Widgets. - On Home, a show you listen to oldest first now picks up exactly where you left off, even deep into a long series, and skips trailers. Daily mix suggests the same next episode. ## Fixed +- Widgets that follow your phone's light and dark mode now switch with it, even when boxlore isn't open. +- The Your Shows and New Episodes widgets now count your whole library instead of stopping at 50. - On Home, a show you've heard every episode of now lists its latest episodes instead of an empty list, and a show whose episodes can't load lets you try again. From 301474e4fecd0fc94275c8cb241655b4c4e3ddb6 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Wed, 11 Nov 2026 01:54:50 +0530 Subject: [PATCH 2/9] feat(settings): keep the cover choice with the Your Shows preview and explain blocked adds --- .../NavGraphSettingsDestinations.kt | 1 + feature/settings/README.md | 2 +- .../settings/SettingsExternalActions.kt | 14 ++ .../settings/pages/AppearanceSettingsPage.kt | 2 + .../settings/pages/AppearanceWidgetsPage.kt | 141 +++++++++++++++--- .../pages/AppearanceWidgetsPageTest.kt | 64 +++++++- .../feature/widgets/HomeScreenWidgets.kt | 13 +- .../feature/widgets/HomeScreenWidgetsTest.kt | 2 + 8 files changed, 205 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt index b362cb608..6c5dbf9e4 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt @@ -181,6 +181,7 @@ internal fun addableWidgets(context: android.content.Context): List WidgetGallery(state.addableWidgets, canAdd, actions.onAddWidget, preview) + preview != null -> WidgetGallery(state, actions, canAdd, preview) canAdd -> WidgetRows(state.addableWidgets, actions.onAddWidget) else -> SettingsInfoTip(ADD_WIDGETS_BY_HAND) } @@ -91,26 +105,44 @@ internal fun AppearanceWidgetsPage( } } -/** Every widget as a picture with its name. The Add buttons are there only when the home screen app can add. */ +@Composable +private fun ShowsCoverChoice(state: AppearanceUiState, actions: AppearanceActions) { + ConnectedOptionSelector( + options = listOf(WidgetShowsArtwork.ROUNDED to "Whole covers", WidgetShowsArtwork.SHAPES to "Shapes"), + selected = WidgetShowsArtwork.sanitize(state.currentWidgetShowsArtwork), + onSelect = actions.onSetWidgetShowsArtwork, + ) +} + +/** + * Every widget as a picture with its name. The Add buttons are there only when the home screen app + * can add; a request the phone turns down without a word is answered in that widget's card. + */ @Composable private fun WidgetGallery( - widgets: List, + state: AppearanceUiState, + actions: AppearanceActions, canAdd: Boolean, - onAdd: (String) -> Unit, preview: AppearanceWidgetPreview, ) { + val add = rememberAddWidgetRequest(actions.onAddWidget) SettingsTileSection( title = if (canAdd) "Add to home screen" else "boxlore widgets", footer = if (canAdd) ADD_WIDGETS_CONFIRM else null, ) { if (!canAdd) SettingsInfoTip(ADD_WIDGETS_BY_HAND) - for (row in widgetGalleryRows(widgets)) { - if (row.size == 1) { - WidgetCard(row.single(), sideBySide = false, onAdd = onAdd.takeIf { canAdd }, preview = preview) - } else { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - row.forEach { widget -> - WidgetCard(widget, sideBySide = true, onAdd = onAdd.takeIf { canAdd }, preview = preview, modifier = Modifier.weight(1f)) + for (row in widgetGalleryRows(state.addableWidgets)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + row.forEach { widget -> + WidgetCard( + widget = widget, + sideBySide = row.size > 1, + onAdd = add.request.takeIf { canAdd }, + unanswered = add.unansweredKey == widget.key, + preview = preview, + modifier = Modifier.weight(1f), + ) { + if (widget.usesShowsCoverStyle) ShowsCoverChoice(state, actions) } } } @@ -118,18 +150,55 @@ private fun WidgetGallery( } } +/** Asks for a widget to be added, and knows which request the phone left unanswered. */ +private class AddWidgetRequest(val request: (String) -> Unit, val unansweredKey: String?) + +/** + * The home screen app answers a request by opening its confirmation over the app, which pauses it. + * Some phones turn the request down without showing anything. So when the app has not been paused + * [ADD_WIDGET_ANSWER_MS] after a request, that request counts as unanswered, until another one is + * answered. + */ +@Composable +private fun rememberAddWidgetRequest(onAdd: (String) -> Unit): AddWidgetRequest { + val lifecycle = LocalLifecycleOwner.current.lifecycle + val scope = rememberCoroutineScope() + var unansweredKey by rememberSaveable { mutableStateOf(null) } + val pausedSinceRequest = remember { mutableStateOf(false) } + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE) pausedSinceRequest.value = true } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + val request: (String) -> Unit = + remember(onAdd) { + { key -> + pausedSinceRequest.value = false + onAdd(key) + scope.launch { + delay(ADD_WIDGET_ANSWER_MS) + unansweredKey = key.takeUnless { pausedSinceRequest.value } + } + } + } + return AddWidgetRequest(request, unansweredKey) +} + /** - * One widget: its picture on a tonal stage that stands in for the wallpaper, then its name, what it - * shows and the Add button. Wide widgets put the button beside the text; a small widget sharing its - * row with another puts it underneath, where it still has room at large text sizes. + * One widget: its picture on a tonal stage that stands in for the wallpaper, any [options] that + * change that picture, then its name, what it shows and the Add button. Wide widgets put the button + * beside the text; a small widget sharing its row with another puts it underneath, where it still + * has room at large text sizes. An [unanswered] request explains itself under the button. */ @Composable private fun WidgetCard( widget: AddableWidget, sideBySide: Boolean, onAdd: ((String) -> Unit)?, + unanswered: Boolean, preview: AppearanceWidgetPreview, modifier: Modifier = Modifier, + options: @Composable () -> Unit = {}, ) { Card( modifier = modifier.fillMaxWidth(), @@ -146,6 +215,7 @@ private fun WidgetCard( preview(widget, Modifier.matchParentSize()) } } + options() if (sideBySide) { Column(Modifier.padding(horizontal = 4.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { WidgetCardText(widget) @@ -157,6 +227,22 @@ private fun WidgetCard( if (onAdd != null) AddWidgetButton(widget, onAdd) } } + if (unanswered) AddWidgetUnanswered() + } + } +} + +/** What to do when the phone did not open its confirmation: allow it in the phone's settings, or add by hand. */ +@Composable +private fun AddWidgetUnanswered() { + val context = LocalContext.current + Column( + modifier = Modifier.fillMaxWidth().semantics { liveRegion = LiveRegionMode.Polite }, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + SettingsInfoTip(ADD_WIDGET_UNANSWERED) + TextButton(onClick = { openAppPermissionSettings(context) }, modifier = Modifier.align(Alignment.End)) { + Text("Open app settings") } } } @@ -217,6 +303,13 @@ internal fun widgetGalleryRows(widgets: List): List() private val added = mutableListOf() + private val coverChoices = mutableListOf() private val widgets = listOf( AddableWidget("full", "Now Playing · Full Controls", "The full player", 320, 170), AddableWidget("round", "Now Playing · Round", "A round player", 160, 160), AddableWidget("round_next", "Now Playing · Round with Next", "A round player with next", 160, 160), - AddableWidget("shows", "Library · Your Shows", "Your subscribed shows as a grid of artwork", 320, 250), + AddableWidget("shows", "Library · Your Shows", "Your subscribed shows as a grid of artwork", 320, 290, usesShowsCoverStyle = true), ) private val state = AppearanceUiState( @@ -45,7 +49,8 @@ class AppearanceWidgetsPageTest { private val picture: AppearanceWidgetPreview = { widget, modifier -> Box(modifier.testTag("picture-${widget.key}")) } private fun render(canAdd: Boolean, state: AppearanceUiState = this.state, preview: AppearanceWidgetPreview? = picture) { - val actions = AppearanceActions({}, {}, {}, {}, canAddWidgets = { canAdd }, onAddWidget = { added.add(it) }) + val actions = + AppearanceActions({}, {}, {}, {}, canAddWidgets = { canAdd }, onAddWidget = { added.add(it) }, onSetWidgetShowsArtwork = { coverChoices.add(it) }) compose.setContent { BoxLoreTheme { AppearanceWidgetsPage(state, actions, onBack = {}, preview = preview) } } } @@ -100,13 +105,60 @@ class AppearanceWidgetsPageTest { compose.onNodeWithTag("picture-full", useUnmergedTree = true).assertExists() compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").assertDoesNotExist() // The widget options stay either way. - compose.onNodeWithText("Your Shows covers").assertExists() + compose.onNodeWithText("Whole covers").assertExists() compose.onNodeWithText("Colors").assertExists() } @Test - fun `without pictures each widget is a row that adds it, or the steps when the home screen cannot`() { + fun `the cover choice sits in the Your Shows card, between its picture and its name`() { + render(canAdd = true) + compose.onNodeWithText("Your Shows covers").assertDoesNotExist() + val picture = compose.onNodeWithTag("picture-shows", useUnmergedTree = true).getUnclippedBoundsInRoot() + val choice = compose.onNodeWithText("Shapes").getUnclippedBoundsInRoot() + val name = compose.onNodeWithText("Library · Your Shows").getUnclippedBoundsInRoot() + assertTrue("under the picture it changes", choice.top >= picture.bottom) + assertTrue("above the widget's name", choice.bottom <= name.top) + compose.onNodeWithText("Shapes").performScrollTo().performClick() + assertEquals(listOf(WidgetShowsArtwork.SHAPES), coverChoices) + } + + @Test + fun `a request the phone leaves unanswered explains itself in that widget's card`() { + render(canAdd = true) + compose.mainClock.autoAdvance = false + compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").performScrollTo().performClick() + // Nothing covered the app: the phone turned the request down without a word. + compose.mainClock.advanceTimeBy(ADD_WIDGET_ANSWER_MS + 100) + compose.onNodeWithText(ADD_WIDGET_UNANSWERED).assertExists() + compose.onNodeWithText("Open app settings").assertExists() + val tip = compose.onNodeWithText(ADD_WIDGET_UNANSWERED).getUnclippedBoundsInRoot() + val nextCard = compose.onNodeWithTag("picture-round", useUnmergedTree = true).getUnclippedBoundsInRoot() + assertTrue("inside the card that was tapped", tip.bottom <= nextCard.top) + assertEquals(listOf("full"), added) + } + + @Test + fun `a request the home screen answers shows nothing more, and clears an earlier unanswered one`() { + render(canAdd = true) + compose.mainClock.autoAdvance = false + compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").performScrollTo().performClick() + compose.mainClock.advanceTimeBy(ADD_WIDGET_ANSWER_MS + 100) + compose.onNodeWithText(ADD_WIDGET_UNANSWERED).assertExists() + + compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").performClick() + // The home screen app's confirmation covers the app, then the listener comes back. + compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED) + compose.mainClock.advanceTimeBy(ADD_WIDGET_ANSWER_MS + 100) + compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + compose.mainClock.advanceTimeBy(100) + compose.onNodeWithText(ADD_WIDGET_UNANSWERED).assertDoesNotExist() + assertEquals(listOf("full", "full"), added) + } + + @Test + fun `without pictures each widget is a row that adds it and the cover choice keeps its own group`() { render(canAdd = true, preview = null) + compose.onNodeWithText("Your Shows covers").assertExists() compose.onNodeWithText("Library · Your Shows").performScrollTo().performClick() assertEquals(listOf("shows"), added) } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt index 5fd0eeda6..9153f876e 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt @@ -19,6 +19,8 @@ object HomeScreenWidgets { @param:StringRes val description: Int, val previewWidthDp: Int, val previewHeightDp: Int, + /** Whether this widget draws show covers the way the Your Shows cover setting says. */ + val usesShowsCoverStyle: Boolean, internal val receiver: Class, ) @@ -28,7 +30,7 @@ object HomeScreenWidgets { entry("compact", R.string.now_playing_bar_widget_label, R.string.now_playing_bar_widget_description), entry("round", R.string.playback_controls_widget_label, R.string.playback_controls_widget_description), entry("round_next", R.string.playback_next_controls_widget_label, R.string.playback_next_controls_widget_description), - entry("shows", R.string.subscriptions_widget_label, R.string.subscriptions_widget_description), + entry("shows", R.string.subscriptions_widget_label, R.string.subscriptions_widget_description, usesShowsCoverStyle = true), entry("new_episodes", R.string.new_episodes_widget_label, R.string.new_episodes_widget_description), ) @@ -58,9 +60,14 @@ object HomeScreenWidgets { return runCatching { preview.build(context.applicationContext) }.getOrNull() } - private fun entry(key: String, @StringRes label: Int, @StringRes description: Int): Entry { + private fun entry( + key: String, + @StringRes label: Int, + @StringRes description: Int, + usesShowsCoverStyle: Boolean = false, + ): Entry { val preview = WidgetPickerPreviews.previews().first { it.key == key } - return Entry(key, label, description, preview.size.widthDp, preview.size.heightDp, preview.receiver) + return Entry(key, label, description, preview.size.widthDp, preview.size.heightDp, usesShowsCoverStyle, preview.receiver) } private fun manager(context: Context): AppWidgetManager? = context.applicationContext.getSystemService(AppWidgetManager::class.java) diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt index a2660884c..693d7624e 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt @@ -55,5 +55,7 @@ class HomeScreenWidgetsTest { views!!.apply(context, android.appwidget.AppWidgetHostView(context)) } assertEquals(null, HomeScreenWidgets.preview(context, "no-such-widget")) + // Only Your Shows follows the cover setting, so only it carries that choice in Settings. + assertEquals(listOf("shows"), HomeScreenWidgets.all.filter { it.usesShowsCoverStyle }.map { it.key }) } } From 4ac82bdff5f4853e00f9a5745e79646c6f63220a Mon Sep 17 00:00:00 2001 From: Aswin C Date: Wed, 11 Nov 2026 02:08:16 +0530 Subject: [PATCH 3/9] chore(widgets): move picker previews and the Widgets settings page to a follow-up --- app/README.md | 2 +- .../NavGraphSettingsDestinations.kt | 43 -- .../boxlore/navigation/NavGraphWiring.kt | 1 - .../cx/aswin/boxlore/ui/BoxLoreAppRoot.kt | 4 - .../widgets/HomeScreenWidgetPreview.kt | 84 ---- .../widgets/HomeScreenWidgetsInstaller.kt | 3 - .../boxlore/navigation/AddableWidgetsTest.kt | 31 -- .../widgets/HomeScreenWidgetPreviewTest.kt | 131 ------ feature/settings/README.md | 9 +- .../settings/ProfileSettingsDestination.kt | 3 +- .../settings/SettingsExternalActions.kt | 14 - .../feature/settings/SettingsScreen.kt | 24 +- .../settings/pages/AppearanceSettingsPage.kt | 52 +-- .../settings/pages/AppearanceThemePage.kt | 18 +- .../settings/pages/AppearanceWidgetsPage.kt | 316 ------------- .../settings/AppearanceActionsTrackedTest.kt | 28 -- .../settings/SettingsBackNavigationTest.kt | 1 - .../settings/TabletSettingsCaptureTest.kt | 3 - .../settings/pages/AppearancePagesTest.kt | 7 - .../pages/AppearanceWidgetsPageTest.kt | 181 -------- feature/widgets/README.md | 13 +- .../feature/widgets/HomeScreenWidgets.kt | 74 ---- .../LibraryWidgetRemoteViewsService.kt | 7 +- .../feature/widgets/LibraryWidgetRenderer.kt | 14 +- .../feature/widgets/WidgetPickerPreviews.kt | 250 ----------- .../res/drawable/widget_art_tile_shape.xml | 6 + .../res/drawable/widget_list_art_frame.xml | 6 + .../widget_preview_button_primary.xml | 6 + .../widget_preview_button_secondary.xml | 6 + .../res/drawable/widget_preview_cover_a.xml | 17 +- .../res/drawable/widget_preview_cover_b.xml | 17 +- .../res/drawable/widget_preview_cover_c.xml | 17 +- .../res/drawable/widget_preview_cover_d.xml | 17 - .../res/drawable/widget_preview_cover_e.xml | 14 - .../res/drawable/widget_preview_cover_f.xml | 17 - .../res/drawable/widget_preview_disc_a.xml | 17 - .../res/drawable/widget_preview_disc_b.xml | 17 - .../res/drawable/widget_preview_disc_c.xml | 17 - .../res/drawable/widget_preview_new_dot.xml | 6 +- .../res/drawable/widget_preview_pill_chip.xml | 6 + .../main/res/drawable/widget_preview_ring.xml | 19 - .../res/drawable/widget_preview_row_chip.xml | 6 + .../res/drawable/widget_preview_surface.xml | 7 + .../res/drawable/widget_preview_tile_a.xml | 17 - .../res/drawable/widget_preview_tile_b.xml | 17 - .../res/drawable/widget_preview_tile_c.xml | 17 - .../res/drawable/widget_preview_tile_d.xml | 20 - .../res/drawable/widget_preview_tile_e.xml | 17 - .../res/drawable/widget_preview_tile_f.xml | 20 - .../res/layout/widget_preview_bar_4x1.xml | 194 ++++---- .../res/layout/widget_preview_control_4x2.xml | 297 ++++++------- .../res/layout/widget_preview_grid_2x2.xml | 188 ++++---- .../layout/widget_preview_grid_next_2x2.xml | 188 ++++---- .../layout/widget_preview_new_episodes.xml | 414 +++++++----------- .../layout/widget_preview_subscriptions.xml | 384 ++++++++-------- .../widgets/src/main/res/values/strings.xml | 7 +- .../feature/widgets/HomeScreenWidgetsTest.kt | 61 --- .../widgets/WidgetPickerPreviewsTest.kt | 104 ----- .../widgets/WidgetRenderGalleryTest.kt | 35 +- release-notes/upcoming.md | 3 +- 60 files changed, 854 insertions(+), 2660 deletions(-) delete mode 100644 app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreview.kt delete mode 100644 app/src/test/java/cx/aswin/boxlore/navigation/AddableWidgetsTest.kt delete mode 100644 app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreviewTest.kt delete mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt delete mode 100644 feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt delete mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt delete mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt create mode 100644 feature/widgets/src/main/res/drawable/widget_art_tile_shape.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_list_art_frame.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_button_primary.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_button_secondary.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_cover_d.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_cover_e.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_cover_f.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_disc_a.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_disc_b.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_disc_c.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_pill_chip.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_ring.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_row_chip.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_surface.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_a.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_b.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_c.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_d.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_e.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_f.xml delete mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt delete mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt diff --git a/app/README.md b/app/README.md index feebc6169..6b354c1bd 100644 --- a/app/README.md +++ b/app/README.md @@ -94,7 +94,7 @@ src/main/java/cx/aswin/boxlore/ AdaptivePlayerChrome, announcement/, libraryimport/, logic/ updates/ AppUpdates, UpdateChecker, direct and Play sources, AppUpdatesPanel util/ InstallSource - widgets/ HomeScreenWidgetsInstaller, widget adapters and HomeScreenWidgetPreview (Settings' widget pictures) + widgets/ HomeScreenWidgetsInstaller and widget adapters src/direct/ APK downloader and installer (debug, release, updateTest) src/playRelease/ Play stub installer src/debug/ App Check debug provider, ADB-only notification preview receiver diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt index 6c5dbf9e4..7a23d4d1a 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt @@ -6,8 +6,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.navArgument @@ -26,15 +24,11 @@ import cx.aswin.boxlore.feature.settings.SettingsScreen import cx.aswin.boxlore.feature.settings.SettingsScreenConfig import cx.aswin.boxlore.feature.settings.downloads.AutoDownloadSettingsScreen import cx.aswin.boxlore.feature.settings.downloads.SmartDownloadsSettingsScreen -import cx.aswin.boxlore.feature.settings.pages.AddableWidget import cx.aswin.boxlore.feature.settings.pages.AppearanceActions import cx.aswin.boxlore.feature.settings.pages.AppearanceUiState -import cx.aswin.boxlore.feature.settings.pages.AppearanceWidgetPreview import cx.aswin.boxlore.feature.settings.pages.PlaybackActions import cx.aswin.boxlore.feature.settings.pages.PlaybackUiState -import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets import cx.aswin.boxlore.ui.libraryimport.OpmlImportState -import cx.aswin.boxlore.widgets.HomeScreenWidgetPreview import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged @@ -173,35 +167,6 @@ private fun resetSettingsAnalytics() { } } -/** Every boxlore widget under its picker name, for Appearance › Widgets. Reads strings only. */ -internal fun addableWidgets(context: android.content.Context): List = HomeScreenWidgets.all.map { widget -> - AddableWidget( - key = widget.key, - name = context.getString(widget.label), - description = context.getString(widget.description), - previewWidthDp = widget.previewWidthDp, - previewHeightDp = widget.previewHeightDp, - usesShowsCoverStyle = widget.usesShowsCoverStyle, - ) -} - -/** Appearance › Widgets draws the real widgets, again whenever a setting changes how they look. */ -private fun widgetPreview(w: NavGraphWiring): AppearanceWidgetPreview = { widget, modifier -> - val settings = w.settingsState - val look = - listOf( - settings.widgetAppearance, - settings.widgetShowsArtwork, - settings.themeConfig, - settings.themeBrand, - settings.surfaceStyle, - settings.useDynamicColor, - settings.fontRoundness, - settings.savedCustomTheme, - ) - HomeScreenWidgetPreview(widget.key, DpSize(widget.previewWidthDp.dp, widget.previewHeightDp.dp), look, modifier) -} - private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings { val scope = w.scope val userPrefs = w.userPrefs @@ -218,8 +183,6 @@ private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings { currentOpenAppTo = settingsState.openAppTo, homeShortcutsInLibrary = settingsState.homeShortcutsInLibrary, currentWidgetAppearance = settingsState.widgetAppearance, - currentWidgetShowsArtwork = settingsState.widgetShowsArtwork, - addableWidgets = addableWidgets(w.context), currentExploreDefaultTab = settingsState.exploreDefaultTab, currentSubscriptionsDefaultTab = settingsState.subscriptionsDefaultTab, currentSubscriptionsTabStyle = settingsState.subscriptionsTabStyle, @@ -262,11 +225,6 @@ private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings { onSetWidgetAppearance = { appearance -> scope.launch { userPrefs.setWidgetAppearance(appearance) } }, - onSetWidgetShowsArtwork = { style -> - scope.launch { userPrefs.setWidgetShowsArtwork(style) } - }, - canAddWidgets = { HomeScreenWidgets.canAdd(w.context) }, - onAddWidget = { key -> HomeScreenWidgets.requestAdd(w.context, key) }, onSetExploreDefaultTab = { tab -> scope.launch { userPrefs.setExploreDefaultTab(tab) } }, @@ -288,7 +246,6 @@ private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings { val sessions by w.playbackRepository.resumeSessions.collectAsState(initial = emptyList()) HeroStylePreview(style, coverShade, collection, sessions, modifier, tideProgress = settingsState.heroTideProgress) }, - widgetPreview = widgetPreview(w), ) } diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt index 2c6b28750..5691f2a5e 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt @@ -147,7 +147,6 @@ data class NavSettingsState( val sameShowQueueOnly: Boolean, val homeShortcutsInLibrary: Boolean, val widgetAppearance: String, - val widgetShowsArtwork: String, val exploreDefaultTab: String, val subscriptionsDefaultTab: String, val subscriptionsTabStyle: String = cx.aswin.boxlore.core.prefs.SubscriptionsTabStyle.TOP, diff --git a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt index d3233c679..910cc9be6 100644 --- a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt +++ b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt @@ -433,9 +433,6 @@ fun BoxLoreAppRoot( val widgetAppearance by userPrefs.widgetAppearanceStream.collectAsState( initial = remember { userPrefs.cachedWidgetAppearance }, ) - val widgetShowsArtwork by userPrefs.widgetShowsArtworkStream.collectAsState( - initial = remember { userPrefs.cachedWidgetShowsArtwork }, - ) val exploreDefaultTab by userPrefs.exploreDefaultTabStream.collectAsState( initial = remember { userPrefs.cachedExploreDefaultTab }, ) @@ -713,7 +710,6 @@ fun BoxLoreAppRoot( homeShortcutsInLibrary = homeShortcutsInLibrary, discoverySections = discoverySections, widgetAppearance = widgetAppearance, - widgetShowsArtwork = widgetShowsArtwork, exploreDefaultTab = exploreDefaultTab, subscriptionsDefaultTab = subscriptionsDefaultTab, subscriptionsTabStyle = subscriptionsTabStyle, diff --git a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreview.kt b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreview.kt deleted file mode 100644 index 58c2090e6..000000000 --- a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreview.kt +++ /dev/null @@ -1,84 +0,0 @@ -package cx.aswin.boxlore.widgets - -import android.annotation.SuppressLint -import android.appwidget.AppWidgetHostView -import android.content.Context -import android.view.MotionEvent -import android.view.View -import android.widget.RemoteViews -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.viewinterop.AndroidView -import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * The home screen widget with this [key], drawn by its own renderer with sample content. The widget - * is laid out at its own [size] and that picture is scaled to fit the modifier's bounds, so it looks - * the same at every window width. It is drawn again when [look] changes: pass everything that changes - * how a widget looks (its colours, the lettering, the Your Shows cover style). It is a picture: - * nothing in it can be tapped, and screen readers skip it. - */ -@Composable -internal fun HomeScreenWidgetPreview(key: String, size: DpSize, look: Any, modifier: Modifier = Modifier) { - val context = LocalContext.current - // Light and dark, and the font scale, change what the widget's own layout resolves to. - val configuration = LocalConfiguration.current - val views by produceState(initialValue = null, key, look, configuration) { - value = withContext(Dispatchers.Default) { HomeScreenWidgets.preview(context, key) } - } - BoxWithConstraints(modifier, contentAlignment = Alignment.Center) { - val scale = widgetPictureScale(size, maxWidth, maxHeight) - AndroidView( - factory = ::WidgetPictureHost, - update = { host -> host.show(views) }, - modifier = - Modifier.requiredSize(size).graphicsLayer { - scaleX = scale - scaleY = scale - }, - ) - } -} - -/** How much a widget laid out at [size] is scaled to fit the space it has. It is shrunk to fit, never enlarged. */ -internal fun widgetPictureScale(size: DpSize, maxWidth: Dp, maxHeight: Dp): Float = minOf(1f, maxWidth / size.width, maxHeight / size.height) - -/** - * Holds one widget's views the way a home screen does: a list or grid a widget carries with it is - * only filled inside a widget host. Touches never reach the widget, so its buttons cannot control - * playback from Settings. - */ -@SuppressLint("ViewConstructor") -private class WidgetPictureHost(context: Context) : AppWidgetHostView(context) { - private var shown: RemoteViews? = null - - init { - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS - } - - fun show(views: RemoteViews?) { - if (views === shown) return - shown = views - removeAllViews() - if (views == null) return - // A preview that cannot be drawn leaves its frame empty; the widget's name still stands beside it. - runCatching { addView(views.apply(context, this), LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) } - } - - override fun onInterceptTouchEvent(event: MotionEvent): Boolean = true - - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean = false -} diff --git a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt index 6112c72d0..c94ae2bfb 100644 --- a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt +++ b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt @@ -9,7 +9,6 @@ import cx.aswin.boxlore.feature.widgets.LibraryWidgetCoordinator import cx.aswin.boxlore.feature.widgets.LibraryWidgetDependencies import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetCoordinator import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetDependencies -import cx.aswin.boxlore.feature.widgets.WidgetPickerPreviews import cx.aswin.boxlore.feature.widgets.configureLibraryWidgets import cx.aswin.boxlore.feature.widgets.configureNowPlayingWidget import kotlinx.coroutines.CoroutineScope @@ -71,8 +70,6 @@ object HomeScreenWidgetsInstaller { ) { _, _, _ -> Unit }.collect { NowPlayingWidgetCoordinator.requestRefresh(context) LibraryWidgetCoordinator.requestRefresh(context) - // The widget picker shows the same look the placed widgets now have. - WidgetPickerPreviews.refresh(context) } } } diff --git a/app/src/test/java/cx/aswin/boxlore/navigation/AddableWidgetsTest.kt b/app/src/test/java/cx/aswin/boxlore/navigation/AddableWidgetsTest.kt deleted file mode 100644 index be9afd891..000000000 --- a/app/src/test/java/cx/aswin/boxlore/navigation/AddableWidgetsTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -package cx.aswin.boxlore.navigation - -import android.app.Application -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class AddableWidgetsTest { - private val context: Context = ApplicationProvider.getApplicationContext() - - @Test - fun settingsOffersEveryWidgetUnderItsPickerName() { - val offered = addableWidgets(context) - - assertEquals(HomeScreenWidgets.all.map { it.key }, offered.map { it.key }) - assertEquals("Now Playing · Full Controls", offered.first().name) - assertEquals("Library · New Episodes", offered.last().name) - assertTrue(offered.all { it.name.isNotBlank() && it.description.isNotBlank() }) - assertEquals("every name is told apart", offered.size, offered.map { it.name }.toSet().size) - // The two round players are small enough to sit side by side in Settings. - assertEquals(listOf("round", "round_next"), offered.filter { it.previewWidthDp <= 200 }.map { it.key }) - } -} diff --git a/app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreviewTest.kt b/app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreviewTest.kt deleted file mode 100644 index dc406b812..000000000 --- a/app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreviewTest.kt +++ /dev/null @@ -1,131 +0,0 @@ -package cx.aswin.boxlore.widgets - -import android.app.Application -import android.appwidget.AppWidgetHostView -import android.graphics.Bitmap -import android.graphics.Canvas -import android.os.SystemClock -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import androidx.activity.ComponentActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.times -import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme -import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets -import java.io.File -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** - * The widget pictures Settings shows. Set `BOXLORE_WIDGET_RENDERS=1` to save them under - * `build/reports/widget-previews/` for a look. - */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w412dp-h2000dp-420dpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class HomeScreenWidgetPreviewTest { - @get:Rule val compose = createAndroidComposeRule() - - /** How big each frame is, as a share of its widget's own size: a narrow Settings card is smaller. */ - private val fit = mutableFloatStateOf(1f) - - private fun render(dark: Boolean) { - compose.setContent { - BoxLoreTheme(darkTheme = dark, dynamicColor = false) { - Column(Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - for (widget in HomeScreenWidgets.all) { - val size = DpSize(widget.previewWidthDp.dp, widget.previewHeightDp.dp) - Box(Modifier.background(MaterialTheme.colorScheme.outlineVariant).padding(14.dp)) { - HomeScreenWidgetPreview(widget.key, size, look = dark, Modifier.size(size * fit.floatValue)) - } - } - } - } - } - compose.waitUntil(timeoutMillis = 10_000) { hosts().size == HomeScreenWidgets.all.size && hosts().all { it.childCount == 1 } } - compose.waitForIdle() - } - - private fun hosts(): List { - val found = mutableListOf() - - fun walk(view: View) { - if (view is AppWidgetHostView) { - found += view - } else if (view is ViewGroup) { - for (index in 0 until view.childCount) walk(view.getChildAt(index)) - } - } - walk(compose.activity.window.decorView) - return found - } - - @Test - fun everyWidgetIsLaidOutAtItsOwnSizeHoweverSmallItsFrame() { - render(dark = false) - for (share in listOf(1f, 0.8f)) { - fit.floatValue = share - compose.waitForIdle() - val density = compose.activity.resources.displayMetrics.density - hosts().zip(HomeScreenWidgets.all).forEach { (host, widget) -> - val root = host.getChildAt(0) - assertNotNull("${widget.key} holds its widget", root) - assertEquals("${widget.key} fills its host", host.width, root.width) - // A smaller frame scales the picture; the widget itself keeps its size, so nothing reflows. - assertEquals("${widget.key} is laid out as wide as it is", widget.previewWidthDp * density, host.width.toFloat(), 2f) - assertEquals("${widget.key} is laid out as tall as it is", widget.previewHeightDp * density, host.height.toFloat(), 2f) - } - save(if (share == 1f) "light" else "light-narrow") - } - } - - @Test - fun aPictureIsShrunkToFitItsFrameAndNeverEnlarged() { - val widget = DpSize(320.dp, 170.dp) - assertEquals(1f, widgetPictureScale(widget, maxWidth = 320.dp, maxHeight = 170.dp)) - assertEquals(1f, widgetPictureScale(widget, maxWidth = 700.dp, maxHeight = 400.dp)) - assertEquals(0.8f, widgetPictureScale(widget, maxWidth = 256.dp, maxHeight = 170.dp), 0.001f) - assertEquals(0.5f, widgetPictureScale(widget, maxWidth = 320.dp, maxHeight = 85.dp), 0.001f) - } - - @Test - fun aPictureNeverPassesATouchToTheWidgetsButtons() { - render(dark = true) - for (host in hosts()) { - val now = SystemClock.uptimeMillis() - val down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, host.width / 2f, host.height / 2f, 0) - assertFalse("a tap is not handled inside the picture", host.dispatchTouchEvent(down)) - down.recycle() - assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, host.importantForAccessibility) - } - save("dark") - } - - private fun save(name: String) { - if (System.getenv("BOXLORE_WIDGET_RENDERS") != "1") return - val content = compose.activity.findViewById(android.R.id.content) - val bitmap = Bitmap.createBitmap(content.width, content.height, Bitmap.Config.ARGB_8888) - content.draw(Canvas(bitmap)) - val directory = File("build/reports/widget-previews").apply { mkdirs() } - File(directory, "previews-$name.png").outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } - } -} diff --git a/feature/settings/README.md b/feature/settings/README.md index c7ee775d1..2739592f0 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -10,7 +10,7 @@ Owns the Settings hub and its pages, sign-in, the Smart and automatic download s - `SettingsScreenConfig.initialPage` opens a page directly (`account`, `sync`, `library`, `recommendations`, `appearance`, `theme`, `playback`, `downloads`, `privacy`, `about`, `support`); anything else opens the hub. `isOnboarding` makes Back leave Settings at once. - Shell callbacks on `SettingsScreenConfig`: `onSendFeedback`, `onCheckForUpdates` (the shell owns release checks and installation), and `onSupportPageVisibilityChanged` / `onAccountPageVisibilityChanged`, used to hide player chrome. - `SettingsViewModel`, built by `SettingsViewModelAssembler` (`create` / `factory`): the Add RSS flow and Reset recommendations. -- `AppearanceUiState`, `AppearanceActions`, `AddableWidget`, `AppearanceHeroPreview` and `AppearanceWidgetPreview`; `PlaybackUiState` and `PlaybackActions`; `LibraryDiscoveryPreferences` and `SavedShowsControl`. App wiring persists every value through `:core:prefs`. +- `AppearanceUiState`, `AppearanceActions` and `AppearanceHeroPreview`; `PlaybackUiState` and `PlaybackActions`; `LibraryDiscoveryPreferences` and `SavedShowsControl`. App wiring persists every value through `:core:prefs`. - `FeedbackScreen(supportRepository, boxcastPrefs, onBack, bottomContentPadding, openThreadId)` on the app's `feedback?thread=` route, plus `FeedbackViewModel`, `DiagnosticCollector`, `LogcatCollector` and `SharedPrefsRetryKeyStore`. - `AutoDownloadSettingsScreen` and `SmartDownloadsSettingsScreen`, each taking `UserPreferencesRepository` and `onBack`. - `LogsPreviewDialog` (in `feedback/FeedbackDialogs.kt`). @@ -23,10 +23,9 @@ Owns the Settings hub and its pages, sign-in, the Smart and automatic download s ## Appearance -- The overview shows Display mode, then one row per page with its current choice (`appearanceSummary`): **Theme & colors**, **Home**, **Player**, **Navigation**, **Text** and **Widgets**. -- Theme & colors offers four looks (boxlore classic, Pure, Material 3, Custom) and **Use artwork colors** (on by default). Legacy palettes show as Custom without changing stored values (`ThemeSettingsLogic.kt`). +- The overview shows Display mode, then one row per page with its current choice (`appearanceSummary`): **Theme & colors**, **Home**, **Player**, **Navigation** and **Text**. +- Theme & colors offers four looks (boxlore classic, Pure, Material 3, Custom), **Use artwork colors** (on by default) and **Widgets**. Legacy palettes show as Custom without changing stored values (`ThemeSettingsLogic.kt`). - The custom theme editor is a full page. Drafts change only its preview; Save applies the palette in one write, and Back discards it. -- Widgets holds the home screen widgets' colours (App theme or System) and every widget (`AddableWidget`, supplied by the app) as a card: its real look drawn by the app through `AppearanceWidgetPreview`, scaled to fit and never enlarged, its name, and an Add button that asks the home screen app to add it. The two round players share a row, and the Your Shows card carries the cover choice (whole covers or shapes) under its picture. A request the phone leaves unanswered (the app is not paused within `ADD_WIDGET_ANSWER_MS`) is explained in that card, with a way to the app's permission settings. When the home screen app cannot add widgets (`AppearanceActions.canAddWidgets`), the cards stay without buttons, with the steps for adding one by hand. - Home holds the hero card style and its colour choice, with live previews drawn by the app through `AppearanceHeroPreview`. Hero styles are described in [`feature/home/README.md`](../home/README.md). - Player holds **Player style** (Standard or bunpod) and the miniplayer's **Seek buttons** (off by default). The bunpod option credits Kamran Bekirov's BunPod design. Video always uses Standard. - Navigation holds the bar style, **Blur behind the bar** (Floating bar only) and **Start page & tabs** (**Explore opens to** hides while Picked for you is off). Text holds the letter shape with a preview. @@ -128,7 +127,7 @@ Hermetic JVM tests live in `feature/settings/src/test`, using fakes from [`:core | :--- | :--- | | Hub, Back and tablet split | `SettingsBackNavigationTest`, `TabletSettingsLayoutTest`, `TabletSettingsDialogTest`, `TabletSettingsCaptureTest`, `SettingsHeaderTitleTest` | | RSS and reset dialogs | `SettingsViewModelTest`, `SettingsViewModelDialogTest`, `SettingsGoldenRoborazziTest` | -| Appearance | `AppearancePagesTest`, `AppearanceActionsTrackedTest`, `SettingsPictureRowTest`, `PlayerStyleSectionTest`, `AppearanceWidgetsPageTest` | +| Appearance | `AppearancePagesTest`, `AppearanceActionsTrackedTest`, `SettingsPictureRowTest`, `PlayerStyleSectionTest` | | Themes | `CustomThemeEditorTest`, `CustomThemeEditorOptionsTest`, `ThemeEditorPressFeedbackTest`, `ThemeSettingsLogicTest`, `ThemePresetResolutionTest`, `AccentSwatchLayoutTest` | | Account and sync | `AccountPagePresentationTest`, `AccountPageAdaptiveTest`, `AccountSettingsPageTest`, `AccountAuthHelpersTest`, `AccountLinkFailedContentTest`, `SyncAndBackupsPageTest` | | Library | `LibraryLanguagesAdaptiveTest`, `ClearSavedShowsAdaptiveTest` | diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt index 8688b585b..605cdeeea 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt @@ -15,7 +15,6 @@ internal enum class ProfileSettingsDestination( AppearancePlayer("Player"), AppearanceNavigation("Navigation"), AppearanceText("Text"), - AppearanceWidgets("Widgets"), Playback("Playback"), Downloads("Downloads"), Privacy("Privacy"), @@ -29,7 +28,7 @@ internal enum class ProfileSettingsDestination( */ val parent: ProfileSettingsDestination? get() = when (this) { - Theme, AppearanceHome, AppearancePlayer, AppearanceNavigation, AppearanceText, AppearanceWidgets -> Appearance + Theme, AppearanceHome, AppearancePlayer, AppearanceNavigation, AppearanceText -> Appearance CustomTheme -> Theme Recommendations -> Library else -> null diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsExternalActions.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsExternalActions.kt index c70ed4492..238944cc9 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsExternalActions.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsExternalActions.kt @@ -5,7 +5,6 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.net.Uri -import android.provider.Settings import android.widget.Toast import cx.aswin.boxlore.core.analytics.AnalyticsHelper @@ -58,16 +57,3 @@ internal fun openChangelog(context: Context) { ) } } - -/** - * Opens the phone's permissions for boxlore, where some phones keep the switch that lets an app add - * to the home screen. Phones with their own permission editor open it directly; the rest open the - * app's system settings page. - */ -internal fun openAppPermissionSettings(context: Context) { - AnalyticsHelper.trackSettingsInteraction("app_permission_settings_opened") - val vendorEditor = Intent("miui.intent.action.APP_PERM_EDITOR").putExtra("extra_pkgname", context.packageName) - val appDetails = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)) - runCatching { context.startActivity(vendorEditor) } - .recoverCatching { context.startActivity(appDetails) } -} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt index 0893ca355..1ed5a643b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt @@ -55,8 +55,6 @@ import cx.aswin.boxlore.feature.settings.pages.AppearanceSettingsPage import cx.aswin.boxlore.feature.settings.pages.AppearanceTextPage import cx.aswin.boxlore.feature.settings.pages.AppearanceThemePage import cx.aswin.boxlore.feature.settings.pages.AppearanceUiState -import cx.aswin.boxlore.feature.settings.pages.AppearanceWidgetPreview -import cx.aswin.boxlore.feature.settings.pages.AppearanceWidgetsPage import cx.aswin.boxlore.feature.settings.pages.CustomThemeEditor import cx.aswin.boxlore.feature.settings.pages.DownloadsSettingsPage import cx.aswin.boxlore.feature.settings.pages.LibraryBackupActions @@ -146,8 +144,6 @@ data class AppearanceSettings( val actions: AppearanceActions = AppearanceActions({}, {}, {}, {}, {}), /** Home's hero cards for the Appearance previews; null shows the choices without previews. */ val heroPreview: AppearanceHeroPreview? = null, - /** The home screen widgets as they really look, for Appearance › Widgets; null lists them by name only. */ - val widgetPreview: AppearanceWidgetPreview? = null, ) /** Playback sub-page state paired with its actions, so [SettingsScreen] can pass both as one. */ @@ -488,7 +484,6 @@ private fun SettingsDestinationContent( ProfileSettingsDestination.AppearancePlayer, ProfileSettingsDestination.AppearanceNavigation, ProfileSettingsDestination.AppearanceText, - ProfileSettingsDestination.AppearanceWidgets, -> AppearanceDestinationContent(destination, contentBundle.appearanceSettings, actions) ProfileSettingsDestination.CustomTheme -> @@ -687,12 +682,8 @@ private fun trackedLibraryBackupActions( */ internal fun AppearanceActions.trackedForAnalytics(currentState: () -> AppearanceUiState? = { null }): AppearanceActions { fun report(key: String, value: T, current: (AppearanceUiState) -> T) { - if (currentState()?.let(current) != value) AnalyticsHelper.trackSettingsInteraction(key, value.toString()) - } - - fun tracked(key: String, current: (AppearanceUiState) -> T, action: (T) -> Unit): (T) -> Unit = { - report(key, it, current) - action(it) + val state = currentState() + if (state == null || current(state) != value) AnalyticsHelper.trackSettingsInteraction(key, value.toString()) } return AppearanceActions( onSetThemeConfig = { @@ -731,11 +722,10 @@ internal fun AppearanceActions.trackedForAnalytics(currentState: () -> Appearanc report("home_shortcuts_in_library_toggled", it) { state -> state.homeShortcutsInLibrary } onSetHomeShortcutsInLibrary(it) }, - onSetWidgetAppearance = tracked("widget_appearance_changed", { it.currentWidgetAppearance }, onSetWidgetAppearance), - onSetWidgetShowsArtwork = tracked("widget_shows_artwork_changed", { it.currentWidgetShowsArtwork }, onSetWidgetShowsArtwork), - canAddWidgets = canAddWidgets, - // Every request is reported: there is no current value a repeat could match. - onAddWidget = tracked("widget_add_requested", { "" }, onAddWidget), + onSetWidgetAppearance = { + report("widget_appearance_changed", it) { state -> state.currentWidgetAppearance } + onSetWidgetAppearance(it) + }, onSetExploreDefaultTab = { report("explore_default_tab_changed", it) { state -> state.currentExploreDefaultTab } onSetExploreDefaultTab(it) @@ -844,7 +834,6 @@ private fun AppearanceDestinationContent( ProfileSettingsDestination.AppearancePlayer -> AppearancePlayerPage(state, tracked, onBack = actions.onReturnToHub) ProfileSettingsDestination.AppearanceNavigation -> AppearanceNavigationPage(state, tracked, onBack = actions.onReturnToHub) ProfileSettingsDestination.AppearanceText -> AppearanceTextPage(state, tracked, onBack = actions.onReturnToHub) - ProfileSettingsDestination.AppearanceWidgets -> AppearanceWidgetsPage(state, tracked, onBack = actions.onReturnToHub, preview = appearance.widgetPreview) else -> AppearanceSettingsPage( state = state, @@ -858,7 +847,6 @@ private fun AppearanceDestinationContent( AppearanceSection.Player -> ProfileSettingsDestination.AppearancePlayer AppearanceSection.Navigation -> ProfileSettingsDestination.AppearanceNavigation AppearanceSection.Text -> ProfileSettingsDestination.AppearanceText - AppearanceSection.Widgets -> ProfileSettingsDestination.AppearanceWidgets }, ) }, diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt index 87dc588fe..83f329cba 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt @@ -17,7 +17,6 @@ import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.PlayCircle import androidx.compose.material.icons.rounded.TextFields import androidx.compose.material.icons.rounded.ViewDay -import androidx.compose.material.icons.rounded.Widgets import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -44,7 +43,6 @@ import cx.aswin.boxlore.core.prefs.SubscriptionsDefaultTab import cx.aswin.boxlore.core.prefs.SubscriptionsTabStyle import cx.aswin.boxlore.core.prefs.ThemeSelection import cx.aswin.boxlore.core.prefs.WidgetAppearance -import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork import cx.aswin.boxlore.feature.settings.components.SettingsContent import cx.aswin.boxlore.feature.settings.components.SettingsDivider import cx.aswin.boxlore.feature.settings.components.SettingsGroup @@ -52,27 +50,6 @@ import cx.aswin.boxlore.feature.settings.components.SettingsNavigationRow import cx.aswin.boxlore.feature.settings.components.SettingsPicture import cx.aswin.boxlore.feature.settings.components.SettingsScaffold -/** - * A home screen widget the listener can add from Appearance › Widgets, named as the widget picker - * names it, with the size its preview is drawn for. - */ -data class AddableWidget( - val key: String, - val name: String, - val description: String, - val previewWidthDp: Int = 320, - val previewHeightDp: Int = 170, - /** Whether this widget follows the Your Shows cover setting, so its card carries that choice. */ - val usesShowsCoverStyle: Boolean = false, -) - -/** - * Draws a home screen widget as it looks on the home screen, fitted into the modifier's bounds. The - * app supplies it from the widgets feature, so Appearance shows the real widgets without depending - * on it; without it the Widgets page lists them by name only. - */ -typealias AppearanceWidgetPreview = @Composable (widget: AddableWidget, modifier: Modifier) -> Unit - /** Current values shown on [AppearanceSettingsPage]. Also used by [cx.aswin.boxlore.feature.settings.SettingsScreen]. */ data class AppearanceUiState( val currentThemeConfig: String, @@ -84,9 +61,6 @@ data class AppearanceUiState( val currentOpenAppTo: String = OpenAppTo.HOME, val homeShortcutsInLibrary: Boolean = false, val currentWidgetAppearance: String = WidgetAppearance.APP, - val currentWidgetShowsArtwork: String = WidgetShowsArtwork.ROUNDED, - /** Every widget the app offers. Whether the home screen app can add them is asked when the page opens. */ - val addableWidgets: List = emptyList(), val currentExploreDefaultTab: String = ExploreDefaultTab.FOR_YOU, val currentSubscriptionsDefaultTab: String = SubscriptionsDefaultTab.SHOWS, val currentSubscriptionsTabStyle: String = SubscriptionsTabStyle.TOP, @@ -118,11 +92,6 @@ data class AppearanceActions( val onSetOpenAppTo: (String) -> Unit = {}, val onSetHomeShortcutsInLibrary: (Boolean) -> Unit = {}, val onSetWidgetAppearance: (String) -> Unit = {}, - val onSetWidgetShowsArtwork: (String) -> Unit = {}, - /** Whether the current home screen app adds a widget when asked. Asked once when the Widgets page opens. */ - val canAddWidgets: () -> Boolean = { false }, - /** Asks the home screen app to add the widget with this key; it confirms with the listener itself. */ - val onAddWidget: (String) -> Unit = {}, val onSetExploreDefaultTab: (String) -> Unit = {}, val onSetSubscriptionsDefaultTab: (String) -> Unit = {}, val onSetSubscriptionsTabStyle: (String) -> Unit = {}, @@ -147,7 +116,7 @@ data class AppearanceActions( typealias AppearanceHeroPreview = @Composable (style: String, coverShade: String, collection: Boolean, modifier: Modifier) -> Unit /** Where each Appearance row leads, so the page needs no navigation types of its own. */ -internal enum class AppearanceSection { Theme, Home, Player, Navigation, Text, Widgets } +internal enum class AppearanceSection { Theme, Home, Player, Navigation, Text } /** * The Appearance overview: Display mode (the setting people change most, also on Theme & colors), @@ -176,21 +145,12 @@ internal fun AppearanceSettingsPage( SettingsNavigationRow(title = "Navigation", supportingText = summary.navigation, icon = Icons.Rounded.ViewDay, onClick = { onOpenSection(AppearanceSection.Navigation) }) SettingsDivider() SettingsNavigationRow(title = "Text", supportingText = summary.text, icon = Icons.Rounded.TextFields, onClick = { onOpenSection(AppearanceSection.Text) }) - SettingsDivider() - SettingsNavigationRow(title = "Widgets", supportingText = summary.widgets, icon = Icons.Rounded.Widgets, onClick = { onOpenSection(AppearanceSection.Widgets) }) } } } /** What each Appearance row shows under its title. */ -internal data class AppearanceSummary( - val theme: String, - val home: String, - val player: String, - val navigation: String, - val text: String, - val widgets: String, -) +internal data class AppearanceSummary(val theme: String, val home: String, val player: String, val navigation: String, val text: String) internal fun appearanceSummary(state: AppearanceUiState): AppearanceSummary { val look = themeLookOptions().firstOrNull { it.key == state.selectedLookKey() }?.name ?: "Custom" @@ -207,17 +167,9 @@ internal fun appearanceSummary(state: AppearanceUiState): AppearanceSummary { player = if (state.miniPlayerSeekButtonsEnabled) "$player · seek buttons on" else player, navigation = "${NavigationStyle.fromKey(state.currentNavigationStyle).label} bar · starts on $start", text = "$letters letters", - widgets = widgetsSummary(state), ) } -/** The widgets' colour source, then how Your Shows cuts its covers. */ -private fun widgetsSummary(state: AppearanceUiState): String { - val colors = if (WidgetAppearance.sanitize(state.currentWidgetAppearance) == WidgetAppearance.SYSTEM) "System colors" else "App theme colors" - val covers = if (WidgetShowsArtwork.usesShapes(state.currentWidgetShowsArtwork)) "shapes" else "whole covers" - return "$colors · $covers" -} - /** The hero card style's name as Settings shows it. */ private fun heroStyleName(style: String): String = when (style) { HeroCardStyle.COVER -> "Cover" diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceThemePage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceThemePage.kt index ff094c726..90d1fa896 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceThemePage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceThemePage.kt @@ -8,6 +8,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView +import cx.aswin.boxlore.core.designsystem.components.ConnectedOptionSelector +import cx.aswin.boxlore.core.prefs.WidgetAppearance import cx.aswin.boxlore.feature.settings.components.SettingsContent import cx.aswin.boxlore.feature.settings.components.SettingsDivider import cx.aswin.boxlore.feature.settings.components.SettingsGroup @@ -21,8 +23,8 @@ import cx.aswin.boxlore.feature.settings.components.ThemeLookPicture /** * Theme & colors: display mode, the four looks as picture tiles (each in light and dark), the - * custom theme editor, artwork colors for episode and podcast pages, the widgets, which follow these - * colors, and how the Your Shows widget draws covers. + * custom theme editor, artwork colors for episode and podcast pages, and the widgets, which follow + * these colors. */ @Composable internal fun AppearanceThemePage(state: AppearanceUiState, actions: AppearanceActions, onBack: () -> Unit, onEditCustomTheme: () -> Unit) { @@ -49,6 +51,18 @@ internal fun AppearanceThemePage(state: AppearanceUiState, actions: AppearanceAc icon = Icons.Rounded.AutoAwesome, ) } + SettingsGroup( + title = "Widgets", + footer = "App theme matches your chosen colors and mode. System follows your phone’s mode and wallpaper colors.", + ) { + SettingsContent { + ConnectedOptionSelector( + options = listOf(WidgetAppearance.APP to "App theme", WidgetAppearance.SYSTEM to "System"), + selected = WidgetAppearance.sanitize(state.currentWidgetAppearance), + onSelect = actions.onSetWidgetAppearance, + ) + } + } } } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt deleted file mode 100644 index 269223622..000000000 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt +++ /dev/null @@ -1,316 +0,0 @@ -package cx.aswin.boxlore.feature.settings.pages - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.LiveRegionMode -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.liveRegion -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView -import cx.aswin.boxlore.core.designsystem.components.ConnectedOptionSelector -import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight -import cx.aswin.boxlore.core.prefs.WidgetAppearance -import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork -import cx.aswin.boxlore.feature.settings.components.SettingsActionRow -import cx.aswin.boxlore.feature.settings.components.SettingsContent -import cx.aswin.boxlore.feature.settings.components.SettingsDivider -import cx.aswin.boxlore.feature.settings.components.SettingsGroup -import cx.aswin.boxlore.feature.settings.components.SettingsInfoTip -import cx.aswin.boxlore.feature.settings.components.SettingsPicture -import cx.aswin.boxlore.feature.settings.components.SettingsScaffold -import cx.aswin.boxlore.feature.settings.components.SettingsTileSection -import cx.aswin.boxlore.feature.settings.openAppPermissionSettings -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -/** - * Widgets: the colours every home screen widget takes, how Your Shows cuts its covers, then every - * widget as it looks on the home screen ([preview]), each with a button that asks the home screen - * app to add it. - */ -@Composable -internal fun AppearanceWidgetsPage( - state: AppearanceUiState, - actions: AppearanceActions, - onBack: () -> Unit, - preview: AppearanceWidgetPreview? = null, -) { - TrackScreenView("appearance_widgets") - SettingsScaffold(title = "Widgets", onBack = onBack) { - SettingsGroup( - title = "Colors", - footer = "App theme matches your chosen colors and mode. System follows your phone’s mode and wallpaper colors.", - ) { - SettingsContent { - ConnectedOptionSelector( - options = listOf(WidgetAppearance.APP to "App theme", WidgetAppearance.SYSTEM to "System"), - selected = WidgetAppearance.sanitize(state.currentWidgetAppearance), - onSelect = actions.onSetWidgetAppearance, - ) - } - } - // With pictures, the cover choice sits in the Your Shows card, right under the picture it changes. - val coverChoiceInGallery = preview != null && state.addableWidgets.any { it.usesShowsCoverStyle } - if (!coverChoiceInGallery) { - SettingsGroup( - title = "Your Shows covers", - footer = "Whole covers shows each cover uncropped. Shapes cuts covers into circles, arches and scallops.", - ) { - SettingsContent { ShowsCoverChoice(state, actions) } - } - } - if (state.addableWidgets.isNotEmpty()) { - // The home screen app is asked once, when the page opens: it only changes when the listener switches it. - val canAdd = remember(actions.canAddWidgets) { actions.canAddWidgets() } - when { - preview != null -> WidgetGallery(state, actions, canAdd, preview) - canAdd -> WidgetRows(state.addableWidgets, actions.onAddWidget) - else -> SettingsInfoTip(ADD_WIDGETS_BY_HAND) - } - } - } -} - -@Composable -private fun ShowsCoverChoice(state: AppearanceUiState, actions: AppearanceActions) { - ConnectedOptionSelector( - options = listOf(WidgetShowsArtwork.ROUNDED to "Whole covers", WidgetShowsArtwork.SHAPES to "Shapes"), - selected = WidgetShowsArtwork.sanitize(state.currentWidgetShowsArtwork), - onSelect = actions.onSetWidgetShowsArtwork, - ) -} - -/** - * Every widget as a picture with its name. The Add buttons are there only when the home screen app - * can add; a request the phone turns down without a word is answered in that widget's card. - */ -@Composable -private fun WidgetGallery( - state: AppearanceUiState, - actions: AppearanceActions, - canAdd: Boolean, - preview: AppearanceWidgetPreview, -) { - val add = rememberAddWidgetRequest(actions.onAddWidget) - SettingsTileSection( - title = if (canAdd) "Add to home screen" else "boxlore widgets", - footer = if (canAdd) ADD_WIDGETS_CONFIRM else null, - ) { - if (!canAdd) SettingsInfoTip(ADD_WIDGETS_BY_HAND) - for (row in widgetGalleryRows(state.addableWidgets)) { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - row.forEach { widget -> - WidgetCard( - widget = widget, - sideBySide = row.size > 1, - onAdd = add.request.takeIf { canAdd }, - unanswered = add.unansweredKey == widget.key, - preview = preview, - modifier = Modifier.weight(1f), - ) { - if (widget.usesShowsCoverStyle) ShowsCoverChoice(state, actions) - } - } - } - } - } -} - -/** Asks for a widget to be added, and knows which request the phone left unanswered. */ -private class AddWidgetRequest(val request: (String) -> Unit, val unansweredKey: String?) - -/** - * The home screen app answers a request by opening its confirmation over the app, which pauses it. - * Some phones turn the request down without showing anything. So when the app has not been paused - * [ADD_WIDGET_ANSWER_MS] after a request, that request counts as unanswered, until another one is - * answered. - */ -@Composable -private fun rememberAddWidgetRequest(onAdd: (String) -> Unit): AddWidgetRequest { - val lifecycle = LocalLifecycleOwner.current.lifecycle - val scope = rememberCoroutineScope() - var unansweredKey by rememberSaveable { mutableStateOf(null) } - val pausedSinceRequest = remember { mutableStateOf(false) } - DisposableEffect(lifecycle) { - val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE) pausedSinceRequest.value = true } - lifecycle.addObserver(observer) - onDispose { lifecycle.removeObserver(observer) } - } - val request: (String) -> Unit = - remember(onAdd) { - { key -> - pausedSinceRequest.value = false - onAdd(key) - scope.launch { - delay(ADD_WIDGET_ANSWER_MS) - unansweredKey = key.takeUnless { pausedSinceRequest.value } - } - } - } - return AddWidgetRequest(request, unansweredKey) -} - -/** - * One widget: its picture on a tonal stage that stands in for the wallpaper, any [options] that - * change that picture, then its name, what it shows and the Add button. Wide widgets put the button - * beside the text; a small widget sharing its row with another puts it underneath, where it still - * has room at large text sizes. An [unanswered] request explains itself under the button. - */ -@Composable -private fun WidgetCard( - widget: AddableWidget, - sideBySide: Boolean, - onAdd: ((String) -> Unit)?, - unanswered: Boolean, - preview: AppearanceWidgetPreview, - modifier: Modifier = Modifier, - options: @Composable () -> Unit = {}, -) { - Card( - modifier = modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraLarge, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Box( - modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.large).background(MaterialTheme.colorScheme.outlineVariant).padding(14.dp), - contentAlignment = Alignment.Center, - ) { - SettingsPicture(Modifier.widthIn(max = widget.previewWidthDp.dp).fillMaxWidth().aspectRatio(widget.previewWidthDp.toFloat() / widget.previewHeightDp)) { - preview(widget, Modifier.matchParentSize()) - } - } - options() - if (sideBySide) { - Column(Modifier.padding(horizontal = 4.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - WidgetCardText(widget) - if (onAdd != null) AddWidgetButton(widget, onAdd, Modifier.fillMaxWidth()) - } - } else { - Row(Modifier.padding(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - WidgetCardText(widget, Modifier.weight(1f)) - if (onAdd != null) AddWidgetButton(widget, onAdd) - } - } - if (unanswered) AddWidgetUnanswered() - } - } -} - -/** What to do when the phone did not open its confirmation: allow it in the phone's settings, or add by hand. */ -@Composable -private fun AddWidgetUnanswered() { - val context = LocalContext.current - Column( - modifier = Modifier.fillMaxWidth().semantics { liveRegion = LiveRegionMode.Polite }, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - SettingsInfoTip(ADD_WIDGET_UNANSWERED) - TextButton(onClick = { openAppPermissionSettings(context) }, modifier = Modifier.align(Alignment.End)) { - Text("Open app settings") - } - } -} - -@Composable -private fun WidgetCardText(widget: AddableWidget, modifier: Modifier = Modifier) { - Column(modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(widget.name, style = MaterialTheme.typography.titleSmall, fontWeight = GoogleSansWeight.bold, color = MaterialTheme.colorScheme.onSurface) - Text(widget.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } -} - -@Composable -private fun AddWidgetButton(widget: AddableWidget, onAdd: (String) -> Unit, modifier: Modifier = Modifier) { - val label = "Add ${widget.name} to home screen" - FilledTonalButton( - onClick = { onAdd(widget.key) }, - modifier = modifier.semantics { contentDescription = label }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - ) { - Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) - Text("Add", modifier = Modifier.padding(start = ButtonDefaults.IconSpacing)) - } -} - -/** Without pictures: one row per widget that asks the home screen app to add it. */ -@Composable -private fun WidgetRows(widgets: List, onAdd: (String) -> Unit) { - SettingsGroup(title = "Add to home screen", footer = ADD_WIDGETS_CONFIRM) { - widgets.forEachIndexed { index, widget -> - if (index > 0) SettingsDivider() - SettingsActionRow( - title = widget.name, - supportingText = widget.description, - icon = Icons.Rounded.Add, - onClick = { onAdd(widget.key) }, - ) - } - } -} - -/** - * The gallery's rows: a small widget shares its row with the small widget after it, so the two round - * players sit side by side; every other widget has a row to itself. - */ -internal fun widgetGalleryRows(widgets: List): List> { - val rows = mutableListOf>() - var index = 0 - while (index < widgets.size) { - val widget = widgets[index] - val next = widgets.getOrNull(index + 1) - val pair = widget.previewWidthDp <= SMALL_WIDGET_MAX_WIDTH_DP && next != null && next.previewWidthDp <= SMALL_WIDGET_MAX_WIDTH_DP - rows += if (pair) listOf(widget, next!!) else listOf(widget) - index += if (pair) 2 else 1 - } - return rows -} - -private const val SMALL_WIDGET_MAX_WIDTH_DP = 200 - -/** How long the home screen app has to open its confirmation before a request counts as unanswered. */ -internal const val ADD_WIDGET_ANSWER_MS = 1_500L - -internal const val ADD_WIDGET_UNANSWERED = - "Your phone didn’t let boxlore add it. Turn on Home screen shortcuts for boxlore in your phone’s app settings, then try again. " + - "Or touch and hold an empty spot on your home screen and choose Widgets." - -internal const val ADD_WIDGETS_CONFIRM = "Your home screen app asks you to confirm." - -internal const val ADD_WIDGETS_BY_HAND = - "To add a boxlore widget, touch and hold an empty spot on your home screen, then choose Widgets." diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt index d9640623e..1d229f726 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt @@ -28,34 +28,6 @@ class AppearanceActionsTrackedTest { assertEquals(0, miniChanges) } - @Test - fun `Your Shows widget artwork choice survives the appearance wrapper`() { - val styles = mutableListOf() - var widgetColourChanges = 0 - val tracked = AppearanceActions( - {}, - {}, - {}, - {}, - onSetWidgetAppearance = { widgetColourChanges++ }, - onSetWidgetShowsArtwork = { styles.add(it) }, - ).trackedForAnalytics() - tracked.onSetWidgetShowsArtwork(cx.aswin.boxlore.core.prefs.WidgetShowsArtwork.SHAPES) - tracked.onSetWidgetShowsArtwork(cx.aswin.boxlore.core.prefs.WidgetShowsArtwork.ROUNDED) - assertEquals(listOf("shapes", "rounded"), styles) - assertEquals(0, widgetColourChanges) - } - - @Test - fun `adding a widget survives the appearance wrapper, every time it is asked`() { - val asked = mutableListOf() - val tracked = AppearanceActions({}, {}, {}, {}, canAddWidgets = { true }, onAddWidget = { asked.add(it) }).trackedForAnalytics() - tracked.onAddWidget("compact") - tracked.onAddWidget("compact") - assertEquals(listOf("compact", "compact"), asked) - assertEquals(true, tracked.canAddWidgets()) - } - @Test fun `LeaveMeAlone switches reach the setter with their section and value`() { val calls = mutableListOf>() diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt index 8e01642ab..ff4617c33 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt @@ -112,7 +112,6 @@ class SettingsBackNavigationTest { ProfileSettingsDestination.AppearancePlayer, ProfileSettingsDestination.AppearanceNavigation, ProfileSettingsDestination.AppearanceText, - ProfileSettingsDestination.AppearanceWidgets, ).forEach { page -> assertEquals( SettingsBackAction.NavigateTo(ProfileSettingsDestination.Appearance), diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt index 90cd20c90..972f7e760 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt @@ -23,7 +23,6 @@ import cx.aswin.boxlore.feature.settings.pages.AppearanceNavigationPage import cx.aswin.boxlore.feature.settings.pages.AppearancePlayerPage import cx.aswin.boxlore.feature.settings.pages.AppearanceSettingsPage import cx.aswin.boxlore.feature.settings.pages.AppearanceTextPage -import cx.aswin.boxlore.feature.settings.pages.AppearanceWidgetsPage import cx.aswin.boxlore.feature.settings.pages.CustomThemeEditor import cx.aswin.boxlore.feature.settings.pages.DownloadsSettingsPage import cx.aswin.boxlore.feature.settings.pages.LibraryBackupActions @@ -112,7 +111,6 @@ private fun CaptureSettingsPage(destination: ProfileSettingsDestination) { ProfileSettingsDestination.AppearancePlayer, ProfileSettingsDestination.AppearanceNavigation, ProfileSettingsDestination.AppearanceText, - ProfileSettingsDestination.AppearanceWidgets, -> CaptureAppearanceSubpage(destination, appearance) ProfileSettingsDestination.CustomTheme -> CustomThemeEditor(appearance.state, {}, {}) ProfileSettingsDestination.Playback -> PlaybackSettingsPage(playback.state, playback.actions, {}) @@ -131,7 +129,6 @@ private fun CaptureAppearanceSubpage(destination: ProfileSettingsDestination, ap ProfileSettingsDestination.AppearanceHome -> AppearanceHomePage(appearance.state, appearance.actions, null, {}) ProfileSettingsDestination.AppearancePlayer -> AppearancePlayerPage(appearance.state, appearance.actions, {}) ProfileSettingsDestination.AppearanceNavigation -> AppearanceNavigationPage(appearance.state, appearance.actions, {}) - ProfileSettingsDestination.AppearanceWidgets -> AppearanceWidgetsPage(appearance.state, appearance.actions, {}) else -> AppearanceTextPage(appearance.state, appearance.actions, {}) } } diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearancePagesTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearancePagesTest.kt index 2e43d6474..b9616d0d0 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearancePagesTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearancePagesTest.kt @@ -21,8 +21,6 @@ import cx.aswin.boxlore.core.prefs.HeroCardStyle import cx.aswin.boxlore.core.prefs.HeroCoverShade import cx.aswin.boxlore.core.prefs.HeroTideProgress import cx.aswin.boxlore.core.prefs.OpenAppTo -import cx.aswin.boxlore.core.prefs.WidgetAppearance -import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -168,11 +166,6 @@ class AppearancePagesTest { assertEquals("Block hero cards, artwork color", appearanceSummary(baseState.copy(heroCoverShade = HeroCoverShade.ARTWORK)).home) assertEquals("bunpod · seek buttons on", summary.player) assertEquals("Crisp letters", summary.text) - assertEquals("App theme colors · whole covers", summary.widgets) - assertEquals( - "System colors · shapes", - appearanceSummary(baseState.copy(currentWidgetAppearance = WidgetAppearance.SYSTEM, currentWidgetShowsArtwork = WidgetShowsArtwork.SHAPES)).widgets, - ) assertEquals("boxlore classic · artwork colors on", appearanceSummary(baseState).theme) // Listeners who never picked see Cover on the Artwork shade. val unpicked = AppearanceUiState(currentThemeConfig = "system", isDynamicColorEnabled = false, currentThemeBrand = "violet", currentSurfaceStyle = "classic_dynamic") diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt deleted file mode 100644 index b6ae5156f..000000000 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt +++ /dev/null @@ -1,181 +0,0 @@ -package cx.aswin.boxlore.feature.settings.pages - -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.Box -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.getUnclippedBoundsInRoot -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollTo -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme -import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [33], qualifiers = "w360dp-h780dp-mdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class AppearanceWidgetsPageTest { - @get:Rule val compose = createAndroidComposeRule() - private val added = mutableListOf() - private val coverChoices = mutableListOf() - private val widgets = - listOf( - AddableWidget("full", "Now Playing · Full Controls", "The full player", 320, 170), - AddableWidget("round", "Now Playing · Round", "A round player", 160, 160), - AddableWidget("round_next", "Now Playing · Round with Next", "A round player with next", 160, 160), - AddableWidget("shows", "Library · Your Shows", "Your subscribed shows as a grid of artwork", 320, 290, usesShowsCoverStyle = true), - ) - private val state = - AppearanceUiState( - currentThemeConfig = "system", - isDynamicColorEnabled = false, - currentThemeBrand = "violet", - currentSurfaceStyle = "classic_dynamic", - addableWidgets = widgets, - ) - private val picture: AppearanceWidgetPreview = { widget, modifier -> Box(modifier.testTag("picture-${widget.key}")) } - - private fun render(canAdd: Boolean, state: AppearanceUiState = this.state, preview: AppearanceWidgetPreview? = picture) { - val actions = - AppearanceActions({}, {}, {}, {}, canAddWidgets = { canAdd }, onAddWidget = { added.add(it) }, onSetWidgetShowsArtwork = { coverChoices.add(it) }) - compose.setContent { BoxLoreTheme { AppearanceWidgetsPage(state, actions, onBack = {}, preview = preview) } } - } - - @Test - fun `each widget shows its picture and a button that asks the home screen to add that widget`() { - render(canAdd = true) - compose.onNodeWithText("Add to home screen").performScrollTo().assertIsDisplayed() - for (widget in widgets) { - compose.onNodeWithTag("picture-${widget.key}", useUnmergedTree = true).assertExists() - compose.onNodeWithText(widget.name).performScrollTo().assertIsDisplayed() - } - compose.onNodeWithContentDescription("Add Library · Your Shows to home screen").performScrollTo().performClick() - compose.onNodeWithContentDescription("Add Now Playing · Round to home screen").performScrollTo().performClick() - assertEquals(listOf("shows", "round"), added) - compose.onNodeWithText(ADD_WIDGETS_BY_HAND).assertDoesNotExist() - } - - @Test - fun `a picture keeps its widget's shape and is never wider than the widget really is`() { - render(canAdd = true) - for (widget in widgets) { - val bounds = compose.onNodeWithTag("picture-${widget.key}", useUnmergedTree = true).getUnclippedBoundsInRoot() - val width = (bounds.right - bounds.left).value - val height = (bounds.bottom - bounds.top).value - assertTrue("${widget.key} is drawn", width > 80f && height > 40f) - assertTrue("${widget.key} is at most its real width", width <= widget.previewWidthDp + 0.5f) - assertEquals("${widget.key} keeps its shape", widget.previewWidthDp.toFloat() / widget.previewHeightDp, width / height, 0.03f) - } - // The two round players share a row. - val round = compose.onNodeWithTag("picture-round", useUnmergedTree = true).getUnclippedBoundsInRoot() - val roundNext = compose.onNodeWithTag("picture-round_next", useUnmergedTree = true).getUnclippedBoundsInRoot() - assertEquals(round.top, roundNext.top) - assertTrue(roundNext.left > round.right) - } - - @Test - @Config(sdk = [33], qualifiers = "w1280dp-h800dp-mdpi") - fun `on a tablet the pictures stay at their real size instead of stretching`() { - render(canAdd = true) - val full = compose.onNodeWithTag("picture-full", useUnmergedTree = true).getUnclippedBoundsInRoot() - assertEquals(320.dp, full.right - full.left) - compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").performScrollTo().performClick() - assertEquals(listOf("full"), added) - } - - @Test - fun `a home screen that cannot add widgets still shows them, with the steps to add one by hand`() { - render(canAdd = false) - compose.onNodeWithText(ADD_WIDGETS_BY_HAND).performScrollTo().assertIsDisplayed() - compose.onNodeWithText("boxlore widgets").assertExists() - compose.onNodeWithText("Add to home screen").assertDoesNotExist() - compose.onNodeWithTag("picture-full", useUnmergedTree = true).assertExists() - compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").assertDoesNotExist() - // The widget options stay either way. - compose.onNodeWithText("Whole covers").assertExists() - compose.onNodeWithText("Colors").assertExists() - } - - @Test - fun `the cover choice sits in the Your Shows card, between its picture and its name`() { - render(canAdd = true) - compose.onNodeWithText("Your Shows covers").assertDoesNotExist() - val picture = compose.onNodeWithTag("picture-shows", useUnmergedTree = true).getUnclippedBoundsInRoot() - val choice = compose.onNodeWithText("Shapes").getUnclippedBoundsInRoot() - val name = compose.onNodeWithText("Library · Your Shows").getUnclippedBoundsInRoot() - assertTrue("under the picture it changes", choice.top >= picture.bottom) - assertTrue("above the widget's name", choice.bottom <= name.top) - compose.onNodeWithText("Shapes").performScrollTo().performClick() - assertEquals(listOf(WidgetShowsArtwork.SHAPES), coverChoices) - } - - @Test - fun `a request the phone leaves unanswered explains itself in that widget's card`() { - render(canAdd = true) - compose.mainClock.autoAdvance = false - compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").performScrollTo().performClick() - // Nothing covered the app: the phone turned the request down without a word. - compose.mainClock.advanceTimeBy(ADD_WIDGET_ANSWER_MS + 100) - compose.onNodeWithText(ADD_WIDGET_UNANSWERED).assertExists() - compose.onNodeWithText("Open app settings").assertExists() - val tip = compose.onNodeWithText(ADD_WIDGET_UNANSWERED).getUnclippedBoundsInRoot() - val nextCard = compose.onNodeWithTag("picture-round", useUnmergedTree = true).getUnclippedBoundsInRoot() - assertTrue("inside the card that was tapped", tip.bottom <= nextCard.top) - assertEquals(listOf("full"), added) - } - - @Test - fun `a request the home screen answers shows nothing more, and clears an earlier unanswered one`() { - render(canAdd = true) - compose.mainClock.autoAdvance = false - compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").performScrollTo().performClick() - compose.mainClock.advanceTimeBy(ADD_WIDGET_ANSWER_MS + 100) - compose.onNodeWithText(ADD_WIDGET_UNANSWERED).assertExists() - - compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").performClick() - // The home screen app's confirmation covers the app, then the listener comes back. - compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED) - compose.mainClock.advanceTimeBy(ADD_WIDGET_ANSWER_MS + 100) - compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) - compose.mainClock.advanceTimeBy(100) - compose.onNodeWithText(ADD_WIDGET_UNANSWERED).assertDoesNotExist() - assertEquals(listOf("full", "full"), added) - } - - @Test - fun `without pictures each widget is a row that adds it and the cover choice keeps its own group`() { - render(canAdd = true, preview = null) - compose.onNodeWithText("Your Shows covers").assertExists() - compose.onNodeWithText("Library · Your Shows").performScrollTo().performClick() - assertEquals(listOf("shows"), added) - } - - @Test - fun `with no widgets to offer the page is only the options`() { - render(canAdd = true, state = state.copy(addableWidgets = emptyList())) - compose.onNodeWithText("Add to home screen").assertDoesNotExist() - compose.onNodeWithText(ADD_WIDGETS_BY_HAND).assertDoesNotExist() - compose.onNodeWithText("Whole covers").assertExists() - } - - @Test - fun `small widgets pair up and every other widget has a row to itself`() { - assertEquals(listOf(listOf("full"), listOf("round", "round_next"), listOf("shows")), widgetGalleryRows(widgets).map { row -> row.map { it.key } }) - // A lone small widget, or a third one, is not stretched into a pair. - val three = widgets.filter { it.previewWidthDp <= 200 } + AddableWidget("extra", "Extra", "Another small one", 160, 160) - assertEquals(listOf(listOf("round", "round_next"), listOf("extra")), widgetGalleryRows(three).map { row -> row.map { it.key } }) - } -} diff --git a/feature/widgets/README.md b/feature/widgets/README.md index 29a0aadf6..0f829e3b7 100644 --- a/feature/widgets/README.md +++ b/feature/widgets/README.md @@ -19,13 +19,12 @@ Owns boxlore's home-screen widgets, built with `AppWidgetProvider` and `RemoteVi - The play button is a circle at rest and the scalloped cookie while playing. Seek icons show the listener's own seek lengths. - When playback is dismissed, every playback widget becomes a launch surface ("Ready when you are") that opens boxlore, never disabled transport. - Library lists are capped at `LibraryWidgetLogic.MAX_ROWS`; the header badge counts the whole library through `WidgetLibraryCounts`. Row and tile taps open the deep link `:app` supplies; the header and its arrow open `boxlore://library/subscriptions?tab=0` or `?tab=1`. -- Your Shows draws whole covers as rounded squares, or cuts them into mixed shapes when the listener chooses that in Appearance › Widgets (`WidgetShowsArtwork`). +- Your Shows draws whole covers as rounded squares, or cuts them into mixed shapes when the `WidgetShowsArtwork` preference says so. - Missing or unreadable artwork falls back to show or episode candidates, then to a tinted placeholder with the boxlore mark, cut to the same shape. ## Public API - The six receivers above: picker-visible providers. -- `HomeScreenWidgets`: the six widgets under their picker names, each one's live preview as `RemoteViews` with the size it is drawn for (`preview`), whether the current home screen app can add a widget on request (`canAdd`), and the request itself (`requestAdd`). Settings shows and offers them through `:app`. - `WidgetControlReceiver`: one explicit, non-exported action endpoint shared by the playback widgets. - `WidgetActionIntents`: builds broadcast, open-app and deep-link `PendingIntent`s with `SecurityException` guards. `cancelAll(context, appWidgetId)` and `LibraryWidgetRenderer.cancelAll` cancel a deleted widget's intents so the app never exhausts the system's `PendingIntent` quota. - `NowPlayingWidgetDependencies`, `configureNowPlayingWidget(...)` and `WidgetPlaybackSource`: the playback port. `refreshPosition()` asks the player for its position, which the app only tracks while it is on screen. @@ -43,7 +42,7 @@ Owns boxlore's home-screen widgets, built with `AppWidgetProvider` and `RemoteVi - Library covers load a few at a time and each batch is shown as it lands. The list service also fetches a missing cover itself, so covers appear while the app is closed; a tile shows its placeholder until then. - Every colour is a tint, never baked into a bitmap. Widget chrome follows the in-app Appearance theme by default: `WidgetChrome` sends one ARGB value for a fixed Light or Dark display mode, and a light and dark pair when the display mode follows the system, so the launcher switches with the phone while the app is not running. Appearance › Widgets can switch to System, which uses the system's dynamic colour resources. - `WidgetThemeSync` re-pushes widgets on configuration changes; `:app` refreshes them when the theme or widget preferences change. -- Picker previews: from Android 15, `WidgetPickerPreviews` publishes previews drawn by the live renderers with sample content, in the listener's own widget colours, again whenever that look changes. Older versions show the static `widget_preview_*.xml` layouts in resource colours. +- Picker previews (`widget_preview_*.xml`) are static layouts in resource colours. ## Design provenance @@ -57,8 +56,8 @@ src/main/java/cx/aswin/boxlore/feature/widgets/ LibraryWidget*.kt # receivers, dependencies, coordinator, renderer, snapshot store, # RemoteViewsService for the lists WidgetArtworkLoader.kt, WidgetBitmapCache.kt, WidgetChrome.kt, WidgetPalette.kt, - WidgetRemoteViewsColors.kt, WidgetShapes.kt, WidgetSeekIcon.kt, WidgetPickerPreviews.kt, - WidgetThemeSync.kt, WidgetHostPresence.kt, WidgetUpdates.kt, HomeScreenWidgets.kt + WidgetRemoteViewsColors.kt, WidgetShapes.kt, WidgetSeekIcon.kt, + WidgetThemeSync.kt, WidgetHostPresence.kt, WidgetUpdates.kt actions/ # WidgetActionIntents, WidgetActionHandler, WidgetControlReceiver logic/ # NowPlayingWidgetLogic, LibraryWidgetLogic, WidgetPresentation, WidgetSizes, # WidgetProgress, WidgetProgressPoller @@ -88,7 +87,7 @@ src/main/res/ | Concern | Contract | | :--- | :--- | -| SharedPreferences files | `boxlore_now_playing_widget`, `boxlore_library_widget`, `boxlore_widget_picker_previews` (the look each published preview was drawn for) | +| SharedPreferences files | `boxlore_now_playing_widget`, `boxlore_library_widget` | | Snapshot key | `snapshot` (JSON) | | Artwork cache | `{cacheDir}/widget_artwork/` | | Receiver FQCNs | the six receivers above, under `cx.aswin.boxlore.feature.widgets` | @@ -108,7 +107,7 @@ Hermetic JVM tests live in `feature/widgets/src/test`, using Robolectric and fak | Library widgets | `LibraryWidgetRemoteViewsTest`, `LibraryWidgetLogicTest` | | Snapshots and dependency holders | `NowPlayingWidgetSnapshotStoreTest`, `NowPlayingWidgetDependenciesHolderTest` | | Startup and updates | `WidgetStartupTest`, `WidgetUpdatesTest` | -| Theming, picker previews and adding from the app | `WidgetChromeTest`, `WidgetRemoteViewsColorsTest`, `WidgetPickerPreviewsTest`, `HomeScreenWidgetsTest` | +| Theming | `WidgetChromeTest`, `WidgetRemoteViewsColorsTest` | | Intents | `WidgetActionIntentsTest` | ```bash diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt deleted file mode 100644 index 9153f876e..000000000 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt +++ /dev/null @@ -1,74 +0,0 @@ -package cx.aswin.boxlore.feature.widgets - -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.ComponentName -import android.content.Context -import android.widget.RemoteViews -import androidx.annotation.StringRes - -/** - * The widgets a listener can see and add to the home screen from inside the app. The home screen app - * does the adding and confirms it with the listener itself; not every home screen app can. - */ -object HomeScreenWidgets { - /** One widget, named as the widget picker names it, with the size its preview is drawn for. */ - class Entry internal constructor( - val key: String, - @param:StringRes val label: Int, - @param:StringRes val description: Int, - val previewWidthDp: Int, - val previewHeightDp: Int, - /** Whether this widget draws show covers the way the Your Shows cover setting says. */ - val usesShowsCoverStyle: Boolean, - internal val receiver: Class, - ) - - val all: List = - listOf( - entry("full", R.string.now_playing_widget_label, R.string.now_playing_widget_description), - entry("compact", R.string.now_playing_bar_widget_label, R.string.now_playing_bar_widget_description), - entry("round", R.string.playback_controls_widget_label, R.string.playback_controls_widget_description), - entry("round_next", R.string.playback_next_controls_widget_label, R.string.playback_next_controls_widget_description), - entry("shows", R.string.subscriptions_widget_label, R.string.subscriptions_widget_description, usesShowsCoverStyle = true), - entry("new_episodes", R.string.new_episodes_widget_label, R.string.new_episodes_widget_description), - ) - - /** Whether the current home screen app adds a widget when an app asks it to. */ - fun canAdd(context: Context): Boolean = runCatching { manager(context)?.isRequestPinAppWidgetSupported == true }.getOrDefault(false) - - /** - * Asks the home screen app to add the widget with this [key]. Returns false when the key is - * unknown or the home screen app turned the request down; it never throws. - */ - fun requestAdd(context: Context, key: String): Boolean { - val entry = all.firstOrNull { it.key == key } ?: return false - return runCatching { - manager(context)?.requestPinAppWidget(ComponentName(context.applicationContext, entry.receiver), null, null) == true - }.getOrDefault(false) - } - - /** - * The widget with this [key] as its own renderer draws it, with sample content and in the - * listener's widget colours. Lay it out at its entry's preview size, then scale that picture to - * fit: at that size every row is whole. It is a picture: show it where its buttons cannot be - * tapped. Cuts and caches artwork, so call it off the main thread. Null when the key is unknown - * or the preview cannot be drawn. - */ - fun preview(context: Context, key: String): RemoteViews? { - val preview = WidgetPickerPreviews.previews().firstOrNull { it.key == key } ?: return null - return runCatching { preview.build(context.applicationContext) }.getOrNull() - } - - private fun entry( - key: String, - @StringRes label: Int, - @StringRes description: Int, - usesShowsCoverStyle: Boolean = false, - ): Entry { - val preview = WidgetPickerPreviews.previews().first { it.key == key } - return Entry(key, label, description, preview.size.widthDp, preview.size.heightDp, usesShowsCoverStyle, preview.receiver) - } - - private fun manager(context: Context): AppWidgetManager? = context.applicationContext.getSystemService(AppWidgetManager::class.java) -} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt index ac38cd331..20a4a4c80 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt @@ -47,8 +47,6 @@ class LibraryWidgetRemoteViewsService : RemoteViewsService() { internal class LibraryWidgetRemoteViewsFactory( private val context: Context, private val kind: LibraryWidgetKind, - /** Rows to show instead of the saved snapshot: the sample content of a picker preview. */ - private val preset: LibraryWidgetSnapshot? = null, ) : RemoteViewsService.RemoteViewsFactory { private var shows: List = emptyList() private var episodes: List = emptyList() @@ -59,7 +57,7 @@ internal class LibraryWidgetRemoteViewsFactory( override fun onCreate() = Unit override fun onDataSetChanged() { - val snapshot = preset ?: LibraryWidgetSnapshotStore(context).read() ?: LibraryWidgetSnapshot() + val snapshot = LibraryWidgetSnapshotStore(context).read() ?: LibraryWidgetSnapshot() shows = snapshot.subscriptions episodes = snapshot.newEpisodes chrome = WidgetChrome.resolve(context) @@ -184,8 +182,7 @@ internal class LibraryWidgetRemoteViewsFactory( */ private fun artworkPath(cachePath: String?, url: String?, fallbackUrls: List): String? { cachePath?.takeIf { it.isNotBlank() && File(it).exists() }?.let { return it } - // Sample rows of a picker preview have no address to fetch from. - if (preset != null || cx.aswin.boxlore.core.model.artworkUrls(url, *fallbackUrls.toTypedArray()).isEmpty()) return null + if (cx.aswin.boxlore.core.model.artworkUrls(url, *fallbackUrls.toTypedArray()).isEmpty()) return null artworkLoader.resolveCachedPath(url)?.let { return it } return runCatching { runBlocking { withTimeoutOrNull(ARTWORK_TIMEOUT_MS) { artworkLoader.load(url, fallbackUrls) } } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt index bfedcdc28..240033392 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt @@ -79,7 +79,6 @@ object LibraryWidgetRenderer { appWidgetId: Int, snapshot: LibraryWidgetSnapshot, kind: LibraryWidgetKind, - collection: RemoteViews.RemoteCollectionItems? = null, ): RemoteViews { val layout = when (kind) { @@ -126,15 +125,10 @@ object LibraryWidgetRenderer { LibraryWidgetKind.SUBSCRIPTIONS -> snapshot.subscriptions.size LibraryWidgetKind.NEW_EPISODES -> snapshot.newEpisodes.size } - if (collection != null) { - // A picker preview carries its rows with it; a placed widget binds the list service. - views.setRemoteAdapter(R.id.widget_list_view, collection) - } else { - views.setRemoteAdapter( - R.id.widget_list_view, - LibraryWidgetRemoteViewsService.adapterIntent(context, appWidgetId, kind), - ) - } + views.setRemoteAdapter( + R.id.widget_list_view, + LibraryWidgetRemoteViewsService.adapterIntent(context, appWidgetId, kind), + ) views.setEmptyView(R.id.widget_list_view, R.id.widget_empty_container) val template = collectionClickTemplate(context, appWidgetId) if (template != null) { diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt deleted file mode 100644 index 56bcfa5c7..000000000 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt +++ /dev/null @@ -1,250 +0,0 @@ -package cx.aswin.boxlore.feature.widgets - -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.appwidget.AppWidgetProviderInfo -import android.content.ComponentName -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.os.Build -import android.os.Bundle -import android.widget.RemoteViews -import androidx.core.content.ContextCompat -import androidx.core.content.edit -import androidx.core.graphics.createBitmap -import cx.aswin.boxlore.core.designsystem.theme.GoogleSansTypefaces -import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork -import cx.aswin.boxlore.feature.widgets.logic.WidgetSize -import java.io.File -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * Gives the launcher's widget picker previews drawn by the live renderers, in the listener's own widget - * colours and lettering. Android 15 and later show them. Older versions, and any preview the system - * turns down, fall back to the static `widget_preview_*` layouts, which can only use system colours. - */ -object WidgetPickerPreviews { - /** One picker entry: the widget it belongs to, the size it is drawn for, and how to draw it. */ - internal class Preview( - val key: String, - val receiver: Class, - /** - * A little under the widget's usual size, so a preview never outgrows the picker's frame. At - * this size the library widgets show whole rows only. - */ - val size: WidgetSize, - val build: (Context) -> RemoteViews, - ) - - /** - * Publishes every preview whose look changed since it was last accepted. Cheap to call often: an - * unchanged look publishes nothing, and a preview the system rate-limits is tried again next time. - */ - suspend fun refresh(context: Context) = withContext(Dispatchers.Default) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return@withContext - val app = context.applicationContext - val manager = app.getSystemService(AppWidgetManager::class.java) ?: return@withContext - val look = lookSignature(app) - val published = app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - for (preview in previews()) { - if (published.getString(preview.key, null) == look) continue - val accepted = - runCatching { - manager.setWidgetPreview( - ComponentName(app, preview.receiver), - AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN, - preview.build(app), - ) - }.getOrDefault(false) - if (accepted) published.edit { putString(preview.key, look) } - } - } - - internal fun previews(): List = listOf( - playback("full", NowPlayingWidgetReceiver::class.java, WidgetVariant.NOW_PLAYING, FULL_WIDTH_DP, FULL_HEIGHT_DP), - playback("compact", NowPlayingBarWidgetReceiver::class.java, WidgetVariant.BAR, FULL_WIDTH_DP, BAR_HEIGHT_DP), - playback("round", PlaybackControlsWidgetReceiver::class.java, WidgetVariant.CONTROLS, ROUND_SIDE_DP, ROUND_SIDE_DP), - playback("round_next", PlaybackNextControlsWidgetReceiver::class.java, WidgetVariant.CONTROLS_NEXT, ROUND_SIDE_DP, ROUND_SIDE_DP), - library("shows", SubscriptionsWidgetReceiver::class.java, LibraryWidgetKind.SUBSCRIPTIONS, SHOWS_HEIGHT_DP), - library("new_episodes", NewEpisodesWidgetReceiver::class.java, LibraryWidgetKind.NEW_EPISODES, EPISODES_HEIGHT_DP), - ) - - private fun playback( - key: String, - receiver: Class, - variant: WidgetVariant, - widthDp: Int, - heightDp: Int, - ) = Preview(key, receiver, WidgetSize(widthDp, heightDp)) { context -> - NowPlayingWidgetRenderer.buildRemoteViewsAt( - context = context, - appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID, - snapshot = samplePlayback(context), - options = - Bundle().apply { - putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, widthDp) - putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, widthDp) - putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, heightDp) - putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, heightDp) - }, - variant = variant, - ) - } - - private fun library( - key: String, - receiver: Class, - kind: LibraryWidgetKind, - heightDp: Int, - ) = Preview(key, receiver, WidgetSize(FULL_WIDTH_DP, heightDp)) { context -> - val sample = sampleLibrary(context) - // The picker cannot bind the widget's list service, so the preview carries its rows with it. - val factory = LibraryWidgetRemoteViewsFactory(context, kind, preset = sample).apply { onDataSetChanged() } - val rows = - RemoteViews.RemoteCollectionItems - .Builder() - .setHasStableIds(true) - .setViewTypeCount(1) - .apply { for (position in 0 until factory.count) addItem(factory.getItemId(position), factory.getViewAt(position)) } - .build() - LibraryWidgetRenderer.build( - context = context, - appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID, - snapshot = sample, - kind = kind, - collection = rows, - ) - } - - /** - * Everything that changes how a preview looks: the widget colours, the lettering, the Your Shows - * cover style and the installed build. A preview is published again only when this changes. - */ - internal fun lookSignature(context: Context): String { - val chrome = WidgetChrome.resolve(context) - val colours = - listOf( - WidgetPalette.surface, - WidgetPalette.onSurface, - WidgetPalette.primary, - WidgetPalette.primaryContainer, - WidgetPalette.tertiary, - WidgetPalette.tertiaryContainer, - ).joinToString(",") { role -> Integer.toHexString(chrome.argb(role)) } - val updatedAt = runCatching { context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime }.getOrDefault(0L) - return listOf( - PREVIEW_VERSION, - updatedAt, - chrome.usesAppTheme, - chrome.followsSystemDayNight, - colours, - GoogleSansTypefaces.cachedRoundness(context), - WidgetShowsArtwork.cached(context), - ).joinToString("|") - } - - internal fun samplePlayback(context: Context) = NowPlayingWidgetSnapshot( - episodeId = SAMPLE_ID, - episodeTitle = context.getString(R.string.widget_preview_episode), - podcastTitle = context.getString(R.string.widget_preview_podcast), - artworkCachePath = sampleCover(context, 0), - isPlaying = true, - positionMs = SAMPLE_POSITION_MS, - durationMs = SAMPLE_DURATION_MS, - seekForwardMs = SAMPLE_SEEK_FORWARD_MS, - seekBackwardMs = SAMPLE_SEEK_BACK_MS, - ) - - internal fun sampleLibrary(context: Context): LibraryWidgetSnapshot { - val episodes = - listOf( - R.string.widget_preview_episode to R.string.widget_preview_podcast, - R.string.widget_preview_episode_2 to R.string.widget_preview_episode_2_show, - R.string.widget_preview_episode_3 to R.string.widget_preview_episode_3_show, - ) - return LibraryWidgetSnapshot( - subscriptions = - List(SAMPLE_SHOWS) { index -> - WidgetShowRow( - podcastId = "$SAMPLE_ID-show-$index", - title = context.getString(episodes[index % episodes.size].second), - artworkCachePath = sampleCover(context, index), - deepLinkUri = SAMPLE_LINK, - isNew = index in SAMPLE_NEW_SHOWS, - ) - }, - newEpisodes = - episodes.mapIndexed { index, (episode, show) -> - WidgetEpisodeRow( - episodeId = "$SAMPLE_ID-episode-$index", - episodeTitle = context.getString(episode), - podcastId = "$SAMPLE_ID-show-$index", - podcastTitle = context.getString(show), - artworkCachePath = sampleCover(context, index), - deepLinkUri = SAMPLE_LINK, - ) - }, - counts = WidgetLibraryCounts(subscriptions = SAMPLE_SHOW_COUNT, newEpisodes = SAMPLE_EPISODE_COUNT), - ) - } - - /** - * The path of sample cover [index], drawn from the same pictures the static previews use and kept - * in the widget artwork cache. Null when it cannot be written; the widget then shows its placeholder. - */ - private fun sampleCover(context: Context, index: Int): String? { - val drawableRes = SAMPLE_COVERS[index.mod(SAMPLE_COVERS.size)] - val directory = File(context.cacheDir, WidgetArtworkLoader.CACHE_DIR_NAME) - val file = File(directory, "preview_cover_${PREVIEW_VERSION}_${index.mod(SAMPLE_COVERS.size)}.png") - if (file.exists() && file.length() > 0L) return file.absolutePath - return runCatching { - directory.mkdirs() - val drawable = ContextCompat.getDrawable(context, drawableRes) ?: return null - val bitmap = createBitmap(SAMPLE_COVER_PX, SAMPLE_COVER_PX) - drawable.setBounds(0, 0, SAMPLE_COVER_PX, SAMPLE_COVER_PX) - drawable.draw(Canvas(bitmap)) - file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, it) } - file.absolutePath - }.getOrNull() - } - - private val SAMPLE_COVERS = - listOf( - R.drawable.widget_preview_cover_a, - R.drawable.widget_preview_cover_b, - R.drawable.widget_preview_cover_c, - R.drawable.widget_preview_cover_d, - R.drawable.widget_preview_cover_e, - R.drawable.widget_preview_cover_f, - ) - - internal const val PREFS_NAME = "boxlore_widget_picker_previews" - - /** Bump when the sample content or the way previews are built changes. */ - private const val PREVIEW_VERSION = 1 - - // A little under a typical 4-column grid, so a preview never outgrows the picker's frame. - private const val FULL_WIDTH_DP = 320 - private const val FULL_HEIGHT_DP = 170 - private const val BAR_HEIGHT_DP = 80 - - // Two whole rows of covers and three whole episode rows at the preview's width. - private const val SHOWS_HEIGHT_DP = 290 - private const val EPISODES_HEIGHT_DP = 296 - private const val ROUND_SIDE_DP = 160 - - private const val SAMPLE_ID = "widget-preview" - private const val SAMPLE_LINK = "boxlore://home" - private const val SAMPLE_POSITION_MS = 24 * 60_000L - private const val SAMPLE_DURATION_MS = 60 * 60_000L - private const val SAMPLE_SEEK_FORWARD_MS = 30_000L - private const val SAMPLE_SEEK_BACK_MS = 10_000L - private const val SAMPLE_SHOWS = 8 - private val SAMPLE_NEW_SHOWS = setOf(0, 2, 5) - private const val SAMPLE_SHOW_COUNT = 24 - private const val SAMPLE_EPISODE_COUNT = 12 - private const val SAMPLE_COVER_PX = 256 - private const val PNG_QUALITY = 100 -} diff --git a/feature/widgets/src/main/res/drawable/widget_art_tile_shape.xml b/feature/widgets/src/main/res/drawable/widget_art_tile_shape.xml new file mode 100644 index 000000000..3279bf657 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_art_tile_shape.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_list_art_frame.xml b/feature/widgets/src/main/res/drawable/widget_list_art_frame.xml new file mode 100644 index 000000000..ea89223fc --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_list_art_frame.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_button_primary.xml b/feature/widgets/src/main/res/drawable/widget_preview_button_primary.xml new file mode 100644 index 000000000..7b3539fc9 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_button_primary.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_button_secondary.xml b/feature/widgets/src/main/res/drawable/widget_preview_button_secondary.xml new file mode 100644 index 000000000..3279bf657 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_button_secondary.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_a.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_a.xml index c7db751ae..46d7b5435 100644 --- a/feature/widgets/src/main/res/drawable/widget_preview_cover_a.xml +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_a.xml @@ -1,14 +1,5 @@ - - - - - + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_b.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_b.xml index 4439bd98b..89c146a7d 100644 --- a/feature/widgets/src/main/res/drawable/widget_preview_cover_b.xml +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_b.xml @@ -1,14 +1,5 @@ - - - - - + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_c.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_c.xml index b490e1c62..07c72c36f 100644 --- a/feature/widgets/src/main/res/drawable/widget_preview_cover_c.xml +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_c.xml @@ -1,14 +1,5 @@ - - - - - + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_d.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_d.xml deleted file mode 100644 index f7b36051f..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_cover_d.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_e.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_e.xml deleted file mode 100644 index 6bd6cf884..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_cover_e.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_f.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_f.xml deleted file mode 100644 index 6310bd1ce..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_cover_f.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_disc_a.xml b/feature/widgets/src/main/res/drawable/widget_preview_disc_a.xml deleted file mode 100644 index f72d6f0f8..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_disc_a.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_disc_b.xml b/feature/widgets/src/main/res/drawable/widget_preview_disc_b.xml deleted file mode 100644 index de004ec79..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_disc_b.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_disc_c.xml b/feature/widgets/src/main/res/drawable/widget_preview_disc_c.xml deleted file mode 100644 index b1d912816..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_disc_c.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_new_dot.xml b/feature/widgets/src/main/res/drawable/widget_preview_new_dot.xml index 84bc89b75..4d6a435b1 100644 --- a/feature/widgets/src/main/res/drawable/widget_preview_new_dot.xml +++ b/feature/widgets/src/main/res/drawable/widget_preview_new_dot.xml @@ -1,9 +1,5 @@ - - - + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_pill_chip.xml b/feature/widgets/src/main/res/drawable/widget_preview_pill_chip.xml new file mode 100644 index 000000000..ad3b3f93e --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_pill_chip.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_ring.xml b/feature/widgets/src/main/res/drawable/widget_preview_ring.xml deleted file mode 100644 index 5ecc94d7e..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_ring.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_row_chip.xml b/feature/widgets/src/main/res/drawable/widget_preview_row_chip.xml new file mode 100644 index 000000000..a75f77455 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_row_chip.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_surface.xml b/feature/widgets/src/main/res/drawable/widget_preview_surface.xml new file mode 100644 index 000000000..f02e9fc59 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_surface.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_a.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_a.xml deleted file mode 100644 index 500a233c4..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_tile_a.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_b.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_b.xml deleted file mode 100644 index 153b956b0..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_tile_b.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_c.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_c.xml deleted file mode 100644 index f85d42ad9..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_tile_c.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_d.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_d.xml deleted file mode 100644 index 3bada6353..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_tile_d.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_e.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_e.xml deleted file mode 100644 index 637d98141..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_tile_e.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_f.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_f.xml deleted file mode 100644 index d50f0c7e6..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_tile_f.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - diff --git a/feature/widgets/src/main/res/layout/widget_preview_bar_4x1.xml b/feature/widgets/src/main/res/layout/widget_preview_bar_4x1.xml index af37aa611..1b014b3cf 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_bar_4x1.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_bar_4x1.xml @@ -1,154 +1,138 @@ - + + android:layout_width="match_parent" + android:layout_height="match_parent" + android:theme="@android:style/Theme.DeviceDefault.DayNight"> - - - + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:background="@drawable/widget_preview_surface" + android:clipToOutline="true" + android:paddingHorizontal="10dp" + android:paddingVertical="4dp"> + + + + + + android:orientation="horizontal"> + android:layout_width="36dp" + android:layout_height="36dp"> + android:src="@drawable/widget_preview_button_secondary" /> + android:src="@drawable/ic_widget_skip_back" /> - - - - - - - + android:layout_width="36dp" + android:layout_height="36dp" + android:layout_marginStart="4dp"> + android:src="@drawable/widget_preview_button_primary" /> + android:src="@drawable/ic_widget_pause" /> + android:layout_width="36dp" + android:layout_height="36dp" + android:layout_marginStart="4dp"> + android:src="@drawable/widget_preview_button_secondary" /> + android:src="@drawable/ic_widget_skip_fwd" /> + - - - + - - + + + - + diff --git a/feature/widgets/src/main/res/layout/widget_preview_control_4x2.xml b/feature/widgets/src/main/res/layout/widget_preview_control_4x2.xml index 36a841d22..2aad65b68 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_control_4x2.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_control_4x2.xml @@ -1,233 +1,182 @@ - + + android:layout_width="match_parent" + android:layout_height="match_parent" + android:theme="@android:style/Theme.DeviceDefault.DayNight"> + android:layout_height="wrap_content" + android:layout_gravity="center" + android:background="@drawable/widget_preview_surface" + android:clipToOutline="true" + android:orientation="vertical" + android:paddingHorizontal="12dp" + android:paddingVertical="5dp"> - + android:layout_height="48dp" + android:gravity="center_vertical" + android:orientation="horizontal"> - + + + + - - - - - - - - - + + + + - - - - - + android:layout_marginTop="1dp" + android:ellipsize="end" + android:fontFamily="sans-serif" + android:includeFontPadding="false" + android:maxLines="1" + android:text="@string/widget_preview_podcast" + android:textColor="@color/widget_on_surface_variant" + android:textSize="11sp" /> - + + android:layout_height="38dp" + android:layout_weight="1"> + android:src="@drawable/widget_preview_button_secondary" /> + + + + + - - - - - - - - - - - + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_preview_button_secondary" /> + + + android:layout_width="0dp" + android:layout_height="38dp" + android:layout_marginStart="4dp" + android:layout_weight="1"> + android:src="@drawable/widget_preview_button_primary" /> + android:src="@drawable/ic_widget_pause" /> + android:layout_height="38dp" + android:layout_marginStart="4dp" + android:layout_weight="1"> + android:src="@drawable/widget_preview_button_secondary" /> + + + - + + - - - - - - - - - - + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_preview_button_secondary" /> + + diff --git a/feature/widgets/src/main/res/layout/widget_preview_grid_2x2.xml b/feature/widgets/src/main/res/layout/widget_preview_grid_2x2.xml index 833334568..8efeab8b6 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_grid_2x2.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_grid_2x2.xml @@ -1,117 +1,109 @@ - + + android:layout_width="match_parent" + android:layout_height="match_parent" + android:theme="@android:style/Theme.DeviceDefault.DayNight"> - + - + android:layout_height="0dp" + android:layout_weight="1" + android:orientation="horizontal"> - - - + - + + - + android:layout_marginStart="6dp" + android:layout_weight="1"> - - + - + + + - + + + android:layout_weight="1"> - - + - + + - + android:layout_marginStart="6dp" + android:layout_weight="1"> + + - - - + + + + diff --git a/feature/widgets/src/main/res/layout/widget_preview_grid_next_2x2.xml b/feature/widgets/src/main/res/layout/widget_preview_grid_next_2x2.xml index 4c44f2199..91eaa9701 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_grid_next_2x2.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_grid_next_2x2.xml @@ -1,117 +1,109 @@ - + + android:layout_width="match_parent" + android:layout_height="match_parent" + android:theme="@android:style/Theme.DeviceDefault.DayNight"> - + - + android:layout_height="0dp" + android:layout_weight="1" + android:orientation="horizontal"> - - - + - + + - + android:layout_marginStart="6dp" + android:layout_weight="1"> - - + - + + + - + + + android:layout_weight="1"> - - + - + + - + android:layout_marginStart="6dp" + android:layout_weight="1"> + + - - - + + + + diff --git a/feature/widgets/src/main/res/layout/widget_preview_new_episodes.xml b/feature/widgets/src/main/res/layout/widget_preview_new_episodes.xml index 5e2884204..a48c9b5ba 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_new_episodes.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_new_episodes.xml @@ -1,298 +1,192 @@ - - + + - + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingBottom="10dp"> + + + + + - + + android:layout_height="68dp" + android:paddingBottom="6dp"> + + + android:orientation="horizontal" + android:paddingHorizontal="8dp"> + android:layout_width="48dp" + android:layout_height="48dp" + android:background="@drawable/widget_list_art_frame" + android:clipToOutline="true"> + android:scaleType="centerCrop" + android:src="@drawable/widget_preview_cover_a" /> + + + - + android:maxLines="2" + android:text="@string/widget_preview_episode" + android:textColor="@color/widget_on_secondary_container" + android:textSize="14sp" /> - + + + + + + + + + + + + android:layout_width="48dp" + android:layout_height="48dp" + android:background="@drawable/widget_list_art_frame" + android:clipToOutline="true"> - - + android:scaleType="centerCrop" + android:src="@drawable/widget_preview_cover_b" /> - - - - - - - - - - + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="10dp" + android:layout_weight="1" + android:orientation="vertical"> - - - - - - - - - - - - - - - - - - - + android:ellipsize="end" + android:fontFamily="sans-serif-medium" + android:includeFontPadding="false" + android:maxLines="2" + android:text="@string/widget_preview_episode_2" + android:textColor="@color/widget_on_secondary_container" + android:textSize="14sp" /> - - - - - - + android:layout_marginTop="2dp" + android:ellipsize="end" + android:fontFamily="sans-serif" + android:includeFontPadding="false" + android:maxLines="1" + android:text="@string/widget_preview_episode_2_show" + android:textColor="@color/widget_on_surface_variant" + android:textSize="12sp" /> - - - - - - - - - - - - - - - - + + - - - - - - + + diff --git a/feature/widgets/src/main/res/layout/widget_preview_subscriptions.xml b/feature/widgets/src/main/res/layout/widget_preview_subscriptions.xml index 525ba5c4f..52a8a84b7 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_subscriptions.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_subscriptions.xml @@ -1,243 +1,265 @@ - - - - + + + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingBottom="10dp"> + + + + + + + + + + + android:orientation="horizontal" + android:paddingHorizontal="8dp"> + android:layout_width="40dp" + android:layout_height="40dp" + android:background="@drawable/widget_list_art_frame" + android:clipToOutline="true"> - - + android:scaleType="centerCrop" + android:src="@drawable/widget_preview_cover_a" /> - + android:orientation="vertical"> - + - + android:layout_height="wrap_content" + android:layout_marginTop="2dp" + android:ellipsize="end" + android:fontFamily="sans-serif" + android:includeFontPadding="false" + android:maxLines="1" + android:text="@string/widget_preview_show_2_sub" + android:textColor="@color/widget_on_surface_variant" + android:textSize="12sp" /> + - - + + + + + + + + android:layout_height="match_parent" + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingHorizontal="8dp"> + android:layout_width="40dp" + android:layout_height="40dp" + android:background="@drawable/widget_list_art_frame" + android:clipToOutline="true"> - - + android:scaleType="centerCrop" + android:src="@drawable/widget_preview_cover_b" /> - + android:layout_height="wrap_content" + android:layout_marginStart="10dp" + android:layout_weight="1" + android:orientation="vertical"> - - - - + android:layout_height="wrap_content" + android:ellipsize="end" + android:fontFamily="sans-serif-medium" + android:includeFontPadding="false" + android:maxLines="1" + android:text="@string/widget_preview_show_2" + android:textColor="@color/widget_on_secondary_container" + android:textSize="14sp" /> - - - - + android:layout_height="wrap_content" + android:layout_marginTop="2dp" + android:ellipsize="end" + android:fontFamily="sans-serif" + android:includeFontPadding="false" + android:maxLines="1" + android:text="@string/widget_preview_show_3_sub" + android:textColor="@color/widget_on_surface_variant" + android:textSize="12sp" /> + + + - + + - - - + - - - - - + android:layout_height="match_parent" + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingHorizontal="8dp"> + android:layout_width="40dp" + android:layout_height="40dp" + android:background="@drawable/widget_list_art_frame" + android:clipToOutline="true"> - - + android:scaleType="centerCrop" + android:src="@drawable/widget_preview_cover_c" /> - + android:layout_height="wrap_content" + android:layout_marginStart="10dp" + android:layout_weight="1" + android:orientation="vertical"> - - - - + android:layout_height="wrap_content" + android:ellipsize="end" + android:fontFamily="sans-serif-medium" + android:includeFontPadding="false" + android:maxLines="1" + android:text="@string/widget_preview_show_3" + android:textColor="@color/widget_on_secondary_container" + android:textSize="14sp" /> - - + android:layout_height="wrap_content" + android:layout_marginTop="2dp" + android:ellipsize="end" + android:fontFamily="sans-serif" + android:includeFontPadding="false" + android:maxLines="1" + android:text="@string/widget_preview_show_2_sub" + android:textColor="@color/widget_on_surface_variant" + android:textSize="12sp" /> + - - + + + + diff --git a/feature/widgets/src/main/res/values/strings.xml b/feature/widgets/src/main/res/values/strings.xml index 862c53a39..ca067b4fc 100644 --- a/feature/widgets/src/main/res/values/strings.xml +++ b/feature/widgets/src/main/res/values/strings.xml @@ -26,14 +26,15 @@ %1$s · %2$s Why curiosity compounds The Daily Lore + Field Notes + 12 new episodes + Soft Power + Updated yesterday A quiet week in Kyoto Field Notes How maps shape memory Soft Power 12 - 24 - 36m left · The Daily Lore - 36m left Library · Your Shows Your subscribed shows as a grid of artwork Subscriptions diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt deleted file mode 100644 index 693d7624e..000000000 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -package cx.aswin.boxlore.feature.widgets - -import android.appwidget.AppWidgetManager -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class HomeScreenWidgetsTest { - private val context: Context = ApplicationProvider.getApplicationContext() - - @Test - fun everyWidgetInThePickerCanBeAddedFromTheAppUnderTheSameName() { - val receivers = WidgetProviders.all.map { it.receiverClass } + LibraryWidgetProviders.all.map { it.receiverClass } - assertEquals(receivers.toSet(), HomeScreenWidgets.all.map { it.receiver }.toSet()) - assertEquals(HomeScreenWidgets.all.size, HomeScreenWidgets.all.map { it.key }.toSet().size) - // The same keys as the picker previews, so a widget is one thing wherever it is listed. - assertEquals(WidgetPickerPreviews.previews().associate { it.key to it.receiver }, HomeScreenWidgets.all.associate { it.key to it.receiver }) - for (entry in HomeScreenWidgets.all) { - val info = context.packageManager.getReceiverInfo(android.content.ComponentName(context, entry.receiver), 0) - assertEquals("${entry.key} is named as the picker names it", context.getString(info.labelRes), context.getString(entry.label)) - assertTrue(context.getString(entry.description).isNotBlank()) - } - } - - @Test - fun aHomeScreenThatCannotAddWidgetsIsReportedAndNeverAsked() { - shadowOf(context.getSystemService(AppWidgetManager::class.java)).setRequestPinAppWidgetSupported(false) - assertFalse(HomeScreenWidgets.canAdd(context)) - assertFalse(HomeScreenWidgets.requestAdd(context, "full")) - } - - @Test - fun aHomeScreenThatCanAddWidgetsIsAskedForKnownWidgetsOnly() { - shadowOf(context.getSystemService(AppWidgetManager::class.java)).setRequestPinAppWidgetSupported(true) - assertTrue(HomeScreenWidgets.canAdd(context)) - assertTrue(HomeScreenWidgets.all.all { HomeScreenWidgets.requestAdd(context, it.key) }) - assertFalse(HomeScreenWidgets.requestAdd(context, "no-such-widget")) - } - - @Test - fun everyWidgetHasAPreviewDrawnForItsOwnSize() { - for (entry in HomeScreenWidgets.all) { - assertTrue("${entry.key} has a preview size", entry.previewWidthDp > 0 && entry.previewHeightDp > 0) - val views = HomeScreenWidgets.preview(context, entry.key) - assertTrue("${entry.key} draws", views != null) - views!!.apply(context, android.appwidget.AppWidgetHostView(context)) - } - assertEquals(null, HomeScreenWidgets.preview(context, "no-such-widget")) - // Only Your Shows follows the cover setting, so only it carries that choice in Settings. - assertEquals(listOf("shows"), HomeScreenWidgets.all.filter { it.usesShowsCoverStyle }.map { it.key }) - } -} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt deleted file mode 100644 index decac7cc8..000000000 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -package cx.aswin.boxlore.feature.widgets - -import android.content.Context -import android.widget.FrameLayout -import androidx.test.core.app.ApplicationProvider -import cx.aswin.boxlore.core.prefs.FontRoundnessAxis -import cx.aswin.boxlore.core.prefs.WidgetAppearance -import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork -import java.io.File -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class WidgetPickerPreviewsTest { - private val context: Context = ApplicationProvider.getApplicationContext() - - @Test - fun everyWidgetInThePickerHasItsOwnLivePreview() { - val previews = WidgetPickerPreviews.previews() - val receivers = WidgetProviders.all.map { it.receiverClass } + LibraryWidgetProviders.all.map { it.receiverClass } - - assertEquals(receivers.toSet(), previews.map { it.receiver }.toSet()) - assertEquals(previews.size, previews.map { it.key }.toSet().size) - } - - @Test - fun everyLivePreviewBuildsAndAppliesLikeAPlacedWidget() { - useTheme(brand = "violet") - for (preview in WidgetPickerPreviews.previews()) { - preview.build(context).apply(context, FrameLayout(context)) - } - } - - @Test - fun sampleContentHasCoversSoPreviewsNeverShowPlaceholders() { - val playback = WidgetPickerPreviews.samplePlayback(context) - val library = WidgetPickerPreviews.sampleLibrary(context) - val covers = listOfNotNull(playback.artworkCachePath) + library.subscriptions.mapNotNull { it.artworkCachePath } + library.newEpisodes.mapNotNull { it.artworkCachePath } - - assertEquals(1 + library.subscriptions.size + library.newEpisodes.size, covers.size) - assertTrue(covers.all { File(it).length() > 0L }) - assertTrue("several different covers", covers.toSet().size >= 4) - assertTrue(library.subscriptions.any { it.isNew } && library.subscriptions.any { !it.isNew }) - // The badge shows the library's size, not the handful of sample tiles. - assertTrue(library.count(LibraryWidgetKind.SUBSCRIPTIONS) > library.subscriptions.size) - } - - @Test - fun thePreviewLookChangesWithTheColoursAndTheCoverStyleAndOnlyThen() { - useTheme(brand = "violet") - val violet = WidgetPickerPreviews.lookSignature(context) - assertEquals("an unchanged look publishes nothing new", violet, WidgetPickerPreviews.lookSignature(context)) - - useTheme(brand = "emerald") - val emerald = WidgetPickerPreviews.lookSignature(context) - assertNotEquals(violet, emerald) - - useTheme(brand = "emerald", appearance = WidgetAppearance.SYSTEM) - assertNotEquals(emerald, WidgetPickerPreviews.lookSignature(context)) - - useTheme(brand = "emerald", showsArtwork = WidgetShowsArtwork.SHAPES) - assertNotEquals(emerald, WidgetPickerPreviews.lookSignature(context)) - } - - @Test - fun beforeAndroid15NothingIsPublishedAndTheStaticPreviewsStay() = runTest { - WidgetPickerPreviews.refresh(context) - assertTrue(context.getSharedPreferences(WidgetPickerPreviews.PREFS_NAME, Context.MODE_PRIVATE).all.isEmpty()) - } - - @Test - @Config(sdk = [35]) - fun onAndroid15PublishingNeverThrowsAndARejectedPreviewIsTriedAgain() = runTest { - useTheme(brand = "violet") - WidgetPickerPreviews.refresh(context) - val published = context.getSharedPreferences(WidgetPickerPreviews.PREFS_NAME, Context.MODE_PRIVATE).all - // Only previews the system accepted are remembered; the rest stay due for the next call. - assertTrue(published.values.all { it == WidgetPickerPreviews.lookSignature(context) }) - } - - private fun useTheme( - brand: String, - appearance: String = WidgetAppearance.APP, - showsArtwork: String = WidgetShowsArtwork.ROUNDED, - ) { - context - .getSharedPreferences(FontRoundnessAxis.THEME_FAST_CACHE, Context.MODE_PRIVATE) - .edit() - .putString("theme_brand", brand) - .putString("theme_config", "dark") - .putString("surface_style", "classic_dynamic") - .putBoolean("use_dynamic_color", false) - .putString(WidgetAppearance.PREF_KEY, appearance) - .putString(WidgetShowsArtwork.PREF_KEY, showsArtwork) - .commit() - } -} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt index a9a550d21..f4772016f 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt @@ -1,6 +1,5 @@ package cx.aswin.boxlore.feature.widgets -import android.appwidget.AppWidgetHostView import android.appwidget.AppWidgetManager import android.content.Context import android.graphics.Bitmap @@ -163,31 +162,6 @@ class WidgetRenderGalleryTest { private class LibraryLook(val kind: LibraryWidgetKind, val artwork: String, val name: String) - @Test - fun pickerPreviewsDrawBothLiveAndStatic() { - val picker = - mapOf( - "full" to (R.layout.widget_preview_control_4x2 to Size("picker", 350, 182)), - "compact" to (R.layout.widget_preview_bar_4x1 to Size("picker", 350, 86)), - "round" to (R.layout.widget_preview_grid_2x2 to Size("picker", 170, 170)), - "round_next" to (R.layout.widget_preview_grid_next_2x2 to Size("picker", 170, 170)), - "shows" to (R.layout.widget_preview_subscriptions to Size("picker", 350, 278)), - "new_episodes" to (R.layout.widget_preview_new_episodes to Size("picker", 350, 278)), - ) - for (dark in listOf(false, true)) { - useAppTheme(dark) - setShowsArtwork(WidgetShowsArtwork.ROUNDED) - for (preview in WidgetPickerPreviews.previews()) { - val (staticLayout, size) = picker.getValue(preview.key) - // Live: what Android 15 and later show, drawn by the widget's own renderer. - // Drawn in a real widget host: rows a preview carries with it are only shown inside one. - save(draw(preview.build(context), size, dark, host = AppWidgetHostView(context)), "picker-live-${preview.key}-${themeName(dark)}") - // Static: the fallback layout older versions inflate. - save(draw(RemoteViews(context.packageName, staticLayout), size, dark), "picker-static-${preview.key}-${themeName(dark)}") - } - } - } - private fun drawLibrary(kind: LibraryWidgetKind, snapshot: LibraryWidgetSnapshot, size: Size, dark: Boolean): Bitmap { LibraryWidgetSnapshotStore(context).write(snapshot) val views = LibraryWidgetRenderer.build(context, appWidgetId = 9, snapshot = snapshot, kind = kind) @@ -209,15 +183,10 @@ class WidgetRenderGalleryTest { } } - private fun draw( - views: RemoteViews, - size: Size, - dark: Boolean, - host: FrameLayout = FrameLayout(context), - prepare: (View) -> Unit = {}, - ): Bitmap { + private fun draw(views: RemoteViews, size: Size, dark: Boolean, prepare: (View) -> Unit = {}): Bitmap { val width = (size.widthDp * density).roundToInt() val height = (size.heightDp * density).roundToInt() + val host = FrameLayout(context) val root = views.apply(context, host) host.addView(root, FrameLayout.LayoutParams(width, height)) prepare(root) diff --git a/release-notes/upcoming.md b/release-notes/upcoming.md index 0d0e68842..bb43fcb53 100644 --- a/release-notes/upcoming.md +++ b/release-notes/upcoming.md @@ -27,8 +27,7 @@ Announcement title: ## Improved - Home screen widgets have a new look with rounder shapes. The players now show how far you are and how much time is left, and the skip buttons show your own skip lengths. -- The Your Shows widget is now a grid of artwork. Choose whole covers or shapes in Settings › Appearance › Widgets. -- See every boxlore widget and add it to your home screen from Settings › Appearance › Widgets. +- The Your Shows widget is now a grid of artwork. - On Home, a show you listen to oldest first now picks up exactly where you left off, even deep into a long series, and skips trailers. Daily mix suggests the same next episode. ## Fixed From f5be90667c911976689ee050588c86958afc6e02 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Wed, 11 Nov 2026 02:08:44 +0530 Subject: [PATCH 4/9] feat(settings): add a Widgets page with live previews and add to home screen --- app/README.md | 2 +- .../NavGraphSettingsDestinations.kt | 43 ++ .../boxlore/navigation/NavGraphWiring.kt | 1 + .../cx/aswin/boxlore/ui/BoxLoreAppRoot.kt | 4 + .../widgets/HomeScreenWidgetPreview.kt | 84 ++++ .../widgets/HomeScreenWidgetsInstaller.kt | 3 + .../boxlore/navigation/AddableWidgetsTest.kt | 31 ++ .../widgets/HomeScreenWidgetPreviewTest.kt | 131 ++++++ feature/settings/README.md | 9 +- .../settings/ProfileSettingsDestination.kt | 3 +- .../settings/SettingsExternalActions.kt | 14 + .../feature/settings/SettingsScreen.kt | 24 +- .../settings/pages/AppearanceSettingsPage.kt | 52 ++- .../settings/pages/AppearanceThemePage.kt | 18 +- .../settings/pages/AppearanceWidgetsPage.kt | 316 +++++++++++++ .../settings/AppearanceActionsTrackedTest.kt | 28 ++ .../settings/SettingsBackNavigationTest.kt | 1 + .../settings/TabletSettingsCaptureTest.kt | 3 + .../settings/pages/AppearancePagesTest.kt | 7 + .../pages/AppearanceWidgetsPageTest.kt | 181 ++++++++ feature/widgets/README.md | 13 +- .../feature/widgets/HomeScreenWidgets.kt | 74 ++++ .../LibraryWidgetRemoteViewsService.kt | 7 +- .../feature/widgets/LibraryWidgetRenderer.kt | 14 +- .../feature/widgets/WidgetPickerPreviews.kt | 250 +++++++++++ .../res/drawable/widget_art_tile_shape.xml | 6 - .../res/drawable/widget_list_art_frame.xml | 6 - .../widget_preview_button_primary.xml | 6 - .../widget_preview_button_secondary.xml | 6 - .../res/drawable/widget_preview_cover_a.xml | 17 +- .../res/drawable/widget_preview_cover_b.xml | 17 +- .../res/drawable/widget_preview_cover_c.xml | 17 +- .../res/drawable/widget_preview_cover_d.xml | 17 + .../res/drawable/widget_preview_cover_e.xml | 14 + .../res/drawable/widget_preview_cover_f.xml | 17 + .../res/drawable/widget_preview_disc_a.xml | 17 + .../res/drawable/widget_preview_disc_b.xml | 17 + .../res/drawable/widget_preview_disc_c.xml | 17 + .../res/drawable/widget_preview_new_dot.xml | 6 +- .../res/drawable/widget_preview_pill_chip.xml | 6 - .../main/res/drawable/widget_preview_ring.xml | 19 + .../res/drawable/widget_preview_row_chip.xml | 6 - .../res/drawable/widget_preview_surface.xml | 7 - .../res/drawable/widget_preview_tile_a.xml | 17 + .../res/drawable/widget_preview_tile_b.xml | 17 + .../res/drawable/widget_preview_tile_c.xml | 17 + .../res/drawable/widget_preview_tile_d.xml | 20 + .../res/drawable/widget_preview_tile_e.xml | 17 + .../res/drawable/widget_preview_tile_f.xml | 20 + .../res/layout/widget_preview_bar_4x1.xml | 194 ++++---- .../res/layout/widget_preview_control_4x2.xml | 297 +++++++------ .../res/layout/widget_preview_grid_2x2.xml | 188 ++++---- .../layout/widget_preview_grid_next_2x2.xml | 188 ++++---- .../layout/widget_preview_new_episodes.xml | 414 +++++++++++------- .../layout/widget_preview_subscriptions.xml | 384 ++++++++-------- .../widgets/src/main/res/values/strings.xml | 7 +- .../feature/widgets/HomeScreenWidgetsTest.kt | 61 +++ .../widgets/WidgetPickerPreviewsTest.kt | 104 +++++ .../widgets/WidgetRenderGalleryTest.kt | 35 +- release-notes/upcoming.md | 3 +- 60 files changed, 2660 insertions(+), 854 deletions(-) create mode 100644 app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreview.kt create mode 100644 app/src/test/java/cx/aswin/boxlore/navigation/AddableWidgetsTest.kt create mode 100644 app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreviewTest.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt create mode 100644 feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt create mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt create mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt delete mode 100644 feature/widgets/src/main/res/drawable/widget_art_tile_shape.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_list_art_frame.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_button_primary.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_button_secondary.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_cover_d.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_cover_e.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_cover_f.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_disc_a.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_disc_b.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_disc_c.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_pill_chip.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_ring.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_row_chip.xml delete mode 100644 feature/widgets/src/main/res/drawable/widget_preview_surface.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_a.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_b.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_c.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_d.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_e.xml create mode 100644 feature/widgets/src/main/res/drawable/widget_preview_tile_f.xml create mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt create mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt diff --git a/app/README.md b/app/README.md index 6b354c1bd..feebc6169 100644 --- a/app/README.md +++ b/app/README.md @@ -94,7 +94,7 @@ src/main/java/cx/aswin/boxlore/ AdaptivePlayerChrome, announcement/, libraryimport/, logic/ updates/ AppUpdates, UpdateChecker, direct and Play sources, AppUpdatesPanel util/ InstallSource - widgets/ HomeScreenWidgetsInstaller and widget adapters + widgets/ HomeScreenWidgetsInstaller, widget adapters and HomeScreenWidgetPreview (Settings' widget pictures) src/direct/ APK downloader and installer (debug, release, updateTest) src/playRelease/ Play stub installer src/debug/ App Check debug provider, ADB-only notification preview receiver diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt index 7a23d4d1a..6c5dbf9e4 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.navArgument @@ -24,11 +26,15 @@ import cx.aswin.boxlore.feature.settings.SettingsScreen import cx.aswin.boxlore.feature.settings.SettingsScreenConfig import cx.aswin.boxlore.feature.settings.downloads.AutoDownloadSettingsScreen import cx.aswin.boxlore.feature.settings.downloads.SmartDownloadsSettingsScreen +import cx.aswin.boxlore.feature.settings.pages.AddableWidget import cx.aswin.boxlore.feature.settings.pages.AppearanceActions import cx.aswin.boxlore.feature.settings.pages.AppearanceUiState +import cx.aswin.boxlore.feature.settings.pages.AppearanceWidgetPreview import cx.aswin.boxlore.feature.settings.pages.PlaybackActions import cx.aswin.boxlore.feature.settings.pages.PlaybackUiState +import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets import cx.aswin.boxlore.ui.libraryimport.OpmlImportState +import cx.aswin.boxlore.widgets.HomeScreenWidgetPreview import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged @@ -167,6 +173,35 @@ private fun resetSettingsAnalytics() { } } +/** Every boxlore widget under its picker name, for Appearance › Widgets. Reads strings only. */ +internal fun addableWidgets(context: android.content.Context): List = HomeScreenWidgets.all.map { widget -> + AddableWidget( + key = widget.key, + name = context.getString(widget.label), + description = context.getString(widget.description), + previewWidthDp = widget.previewWidthDp, + previewHeightDp = widget.previewHeightDp, + usesShowsCoverStyle = widget.usesShowsCoverStyle, + ) +} + +/** Appearance › Widgets draws the real widgets, again whenever a setting changes how they look. */ +private fun widgetPreview(w: NavGraphWiring): AppearanceWidgetPreview = { widget, modifier -> + val settings = w.settingsState + val look = + listOf( + settings.widgetAppearance, + settings.widgetShowsArtwork, + settings.themeConfig, + settings.themeBrand, + settings.surfaceStyle, + settings.useDynamicColor, + settings.fontRoundness, + settings.savedCustomTheme, + ) + HomeScreenWidgetPreview(widget.key, DpSize(widget.previewWidthDp.dp, widget.previewHeightDp.dp), look, modifier) +} + private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings { val scope = w.scope val userPrefs = w.userPrefs @@ -183,6 +218,8 @@ private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings { currentOpenAppTo = settingsState.openAppTo, homeShortcutsInLibrary = settingsState.homeShortcutsInLibrary, currentWidgetAppearance = settingsState.widgetAppearance, + currentWidgetShowsArtwork = settingsState.widgetShowsArtwork, + addableWidgets = addableWidgets(w.context), currentExploreDefaultTab = settingsState.exploreDefaultTab, currentSubscriptionsDefaultTab = settingsState.subscriptionsDefaultTab, currentSubscriptionsTabStyle = settingsState.subscriptionsTabStyle, @@ -225,6 +262,11 @@ private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings { onSetWidgetAppearance = { appearance -> scope.launch { userPrefs.setWidgetAppearance(appearance) } }, + onSetWidgetShowsArtwork = { style -> + scope.launch { userPrefs.setWidgetShowsArtwork(style) } + }, + canAddWidgets = { HomeScreenWidgets.canAdd(w.context) }, + onAddWidget = { key -> HomeScreenWidgets.requestAdd(w.context, key) }, onSetExploreDefaultTab = { tab -> scope.launch { userPrefs.setExploreDefaultTab(tab) } }, @@ -246,6 +288,7 @@ private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings { val sessions by w.playbackRepository.resumeSessions.collectAsState(initial = emptyList()) HeroStylePreview(style, coverShade, collection, sessions, modifier, tideProgress = settingsState.heroTideProgress) }, + widgetPreview = widgetPreview(w), ) } diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt index 5691f2a5e..2c6b28750 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt @@ -147,6 +147,7 @@ data class NavSettingsState( val sameShowQueueOnly: Boolean, val homeShortcutsInLibrary: Boolean, val widgetAppearance: String, + val widgetShowsArtwork: String, val exploreDefaultTab: String, val subscriptionsDefaultTab: String, val subscriptionsTabStyle: String = cx.aswin.boxlore.core.prefs.SubscriptionsTabStyle.TOP, diff --git a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt index 910cc9be6..d3233c679 100644 --- a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt +++ b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt @@ -433,6 +433,9 @@ fun BoxLoreAppRoot( val widgetAppearance by userPrefs.widgetAppearanceStream.collectAsState( initial = remember { userPrefs.cachedWidgetAppearance }, ) + val widgetShowsArtwork by userPrefs.widgetShowsArtworkStream.collectAsState( + initial = remember { userPrefs.cachedWidgetShowsArtwork }, + ) val exploreDefaultTab by userPrefs.exploreDefaultTabStream.collectAsState( initial = remember { userPrefs.cachedExploreDefaultTab }, ) @@ -710,6 +713,7 @@ fun BoxLoreAppRoot( homeShortcutsInLibrary = homeShortcutsInLibrary, discoverySections = discoverySections, widgetAppearance = widgetAppearance, + widgetShowsArtwork = widgetShowsArtwork, exploreDefaultTab = exploreDefaultTab, subscriptionsDefaultTab = subscriptionsDefaultTab, subscriptionsTabStyle = subscriptionsTabStyle, diff --git a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreview.kt b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreview.kt new file mode 100644 index 000000000..58c2090e6 --- /dev/null +++ b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreview.kt @@ -0,0 +1,84 @@ +package cx.aswin.boxlore.widgets + +import android.annotation.SuppressLint +import android.appwidget.AppWidgetHostView +import android.content.Context +import android.view.MotionEvent +import android.view.View +import android.widget.RemoteViews +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.viewinterop.AndroidView +import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * The home screen widget with this [key], drawn by its own renderer with sample content. The widget + * is laid out at its own [size] and that picture is scaled to fit the modifier's bounds, so it looks + * the same at every window width. It is drawn again when [look] changes: pass everything that changes + * how a widget looks (its colours, the lettering, the Your Shows cover style). It is a picture: + * nothing in it can be tapped, and screen readers skip it. + */ +@Composable +internal fun HomeScreenWidgetPreview(key: String, size: DpSize, look: Any, modifier: Modifier = Modifier) { + val context = LocalContext.current + // Light and dark, and the font scale, change what the widget's own layout resolves to. + val configuration = LocalConfiguration.current + val views by produceState(initialValue = null, key, look, configuration) { + value = withContext(Dispatchers.Default) { HomeScreenWidgets.preview(context, key) } + } + BoxWithConstraints(modifier, contentAlignment = Alignment.Center) { + val scale = widgetPictureScale(size, maxWidth, maxHeight) + AndroidView( + factory = ::WidgetPictureHost, + update = { host -> host.show(views) }, + modifier = + Modifier.requiredSize(size).graphicsLayer { + scaleX = scale + scaleY = scale + }, + ) + } +} + +/** How much a widget laid out at [size] is scaled to fit the space it has. It is shrunk to fit, never enlarged. */ +internal fun widgetPictureScale(size: DpSize, maxWidth: Dp, maxHeight: Dp): Float = minOf(1f, maxWidth / size.width, maxHeight / size.height) + +/** + * Holds one widget's views the way a home screen does: a list or grid a widget carries with it is + * only filled inside a widget host. Touches never reach the widget, so its buttons cannot control + * playback from Settings. + */ +@SuppressLint("ViewConstructor") +private class WidgetPictureHost(context: Context) : AppWidgetHostView(context) { + private var shown: RemoteViews? = null + + init { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + + fun show(views: RemoteViews?) { + if (views === shown) return + shown = views + removeAllViews() + if (views == null) return + // A preview that cannot be drawn leaves its frame empty; the widget's name still stands beside it. + runCatching { addView(views.apply(context, this), LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) } + } + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean = true + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean = false +} diff --git a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt index c94ae2bfb..6112c72d0 100644 --- a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt +++ b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt @@ -9,6 +9,7 @@ import cx.aswin.boxlore.feature.widgets.LibraryWidgetCoordinator import cx.aswin.boxlore.feature.widgets.LibraryWidgetDependencies import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetCoordinator import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetDependencies +import cx.aswin.boxlore.feature.widgets.WidgetPickerPreviews import cx.aswin.boxlore.feature.widgets.configureLibraryWidgets import cx.aswin.boxlore.feature.widgets.configureNowPlayingWidget import kotlinx.coroutines.CoroutineScope @@ -70,6 +71,8 @@ object HomeScreenWidgetsInstaller { ) { _, _, _ -> Unit }.collect { NowPlayingWidgetCoordinator.requestRefresh(context) LibraryWidgetCoordinator.requestRefresh(context) + // The widget picker shows the same look the placed widgets now have. + WidgetPickerPreviews.refresh(context) } } } diff --git a/app/src/test/java/cx/aswin/boxlore/navigation/AddableWidgetsTest.kt b/app/src/test/java/cx/aswin/boxlore/navigation/AddableWidgetsTest.kt new file mode 100644 index 000000000..be9afd891 --- /dev/null +++ b/app/src/test/java/cx/aswin/boxlore/navigation/AddableWidgetsTest.kt @@ -0,0 +1,31 @@ +package cx.aswin.boxlore.navigation + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class AddableWidgetsTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun settingsOffersEveryWidgetUnderItsPickerName() { + val offered = addableWidgets(context) + + assertEquals(HomeScreenWidgets.all.map { it.key }, offered.map { it.key }) + assertEquals("Now Playing · Full Controls", offered.first().name) + assertEquals("Library · New Episodes", offered.last().name) + assertTrue(offered.all { it.name.isNotBlank() && it.description.isNotBlank() }) + assertEquals("every name is told apart", offered.size, offered.map { it.name }.toSet().size) + // The two round players are small enough to sit side by side in Settings. + assertEquals(listOf("round", "round_next"), offered.filter { it.previewWidthDp <= 200 }.map { it.key }) + } +} diff --git a/app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreviewTest.kt b/app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreviewTest.kt new file mode 100644 index 000000000..dc406b812 --- /dev/null +++ b/app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetPreviewTest.kt @@ -0,0 +1,131 @@ +package cx.aswin.boxlore.widgets + +import android.app.Application +import android.appwidget.AppWidgetHostView +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme +import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The widget pictures Settings shows. Set `BOXLORE_WIDGET_RENDERS=1` to save them under + * `build/reports/widget-previews/` for a look. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w412dp-h2000dp-420dpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class HomeScreenWidgetPreviewTest { + @get:Rule val compose = createAndroidComposeRule() + + /** How big each frame is, as a share of its widget's own size: a narrow Settings card is smaller. */ + private val fit = mutableFloatStateOf(1f) + + private fun render(dark: Boolean) { + compose.setContent { + BoxLoreTheme(darkTheme = dark, dynamicColor = false) { + Column(Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + for (widget in HomeScreenWidgets.all) { + val size = DpSize(widget.previewWidthDp.dp, widget.previewHeightDp.dp) + Box(Modifier.background(MaterialTheme.colorScheme.outlineVariant).padding(14.dp)) { + HomeScreenWidgetPreview(widget.key, size, look = dark, Modifier.size(size * fit.floatValue)) + } + } + } + } + } + compose.waitUntil(timeoutMillis = 10_000) { hosts().size == HomeScreenWidgets.all.size && hosts().all { it.childCount == 1 } } + compose.waitForIdle() + } + + private fun hosts(): List { + val found = mutableListOf() + + fun walk(view: View) { + if (view is AppWidgetHostView) { + found += view + } else if (view is ViewGroup) { + for (index in 0 until view.childCount) walk(view.getChildAt(index)) + } + } + walk(compose.activity.window.decorView) + return found + } + + @Test + fun everyWidgetIsLaidOutAtItsOwnSizeHoweverSmallItsFrame() { + render(dark = false) + for (share in listOf(1f, 0.8f)) { + fit.floatValue = share + compose.waitForIdle() + val density = compose.activity.resources.displayMetrics.density + hosts().zip(HomeScreenWidgets.all).forEach { (host, widget) -> + val root = host.getChildAt(0) + assertNotNull("${widget.key} holds its widget", root) + assertEquals("${widget.key} fills its host", host.width, root.width) + // A smaller frame scales the picture; the widget itself keeps its size, so nothing reflows. + assertEquals("${widget.key} is laid out as wide as it is", widget.previewWidthDp * density, host.width.toFloat(), 2f) + assertEquals("${widget.key} is laid out as tall as it is", widget.previewHeightDp * density, host.height.toFloat(), 2f) + } + save(if (share == 1f) "light" else "light-narrow") + } + } + + @Test + fun aPictureIsShrunkToFitItsFrameAndNeverEnlarged() { + val widget = DpSize(320.dp, 170.dp) + assertEquals(1f, widgetPictureScale(widget, maxWidth = 320.dp, maxHeight = 170.dp)) + assertEquals(1f, widgetPictureScale(widget, maxWidth = 700.dp, maxHeight = 400.dp)) + assertEquals(0.8f, widgetPictureScale(widget, maxWidth = 256.dp, maxHeight = 170.dp), 0.001f) + assertEquals(0.5f, widgetPictureScale(widget, maxWidth = 320.dp, maxHeight = 85.dp), 0.001f) + } + + @Test + fun aPictureNeverPassesATouchToTheWidgetsButtons() { + render(dark = true) + for (host in hosts()) { + val now = SystemClock.uptimeMillis() + val down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, host.width / 2f, host.height / 2f, 0) + assertFalse("a tap is not handled inside the picture", host.dispatchTouchEvent(down)) + down.recycle() + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, host.importantForAccessibility) + } + save("dark") + } + + private fun save(name: String) { + if (System.getenv("BOXLORE_WIDGET_RENDERS") != "1") return + val content = compose.activity.findViewById(android.R.id.content) + val bitmap = Bitmap.createBitmap(content.width, content.height, Bitmap.Config.ARGB_8888) + content.draw(Canvas(bitmap)) + val directory = File("build/reports/widget-previews").apply { mkdirs() } + File(directory, "previews-$name.png").outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + } +} diff --git a/feature/settings/README.md b/feature/settings/README.md index 2739592f0..c7ee775d1 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -10,7 +10,7 @@ Owns the Settings hub and its pages, sign-in, the Smart and automatic download s - `SettingsScreenConfig.initialPage` opens a page directly (`account`, `sync`, `library`, `recommendations`, `appearance`, `theme`, `playback`, `downloads`, `privacy`, `about`, `support`); anything else opens the hub. `isOnboarding` makes Back leave Settings at once. - Shell callbacks on `SettingsScreenConfig`: `onSendFeedback`, `onCheckForUpdates` (the shell owns release checks and installation), and `onSupportPageVisibilityChanged` / `onAccountPageVisibilityChanged`, used to hide player chrome. - `SettingsViewModel`, built by `SettingsViewModelAssembler` (`create` / `factory`): the Add RSS flow and Reset recommendations. -- `AppearanceUiState`, `AppearanceActions` and `AppearanceHeroPreview`; `PlaybackUiState` and `PlaybackActions`; `LibraryDiscoveryPreferences` and `SavedShowsControl`. App wiring persists every value through `:core:prefs`. +- `AppearanceUiState`, `AppearanceActions`, `AddableWidget`, `AppearanceHeroPreview` and `AppearanceWidgetPreview`; `PlaybackUiState` and `PlaybackActions`; `LibraryDiscoveryPreferences` and `SavedShowsControl`. App wiring persists every value through `:core:prefs`. - `FeedbackScreen(supportRepository, boxcastPrefs, onBack, bottomContentPadding, openThreadId)` on the app's `feedback?thread=` route, plus `FeedbackViewModel`, `DiagnosticCollector`, `LogcatCollector` and `SharedPrefsRetryKeyStore`. - `AutoDownloadSettingsScreen` and `SmartDownloadsSettingsScreen`, each taking `UserPreferencesRepository` and `onBack`. - `LogsPreviewDialog` (in `feedback/FeedbackDialogs.kt`). @@ -23,9 +23,10 @@ Owns the Settings hub and its pages, sign-in, the Smart and automatic download s ## Appearance -- The overview shows Display mode, then one row per page with its current choice (`appearanceSummary`): **Theme & colors**, **Home**, **Player**, **Navigation** and **Text**. -- Theme & colors offers four looks (boxlore classic, Pure, Material 3, Custom), **Use artwork colors** (on by default) and **Widgets**. Legacy palettes show as Custom without changing stored values (`ThemeSettingsLogic.kt`). +- The overview shows Display mode, then one row per page with its current choice (`appearanceSummary`): **Theme & colors**, **Home**, **Player**, **Navigation**, **Text** and **Widgets**. +- Theme & colors offers four looks (boxlore classic, Pure, Material 3, Custom) and **Use artwork colors** (on by default). Legacy palettes show as Custom without changing stored values (`ThemeSettingsLogic.kt`). - The custom theme editor is a full page. Drafts change only its preview; Save applies the palette in one write, and Back discards it. +- Widgets holds the home screen widgets' colours (App theme or System) and every widget (`AddableWidget`, supplied by the app) as a card: its real look drawn by the app through `AppearanceWidgetPreview`, scaled to fit and never enlarged, its name, and an Add button that asks the home screen app to add it. The two round players share a row, and the Your Shows card carries the cover choice (whole covers or shapes) under its picture. A request the phone leaves unanswered (the app is not paused within `ADD_WIDGET_ANSWER_MS`) is explained in that card, with a way to the app's permission settings. When the home screen app cannot add widgets (`AppearanceActions.canAddWidgets`), the cards stay without buttons, with the steps for adding one by hand. - Home holds the hero card style and its colour choice, with live previews drawn by the app through `AppearanceHeroPreview`. Hero styles are described in [`feature/home/README.md`](../home/README.md). - Player holds **Player style** (Standard or bunpod) and the miniplayer's **Seek buttons** (off by default). The bunpod option credits Kamran Bekirov's BunPod design. Video always uses Standard. - Navigation holds the bar style, **Blur behind the bar** (Floating bar only) and **Start page & tabs** (**Explore opens to** hides while Picked for you is off). Text holds the letter shape with a preview. @@ -127,7 +128,7 @@ Hermetic JVM tests live in `feature/settings/src/test`, using fakes from [`:core | :--- | :--- | | Hub, Back and tablet split | `SettingsBackNavigationTest`, `TabletSettingsLayoutTest`, `TabletSettingsDialogTest`, `TabletSettingsCaptureTest`, `SettingsHeaderTitleTest` | | RSS and reset dialogs | `SettingsViewModelTest`, `SettingsViewModelDialogTest`, `SettingsGoldenRoborazziTest` | -| Appearance | `AppearancePagesTest`, `AppearanceActionsTrackedTest`, `SettingsPictureRowTest`, `PlayerStyleSectionTest` | +| Appearance | `AppearancePagesTest`, `AppearanceActionsTrackedTest`, `SettingsPictureRowTest`, `PlayerStyleSectionTest`, `AppearanceWidgetsPageTest` | | Themes | `CustomThemeEditorTest`, `CustomThemeEditorOptionsTest`, `ThemeEditorPressFeedbackTest`, `ThemeSettingsLogicTest`, `ThemePresetResolutionTest`, `AccentSwatchLayoutTest` | | Account and sync | `AccountPagePresentationTest`, `AccountPageAdaptiveTest`, `AccountSettingsPageTest`, `AccountAuthHelpersTest`, `AccountLinkFailedContentTest`, `SyncAndBackupsPageTest` | | Library | `LibraryLanguagesAdaptiveTest`, `ClearSavedShowsAdaptiveTest` | diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt index 605cdeeea..8688b585b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt @@ -15,6 +15,7 @@ internal enum class ProfileSettingsDestination( AppearancePlayer("Player"), AppearanceNavigation("Navigation"), AppearanceText("Text"), + AppearanceWidgets("Widgets"), Playback("Playback"), Downloads("Downloads"), Privacy("Privacy"), @@ -28,7 +29,7 @@ internal enum class ProfileSettingsDestination( */ val parent: ProfileSettingsDestination? get() = when (this) { - Theme, AppearanceHome, AppearancePlayer, AppearanceNavigation, AppearanceText -> Appearance + Theme, AppearanceHome, AppearancePlayer, AppearanceNavigation, AppearanceText, AppearanceWidgets -> Appearance CustomTheme -> Theme Recommendations -> Library else -> null diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsExternalActions.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsExternalActions.kt index 238944cc9..c70ed4492 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsExternalActions.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsExternalActions.kt @@ -5,6 +5,7 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.net.Uri +import android.provider.Settings import android.widget.Toast import cx.aswin.boxlore.core.analytics.AnalyticsHelper @@ -57,3 +58,16 @@ internal fun openChangelog(context: Context) { ) } } + +/** + * Opens the phone's permissions for boxlore, where some phones keep the switch that lets an app add + * to the home screen. Phones with their own permission editor open it directly; the rest open the + * app's system settings page. + */ +internal fun openAppPermissionSettings(context: Context) { + AnalyticsHelper.trackSettingsInteraction("app_permission_settings_opened") + val vendorEditor = Intent("miui.intent.action.APP_PERM_EDITOR").putExtra("extra_pkgname", context.packageName) + val appDetails = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)) + runCatching { context.startActivity(vendorEditor) } + .recoverCatching { context.startActivity(appDetails) } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt index 1ed5a643b..0893ca355 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt @@ -55,6 +55,8 @@ import cx.aswin.boxlore.feature.settings.pages.AppearanceSettingsPage import cx.aswin.boxlore.feature.settings.pages.AppearanceTextPage import cx.aswin.boxlore.feature.settings.pages.AppearanceThemePage import cx.aswin.boxlore.feature.settings.pages.AppearanceUiState +import cx.aswin.boxlore.feature.settings.pages.AppearanceWidgetPreview +import cx.aswin.boxlore.feature.settings.pages.AppearanceWidgetsPage import cx.aswin.boxlore.feature.settings.pages.CustomThemeEditor import cx.aswin.boxlore.feature.settings.pages.DownloadsSettingsPage import cx.aswin.boxlore.feature.settings.pages.LibraryBackupActions @@ -144,6 +146,8 @@ data class AppearanceSettings( val actions: AppearanceActions = AppearanceActions({}, {}, {}, {}, {}), /** Home's hero cards for the Appearance previews; null shows the choices without previews. */ val heroPreview: AppearanceHeroPreview? = null, + /** The home screen widgets as they really look, for Appearance › Widgets; null lists them by name only. */ + val widgetPreview: AppearanceWidgetPreview? = null, ) /** Playback sub-page state paired with its actions, so [SettingsScreen] can pass both as one. */ @@ -484,6 +488,7 @@ private fun SettingsDestinationContent( ProfileSettingsDestination.AppearancePlayer, ProfileSettingsDestination.AppearanceNavigation, ProfileSettingsDestination.AppearanceText, + ProfileSettingsDestination.AppearanceWidgets, -> AppearanceDestinationContent(destination, contentBundle.appearanceSettings, actions) ProfileSettingsDestination.CustomTheme -> @@ -682,8 +687,12 @@ private fun trackedLibraryBackupActions( */ internal fun AppearanceActions.trackedForAnalytics(currentState: () -> AppearanceUiState? = { null }): AppearanceActions { fun report(key: String, value: T, current: (AppearanceUiState) -> T) { - val state = currentState() - if (state == null || current(state) != value) AnalyticsHelper.trackSettingsInteraction(key, value.toString()) + if (currentState()?.let(current) != value) AnalyticsHelper.trackSettingsInteraction(key, value.toString()) + } + + fun tracked(key: String, current: (AppearanceUiState) -> T, action: (T) -> Unit): (T) -> Unit = { + report(key, it, current) + action(it) } return AppearanceActions( onSetThemeConfig = { @@ -722,10 +731,11 @@ internal fun AppearanceActions.trackedForAnalytics(currentState: () -> Appearanc report("home_shortcuts_in_library_toggled", it) { state -> state.homeShortcutsInLibrary } onSetHomeShortcutsInLibrary(it) }, - onSetWidgetAppearance = { - report("widget_appearance_changed", it) { state -> state.currentWidgetAppearance } - onSetWidgetAppearance(it) - }, + onSetWidgetAppearance = tracked("widget_appearance_changed", { it.currentWidgetAppearance }, onSetWidgetAppearance), + onSetWidgetShowsArtwork = tracked("widget_shows_artwork_changed", { it.currentWidgetShowsArtwork }, onSetWidgetShowsArtwork), + canAddWidgets = canAddWidgets, + // Every request is reported: there is no current value a repeat could match. + onAddWidget = tracked("widget_add_requested", { "" }, onAddWidget), onSetExploreDefaultTab = { report("explore_default_tab_changed", it) { state -> state.currentExploreDefaultTab } onSetExploreDefaultTab(it) @@ -834,6 +844,7 @@ private fun AppearanceDestinationContent( ProfileSettingsDestination.AppearancePlayer -> AppearancePlayerPage(state, tracked, onBack = actions.onReturnToHub) ProfileSettingsDestination.AppearanceNavigation -> AppearanceNavigationPage(state, tracked, onBack = actions.onReturnToHub) ProfileSettingsDestination.AppearanceText -> AppearanceTextPage(state, tracked, onBack = actions.onReturnToHub) + ProfileSettingsDestination.AppearanceWidgets -> AppearanceWidgetsPage(state, tracked, onBack = actions.onReturnToHub, preview = appearance.widgetPreview) else -> AppearanceSettingsPage( state = state, @@ -847,6 +858,7 @@ private fun AppearanceDestinationContent( AppearanceSection.Player -> ProfileSettingsDestination.AppearancePlayer AppearanceSection.Navigation -> ProfileSettingsDestination.AppearanceNavigation AppearanceSection.Text -> ProfileSettingsDestination.AppearanceText + AppearanceSection.Widgets -> ProfileSettingsDestination.AppearanceWidgets }, ) }, diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt index 83f329cba..87dc588fe 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.PlayCircle import androidx.compose.material.icons.rounded.TextFields import androidx.compose.material.icons.rounded.ViewDay +import androidx.compose.material.icons.rounded.Widgets import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -43,6 +44,7 @@ import cx.aswin.boxlore.core.prefs.SubscriptionsDefaultTab import cx.aswin.boxlore.core.prefs.SubscriptionsTabStyle import cx.aswin.boxlore.core.prefs.ThemeSelection import cx.aswin.boxlore.core.prefs.WidgetAppearance +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork import cx.aswin.boxlore.feature.settings.components.SettingsContent import cx.aswin.boxlore.feature.settings.components.SettingsDivider import cx.aswin.boxlore.feature.settings.components.SettingsGroup @@ -50,6 +52,27 @@ import cx.aswin.boxlore.feature.settings.components.SettingsNavigationRow import cx.aswin.boxlore.feature.settings.components.SettingsPicture import cx.aswin.boxlore.feature.settings.components.SettingsScaffold +/** + * A home screen widget the listener can add from Appearance › Widgets, named as the widget picker + * names it, with the size its preview is drawn for. + */ +data class AddableWidget( + val key: String, + val name: String, + val description: String, + val previewWidthDp: Int = 320, + val previewHeightDp: Int = 170, + /** Whether this widget follows the Your Shows cover setting, so its card carries that choice. */ + val usesShowsCoverStyle: Boolean = false, +) + +/** + * Draws a home screen widget as it looks on the home screen, fitted into the modifier's bounds. The + * app supplies it from the widgets feature, so Appearance shows the real widgets without depending + * on it; without it the Widgets page lists them by name only. + */ +typealias AppearanceWidgetPreview = @Composable (widget: AddableWidget, modifier: Modifier) -> Unit + /** Current values shown on [AppearanceSettingsPage]. Also used by [cx.aswin.boxlore.feature.settings.SettingsScreen]. */ data class AppearanceUiState( val currentThemeConfig: String, @@ -61,6 +84,9 @@ data class AppearanceUiState( val currentOpenAppTo: String = OpenAppTo.HOME, val homeShortcutsInLibrary: Boolean = false, val currentWidgetAppearance: String = WidgetAppearance.APP, + val currentWidgetShowsArtwork: String = WidgetShowsArtwork.ROUNDED, + /** Every widget the app offers. Whether the home screen app can add them is asked when the page opens. */ + val addableWidgets: List = emptyList(), val currentExploreDefaultTab: String = ExploreDefaultTab.FOR_YOU, val currentSubscriptionsDefaultTab: String = SubscriptionsDefaultTab.SHOWS, val currentSubscriptionsTabStyle: String = SubscriptionsTabStyle.TOP, @@ -92,6 +118,11 @@ data class AppearanceActions( val onSetOpenAppTo: (String) -> Unit = {}, val onSetHomeShortcutsInLibrary: (Boolean) -> Unit = {}, val onSetWidgetAppearance: (String) -> Unit = {}, + val onSetWidgetShowsArtwork: (String) -> Unit = {}, + /** Whether the current home screen app adds a widget when asked. Asked once when the Widgets page opens. */ + val canAddWidgets: () -> Boolean = { false }, + /** Asks the home screen app to add the widget with this key; it confirms with the listener itself. */ + val onAddWidget: (String) -> Unit = {}, val onSetExploreDefaultTab: (String) -> Unit = {}, val onSetSubscriptionsDefaultTab: (String) -> Unit = {}, val onSetSubscriptionsTabStyle: (String) -> Unit = {}, @@ -116,7 +147,7 @@ data class AppearanceActions( typealias AppearanceHeroPreview = @Composable (style: String, coverShade: String, collection: Boolean, modifier: Modifier) -> Unit /** Where each Appearance row leads, so the page needs no navigation types of its own. */ -internal enum class AppearanceSection { Theme, Home, Player, Navigation, Text } +internal enum class AppearanceSection { Theme, Home, Player, Navigation, Text, Widgets } /** * The Appearance overview: Display mode (the setting people change most, also on Theme & colors), @@ -145,12 +176,21 @@ internal fun AppearanceSettingsPage( SettingsNavigationRow(title = "Navigation", supportingText = summary.navigation, icon = Icons.Rounded.ViewDay, onClick = { onOpenSection(AppearanceSection.Navigation) }) SettingsDivider() SettingsNavigationRow(title = "Text", supportingText = summary.text, icon = Icons.Rounded.TextFields, onClick = { onOpenSection(AppearanceSection.Text) }) + SettingsDivider() + SettingsNavigationRow(title = "Widgets", supportingText = summary.widgets, icon = Icons.Rounded.Widgets, onClick = { onOpenSection(AppearanceSection.Widgets) }) } } } /** What each Appearance row shows under its title. */ -internal data class AppearanceSummary(val theme: String, val home: String, val player: String, val navigation: String, val text: String) +internal data class AppearanceSummary( + val theme: String, + val home: String, + val player: String, + val navigation: String, + val text: String, + val widgets: String, +) internal fun appearanceSummary(state: AppearanceUiState): AppearanceSummary { val look = themeLookOptions().firstOrNull { it.key == state.selectedLookKey() }?.name ?: "Custom" @@ -167,9 +207,17 @@ internal fun appearanceSummary(state: AppearanceUiState): AppearanceSummary { player = if (state.miniPlayerSeekButtonsEnabled) "$player · seek buttons on" else player, navigation = "${NavigationStyle.fromKey(state.currentNavigationStyle).label} bar · starts on $start", text = "$letters letters", + widgets = widgetsSummary(state), ) } +/** The widgets' colour source, then how Your Shows cuts its covers. */ +private fun widgetsSummary(state: AppearanceUiState): String { + val colors = if (WidgetAppearance.sanitize(state.currentWidgetAppearance) == WidgetAppearance.SYSTEM) "System colors" else "App theme colors" + val covers = if (WidgetShowsArtwork.usesShapes(state.currentWidgetShowsArtwork)) "shapes" else "whole covers" + return "$colors · $covers" +} + /** The hero card style's name as Settings shows it. */ private fun heroStyleName(style: String): String = when (style) { HeroCardStyle.COVER -> "Cover" diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceThemePage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceThemePage.kt index 90d1fa896..ff094c726 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceThemePage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceThemePage.kt @@ -8,8 +8,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView -import cx.aswin.boxlore.core.designsystem.components.ConnectedOptionSelector -import cx.aswin.boxlore.core.prefs.WidgetAppearance import cx.aswin.boxlore.feature.settings.components.SettingsContent import cx.aswin.boxlore.feature.settings.components.SettingsDivider import cx.aswin.boxlore.feature.settings.components.SettingsGroup @@ -23,8 +21,8 @@ import cx.aswin.boxlore.feature.settings.components.ThemeLookPicture /** * Theme & colors: display mode, the four looks as picture tiles (each in light and dark), the - * custom theme editor, artwork colors for episode and podcast pages, and the widgets, which follow - * these colors. + * custom theme editor, artwork colors for episode and podcast pages, the widgets, which follow these + * colors, and how the Your Shows widget draws covers. */ @Composable internal fun AppearanceThemePage(state: AppearanceUiState, actions: AppearanceActions, onBack: () -> Unit, onEditCustomTheme: () -> Unit) { @@ -51,18 +49,6 @@ internal fun AppearanceThemePage(state: AppearanceUiState, actions: AppearanceAc icon = Icons.Rounded.AutoAwesome, ) } - SettingsGroup( - title = "Widgets", - footer = "App theme matches your chosen colors and mode. System follows your phone’s mode and wallpaper colors.", - ) { - SettingsContent { - ConnectedOptionSelector( - options = listOf(WidgetAppearance.APP to "App theme", WidgetAppearance.SYSTEM to "System"), - selected = WidgetAppearance.sanitize(state.currentWidgetAppearance), - onSelect = actions.onSetWidgetAppearance, - ) - } - } } } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt new file mode 100644 index 000000000..269223622 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt @@ -0,0 +1,316 @@ +package cx.aswin.boxlore.feature.settings.pages + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView +import cx.aswin.boxlore.core.designsystem.components.ConnectedOptionSelector +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight +import cx.aswin.boxlore.core.prefs.WidgetAppearance +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork +import cx.aswin.boxlore.feature.settings.components.SettingsActionRow +import cx.aswin.boxlore.feature.settings.components.SettingsContent +import cx.aswin.boxlore.feature.settings.components.SettingsDivider +import cx.aswin.boxlore.feature.settings.components.SettingsGroup +import cx.aswin.boxlore.feature.settings.components.SettingsInfoTip +import cx.aswin.boxlore.feature.settings.components.SettingsPicture +import cx.aswin.boxlore.feature.settings.components.SettingsScaffold +import cx.aswin.boxlore.feature.settings.components.SettingsTileSection +import cx.aswin.boxlore.feature.settings.openAppPermissionSettings +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Widgets: the colours every home screen widget takes, how Your Shows cuts its covers, then every + * widget as it looks on the home screen ([preview]), each with a button that asks the home screen + * app to add it. + */ +@Composable +internal fun AppearanceWidgetsPage( + state: AppearanceUiState, + actions: AppearanceActions, + onBack: () -> Unit, + preview: AppearanceWidgetPreview? = null, +) { + TrackScreenView("appearance_widgets") + SettingsScaffold(title = "Widgets", onBack = onBack) { + SettingsGroup( + title = "Colors", + footer = "App theme matches your chosen colors and mode. System follows your phone’s mode and wallpaper colors.", + ) { + SettingsContent { + ConnectedOptionSelector( + options = listOf(WidgetAppearance.APP to "App theme", WidgetAppearance.SYSTEM to "System"), + selected = WidgetAppearance.sanitize(state.currentWidgetAppearance), + onSelect = actions.onSetWidgetAppearance, + ) + } + } + // With pictures, the cover choice sits in the Your Shows card, right under the picture it changes. + val coverChoiceInGallery = preview != null && state.addableWidgets.any { it.usesShowsCoverStyle } + if (!coverChoiceInGallery) { + SettingsGroup( + title = "Your Shows covers", + footer = "Whole covers shows each cover uncropped. Shapes cuts covers into circles, arches and scallops.", + ) { + SettingsContent { ShowsCoverChoice(state, actions) } + } + } + if (state.addableWidgets.isNotEmpty()) { + // The home screen app is asked once, when the page opens: it only changes when the listener switches it. + val canAdd = remember(actions.canAddWidgets) { actions.canAddWidgets() } + when { + preview != null -> WidgetGallery(state, actions, canAdd, preview) + canAdd -> WidgetRows(state.addableWidgets, actions.onAddWidget) + else -> SettingsInfoTip(ADD_WIDGETS_BY_HAND) + } + } + } +} + +@Composable +private fun ShowsCoverChoice(state: AppearanceUiState, actions: AppearanceActions) { + ConnectedOptionSelector( + options = listOf(WidgetShowsArtwork.ROUNDED to "Whole covers", WidgetShowsArtwork.SHAPES to "Shapes"), + selected = WidgetShowsArtwork.sanitize(state.currentWidgetShowsArtwork), + onSelect = actions.onSetWidgetShowsArtwork, + ) +} + +/** + * Every widget as a picture with its name. The Add buttons are there only when the home screen app + * can add; a request the phone turns down without a word is answered in that widget's card. + */ +@Composable +private fun WidgetGallery( + state: AppearanceUiState, + actions: AppearanceActions, + canAdd: Boolean, + preview: AppearanceWidgetPreview, +) { + val add = rememberAddWidgetRequest(actions.onAddWidget) + SettingsTileSection( + title = if (canAdd) "Add to home screen" else "boxlore widgets", + footer = if (canAdd) ADD_WIDGETS_CONFIRM else null, + ) { + if (!canAdd) SettingsInfoTip(ADD_WIDGETS_BY_HAND) + for (row in widgetGalleryRows(state.addableWidgets)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + row.forEach { widget -> + WidgetCard( + widget = widget, + sideBySide = row.size > 1, + onAdd = add.request.takeIf { canAdd }, + unanswered = add.unansweredKey == widget.key, + preview = preview, + modifier = Modifier.weight(1f), + ) { + if (widget.usesShowsCoverStyle) ShowsCoverChoice(state, actions) + } + } + } + } + } +} + +/** Asks for a widget to be added, and knows which request the phone left unanswered. */ +private class AddWidgetRequest(val request: (String) -> Unit, val unansweredKey: String?) + +/** + * The home screen app answers a request by opening its confirmation over the app, which pauses it. + * Some phones turn the request down without showing anything. So when the app has not been paused + * [ADD_WIDGET_ANSWER_MS] after a request, that request counts as unanswered, until another one is + * answered. + */ +@Composable +private fun rememberAddWidgetRequest(onAdd: (String) -> Unit): AddWidgetRequest { + val lifecycle = LocalLifecycleOwner.current.lifecycle + val scope = rememberCoroutineScope() + var unansweredKey by rememberSaveable { mutableStateOf(null) } + val pausedSinceRequest = remember { mutableStateOf(false) } + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE) pausedSinceRequest.value = true } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + val request: (String) -> Unit = + remember(onAdd) { + { key -> + pausedSinceRequest.value = false + onAdd(key) + scope.launch { + delay(ADD_WIDGET_ANSWER_MS) + unansweredKey = key.takeUnless { pausedSinceRequest.value } + } + } + } + return AddWidgetRequest(request, unansweredKey) +} + +/** + * One widget: its picture on a tonal stage that stands in for the wallpaper, any [options] that + * change that picture, then its name, what it shows and the Add button. Wide widgets put the button + * beside the text; a small widget sharing its row with another puts it underneath, where it still + * has room at large text sizes. An [unanswered] request explains itself under the button. + */ +@Composable +private fun WidgetCard( + widget: AddableWidget, + sideBySide: Boolean, + onAdd: ((String) -> Unit)?, + unanswered: Boolean, + preview: AppearanceWidgetPreview, + modifier: Modifier = Modifier, + options: @Composable () -> Unit = {}, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Box( + modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.large).background(MaterialTheme.colorScheme.outlineVariant).padding(14.dp), + contentAlignment = Alignment.Center, + ) { + SettingsPicture(Modifier.widthIn(max = widget.previewWidthDp.dp).fillMaxWidth().aspectRatio(widget.previewWidthDp.toFloat() / widget.previewHeightDp)) { + preview(widget, Modifier.matchParentSize()) + } + } + options() + if (sideBySide) { + Column(Modifier.padding(horizontal = 4.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + WidgetCardText(widget) + if (onAdd != null) AddWidgetButton(widget, onAdd, Modifier.fillMaxWidth()) + } + } else { + Row(Modifier.padding(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + WidgetCardText(widget, Modifier.weight(1f)) + if (onAdd != null) AddWidgetButton(widget, onAdd) + } + } + if (unanswered) AddWidgetUnanswered() + } + } +} + +/** What to do when the phone did not open its confirmation: allow it in the phone's settings, or add by hand. */ +@Composable +private fun AddWidgetUnanswered() { + val context = LocalContext.current + Column( + modifier = Modifier.fillMaxWidth().semantics { liveRegion = LiveRegionMode.Polite }, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + SettingsInfoTip(ADD_WIDGET_UNANSWERED) + TextButton(onClick = { openAppPermissionSettings(context) }, modifier = Modifier.align(Alignment.End)) { + Text("Open app settings") + } + } +} + +@Composable +private fun WidgetCardText(widget: AddableWidget, modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(widget.name, style = MaterialTheme.typography.titleSmall, fontWeight = GoogleSansWeight.bold, color = MaterialTheme.colorScheme.onSurface) + Text(widget.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun AddWidgetButton(widget: AddableWidget, onAdd: (String) -> Unit, modifier: Modifier = Modifier) { + val label = "Add ${widget.name} to home screen" + FilledTonalButton( + onClick = { onAdd(widget.key) }, + modifier = modifier.semantics { contentDescription = label }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + Text("Add", modifier = Modifier.padding(start = ButtonDefaults.IconSpacing)) + } +} + +/** Without pictures: one row per widget that asks the home screen app to add it. */ +@Composable +private fun WidgetRows(widgets: List, onAdd: (String) -> Unit) { + SettingsGroup(title = "Add to home screen", footer = ADD_WIDGETS_CONFIRM) { + widgets.forEachIndexed { index, widget -> + if (index > 0) SettingsDivider() + SettingsActionRow( + title = widget.name, + supportingText = widget.description, + icon = Icons.Rounded.Add, + onClick = { onAdd(widget.key) }, + ) + } + } +} + +/** + * The gallery's rows: a small widget shares its row with the small widget after it, so the two round + * players sit side by side; every other widget has a row to itself. + */ +internal fun widgetGalleryRows(widgets: List): List> { + val rows = mutableListOf>() + var index = 0 + while (index < widgets.size) { + val widget = widgets[index] + val next = widgets.getOrNull(index + 1) + val pair = widget.previewWidthDp <= SMALL_WIDGET_MAX_WIDTH_DP && next != null && next.previewWidthDp <= SMALL_WIDGET_MAX_WIDTH_DP + rows += if (pair) listOf(widget, next!!) else listOf(widget) + index += if (pair) 2 else 1 + } + return rows +} + +private const val SMALL_WIDGET_MAX_WIDTH_DP = 200 + +/** How long the home screen app has to open its confirmation before a request counts as unanswered. */ +internal const val ADD_WIDGET_ANSWER_MS = 1_500L + +internal const val ADD_WIDGET_UNANSWERED = + "Your phone didn’t let boxlore add it. Turn on Home screen shortcuts for boxlore in your phone’s app settings, then try again. " + + "Or touch and hold an empty spot on your home screen and choose Widgets." + +internal const val ADD_WIDGETS_CONFIRM = "Your home screen app asks you to confirm." + +internal const val ADD_WIDGETS_BY_HAND = + "To add a boxlore widget, touch and hold an empty spot on your home screen, then choose Widgets." diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt index 1d229f726..d9640623e 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt @@ -28,6 +28,34 @@ class AppearanceActionsTrackedTest { assertEquals(0, miniChanges) } + @Test + fun `Your Shows widget artwork choice survives the appearance wrapper`() { + val styles = mutableListOf() + var widgetColourChanges = 0 + val tracked = AppearanceActions( + {}, + {}, + {}, + {}, + onSetWidgetAppearance = { widgetColourChanges++ }, + onSetWidgetShowsArtwork = { styles.add(it) }, + ).trackedForAnalytics() + tracked.onSetWidgetShowsArtwork(cx.aswin.boxlore.core.prefs.WidgetShowsArtwork.SHAPES) + tracked.onSetWidgetShowsArtwork(cx.aswin.boxlore.core.prefs.WidgetShowsArtwork.ROUNDED) + assertEquals(listOf("shapes", "rounded"), styles) + assertEquals(0, widgetColourChanges) + } + + @Test + fun `adding a widget survives the appearance wrapper, every time it is asked`() { + val asked = mutableListOf() + val tracked = AppearanceActions({}, {}, {}, {}, canAddWidgets = { true }, onAddWidget = { asked.add(it) }).trackedForAnalytics() + tracked.onAddWidget("compact") + tracked.onAddWidget("compact") + assertEquals(listOf("compact", "compact"), asked) + assertEquals(true, tracked.canAddWidgets()) + } + @Test fun `LeaveMeAlone switches reach the setter with their section and value`() { val calls = mutableListOf>() diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt index ff4617c33..8e01642ab 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt @@ -112,6 +112,7 @@ class SettingsBackNavigationTest { ProfileSettingsDestination.AppearancePlayer, ProfileSettingsDestination.AppearanceNavigation, ProfileSettingsDestination.AppearanceText, + ProfileSettingsDestination.AppearanceWidgets, ).forEach { page -> assertEquals( SettingsBackAction.NavigateTo(ProfileSettingsDestination.Appearance), diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt index 972f7e760..90cd20c90 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt @@ -23,6 +23,7 @@ import cx.aswin.boxlore.feature.settings.pages.AppearanceNavigationPage import cx.aswin.boxlore.feature.settings.pages.AppearancePlayerPage import cx.aswin.boxlore.feature.settings.pages.AppearanceSettingsPage import cx.aswin.boxlore.feature.settings.pages.AppearanceTextPage +import cx.aswin.boxlore.feature.settings.pages.AppearanceWidgetsPage import cx.aswin.boxlore.feature.settings.pages.CustomThemeEditor import cx.aswin.boxlore.feature.settings.pages.DownloadsSettingsPage import cx.aswin.boxlore.feature.settings.pages.LibraryBackupActions @@ -111,6 +112,7 @@ private fun CaptureSettingsPage(destination: ProfileSettingsDestination) { ProfileSettingsDestination.AppearancePlayer, ProfileSettingsDestination.AppearanceNavigation, ProfileSettingsDestination.AppearanceText, + ProfileSettingsDestination.AppearanceWidgets, -> CaptureAppearanceSubpage(destination, appearance) ProfileSettingsDestination.CustomTheme -> CustomThemeEditor(appearance.state, {}, {}) ProfileSettingsDestination.Playback -> PlaybackSettingsPage(playback.state, playback.actions, {}) @@ -129,6 +131,7 @@ private fun CaptureAppearanceSubpage(destination: ProfileSettingsDestination, ap ProfileSettingsDestination.AppearanceHome -> AppearanceHomePage(appearance.state, appearance.actions, null, {}) ProfileSettingsDestination.AppearancePlayer -> AppearancePlayerPage(appearance.state, appearance.actions, {}) ProfileSettingsDestination.AppearanceNavigation -> AppearanceNavigationPage(appearance.state, appearance.actions, {}) + ProfileSettingsDestination.AppearanceWidgets -> AppearanceWidgetsPage(appearance.state, appearance.actions, {}) else -> AppearanceTextPage(appearance.state, appearance.actions, {}) } } diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearancePagesTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearancePagesTest.kt index b9616d0d0..2e43d6474 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearancePagesTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearancePagesTest.kt @@ -21,6 +21,8 @@ import cx.aswin.boxlore.core.prefs.HeroCardStyle import cx.aswin.boxlore.core.prefs.HeroCoverShade import cx.aswin.boxlore.core.prefs.HeroTideProgress import cx.aswin.boxlore.core.prefs.OpenAppTo +import cx.aswin.boxlore.core.prefs.WidgetAppearance +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -166,6 +168,11 @@ class AppearancePagesTest { assertEquals("Block hero cards, artwork color", appearanceSummary(baseState.copy(heroCoverShade = HeroCoverShade.ARTWORK)).home) assertEquals("bunpod · seek buttons on", summary.player) assertEquals("Crisp letters", summary.text) + assertEquals("App theme colors · whole covers", summary.widgets) + assertEquals( + "System colors · shapes", + appearanceSummary(baseState.copy(currentWidgetAppearance = WidgetAppearance.SYSTEM, currentWidgetShowsArtwork = WidgetShowsArtwork.SHAPES)).widgets, + ) assertEquals("boxlore classic · artwork colors on", appearanceSummary(baseState).theme) // Listeners who never picked see Cover on the Artwork shade. val unpicked = AppearanceUiState(currentThemeConfig = "system", isDynamicColorEnabled = false, currentThemeBrand = "violet", currentSurfaceStyle = "classic_dynamic") diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt new file mode 100644 index 000000000..b6ae5156f --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt @@ -0,0 +1,181 @@ +package cx.aswin.boxlore.feature.settings.pages + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], qualifiers = "w360dp-h780dp-mdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class AppearanceWidgetsPageTest { + @get:Rule val compose = createAndroidComposeRule() + private val added = mutableListOf() + private val coverChoices = mutableListOf() + private val widgets = + listOf( + AddableWidget("full", "Now Playing · Full Controls", "The full player", 320, 170), + AddableWidget("round", "Now Playing · Round", "A round player", 160, 160), + AddableWidget("round_next", "Now Playing · Round with Next", "A round player with next", 160, 160), + AddableWidget("shows", "Library · Your Shows", "Your subscribed shows as a grid of artwork", 320, 290, usesShowsCoverStyle = true), + ) + private val state = + AppearanceUiState( + currentThemeConfig = "system", + isDynamicColorEnabled = false, + currentThemeBrand = "violet", + currentSurfaceStyle = "classic_dynamic", + addableWidgets = widgets, + ) + private val picture: AppearanceWidgetPreview = { widget, modifier -> Box(modifier.testTag("picture-${widget.key}")) } + + private fun render(canAdd: Boolean, state: AppearanceUiState = this.state, preview: AppearanceWidgetPreview? = picture) { + val actions = + AppearanceActions({}, {}, {}, {}, canAddWidgets = { canAdd }, onAddWidget = { added.add(it) }, onSetWidgetShowsArtwork = { coverChoices.add(it) }) + compose.setContent { BoxLoreTheme { AppearanceWidgetsPage(state, actions, onBack = {}, preview = preview) } } + } + + @Test + fun `each widget shows its picture and a button that asks the home screen to add that widget`() { + render(canAdd = true) + compose.onNodeWithText("Add to home screen").performScrollTo().assertIsDisplayed() + for (widget in widgets) { + compose.onNodeWithTag("picture-${widget.key}", useUnmergedTree = true).assertExists() + compose.onNodeWithText(widget.name).performScrollTo().assertIsDisplayed() + } + compose.onNodeWithContentDescription("Add Library · Your Shows to home screen").performScrollTo().performClick() + compose.onNodeWithContentDescription("Add Now Playing · Round to home screen").performScrollTo().performClick() + assertEquals(listOf("shows", "round"), added) + compose.onNodeWithText(ADD_WIDGETS_BY_HAND).assertDoesNotExist() + } + + @Test + fun `a picture keeps its widget's shape and is never wider than the widget really is`() { + render(canAdd = true) + for (widget in widgets) { + val bounds = compose.onNodeWithTag("picture-${widget.key}", useUnmergedTree = true).getUnclippedBoundsInRoot() + val width = (bounds.right - bounds.left).value + val height = (bounds.bottom - bounds.top).value + assertTrue("${widget.key} is drawn", width > 80f && height > 40f) + assertTrue("${widget.key} is at most its real width", width <= widget.previewWidthDp + 0.5f) + assertEquals("${widget.key} keeps its shape", widget.previewWidthDp.toFloat() / widget.previewHeightDp, width / height, 0.03f) + } + // The two round players share a row. + val round = compose.onNodeWithTag("picture-round", useUnmergedTree = true).getUnclippedBoundsInRoot() + val roundNext = compose.onNodeWithTag("picture-round_next", useUnmergedTree = true).getUnclippedBoundsInRoot() + assertEquals(round.top, roundNext.top) + assertTrue(roundNext.left > round.right) + } + + @Test + @Config(sdk = [33], qualifiers = "w1280dp-h800dp-mdpi") + fun `on a tablet the pictures stay at their real size instead of stretching`() { + render(canAdd = true) + val full = compose.onNodeWithTag("picture-full", useUnmergedTree = true).getUnclippedBoundsInRoot() + assertEquals(320.dp, full.right - full.left) + compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").performScrollTo().performClick() + assertEquals(listOf("full"), added) + } + + @Test + fun `a home screen that cannot add widgets still shows them, with the steps to add one by hand`() { + render(canAdd = false) + compose.onNodeWithText(ADD_WIDGETS_BY_HAND).performScrollTo().assertIsDisplayed() + compose.onNodeWithText("boxlore widgets").assertExists() + compose.onNodeWithText("Add to home screen").assertDoesNotExist() + compose.onNodeWithTag("picture-full", useUnmergedTree = true).assertExists() + compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").assertDoesNotExist() + // The widget options stay either way. + compose.onNodeWithText("Whole covers").assertExists() + compose.onNodeWithText("Colors").assertExists() + } + + @Test + fun `the cover choice sits in the Your Shows card, between its picture and its name`() { + render(canAdd = true) + compose.onNodeWithText("Your Shows covers").assertDoesNotExist() + val picture = compose.onNodeWithTag("picture-shows", useUnmergedTree = true).getUnclippedBoundsInRoot() + val choice = compose.onNodeWithText("Shapes").getUnclippedBoundsInRoot() + val name = compose.onNodeWithText("Library · Your Shows").getUnclippedBoundsInRoot() + assertTrue("under the picture it changes", choice.top >= picture.bottom) + assertTrue("above the widget's name", choice.bottom <= name.top) + compose.onNodeWithText("Shapes").performScrollTo().performClick() + assertEquals(listOf(WidgetShowsArtwork.SHAPES), coverChoices) + } + + @Test + fun `a request the phone leaves unanswered explains itself in that widget's card`() { + render(canAdd = true) + compose.mainClock.autoAdvance = false + compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").performScrollTo().performClick() + // Nothing covered the app: the phone turned the request down without a word. + compose.mainClock.advanceTimeBy(ADD_WIDGET_ANSWER_MS + 100) + compose.onNodeWithText(ADD_WIDGET_UNANSWERED).assertExists() + compose.onNodeWithText("Open app settings").assertExists() + val tip = compose.onNodeWithText(ADD_WIDGET_UNANSWERED).getUnclippedBoundsInRoot() + val nextCard = compose.onNodeWithTag("picture-round", useUnmergedTree = true).getUnclippedBoundsInRoot() + assertTrue("inside the card that was tapped", tip.bottom <= nextCard.top) + assertEquals(listOf("full"), added) + } + + @Test + fun `a request the home screen answers shows nothing more, and clears an earlier unanswered one`() { + render(canAdd = true) + compose.mainClock.autoAdvance = false + compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").performScrollTo().performClick() + compose.mainClock.advanceTimeBy(ADD_WIDGET_ANSWER_MS + 100) + compose.onNodeWithText(ADD_WIDGET_UNANSWERED).assertExists() + + compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").performClick() + // The home screen app's confirmation covers the app, then the listener comes back. + compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED) + compose.mainClock.advanceTimeBy(ADD_WIDGET_ANSWER_MS + 100) + compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + compose.mainClock.advanceTimeBy(100) + compose.onNodeWithText(ADD_WIDGET_UNANSWERED).assertDoesNotExist() + assertEquals(listOf("full", "full"), added) + } + + @Test + fun `without pictures each widget is a row that adds it and the cover choice keeps its own group`() { + render(canAdd = true, preview = null) + compose.onNodeWithText("Your Shows covers").assertExists() + compose.onNodeWithText("Library · Your Shows").performScrollTo().performClick() + assertEquals(listOf("shows"), added) + } + + @Test + fun `with no widgets to offer the page is only the options`() { + render(canAdd = true, state = state.copy(addableWidgets = emptyList())) + compose.onNodeWithText("Add to home screen").assertDoesNotExist() + compose.onNodeWithText(ADD_WIDGETS_BY_HAND).assertDoesNotExist() + compose.onNodeWithText("Whole covers").assertExists() + } + + @Test + fun `small widgets pair up and every other widget has a row to itself`() { + assertEquals(listOf(listOf("full"), listOf("round", "round_next"), listOf("shows")), widgetGalleryRows(widgets).map { row -> row.map { it.key } }) + // A lone small widget, or a third one, is not stretched into a pair. + val three = widgets.filter { it.previewWidthDp <= 200 } + AddableWidget("extra", "Extra", "Another small one", 160, 160) + assertEquals(listOf(listOf("round", "round_next"), listOf("extra")), widgetGalleryRows(three).map { row -> row.map { it.key } }) + } +} diff --git a/feature/widgets/README.md b/feature/widgets/README.md index 0f829e3b7..29a0aadf6 100644 --- a/feature/widgets/README.md +++ b/feature/widgets/README.md @@ -19,12 +19,13 @@ Owns boxlore's home-screen widgets, built with `AppWidgetProvider` and `RemoteVi - The play button is a circle at rest and the scalloped cookie while playing. Seek icons show the listener's own seek lengths. - When playback is dismissed, every playback widget becomes a launch surface ("Ready when you are") that opens boxlore, never disabled transport. - Library lists are capped at `LibraryWidgetLogic.MAX_ROWS`; the header badge counts the whole library through `WidgetLibraryCounts`. Row and tile taps open the deep link `:app` supplies; the header and its arrow open `boxlore://library/subscriptions?tab=0` or `?tab=1`. -- Your Shows draws whole covers as rounded squares, or cuts them into mixed shapes when the `WidgetShowsArtwork` preference says so. +- Your Shows draws whole covers as rounded squares, or cuts them into mixed shapes when the listener chooses that in Appearance › Widgets (`WidgetShowsArtwork`). - Missing or unreadable artwork falls back to show or episode candidates, then to a tinted placeholder with the boxlore mark, cut to the same shape. ## Public API - The six receivers above: picker-visible providers. +- `HomeScreenWidgets`: the six widgets under their picker names, each one's live preview as `RemoteViews` with the size it is drawn for (`preview`), whether the current home screen app can add a widget on request (`canAdd`), and the request itself (`requestAdd`). Settings shows and offers them through `:app`. - `WidgetControlReceiver`: one explicit, non-exported action endpoint shared by the playback widgets. - `WidgetActionIntents`: builds broadcast, open-app and deep-link `PendingIntent`s with `SecurityException` guards. `cancelAll(context, appWidgetId)` and `LibraryWidgetRenderer.cancelAll` cancel a deleted widget's intents so the app never exhausts the system's `PendingIntent` quota. - `NowPlayingWidgetDependencies`, `configureNowPlayingWidget(...)` and `WidgetPlaybackSource`: the playback port. `refreshPosition()` asks the player for its position, which the app only tracks while it is on screen. @@ -42,7 +43,7 @@ Owns boxlore's home-screen widgets, built with `AppWidgetProvider` and `RemoteVi - Library covers load a few at a time and each batch is shown as it lands. The list service also fetches a missing cover itself, so covers appear while the app is closed; a tile shows its placeholder until then. - Every colour is a tint, never baked into a bitmap. Widget chrome follows the in-app Appearance theme by default: `WidgetChrome` sends one ARGB value for a fixed Light or Dark display mode, and a light and dark pair when the display mode follows the system, so the launcher switches with the phone while the app is not running. Appearance › Widgets can switch to System, which uses the system's dynamic colour resources. - `WidgetThemeSync` re-pushes widgets on configuration changes; `:app` refreshes them when the theme or widget preferences change. -- Picker previews (`widget_preview_*.xml`) are static layouts in resource colours. +- Picker previews: from Android 15, `WidgetPickerPreviews` publishes previews drawn by the live renderers with sample content, in the listener's own widget colours, again whenever that look changes. Older versions show the static `widget_preview_*.xml` layouts in resource colours. ## Design provenance @@ -56,8 +57,8 @@ src/main/java/cx/aswin/boxlore/feature/widgets/ LibraryWidget*.kt # receivers, dependencies, coordinator, renderer, snapshot store, # RemoteViewsService for the lists WidgetArtworkLoader.kt, WidgetBitmapCache.kt, WidgetChrome.kt, WidgetPalette.kt, - WidgetRemoteViewsColors.kt, WidgetShapes.kt, WidgetSeekIcon.kt, - WidgetThemeSync.kt, WidgetHostPresence.kt, WidgetUpdates.kt + WidgetRemoteViewsColors.kt, WidgetShapes.kt, WidgetSeekIcon.kt, WidgetPickerPreviews.kt, + WidgetThemeSync.kt, WidgetHostPresence.kt, WidgetUpdates.kt, HomeScreenWidgets.kt actions/ # WidgetActionIntents, WidgetActionHandler, WidgetControlReceiver logic/ # NowPlayingWidgetLogic, LibraryWidgetLogic, WidgetPresentation, WidgetSizes, # WidgetProgress, WidgetProgressPoller @@ -87,7 +88,7 @@ src/main/res/ | Concern | Contract | | :--- | :--- | -| SharedPreferences files | `boxlore_now_playing_widget`, `boxlore_library_widget` | +| SharedPreferences files | `boxlore_now_playing_widget`, `boxlore_library_widget`, `boxlore_widget_picker_previews` (the look each published preview was drawn for) | | Snapshot key | `snapshot` (JSON) | | Artwork cache | `{cacheDir}/widget_artwork/` | | Receiver FQCNs | the six receivers above, under `cx.aswin.boxlore.feature.widgets` | @@ -107,7 +108,7 @@ Hermetic JVM tests live in `feature/widgets/src/test`, using Robolectric and fak | Library widgets | `LibraryWidgetRemoteViewsTest`, `LibraryWidgetLogicTest` | | Snapshots and dependency holders | `NowPlayingWidgetSnapshotStoreTest`, `NowPlayingWidgetDependenciesHolderTest` | | Startup and updates | `WidgetStartupTest`, `WidgetUpdatesTest` | -| Theming | `WidgetChromeTest`, `WidgetRemoteViewsColorsTest` | +| Theming, picker previews and adding from the app | `WidgetChromeTest`, `WidgetRemoteViewsColorsTest`, `WidgetPickerPreviewsTest`, `HomeScreenWidgetsTest` | | Intents | `WidgetActionIntentsTest` | ```bash diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt new file mode 100644 index 000000000..9153f876e --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt @@ -0,0 +1,74 @@ +package cx.aswin.boxlore.feature.widgets + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.widget.RemoteViews +import androidx.annotation.StringRes + +/** + * The widgets a listener can see and add to the home screen from inside the app. The home screen app + * does the adding and confirms it with the listener itself; not every home screen app can. + */ +object HomeScreenWidgets { + /** One widget, named as the widget picker names it, with the size its preview is drawn for. */ + class Entry internal constructor( + val key: String, + @param:StringRes val label: Int, + @param:StringRes val description: Int, + val previewWidthDp: Int, + val previewHeightDp: Int, + /** Whether this widget draws show covers the way the Your Shows cover setting says. */ + val usesShowsCoverStyle: Boolean, + internal val receiver: Class, + ) + + val all: List = + listOf( + entry("full", R.string.now_playing_widget_label, R.string.now_playing_widget_description), + entry("compact", R.string.now_playing_bar_widget_label, R.string.now_playing_bar_widget_description), + entry("round", R.string.playback_controls_widget_label, R.string.playback_controls_widget_description), + entry("round_next", R.string.playback_next_controls_widget_label, R.string.playback_next_controls_widget_description), + entry("shows", R.string.subscriptions_widget_label, R.string.subscriptions_widget_description, usesShowsCoverStyle = true), + entry("new_episodes", R.string.new_episodes_widget_label, R.string.new_episodes_widget_description), + ) + + /** Whether the current home screen app adds a widget when an app asks it to. */ + fun canAdd(context: Context): Boolean = runCatching { manager(context)?.isRequestPinAppWidgetSupported == true }.getOrDefault(false) + + /** + * Asks the home screen app to add the widget with this [key]. Returns false when the key is + * unknown or the home screen app turned the request down; it never throws. + */ + fun requestAdd(context: Context, key: String): Boolean { + val entry = all.firstOrNull { it.key == key } ?: return false + return runCatching { + manager(context)?.requestPinAppWidget(ComponentName(context.applicationContext, entry.receiver), null, null) == true + }.getOrDefault(false) + } + + /** + * The widget with this [key] as its own renderer draws it, with sample content and in the + * listener's widget colours. Lay it out at its entry's preview size, then scale that picture to + * fit: at that size every row is whole. It is a picture: show it where its buttons cannot be + * tapped. Cuts and caches artwork, so call it off the main thread. Null when the key is unknown + * or the preview cannot be drawn. + */ + fun preview(context: Context, key: String): RemoteViews? { + val preview = WidgetPickerPreviews.previews().firstOrNull { it.key == key } ?: return null + return runCatching { preview.build(context.applicationContext) }.getOrNull() + } + + private fun entry( + key: String, + @StringRes label: Int, + @StringRes description: Int, + usesShowsCoverStyle: Boolean = false, + ): Entry { + val preview = WidgetPickerPreviews.previews().first { it.key == key } + return Entry(key, label, description, preview.size.widthDp, preview.size.heightDp, usesShowsCoverStyle, preview.receiver) + } + + private fun manager(context: Context): AppWidgetManager? = context.applicationContext.getSystemService(AppWidgetManager::class.java) +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt index 20a4a4c80..ac38cd331 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt @@ -47,6 +47,8 @@ class LibraryWidgetRemoteViewsService : RemoteViewsService() { internal class LibraryWidgetRemoteViewsFactory( private val context: Context, private val kind: LibraryWidgetKind, + /** Rows to show instead of the saved snapshot: the sample content of a picker preview. */ + private val preset: LibraryWidgetSnapshot? = null, ) : RemoteViewsService.RemoteViewsFactory { private var shows: List = emptyList() private var episodes: List = emptyList() @@ -57,7 +59,7 @@ internal class LibraryWidgetRemoteViewsFactory( override fun onCreate() = Unit override fun onDataSetChanged() { - val snapshot = LibraryWidgetSnapshotStore(context).read() ?: LibraryWidgetSnapshot() + val snapshot = preset ?: LibraryWidgetSnapshotStore(context).read() ?: LibraryWidgetSnapshot() shows = snapshot.subscriptions episodes = snapshot.newEpisodes chrome = WidgetChrome.resolve(context) @@ -182,7 +184,8 @@ internal class LibraryWidgetRemoteViewsFactory( */ private fun artworkPath(cachePath: String?, url: String?, fallbackUrls: List): String? { cachePath?.takeIf { it.isNotBlank() && File(it).exists() }?.let { return it } - if (cx.aswin.boxlore.core.model.artworkUrls(url, *fallbackUrls.toTypedArray()).isEmpty()) return null + // Sample rows of a picker preview have no address to fetch from. + if (preset != null || cx.aswin.boxlore.core.model.artworkUrls(url, *fallbackUrls.toTypedArray()).isEmpty()) return null artworkLoader.resolveCachedPath(url)?.let { return it } return runCatching { runBlocking { withTimeoutOrNull(ARTWORK_TIMEOUT_MS) { artworkLoader.load(url, fallbackUrls) } } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt index 240033392..bfedcdc28 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt @@ -79,6 +79,7 @@ object LibraryWidgetRenderer { appWidgetId: Int, snapshot: LibraryWidgetSnapshot, kind: LibraryWidgetKind, + collection: RemoteViews.RemoteCollectionItems? = null, ): RemoteViews { val layout = when (kind) { @@ -125,10 +126,15 @@ object LibraryWidgetRenderer { LibraryWidgetKind.SUBSCRIPTIONS -> snapshot.subscriptions.size LibraryWidgetKind.NEW_EPISODES -> snapshot.newEpisodes.size } - views.setRemoteAdapter( - R.id.widget_list_view, - LibraryWidgetRemoteViewsService.adapterIntent(context, appWidgetId, kind), - ) + if (collection != null) { + // A picker preview carries its rows with it; a placed widget binds the list service. + views.setRemoteAdapter(R.id.widget_list_view, collection) + } else { + views.setRemoteAdapter( + R.id.widget_list_view, + LibraryWidgetRemoteViewsService.adapterIntent(context, appWidgetId, kind), + ) + } views.setEmptyView(R.id.widget_list_view, R.id.widget_empty_container) val template = collectionClickTemplate(context, appWidgetId) if (template != null) { diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt new file mode 100644 index 000000000..56bcfa5c7 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt @@ -0,0 +1,250 @@ +package cx.aswin.boxlore.feature.widgets + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.appwidget.AppWidgetProviderInfo +import android.content.ComponentName +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import android.os.Bundle +import android.widget.RemoteViews +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.core.graphics.createBitmap +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansTypefaces +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork +import cx.aswin.boxlore.feature.widgets.logic.WidgetSize +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Gives the launcher's widget picker previews drawn by the live renderers, in the listener's own widget + * colours and lettering. Android 15 and later show them. Older versions, and any preview the system + * turns down, fall back to the static `widget_preview_*` layouts, which can only use system colours. + */ +object WidgetPickerPreviews { + /** One picker entry: the widget it belongs to, the size it is drawn for, and how to draw it. */ + internal class Preview( + val key: String, + val receiver: Class, + /** + * A little under the widget's usual size, so a preview never outgrows the picker's frame. At + * this size the library widgets show whole rows only. + */ + val size: WidgetSize, + val build: (Context) -> RemoteViews, + ) + + /** + * Publishes every preview whose look changed since it was last accepted. Cheap to call often: an + * unchanged look publishes nothing, and a preview the system rate-limits is tried again next time. + */ + suspend fun refresh(context: Context) = withContext(Dispatchers.Default) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return@withContext + val app = context.applicationContext + val manager = app.getSystemService(AppWidgetManager::class.java) ?: return@withContext + val look = lookSignature(app) + val published = app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + for (preview in previews()) { + if (published.getString(preview.key, null) == look) continue + val accepted = + runCatching { + manager.setWidgetPreview( + ComponentName(app, preview.receiver), + AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN, + preview.build(app), + ) + }.getOrDefault(false) + if (accepted) published.edit { putString(preview.key, look) } + } + } + + internal fun previews(): List = listOf( + playback("full", NowPlayingWidgetReceiver::class.java, WidgetVariant.NOW_PLAYING, FULL_WIDTH_DP, FULL_HEIGHT_DP), + playback("compact", NowPlayingBarWidgetReceiver::class.java, WidgetVariant.BAR, FULL_WIDTH_DP, BAR_HEIGHT_DP), + playback("round", PlaybackControlsWidgetReceiver::class.java, WidgetVariant.CONTROLS, ROUND_SIDE_DP, ROUND_SIDE_DP), + playback("round_next", PlaybackNextControlsWidgetReceiver::class.java, WidgetVariant.CONTROLS_NEXT, ROUND_SIDE_DP, ROUND_SIDE_DP), + library("shows", SubscriptionsWidgetReceiver::class.java, LibraryWidgetKind.SUBSCRIPTIONS, SHOWS_HEIGHT_DP), + library("new_episodes", NewEpisodesWidgetReceiver::class.java, LibraryWidgetKind.NEW_EPISODES, EPISODES_HEIGHT_DP), + ) + + private fun playback( + key: String, + receiver: Class, + variant: WidgetVariant, + widthDp: Int, + heightDp: Int, + ) = Preview(key, receiver, WidgetSize(widthDp, heightDp)) { context -> + NowPlayingWidgetRenderer.buildRemoteViewsAt( + context = context, + appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID, + snapshot = samplePlayback(context), + options = + Bundle().apply { + putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, widthDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, widthDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, heightDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, heightDp) + }, + variant = variant, + ) + } + + private fun library( + key: String, + receiver: Class, + kind: LibraryWidgetKind, + heightDp: Int, + ) = Preview(key, receiver, WidgetSize(FULL_WIDTH_DP, heightDp)) { context -> + val sample = sampleLibrary(context) + // The picker cannot bind the widget's list service, so the preview carries its rows with it. + val factory = LibraryWidgetRemoteViewsFactory(context, kind, preset = sample).apply { onDataSetChanged() } + val rows = + RemoteViews.RemoteCollectionItems + .Builder() + .setHasStableIds(true) + .setViewTypeCount(1) + .apply { for (position in 0 until factory.count) addItem(factory.getItemId(position), factory.getViewAt(position)) } + .build() + LibraryWidgetRenderer.build( + context = context, + appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID, + snapshot = sample, + kind = kind, + collection = rows, + ) + } + + /** + * Everything that changes how a preview looks: the widget colours, the lettering, the Your Shows + * cover style and the installed build. A preview is published again only when this changes. + */ + internal fun lookSignature(context: Context): String { + val chrome = WidgetChrome.resolve(context) + val colours = + listOf( + WidgetPalette.surface, + WidgetPalette.onSurface, + WidgetPalette.primary, + WidgetPalette.primaryContainer, + WidgetPalette.tertiary, + WidgetPalette.tertiaryContainer, + ).joinToString(",") { role -> Integer.toHexString(chrome.argb(role)) } + val updatedAt = runCatching { context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime }.getOrDefault(0L) + return listOf( + PREVIEW_VERSION, + updatedAt, + chrome.usesAppTheme, + chrome.followsSystemDayNight, + colours, + GoogleSansTypefaces.cachedRoundness(context), + WidgetShowsArtwork.cached(context), + ).joinToString("|") + } + + internal fun samplePlayback(context: Context) = NowPlayingWidgetSnapshot( + episodeId = SAMPLE_ID, + episodeTitle = context.getString(R.string.widget_preview_episode), + podcastTitle = context.getString(R.string.widget_preview_podcast), + artworkCachePath = sampleCover(context, 0), + isPlaying = true, + positionMs = SAMPLE_POSITION_MS, + durationMs = SAMPLE_DURATION_MS, + seekForwardMs = SAMPLE_SEEK_FORWARD_MS, + seekBackwardMs = SAMPLE_SEEK_BACK_MS, + ) + + internal fun sampleLibrary(context: Context): LibraryWidgetSnapshot { + val episodes = + listOf( + R.string.widget_preview_episode to R.string.widget_preview_podcast, + R.string.widget_preview_episode_2 to R.string.widget_preview_episode_2_show, + R.string.widget_preview_episode_3 to R.string.widget_preview_episode_3_show, + ) + return LibraryWidgetSnapshot( + subscriptions = + List(SAMPLE_SHOWS) { index -> + WidgetShowRow( + podcastId = "$SAMPLE_ID-show-$index", + title = context.getString(episodes[index % episodes.size].second), + artworkCachePath = sampleCover(context, index), + deepLinkUri = SAMPLE_LINK, + isNew = index in SAMPLE_NEW_SHOWS, + ) + }, + newEpisodes = + episodes.mapIndexed { index, (episode, show) -> + WidgetEpisodeRow( + episodeId = "$SAMPLE_ID-episode-$index", + episodeTitle = context.getString(episode), + podcastId = "$SAMPLE_ID-show-$index", + podcastTitle = context.getString(show), + artworkCachePath = sampleCover(context, index), + deepLinkUri = SAMPLE_LINK, + ) + }, + counts = WidgetLibraryCounts(subscriptions = SAMPLE_SHOW_COUNT, newEpisodes = SAMPLE_EPISODE_COUNT), + ) + } + + /** + * The path of sample cover [index], drawn from the same pictures the static previews use and kept + * in the widget artwork cache. Null when it cannot be written; the widget then shows its placeholder. + */ + private fun sampleCover(context: Context, index: Int): String? { + val drawableRes = SAMPLE_COVERS[index.mod(SAMPLE_COVERS.size)] + val directory = File(context.cacheDir, WidgetArtworkLoader.CACHE_DIR_NAME) + val file = File(directory, "preview_cover_${PREVIEW_VERSION}_${index.mod(SAMPLE_COVERS.size)}.png") + if (file.exists() && file.length() > 0L) return file.absolutePath + return runCatching { + directory.mkdirs() + val drawable = ContextCompat.getDrawable(context, drawableRes) ?: return null + val bitmap = createBitmap(SAMPLE_COVER_PX, SAMPLE_COVER_PX) + drawable.setBounds(0, 0, SAMPLE_COVER_PX, SAMPLE_COVER_PX) + drawable.draw(Canvas(bitmap)) + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, it) } + file.absolutePath + }.getOrNull() + } + + private val SAMPLE_COVERS = + listOf( + R.drawable.widget_preview_cover_a, + R.drawable.widget_preview_cover_b, + R.drawable.widget_preview_cover_c, + R.drawable.widget_preview_cover_d, + R.drawable.widget_preview_cover_e, + R.drawable.widget_preview_cover_f, + ) + + internal const val PREFS_NAME = "boxlore_widget_picker_previews" + + /** Bump when the sample content or the way previews are built changes. */ + private const val PREVIEW_VERSION = 1 + + // A little under a typical 4-column grid, so a preview never outgrows the picker's frame. + private const val FULL_WIDTH_DP = 320 + private const val FULL_HEIGHT_DP = 170 + private const val BAR_HEIGHT_DP = 80 + + // Two whole rows of covers and three whole episode rows at the preview's width. + private const val SHOWS_HEIGHT_DP = 290 + private const val EPISODES_HEIGHT_DP = 296 + private const val ROUND_SIDE_DP = 160 + + private const val SAMPLE_ID = "widget-preview" + private const val SAMPLE_LINK = "boxlore://home" + private const val SAMPLE_POSITION_MS = 24 * 60_000L + private const val SAMPLE_DURATION_MS = 60 * 60_000L + private const val SAMPLE_SEEK_FORWARD_MS = 30_000L + private const val SAMPLE_SEEK_BACK_MS = 10_000L + private const val SAMPLE_SHOWS = 8 + private val SAMPLE_NEW_SHOWS = setOf(0, 2, 5) + private const val SAMPLE_SHOW_COUNT = 24 + private const val SAMPLE_EPISODE_COUNT = 12 + private const val SAMPLE_COVER_PX = 256 + private const val PNG_QUALITY = 100 +} diff --git a/feature/widgets/src/main/res/drawable/widget_art_tile_shape.xml b/feature/widgets/src/main/res/drawable/widget_art_tile_shape.xml deleted file mode 100644 index 3279bf657..000000000 --- a/feature/widgets/src/main/res/drawable/widget_art_tile_shape.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_list_art_frame.xml b/feature/widgets/src/main/res/drawable/widget_list_art_frame.xml deleted file mode 100644 index ea89223fc..000000000 --- a/feature/widgets/src/main/res/drawable/widget_list_art_frame.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_button_primary.xml b/feature/widgets/src/main/res/drawable/widget_preview_button_primary.xml deleted file mode 100644 index 7b3539fc9..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_button_primary.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_button_secondary.xml b/feature/widgets/src/main/res/drawable/widget_preview_button_secondary.xml deleted file mode 100644 index 3279bf657..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_button_secondary.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_a.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_a.xml index 46d7b5435..c7db751ae 100644 --- a/feature/widgets/src/main/res/drawable/widget_preview_cover_a.xml +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_a.xml @@ -1,5 +1,14 @@ - - - + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_b.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_b.xml index 89c146a7d..4439bd98b 100644 --- a/feature/widgets/src/main/res/drawable/widget_preview_cover_b.xml +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_b.xml @@ -1,5 +1,14 @@ - - - + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_c.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_c.xml index 07c72c36f..b490e1c62 100644 --- a/feature/widgets/src/main/res/drawable/widget_preview_cover_c.xml +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_c.xml @@ -1,5 +1,14 @@ - - - + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_d.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_d.xml new file mode 100644 index 000000000..f7b36051f --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_d.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_e.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_e.xml new file mode 100644 index 000000000..6bd6cf884 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_e.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_cover_f.xml b/feature/widgets/src/main/res/drawable/widget_preview_cover_f.xml new file mode 100644 index 000000000..6310bd1ce --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_cover_f.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_disc_a.xml b/feature/widgets/src/main/res/drawable/widget_preview_disc_a.xml new file mode 100644 index 000000000..f72d6f0f8 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_disc_a.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_disc_b.xml b/feature/widgets/src/main/res/drawable/widget_preview_disc_b.xml new file mode 100644 index 000000000..de004ec79 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_disc_b.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_disc_c.xml b/feature/widgets/src/main/res/drawable/widget_preview_disc_c.xml new file mode 100644 index 000000000..b1d912816 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_disc_c.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_new_dot.xml b/feature/widgets/src/main/res/drawable/widget_preview_new_dot.xml index 4d6a435b1..84bc89b75 100644 --- a/feature/widgets/src/main/res/drawable/widget_preview_new_dot.xml +++ b/feature/widgets/src/main/res/drawable/widget_preview_new_dot.xml @@ -1,5 +1,9 @@ + - + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_pill_chip.xml b/feature/widgets/src/main/res/drawable/widget_preview_pill_chip.xml deleted file mode 100644 index ad3b3f93e..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_pill_chip.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_ring.xml b/feature/widgets/src/main/res/drawable/widget_preview_ring.xml new file mode 100644 index 000000000..5ecc94d7e --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_ring.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_row_chip.xml b/feature/widgets/src/main/res/drawable/widget_preview_row_chip.xml deleted file mode 100644 index a75f77455..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_row_chip.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_surface.xml b/feature/widgets/src/main/res/drawable/widget_preview_surface.xml deleted file mode 100644 index f02e9fc59..000000000 --- a/feature/widgets/src/main/res/drawable/widget_preview_surface.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_a.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_a.xml new file mode 100644 index 000000000..500a233c4 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_tile_a.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_b.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_b.xml new file mode 100644 index 000000000..153b956b0 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_tile_b.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_c.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_c.xml new file mode 100644 index 000000000..f85d42ad9 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_tile_c.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_d.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_d.xml new file mode 100644 index 000000000..3bada6353 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_tile_d.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_e.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_e.xml new file mode 100644 index 000000000..637d98141 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_tile_e.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_preview_tile_f.xml b/feature/widgets/src/main/res/drawable/widget_preview_tile_f.xml new file mode 100644 index 000000000..d50f0c7e6 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_preview_tile_f.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/feature/widgets/src/main/res/layout/widget_preview_bar_4x1.xml b/feature/widgets/src/main/res/layout/widget_preview_bar_4x1.xml index 1b014b3cf..af37aa611 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_bar_4x1.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_bar_4x1.xml @@ -1,138 +1,154 @@ - + + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="350dp" + android:layout_height="86dp" + android:theme="@android:style/Theme.DeviceDefault.DayNight" + tools:ignore="UseAppTint"> - - - - - - + android:layout_height="match_parent"> + + + android:layoutDirection="ltr" + android:orientation="horizontal" + android:paddingStart="4dp" + android:paddingEnd="14dp"> + android:layout_width="78dp" + android:layout_height="78dp"> + android:src="@drawable/widget_preview_ring" /> + android:scaleType="fitXY" + android:src="@drawable/widget_preview_disc_a" /> + + + + + + + + android:layout_width="38dp" + android:layout_height="38dp"> + android:src="@drawable/widget_circle" + android:tint="@color/widget_surface" /> + android:scaleType="fitCenter" + android:src="@drawable/ic_widget_skip_back" + android:tint="@color/widget_on_surface" /> + android:layout_width="56dp" + android:layout_height="56dp" + android:layout_marginHorizontal="3dp"> + android:src="@drawable/widget_cookie" + android:tint="@color/widget_primary" /> + android:scaleType="fitCenter" + android:src="@drawable/ic_widget_pause" + android:tint="@color/widget_on_primary" /> - - + - - - + + + + - + diff --git a/feature/widgets/src/main/res/layout/widget_preview_control_4x2.xml b/feature/widgets/src/main/res/layout/widget_preview_control_4x2.xml index 2aad65b68..36a841d22 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_control_4x2.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_control_4x2.xml @@ -1,182 +1,233 @@ - + + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="350dp" + android:layout_height="182dp" + android:theme="@android:style/Theme.DeviceDefault.DayNight" + tools:ignore="UseAppTint,RtlHardcoded,NestedWeights"> + android:layout_height="match_parent" + android:orientation="vertical"> - - - + android:layout_height="0dp" + android:layout_weight="1"> - - + - - - - + + + + + + + + + + android:layout_marginStart="10dp" + android:layout_weight="1" + android:orientation="vertical"> + + + + + - + + android:layout_height="match_parent" + android:layout_weight="1" + android:layout_marginRight="8dp"> - - - - - + android:src="@drawable/widget_pill" + android:tint="@color/widget_surface" /> - - - + android:baselineAligned="false" + android:gravity="center" + android:orientation="horizontal"> + + + + + + + + + + + android:layout_width="86dp" + android:layout_height="86dp"> + android:src="@drawable/widget_cookie" + android:tint="@color/widget_primary" /> + android:scaleType="fitCenter" + android:src="@drawable/ic_widget_pause" + android:tint="@color/widget_on_primary" /> + android:layout_height="match_parent" + android:layout_weight="1" + android:layout_marginLeft="8dp"> - - - + android:src="@drawable/widget_pill" + android:tint="@color/widget_surface" /> - - - - - + android:baselineAligned="false" + android:gravity="center" + android:orientation="horizontal"> + + + + + + + + + + diff --git a/feature/widgets/src/main/res/layout/widget_preview_grid_2x2.xml b/feature/widgets/src/main/res/layout/widget_preview_grid_2x2.xml index 8efeab8b6..833334568 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_grid_2x2.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_grid_2x2.xml @@ -1,109 +1,117 @@ - + + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="170dp" + android:layout_height="170dp" + android:theme="@android:style/Theme.DeviceDefault.DayNight" + tools:ignore="UseAppTint,RtlHardcoded"> - + - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_circle" + android:tint="@color/widget_primary_container" /> - + - - + - + - + - - - + + - + - + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_cookie" + android:tint="@color/widget_primary" /> - + + - - + - - - + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_circle" + android:tint="@color/widget_surface" /> - - - - + + + diff --git a/feature/widgets/src/main/res/layout/widget_preview_grid_next_2x2.xml b/feature/widgets/src/main/res/layout/widget_preview_grid_next_2x2.xml index 91eaa9701..4c44f2199 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_grid_next_2x2.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_grid_next_2x2.xml @@ -1,109 +1,117 @@ - + + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="170dp" + android:layout_height="170dp" + android:theme="@android:style/Theme.DeviceDefault.DayNight" + tools:ignore="UseAppTint,RtlHardcoded"> - + - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_circle" + android:tint="@color/widget_primary_container" /> - + - - + - + - + - - - + + - + - + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_cookie" + android:tint="@color/widget_primary" /> - + + - - + - - - + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_circle" + android:tint="@color/widget_surface" /> - - - - + + + diff --git a/feature/widgets/src/main/res/layout/widget_preview_new_episodes.xml b/feature/widgets/src/main/res/layout/widget_preview_new_episodes.xml index a48c9b5ba..5e2884204 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_new_episodes.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_new_episodes.xml @@ -1,192 +1,298 @@ - - + + - - - - - - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_container" + android:tint="@color/widget_tertiary_container" /> - - - - + android:layout_height="match_parent" + android:orientation="vertical" + android:padding="14dp"> + android:orientation="horizontal"> + android:layout_width="52dp" + android:layout_height="52dp" + android:layout_marginEnd="10dp"> - - - + android:scaleType="fitXY" + android:src="@drawable/widget_cookie" + android:tint="@color/widget_tertiary" /> - - - - - - - - - - + android:text="@string/widget_preview_count" + android:textColor="@color/widget_on_tertiary" + android:textSize="19sp" /> + - + + android:layout_width="40dp" + android:layout_height="40dp"> + android:scaleType="fitXY" + android:src="@drawable/widget_circle" + android:tint="@color/widget_surface" /> + + + + + + + + android:layout_width="match_parent" + android:layout_height="match_parent" + android:baselineAligned="false" + android:gravity="center_vertical" + android:layoutDirection="ltr" + android:orientation="horizontal" + android:paddingStart="6dp" + android:paddingEnd="22dp"> - + + + + + + android:layout_marginStart="12dp" + android:layout_weight="1" + android:orientation="vertical"> - + + + + + + + + + + + + + + + + + + + android:layout_marginStart="12dp" + android:layout_weight="1" + android:orientation="vertical"> + + + + + - - + - - + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/widgets/src/main/res/layout/widget_preview_subscriptions.xml b/feature/widgets/src/main/res/layout/widget_preview_subscriptions.xml index 52a8a84b7..525ba5c4f 100644 --- a/feature/widgets/src/main/res/layout/widget_preview_subscriptions.xml +++ b/feature/widgets/src/main/res/layout/widget_preview_subscriptions.xml @@ -1,265 +1,243 @@ - - - - + + + + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_container" + android:tint="@color/widget_surface" /> - - - - - - - - - + android:layout_height="match_parent" + android:orientation="vertical" + android:padding="14dp"> + android:orientation="horizontal"> + android:layout_width="52dp" + android:layout_height="52dp" + android:layout_marginEnd="10dp"> + android:scaleType="fitXY" + android:src="@drawable/widget_cookie" + android:tint="@color/widget_primary" /> + + - + android:fontFamily="sans-serif-medium" + android:includeFontPadding="false" + android:maxLines="1" + android:text="@string/subscriptions_widget_header" + android:textColor="@color/widget_on_surface" + android:textSize="20sp" /> - + - - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_circle" + android:tint="@color/widget_primary_container" /> - + + - - - - - - + android:layout_height="82dp" + android:layout_marginHorizontal="-4dp" + android:baselineAligned="false" + android:orientation="horizontal"> + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_margin="4dp" + android:layout_weight="1"> + android:scaleType="fitXY" + android:src="@drawable/widget_preview_tile_a" /> + + - + android:layout_height="match_parent" + android:layout_margin="4dp" + android:layout_weight="1"> - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_preview_tile_b" /> + - + + - - - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_preview_tile_c" /> - - + + - + + + + + + android:layout_height="82dp" + android:layout_marginHorizontal="-4dp" + android:baselineAligned="false" + android:orientation="horizontal"> + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_margin="4dp" + android:layout_weight="1"> + android:scaleType="fitXY" + android:src="@drawable/widget_preview_tile_e" /> - + android:layout_height="match_parent" + android:layout_margin="4dp" + android:layout_weight="1"> - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_preview_tile_f" /> - + + + + + - - - + android:layout_height="match_parent" + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_preview_tile_c" /> + - - + + + + + + + diff --git a/feature/widgets/src/main/res/values/strings.xml b/feature/widgets/src/main/res/values/strings.xml index ca067b4fc..862c53a39 100644 --- a/feature/widgets/src/main/res/values/strings.xml +++ b/feature/widgets/src/main/res/values/strings.xml @@ -26,15 +26,14 @@ %1$s · %2$s Why curiosity compounds The Daily Lore - Field Notes - 12 new episodes - Soft Power - Updated yesterday A quiet week in Kyoto Field Notes How maps shape memory Soft Power 12 + 24 + 36m left · The Daily Lore + 36m left Library · Your Shows Your subscribed shows as a grid of artwork Subscriptions diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt new file mode 100644 index 000000000..693d7624e --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt @@ -0,0 +1,61 @@ +package cx.aswin.boxlore.feature.widgets + +import android.appwidget.AppWidgetManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class HomeScreenWidgetsTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun everyWidgetInThePickerCanBeAddedFromTheAppUnderTheSameName() { + val receivers = WidgetProviders.all.map { it.receiverClass } + LibraryWidgetProviders.all.map { it.receiverClass } + assertEquals(receivers.toSet(), HomeScreenWidgets.all.map { it.receiver }.toSet()) + assertEquals(HomeScreenWidgets.all.size, HomeScreenWidgets.all.map { it.key }.toSet().size) + // The same keys as the picker previews, so a widget is one thing wherever it is listed. + assertEquals(WidgetPickerPreviews.previews().associate { it.key to it.receiver }, HomeScreenWidgets.all.associate { it.key to it.receiver }) + for (entry in HomeScreenWidgets.all) { + val info = context.packageManager.getReceiverInfo(android.content.ComponentName(context, entry.receiver), 0) + assertEquals("${entry.key} is named as the picker names it", context.getString(info.labelRes), context.getString(entry.label)) + assertTrue(context.getString(entry.description).isNotBlank()) + } + } + + @Test + fun aHomeScreenThatCannotAddWidgetsIsReportedAndNeverAsked() { + shadowOf(context.getSystemService(AppWidgetManager::class.java)).setRequestPinAppWidgetSupported(false) + assertFalse(HomeScreenWidgets.canAdd(context)) + assertFalse(HomeScreenWidgets.requestAdd(context, "full")) + } + + @Test + fun aHomeScreenThatCanAddWidgetsIsAskedForKnownWidgetsOnly() { + shadowOf(context.getSystemService(AppWidgetManager::class.java)).setRequestPinAppWidgetSupported(true) + assertTrue(HomeScreenWidgets.canAdd(context)) + assertTrue(HomeScreenWidgets.all.all { HomeScreenWidgets.requestAdd(context, it.key) }) + assertFalse(HomeScreenWidgets.requestAdd(context, "no-such-widget")) + } + + @Test + fun everyWidgetHasAPreviewDrawnForItsOwnSize() { + for (entry in HomeScreenWidgets.all) { + assertTrue("${entry.key} has a preview size", entry.previewWidthDp > 0 && entry.previewHeightDp > 0) + val views = HomeScreenWidgets.preview(context, entry.key) + assertTrue("${entry.key} draws", views != null) + views!!.apply(context, android.appwidget.AppWidgetHostView(context)) + } + assertEquals(null, HomeScreenWidgets.preview(context, "no-such-widget")) + // Only Your Shows follows the cover setting, so only it carries that choice in Settings. + assertEquals(listOf("shows"), HomeScreenWidgets.all.filter { it.usesShowsCoverStyle }.map { it.key }) + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt new file mode 100644 index 000000000..decac7cc8 --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt @@ -0,0 +1,104 @@ +package cx.aswin.boxlore.feature.widgets + +import android.content.Context +import android.widget.FrameLayout +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.prefs.FontRoundnessAxis +import cx.aswin.boxlore.core.prefs.WidgetAppearance +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork +import java.io.File +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class WidgetPickerPreviewsTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun everyWidgetInThePickerHasItsOwnLivePreview() { + val previews = WidgetPickerPreviews.previews() + val receivers = WidgetProviders.all.map { it.receiverClass } + LibraryWidgetProviders.all.map { it.receiverClass } + + assertEquals(receivers.toSet(), previews.map { it.receiver }.toSet()) + assertEquals(previews.size, previews.map { it.key }.toSet().size) + } + + @Test + fun everyLivePreviewBuildsAndAppliesLikeAPlacedWidget() { + useTheme(brand = "violet") + for (preview in WidgetPickerPreviews.previews()) { + preview.build(context).apply(context, FrameLayout(context)) + } + } + + @Test + fun sampleContentHasCoversSoPreviewsNeverShowPlaceholders() { + val playback = WidgetPickerPreviews.samplePlayback(context) + val library = WidgetPickerPreviews.sampleLibrary(context) + val covers = listOfNotNull(playback.artworkCachePath) + library.subscriptions.mapNotNull { it.artworkCachePath } + library.newEpisodes.mapNotNull { it.artworkCachePath } + + assertEquals(1 + library.subscriptions.size + library.newEpisodes.size, covers.size) + assertTrue(covers.all { File(it).length() > 0L }) + assertTrue("several different covers", covers.toSet().size >= 4) + assertTrue(library.subscriptions.any { it.isNew } && library.subscriptions.any { !it.isNew }) + // The badge shows the library's size, not the handful of sample tiles. + assertTrue(library.count(LibraryWidgetKind.SUBSCRIPTIONS) > library.subscriptions.size) + } + + @Test + fun thePreviewLookChangesWithTheColoursAndTheCoverStyleAndOnlyThen() { + useTheme(brand = "violet") + val violet = WidgetPickerPreviews.lookSignature(context) + assertEquals("an unchanged look publishes nothing new", violet, WidgetPickerPreviews.lookSignature(context)) + + useTheme(brand = "emerald") + val emerald = WidgetPickerPreviews.lookSignature(context) + assertNotEquals(violet, emerald) + + useTheme(brand = "emerald", appearance = WidgetAppearance.SYSTEM) + assertNotEquals(emerald, WidgetPickerPreviews.lookSignature(context)) + + useTheme(brand = "emerald", showsArtwork = WidgetShowsArtwork.SHAPES) + assertNotEquals(emerald, WidgetPickerPreviews.lookSignature(context)) + } + + @Test + fun beforeAndroid15NothingIsPublishedAndTheStaticPreviewsStay() = runTest { + WidgetPickerPreviews.refresh(context) + assertTrue(context.getSharedPreferences(WidgetPickerPreviews.PREFS_NAME, Context.MODE_PRIVATE).all.isEmpty()) + } + + @Test + @Config(sdk = [35]) + fun onAndroid15PublishingNeverThrowsAndARejectedPreviewIsTriedAgain() = runTest { + useTheme(brand = "violet") + WidgetPickerPreviews.refresh(context) + val published = context.getSharedPreferences(WidgetPickerPreviews.PREFS_NAME, Context.MODE_PRIVATE).all + // Only previews the system accepted are remembered; the rest stay due for the next call. + assertTrue(published.values.all { it == WidgetPickerPreviews.lookSignature(context) }) + } + + private fun useTheme( + brand: String, + appearance: String = WidgetAppearance.APP, + showsArtwork: String = WidgetShowsArtwork.ROUNDED, + ) { + context + .getSharedPreferences(FontRoundnessAxis.THEME_FAST_CACHE, Context.MODE_PRIVATE) + .edit() + .putString("theme_brand", brand) + .putString("theme_config", "dark") + .putString("surface_style", "classic_dynamic") + .putBoolean("use_dynamic_color", false) + .putString(WidgetAppearance.PREF_KEY, appearance) + .putString(WidgetShowsArtwork.PREF_KEY, showsArtwork) + .commit() + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt index f4772016f..a9a550d21 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt @@ -1,5 +1,6 @@ package cx.aswin.boxlore.feature.widgets +import android.appwidget.AppWidgetHostView import android.appwidget.AppWidgetManager import android.content.Context import android.graphics.Bitmap @@ -162,6 +163,31 @@ class WidgetRenderGalleryTest { private class LibraryLook(val kind: LibraryWidgetKind, val artwork: String, val name: String) + @Test + fun pickerPreviewsDrawBothLiveAndStatic() { + val picker = + mapOf( + "full" to (R.layout.widget_preview_control_4x2 to Size("picker", 350, 182)), + "compact" to (R.layout.widget_preview_bar_4x1 to Size("picker", 350, 86)), + "round" to (R.layout.widget_preview_grid_2x2 to Size("picker", 170, 170)), + "round_next" to (R.layout.widget_preview_grid_next_2x2 to Size("picker", 170, 170)), + "shows" to (R.layout.widget_preview_subscriptions to Size("picker", 350, 278)), + "new_episodes" to (R.layout.widget_preview_new_episodes to Size("picker", 350, 278)), + ) + for (dark in listOf(false, true)) { + useAppTheme(dark) + setShowsArtwork(WidgetShowsArtwork.ROUNDED) + for (preview in WidgetPickerPreviews.previews()) { + val (staticLayout, size) = picker.getValue(preview.key) + // Live: what Android 15 and later show, drawn by the widget's own renderer. + // Drawn in a real widget host: rows a preview carries with it are only shown inside one. + save(draw(preview.build(context), size, dark, host = AppWidgetHostView(context)), "picker-live-${preview.key}-${themeName(dark)}") + // Static: the fallback layout older versions inflate. + save(draw(RemoteViews(context.packageName, staticLayout), size, dark), "picker-static-${preview.key}-${themeName(dark)}") + } + } + } + private fun drawLibrary(kind: LibraryWidgetKind, snapshot: LibraryWidgetSnapshot, size: Size, dark: Boolean): Bitmap { LibraryWidgetSnapshotStore(context).write(snapshot) val views = LibraryWidgetRenderer.build(context, appWidgetId = 9, snapshot = snapshot, kind = kind) @@ -183,10 +209,15 @@ class WidgetRenderGalleryTest { } } - private fun draw(views: RemoteViews, size: Size, dark: Boolean, prepare: (View) -> Unit = {}): Bitmap { + private fun draw( + views: RemoteViews, + size: Size, + dark: Boolean, + host: FrameLayout = FrameLayout(context), + prepare: (View) -> Unit = {}, + ): Bitmap { val width = (size.widthDp * density).roundToInt() val height = (size.heightDp * density).roundToInt() - val host = FrameLayout(context) val root = views.apply(context, host) host.addView(root, FrameLayout.LayoutParams(width, height)) prepare(root) diff --git a/release-notes/upcoming.md b/release-notes/upcoming.md index bb43fcb53..0d0e68842 100644 --- a/release-notes/upcoming.md +++ b/release-notes/upcoming.md @@ -27,7 +27,8 @@ Announcement title: ## Improved - Home screen widgets have a new look with rounder shapes. The players now show how far you are and how much time is left, and the skip buttons show your own skip lengths. -- The Your Shows widget is now a grid of artwork. +- The Your Shows widget is now a grid of artwork. Choose whole covers or shapes in Settings › Appearance › Widgets. +- See every boxlore widget and add it to your home screen from Settings › Appearance › Widgets. - On Home, a show you listen to oldest first now picks up exactly where you left off, even deep into a long series, and skips trailers. Daily mix suggests the same next episode. ## Fixed From 63a55f421141af4428949c984578b3cadb2230fe Mon Sep 17 00:00:00 2001 From: Aswin C Date: Wed, 11 Nov 2026 02:11:19 +0530 Subject: [PATCH 5/9] refactor(settings): pair small widget cards without a non-null assertion --- .../feature/settings/pages/AppearanceWidgetsPage.kt | 9 +++++---- .../feature/widgets/LibraryWidgetRemoteViewsService.kt | 4 +++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt index 269223622..17c9bbd31 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt @@ -293,14 +293,15 @@ internal fun widgetGalleryRows(widgets: List): List Date: Wed, 11 Nov 2026 02:12:14 +0530 Subject: [PATCH 6/9] refactor(widgets): read the saved library rows outside the list factory --- .../feature/widgets/LibraryWidgetRemoteViewsService.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt index 5900e6454..386987dbc 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt @@ -44,6 +44,9 @@ class LibraryWidgetRemoteViewsService : RemoteViewsService() { } } +/** The rows last saved for the library widgets, or none before the first save. */ +private fun savedSnapshot(context: Context): LibraryWidgetSnapshot = LibraryWidgetSnapshotStore(context).read() ?: LibraryWidgetSnapshot() + internal class LibraryWidgetRemoteViewsFactory( private val context: Context, private val kind: LibraryWidgetKind, @@ -58,10 +61,8 @@ internal class LibraryWidgetRemoteViewsFactory( override fun onCreate() = Unit - private fun savedSnapshot(): LibraryWidgetSnapshot = LibraryWidgetSnapshotStore(context).read() ?: LibraryWidgetSnapshot() - override fun onDataSetChanged() { - val snapshot = preset ?: savedSnapshot() + val snapshot = preset ?: savedSnapshot(context) shows = snapshot.subscriptions episodes = snapshot.newEpisodes chrome = WidgetChrome.resolve(context) From 457fa80cd6809202161b795d6992fb89e4fa28d8 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Wed, 11 Nov 2026 22:24:30 +0530 Subject: [PATCH 7/9] fix(widgets): pair background progress with its episode and harden cover loading --- .../widgets/WidgetLibrarySourceAdapter.kt | 20 +-- .../widgets/WidgetLibrarySourceAdapterTest.kt | 36 ++++++ core/designsystem/README.md | 2 +- core/playback/README.md | 2 +- .../playback/PlaybackControllerStatePolicy.kt | 8 ++ .../core/playback/PlaybackRepository.kt | 6 +- .../PlaybackControllerStatePolicyTest.kt | 35 +++++ feature/widgets/README.md | 9 +- .../widgets/LibraryWidgetCoordinator.kt | 51 ++------ .../LibraryWidgetRemoteViewsService.kt | 15 ++- .../widgets/logic/ArtworkTimeoutCooldown.kt | 37 ++++++ .../widgets/logic/LibraryArtworkBatches.kt | 56 ++++++++ .../NowPlayingWidgetRemoteViewsTest.kt | 18 --- .../widgets/WidgetActionHandlerTest.kt | 122 ++++++++++++++++++ .../logic/ArtworkTimeoutCooldownTest.kt | 36 ++++++ .../logic/LibraryArtworkBatchesTest.kt | 110 ++++++++++++++++ 16 files changed, 483 insertions(+), 80 deletions(-) create mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/ArtworkTimeoutCooldown.kt create mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/LibraryArtworkBatches.kt create mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetActionHandlerTest.kt create mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/ArtworkTimeoutCooldownTest.kt create mode 100644 feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/LibraryArtworkBatchesTest.kt diff --git a/app/src/main/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapter.kt b/app/src/main/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapter.kt index 4b273c1dc..de403ed6a 100644 --- a/app/src/main/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapter.kt +++ b/app/src/main/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapter.kt @@ -89,14 +89,7 @@ class WidgetLibrarySourceAdapter( }, ) - val withLatest = enriched.filter { it.latestEpisode != null } - val latestPool = - if (inputs.hideCompleted) { - withLatest.filter { it.episodeStatus != EpisodeStatus.COMPLETED } - } else { - withLatest - } - val sortedLatest = sortLatest(latestPool, inputs.history, inputs.useSmart) + val sortedLatest = sortLatest(latestEpisodePool(enriched, inputs.hideCompleted), inputs.history, inputs.useSmart) val episodeRows = LibraryWidgetLogic.truncateEpisodes( sortedLatest.mapNotNull { podcast -> @@ -115,7 +108,7 @@ class WidgetLibrarySourceAdapter( Projection( subscriptions = showRows, newEpisodes = episodeRows, - counts = WidgetLibraryCounts(subscriptions = sortedShows.size, newEpisodes = sortedLatest.size), + counts = libraryCounts(sortedShows, sortedLatest), ) }.stateIn( scope = scope, @@ -209,6 +202,15 @@ class WidgetLibrarySourceAdapter( private fun parseSort(name: String): ShowSort = runCatching { ShowSort.valueOf(name) }.getOrDefault(ShowSort.SmartRank) companion object { + /** The shows whose latest episode New Episodes lists: all of them, or only the unfinished ones. */ + fun latestEpisodePool(enriched: List, hideCompleted: Boolean): List { + val withLatest = enriched.filter { it.latestEpisode != null } + return if (hideCompleted) withLatest.filter { it.episodeStatus != EpisodeStatus.COMPLETED } else withLatest + } + + /** The whole library's sizes, taken from the full lists before the widget's rows are capped. */ + fun libraryCounts(shows: List, latest: List): WidgetLibraryCounts = WidgetLibraryCounts(subscriptions = shows.size, newEpisodes = latest.size) + fun enrichWithHistory(podcasts: List, allHistory: List,): List = podcasts.map { podcast -> val episode = podcast.latestEpisode ?: return@map podcast val history = allHistory.find { it.episodeId == episode.id } diff --git a/app/src/test/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapterTest.kt b/app/src/test/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapterTest.kt index 619389c02..bc9a1e21a 100644 --- a/app/src/test/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapterTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapterTest.kt @@ -4,6 +4,8 @@ import cx.aswin.boxlore.core.database.ListeningHistoryEntity import cx.aswin.boxlore.core.model.Episode import cx.aswin.boxlore.core.model.EpisodeStatus import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.feature.widgets.WidgetShowRow +import cx.aswin.boxlore.feature.widgets.logic.LibraryWidgetLogic import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -66,4 +68,38 @@ class WidgetLibrarySourceAdapterTest { assertTrue(episode.contains("autoplay=false")) assertTrue(episode.contains("podcastId=p1")) } + + @Test + fun countsCoverTheWholeLibraryWhileTheRowsStayCapped() { + val total = LibraryWidgetLogic.MAX_ROWS + 12 + val finished = 5 + val library = + List(total) { index -> + Podcast( + id = "p$index", + title = "Show $index", + artist = "Host", + imageUrl = "https://example.com/$index.jpg", + latestEpisode = Episode(id = "e$index", title = "Ep $index", description = "", audioUrl = "https://example.com/$index.mp3", publishedDate = 1_700_000_000L + index), + episodeStatus = if (index < finished) EpisodeStatus.COMPLETED else EpisodeStatus.UNPLAYED, + ) + } + + val everyEpisode = WidgetLibrarySourceAdapter.latestEpisodePool(library, hideCompleted = false) + val counts = WidgetLibrarySourceAdapter.libraryCounts(library, everyEpisode) + assertEquals(total, counts.subscriptions) + assertEquals(total, counts.newEpisodes) + // The widget still scrolls a capped list. + assertEquals(LibraryWidgetLogic.MAX_ROWS, LibraryWidgetLogic.truncateShows(library.map { WidgetShowRow(podcastId = it.id, title = it.title, deepLinkUri = "") }).size) + + // Hiding finished episodes shrinks the New Episodes count, not the show count. + val unfinished = WidgetLibrarySourceAdapter.latestEpisodePool(library, hideCompleted = true) + val hidden = WidgetLibrarySourceAdapter.libraryCounts(library, unfinished) + assertEquals(total, hidden.subscriptions) + assertEquals(total - finished, hidden.newEpisodes) + + // A show without a latest episode is a show, but not a New Episodes row. + val withoutEpisode = library + Podcast(id = "bare", title = "Bare", artist = "Host", imageUrl = "") + assertEquals(total, WidgetLibrarySourceAdapter.latestEpisodePool(withoutEpisode, hideCompleted = false).size) + } } diff --git a/core/designsystem/README.md b/core/designsystem/README.md index 88e95ba84..d97b55b57 100644 --- a/core/designsystem/README.md +++ b/core/designsystem/README.md @@ -9,7 +9,7 @@ Owns boxlore's shared Compose visuals: theme and colour, typography, shapes, mot ### Theme and colour - `BoxLoreTheme`: the app theme, applied by `:app` and feature roots from the saved Appearance choices. -- `resolveBoxLoreColorScheme`, `resolveBoxLoreChromeColors`, `resolveFixedThemeColorScheme`: the same scheme for widgets and Appearance previews. +- `resolveBoxLoreColorScheme`, `resolveBoxLoreChromeColors`, `resolveFixedThemeColorScheme`: the same scheme for widgets and Appearance previews. `BoxLoreChromeColors` carries the surface, primary, secondary-container and tertiary roles widgets tint with. - `ThemePresets`: the sixteen authored light and dark themes, selected by stable `preset:*` keys. - `CustomThemeSeeds`, `generatePersonalColorScheme`: custom accent seeds and the tonal scheme built from them. - `ClassicBrandColors`, `BrandSeeds`, `SurfaceStyles`: the boxlore classic palette, named seeds and surface-style keys, including Pure. diff --git a/core/playback/README.md b/core/playback/README.md index cee3bf3fb..1992dc9f0 100644 --- a/core/playback/README.md +++ b/core/playback/README.md @@ -121,7 +121,7 @@ src/main/java/cx/aswin/boxlore/core/data/service/ # stubs for legacy service n - `AppContainer` builds the graph once: DB → `PodcastRepository` → `QueueRepository` → `PlaybackRepository` → `QueueManager`. - Player and controller callbacks run on the main thread. `restoreLastSession` and transport calls hop to `PlaybackThreadPolicy.mainDispatcher`; database, artwork and recommendation work runs on background dispatchers. -- `PlaybackRepository` polls the controller position only while app UI is in the foreground; `refreshProgressOnce()` publishes it once on request, which home-screen widgets use while an episode plays in the background. The service alone saves active-session progress and telemetry. +- `PlaybackRepository` polls the controller position only while app UI is in the foreground; `refreshProgressOnce()` publishes it once on request, and only while the player still holds the episode the state names, which home-screen widgets use while an episode plays in the background. The service alone saves active-session progress and telemetry. - `PlaybackMediaControllerHandle.awaitController()` returns null on a rejected connection and reconnects with growing delays, at most `MediaControllerReconnectPolicy.MAX_ATTEMPTS` times. - `BoxLorePlaybackService` resolves shared dependencies lazily after app start; its players are service-scoped, so playback and transfers continue without an Activity. - Idle handoff: a local session paused in the background for `PlaybackPowerPolicy.PAUSED_IDLE_TIMEOUT_MS` saves progress, rechecks eligibility and stops the service. Playback, buffering intent, foreground UI and Cast skip that teardown. diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicy.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicy.kt index 9a32351dc..98a702d92 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicy.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicy.kt @@ -7,6 +7,14 @@ package cx.aswin.boxlore.core.playback internal object PlaybackControllerStatePolicy { data class Snapshot(val hasMedia: Boolean, val positionMs: Long, val bufferedPositionMs: Long, val durationMs: Long,) + /** + * Merges progress read once, outside the ticker, only when the player still holds the episode the + * state names. While the player has moved to its next item and the state still shows the previous + * episode, that progress belongs to the new item, so it waits for the state to catch up. + */ + fun mergeProgressForCurrentItem(previous: PlayerState, snapshot: Snapshot, playerEpisodeId: String?): PlayerState = + if (playerEpisodeId != null && playerEpisodeId == previous.currentEpisode?.id) mergeProgress(previous, snapshot) else previous + fun mergeProgress(previous: PlayerState, snapshot: Snapshot,): PlayerState { if (!snapshot.hasMedia) return previous diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackRepository.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackRepository.kt index 8e3e5e8e0..767befb28 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackRepository.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackRepository.kt @@ -542,15 +542,17 @@ class PlaybackRepository internal constructor( /** * Reads the player's position once and publishes it. Home-screen widgets call this while the app - * is in the background, where [startProgressTicker] is stopped to save power. + * is in the background, where [startProgressTicker] is stopped to save power. Nothing is published + * while the player is already on another item than the episode the state names. */ fun refreshProgressOnce() { runOnMainThread { val controller = mediaHandle.controller ?: return@runOnMainThread playerStateFlow.value = - PlaybackControllerStatePolicy.mergeProgress( + PlaybackControllerStatePolicy.mergeProgressForCurrentItem( previous = playerStateFlow.value, snapshot = controller.progressSnapshot(), + playerEpisodeId = controller.currentMediaItem?.mediaId?.let(PlaybackMediaIdPolicy::stripMediaIdPrefixes), ) } } diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicyTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicyTest.kt index 16f682c71..e57956509 100644 --- a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicyTest.kt +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicyTest.kt @@ -113,4 +113,39 @@ class PlaybackControllerStatePolicyTest { ), ) } + + @Test + fun `progress read once is published for the episode the state names`() { + val state = restoredState.copy(currentEpisode = episode("ep-1")) + + val merged = + PlaybackControllerStatePolicy.mergeProgressForCurrentItem( + previous = state, + snapshot = PlaybackControllerStatePolicy.Snapshot(hasMedia = true, positionMs = 90_000L, bufferedPositionMs = 120_000L, durationMs = 1_800_000L), + playerEpisodeId = "ep-1", + ) + + assertEquals(90_000L, merged.position) + assertEquals(1_800_000L, merged.duration) + } + + @Test + fun `progress of the player's next item is not paired with the previous episode`() { + val state = restoredState.copy(currentEpisode = episode("ep-1")) + val nextItemProgress = PlaybackControllerStatePolicy.Snapshot(hasMedia = true, positionMs = 4_000L, bufferedPositionMs = 9_000L, durationMs = 2_400_000L) + + // The player moved on while the state still names the previous episode. + assertEquals(state, PlaybackControllerStatePolicy.mergeProgressForCurrentItem(state, nextItemProgress, playerEpisodeId = "ep-2")) + // Without a known item, or without an episode in the state, there is nothing to pair it with. + assertEquals(state, PlaybackControllerStatePolicy.mergeProgressForCurrentItem(state, nextItemProgress, playerEpisodeId = null)) + assertEquals(restoredState, PlaybackControllerStatePolicy.mergeProgressForCurrentItem(restoredState, nextItemProgress, playerEpisodeId = "ep-2")) + } + + private fun episode(id: String) = cx.aswin.boxlore.core.model.Episode( + id = id, + title = "Episode $id", + description = "", + audioUrl = "https://example.com/$id.mp3", + publishedDate = 1_700_000_000L, + ) } diff --git a/feature/widgets/README.md b/feature/widgets/README.md index 0f829e3b7..bba713471 100644 --- a/feature/widgets/README.md +++ b/feature/widgets/README.md @@ -39,7 +39,7 @@ Owns boxlore's home-screen widgets, built with `AppWidgetProvider` and `RemoteVi - While an episode plays and a playback widget is placed, `WidgetProgressPoller` asks the port for the position every `PROGRESS_POLL_MS`. `WidgetUpdatePolicy` repaints only when the ring or the time-left label would change, about once a minute. - Both renderers push through `pushWidgetUpdate`. From Android 17, an update over the launcher's bitmap cap throws; the guard catches it, retries once with a lighter update where one exists, and otherwise waits for the next update, so the player service never crashes. - Artwork is decoded to fit the widget and cut to its shape (`WidgetShapes`), keeping updates under that cap. `WidgetBitmapCache` keeps cut artwork, rings, masks and seek icons in memory, so a tap redraws without decoding anything again. -- Library covers load a few at a time and each batch is shown as it lands. The list service also fetches a missing cover itself, so covers appear while the app is closed; a tile shows its placeholder until then. +- Library covers load a few at a time and each batch is shown as it lands (`LibraryArtworkBatches`); a newer library waits for the older load to stop before it saves. The list service also fetches a missing cover itself, so covers appear while the app is closed; a tile shows its placeholder until then, and a cover that ran out of time is left alone for a while (`ArtworkTimeoutCooldown`). - Every colour is a tint, never baked into a bitmap. Widget chrome follows the in-app Appearance theme by default: `WidgetChrome` sends one ARGB value for a fixed Light or Dark display mode, and a light and dark pair when the display mode follows the system, so the launcher switches with the phone while the app is not running. Appearance › Widgets can switch to System, which uses the system's dynamic colour resources. - `WidgetThemeSync` re-pushes widgets on configuration changes; `:app` refreshes them when the theme or widget preferences change. - Picker previews (`widget_preview_*.xml`) are static layouts in resource colours. @@ -60,7 +60,8 @@ src/main/java/cx/aswin/boxlore/feature/widgets/ WidgetThemeSync.kt, WidgetHostPresence.kt, WidgetUpdates.kt actions/ # WidgetActionIntents, WidgetActionHandler, WidgetControlReceiver logic/ # NowPlayingWidgetLogic, LibraryWidgetLogic, WidgetPresentation, WidgetSizes, - # WidgetProgress, WidgetProgressPoller + # WidgetProgress, WidgetProgressPoller, LibraryArtworkBatches, + # ArtworkTimeoutCooldown src/main/res/ layout/ # live layouts and picker previews xml/*_widget_info.xml # provider info @@ -101,10 +102,10 @@ Hermetic JVM tests live in `feature/widgets/src/test`, using Robolectric and fak | Area | Tests | | :--- | :--- | -| Playback widgets | `NowPlayingWidgetRemoteViewsTest`, `NowPlayingWidgetCoordinatorTest`, `NowPlayingWidgetLogicTest`, `WidgetOptimisticActionTest` | +| Playback widgets | `NowPlayingWidgetRemoteViewsTest`, `NowPlayingWidgetCoordinatorTest`, `NowPlayingWidgetLogicTest`, `WidgetOptimisticActionTest`, `WidgetActionHandlerTest` | | Sizing, resizing and progress | `WidgetPresentationTest`, `WidgetSizesTest`, `WidgetProgressTest`, `WidgetProgressPollerTest`, `WidgetShapesTest` | | Every widget, state and size applied and drawn | `WidgetRenderGalleryTest` (set `BOXLORE_WIDGET_RENDERS=1` to save the drawings under `build/reports/widget-renders/`) | -| Library widgets | `LibraryWidgetRemoteViewsTest`, `LibraryWidgetLogicTest` | +| Library widgets | `LibraryWidgetRemoteViewsTest`, `LibraryWidgetLogicTest`, `LibraryArtworkBatchesTest`, `ArtworkTimeoutCooldownTest` | | Snapshots and dependency holders | `NowPlayingWidgetSnapshotStoreTest`, `NowPlayingWidgetDependenciesHolderTest` | | Startup and updates | `WidgetStartupTest`, `WidgetUpdatesTest` | | Theming | `WidgetChromeTest`, `WidgetRemoteViewsColorsTest` | diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetCoordinator.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetCoordinator.kt index dfd5298c3..39eaa83f0 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetCoordinator.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetCoordinator.kt @@ -3,6 +3,7 @@ package cx.aswin.boxlore.feature.widgets import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context +import cx.aswin.boxlore.feature.widgets.logic.LibraryArtworkBatches import cx.aswin.boxlore.feature.widgets.logic.LibraryWidgetLogic import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Job @@ -12,7 +13,6 @@ import kotlinx.coroutines.launch object LibraryWidgetCoordinator { private val collectJob = AtomicReference(null) - private val artworkJob = AtomicReference(null) @Volatile private var dependencies: LibraryWidgetDependencies? = null @@ -23,7 +23,6 @@ object LibraryWidgetCoordinator { if (current?.isActive == true && dependencies === deps) return dependencies = deps current?.cancel() - artworkJob.getAndSet(null)?.cancel() val context = deps.context.applicationContext val store = LibraryWidgetSnapshotStore(context) @@ -110,44 +109,20 @@ object LibraryWidgetCoordinator { } /** - * Fetches covers the widgets do not have yet and shows them as they arrive, a few at a time. - * Waiting for the whole library first left a widget without covers whenever the app was closed - * before the last one came in. + * Fetches covers the widgets do not have yet and shows them as they arrive. It runs inside the + * collector's own block: a newer library cancels it and waits for it to stop before writing its own + * snapshot, so an older snapshot can never be saved over a newer one. */ - private fun loadMissingArtwork( + private suspend fun loadMissingArtwork( context: Context, snapshot: LibraryWidgetSnapshot, artworkLoader: WidgetArtworkLoader, store: LibraryWidgetSnapshotStore, ) { - val deps = dependencies ?: return - artworkJob.getAndSet(null)?.cancel() - artworkJob.set( - deps.scope.launch { - var current = snapshot - var unpublished = 0 - - fun publish() { - store.write(current.copy(updatedAtMs = System.currentTimeMillis())) - notifyRowsChanged(context.applicationContext) - unpublished = 0 - } - - snapshot.subscriptions.forEachIndexed { index, row -> - if (!row.needsArtwork()) return@forEachIndexed - val path = artworkLoader.load(row.artworkUrl, row.fallbackArtworkUrls) ?: return@forEachIndexed - current = current.copy(subscriptions = current.subscriptions.toMutableList().also { it[index] = row.copy(artworkCachePath = path) }) - if (++unpublished >= PUBLISH_EVERY) publish() - } - snapshot.newEpisodes.forEachIndexed { index, row -> - if (!row.needsArtwork()) return@forEachIndexed - val path = artworkLoader.load(row.artworkUrl, row.fallbackArtworkUrls) ?: return@forEachIndexed - current = current.copy(newEpisodes = current.newEpisodes.toMutableList().also { it[index] = row.copy(artworkCachePath = path) }) - if (++unpublished >= PUBLISH_EVERY) publish() - } - if (unpublished > 0) publish() - }, - ) + LibraryArtworkBatches.load(snapshot, load = { url, fallbackUrls -> artworkLoader.load(url, fallbackUrls) }) { withCovers -> + store.write(withCovers.copy(updatedAtMs = System.currentTimeMillis())) + notifyRowsChanged(context.applicationContext) + } } /** Tells the placed library widgets to redraw their rows; the frame around them stays as it is. */ @@ -159,14 +134,6 @@ object LibraryWidgetCoordinator { } } - private fun WidgetShowRow.needsArtwork(): Boolean = artworkCachePath.isNullOrBlank() && - cx.aswin.boxlore.core.model.artworkUrls(artworkUrl, *fallbackArtworkUrls.toTypedArray()).isNotEmpty() - - private fun WidgetEpisodeRow.needsArtwork(): Boolean = artworkCachePath.isNullOrBlank() && - cx.aswin.boxlore.core.model.artworkUrls(artworkUrl, *fallbackArtworkUrls.toTypedArray()).isNotEmpty() - /** The system may clear the artwork cache, so a remembered path only counts while its file is there. */ private fun String?.ifOnDisk(): String? = this?.takeIf { it.isNotBlank() && java.io.File(it).exists() } - - private const val PUBLISH_EVERY = 4 } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt index 20a4a4c80..e394031ca 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt @@ -4,11 +4,13 @@ import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent import android.net.Uri +import android.os.SystemClock import android.view.View import android.widget.RemoteViews import android.widget.RemoteViewsService import androidx.core.net.toUri import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork +import cx.aswin.boxlore.feature.widgets.logic.ArtworkTimeoutCooldown import java.io.File import kotlin.math.roundToInt import kotlinx.coroutines.runBlocking @@ -184,11 +186,15 @@ internal class LibraryWidgetRemoteViewsFactory( cachePath?.takeIf { it.isNotBlank() && File(it).exists() }?.let { return it } if (cx.aswin.boxlore.core.model.artworkUrls(url, *fallbackUrls.toTypedArray()).isEmpty()) return null artworkLoader.resolveCachedPath(url)?.let { return it } - return runCatching { - runBlocking { withTimeoutOrNull(ARTWORK_TIMEOUT_MS) { artworkLoader.load(url, fallbackUrls) } } - }.getOrNull() + if (timeouts.isCoolingDown(url, fallbackUrls)) return null + // A null answer is a finished load that found nothing; no answer at all means it ran out of time. + val answer = runCatching { runBlocking { withTimeoutOrNull(ARTWORK_TIMEOUT_MS) { LoadAnswer(artworkLoader.load(url, fallbackUrls)) } } }.getOrElse { return null } + if (answer == null) timeouts.recordTimeout(url, fallbackUrls) + return answer?.path } + private class LoadAnswer(val path: String?) + private fun px(dp: Int): Int = (dp * context.resources.displayMetrics.density).roundToInt().coerceIn(1, MAX_ART_PX) internal companion object { @@ -199,5 +205,8 @@ internal class LibraryWidgetRemoteViewsFactory( private const val ROW_ART_DP = 46 private const val MAX_ART_PX = 288 private const val ARTWORK_TIMEOUT_MS = 6_000L + + // Shared by both library widgets and kept across redraws, which make a new factory each time. + private val timeouts = ArtworkTimeoutCooldown(nowMs = SystemClock::elapsedRealtime) } } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/ArtworkTimeoutCooldown.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/ArtworkTimeoutCooldown.kt new file mode 100644 index 000000000..c489431f6 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/ArtworkTimeoutCooldown.kt @@ -0,0 +1,37 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import java.util.concurrent.ConcurrentHashMap + +/** + * Remembers cover requests that ran out of time while a list row was waiting for them, so the rows + * after it, and the same row on the next redraw, are not each kept waiting again. A request that + * fails outright is the loader's to remember; this only covers the ones cut off before they finished. + */ +internal class ArtworkTimeoutCooldown( + private val cooldownMs: Long = COOLDOWN_MS, + private val nowMs: () -> Long, +) { + private val timedOutAt = ConcurrentHashMap() + + /** Whether this cover, with these fallbacks, timed out recently and should not be waited for now. */ + fun isCoolingDown(url: String?, fallbackUrls: List): Boolean { + val key = key(url, fallbackUrls) + val at = timedOutAt[key] ?: return false + if (nowMs() - at < cooldownMs) return true + timedOutAt.remove(key, at) + return false + } + + fun recordTimeout(url: String?, fallbackUrls: List) { + if (timedOutAt.size >= MAX_ENTRIES) timedOutAt.clear() + timedOutAt[key(url, fallbackUrls)] = nowMs() + } + + // The fallbacks are part of the request: the same cover with another fallback is another attempt. + private fun key(url: String?, fallbackUrls: List): String = (listOf(url.orEmpty()) + fallbackUrls).joinToString("\n") + + internal companion object { + const val COOLDOWN_MS = 5 * 60_000L + private const val MAX_ENTRIES = 256 + } +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/LibraryArtworkBatches.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/LibraryArtworkBatches.kt new file mode 100644 index 000000000..585042351 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/LibraryArtworkBatches.kt @@ -0,0 +1,56 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import cx.aswin.boxlore.core.model.artworkUrls +import cx.aswin.boxlore.feature.widgets.LibraryWidgetSnapshot +import cx.aswin.boxlore.feature.widgets.WidgetEpisodeRow +import cx.aswin.boxlore.feature.widgets.WidgetShowRow + +/** + * Fetches the covers a library snapshot is missing and hands back the snapshot as they arrive, a few + * at a time. Waiting for the whole library first left a widget without covers whenever the app was + * closed before the last one came in. + */ +internal object LibraryArtworkBatches { + const val PUBLISH_EVERY = 4 + + /** + * Loads every missing cover in [snapshot] through [load], shows first, then episodes. [publish] + * gets the snapshot with the covers so far after every [publishEvery] loads that found one, and + * once more for the rest. A cover that cannot be loaded is skipped. Cancelling the caller stops + * the loading, and nothing is published after that. + */ + suspend fun load( + snapshot: LibraryWidgetSnapshot, + publishEvery: Int = PUBLISH_EVERY, + load: suspend (url: String?, fallbackUrls: List) -> String?, + publish: (LibraryWidgetSnapshot) -> Unit, + ) { + var current = snapshot + var unpublished = 0 + + fun loaded(next: LibraryWidgetSnapshot) { + current = next + unpublished++ + if (unpublished >= publishEvery) { + publish(current) + unpublished = 0 + } + } + + snapshot.subscriptions.forEachIndexed { index, row -> + if (!row.needsArtwork()) return@forEachIndexed + val path = load(row.artworkUrl, row.fallbackArtworkUrls) ?: return@forEachIndexed + loaded(current.copy(subscriptions = current.subscriptions.toMutableList().also { it[index] = row.copy(artworkCachePath = path) })) + } + snapshot.newEpisodes.forEachIndexed { index, row -> + if (!row.needsArtwork()) return@forEachIndexed + val path = load(row.artworkUrl, row.fallbackArtworkUrls) ?: return@forEachIndexed + loaded(current.copy(newEpisodes = current.newEpisodes.toMutableList().also { it[index] = row.copy(artworkCachePath = path) })) + } + if (unpublished > 0) publish(current) + } + + private fun WidgetShowRow.needsArtwork(): Boolean = artworkCachePath.isNullOrBlank() && artworkUrls(artworkUrl, *fallbackArtworkUrls.toTypedArray()).isNotEmpty() + + private fun WidgetEpisodeRow.needsArtwork(): Boolean = artworkCachePath.isNullOrBlank() && artworkUrls(artworkUrl, *fallbackArtworkUrls.toTypedArray()).isNotEmpty() +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRemoteViewsTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRemoteViewsTest.kt index 5a0565bff..d5c513445 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRemoteViewsTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRemoteViewsTest.kt @@ -147,24 +147,6 @@ class NowPlayingWidgetRemoteViewsTest { assertFullyInside(root, R.id.widget_empty_title) } - @Test - fun responsiveSizesUseLauncherSizeList() { - val options = - defaultOptions().apply { - putParcelableArrayList( - AppWidgetManager.OPTION_APPWIDGET_SIZES, - arrayListOf(SizeF(245f, 110f), SizeF(300f, 150f)), - ) - } - - NowPlayingWidgetRenderer.buildRemoteViewsAt( - context = context, - appWidgetId = 2, - snapshot = playingSnapshot(), - options = options, - ) - } - @Test fun barVariantUsesDedicatedHorizontalLayout() { val views = diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetActionHandlerTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetActionHandlerTest.kt new file mode 100644 index 000000000..ab1e727a4 --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetActionHandlerTest.kt @@ -0,0 +1,122 @@ +package cx.aswin.boxlore.feature.widgets + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.feature.widgets.actions.WidgetActionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [31]) +class WidgetActionHandlerTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val playback = FakePlayback() + private val store = NowPlayingWidgetSnapshotStore(context) + + private class FakePlayback : WidgetPlaybackSource { + override val state = MutableStateFlow(WidgetPlaybackState()) + val calls = mutableListOf() + + override suspend fun restoreBeforeCollect() = Unit + + override suspend fun restoreBeforeAction() { + calls += "restore" + } + + override suspend fun togglePlayPause() { + calls += "toggle" + } + + override suspend fun previous() { + calls += "previous" + } + + override suspend fun next() { + calls += "next" + } + + override suspend fun skipForward() { + calls += "forward" + } + + override suspend fun skipBackward() { + calls += "back" + } + + override suspend fun refreshPosition() { + calls += "refresh" + } + } + + @Before + fun install() { + val fake = playback + NowPlayingWidgetDependenciesHolder.instance = + object : NowPlayingWidgetDependencies { + override val context: Context = this@WidgetActionHandlerTest.context + override val scope: CoroutineScope = CoroutineScope(SupervisorJob() + UnconfinedTestDispatcher()) + override val playback: WidgetPlaybackSource = fake + } + // What the widget last painted: ten seconds in. + store.write(NowPlayingWidgetSnapshot(episodeId = "ep-1", positionMs = 10_000L, durationMs = 3_600_000L, seekForwardMs = 30_000L, seekBackwardMs = 10_000L)) + } + + @After + fun uninstall() { + NowPlayingWidgetDependenciesHolder.instance = null + } + + @Test + fun aSeekIsPreviewedFromThePlayersLivePositionOnTheSameEpisode() = runTest { + playback.state.value = WidgetPlaybackState(episodeId = "ep-1", positionMs = 100_000L, durationMs = 3_600_000L) + + WidgetActionHandler.handle(WidgetControl.SKIP_FORWARD) + + assertEquals(130_000L, store.read()?.positionMs) + } + + @Test + fun aSeekKeepsThePaintedPositionWhenThePlayerIsOnAnotherEpisode() = runTest { + playback.state.value = WidgetPlaybackState(episodeId = "ep-2", positionMs = 100_000L, durationMs = 900_000L) + + WidgetActionHandler.handle(WidgetControl.SKIP_FORWARD) + + assertEquals(40_000L, store.read()?.positionMs) + assertEquals(3_600_000L, store.read()?.durationMs) + } + + @Test + fun everyTransportControlAsksForTheRealPositionOnceAfterItsAction() = runTest { + val actions = + mapOf( + WidgetControl.TOGGLE to "toggle", + WidgetControl.PREVIOUS to "previous", + WidgetControl.NEXT to "next", + WidgetControl.SKIP_BACK to "back", + WidgetControl.SKIP_FORWARD to "forward", + ) + for ((control, action) in actions) { + playback.calls.clear() + WidgetActionHandler.handle(control) + assertEquals("$control", listOf("restore", action, "refresh"), playback.calls) + } + } + + @Test + fun openingTheAppTouchesNeitherThePlayerNorThePosition() = runTest { + WidgetActionHandler.handle(WidgetControl.OPEN_APP) + + assertEquals(emptyList(), playback.calls) + assertEquals(10_000L, store.read()?.positionMs) + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/ArtworkTimeoutCooldownTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/ArtworkTimeoutCooldownTest.kt new file mode 100644 index 000000000..3a9d5f009 --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/ArtworkTimeoutCooldownTest.kt @@ -0,0 +1,36 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ArtworkTimeoutCooldownTest { + private var now = 1_000L + private val cooldown = ArtworkTimeoutCooldown(cooldownMs = 60_000L, nowMs = { now }) + private val cover = "https://example.com/cover.jpg" + + @Test + fun aCoverThatTimedOutIsNotWaitedForAgainUntilTheCooldownEnds() { + assertFalse(cooldown.isCoolingDown(cover, emptyList())) + + cooldown.recordTimeout(cover, emptyList()) + assertTrue(cooldown.isCoolingDown(cover, emptyList())) + + now += 59_999L + assertTrue(cooldown.isCoolingDown(cover, emptyList())) + + now += 1L + assertFalse("the next row may try it again", cooldown.isCoolingDown(cover, emptyList())) + } + + @Test + fun theFallbacksArePartOfTheRequestAndOtherCoversAreNotHeldBack() { + cooldown.recordTimeout(cover, listOf("https://example.com/show.jpg")) + + assertTrue(cooldown.isCoolingDown(cover, listOf("https://example.com/show.jpg"))) + assertFalse("another fallback is another attempt", cooldown.isCoolingDown(cover, listOf("https://example.com/other.jpg"))) + assertFalse(cooldown.isCoolingDown(cover, emptyList())) + assertFalse(cooldown.isCoolingDown("https://example.com/another.jpg", listOf("https://example.com/show.jpg"))) + assertFalse(cooldown.isCoolingDown(null, emptyList())) + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/LibraryArtworkBatchesTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/LibraryArtworkBatchesTest.kt new file mode 100644 index 000000000..564560d97 --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/LibraryArtworkBatchesTest.kt @@ -0,0 +1,110 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import cx.aswin.boxlore.feature.widgets.LibraryWidgetSnapshot +import cx.aswin.boxlore.feature.widgets.WidgetEpisodeRow +import cx.aswin.boxlore.feature.widgets.WidgetShowRow +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LibraryArtworkBatchesTest { + private fun show(index: Int, url: String? = "https://example.com/show-$index.jpg", cached: String? = null) = + WidgetShowRow(podcastId = "show-$index", title = "Show $index", artworkUrl = url, artworkCachePath = cached, deepLinkUri = "boxlore://podcast/show-$index") + + private fun episode(index: Int) = WidgetEpisodeRow( + episodeId = "episode-$index", + episodeTitle = "Episode $index", + podcastId = "show-$index", + podcastTitle = "Show $index", + artworkUrl = "https://example.com/episode-$index.jpg", + deepLinkUri = "boxlore://episode/episode-$index", + ) + + private val pathFor: suspend (String?, List) -> String? = { url, _ -> "/cache/${url!!.substringAfterLast('/')}" } + + @Test + fun fourCoversArePublishedTogetherAndAFifthOnItsOwn() = runTest { + val four = mutableListOf() + LibraryArtworkBatches.load(LibraryWidgetSnapshot(subscriptions = List(4) { show(it) }), load = pathFor) { four += it } + assertEquals(1, four.size) + assertTrue(four.single().subscriptions.all { it.artworkCachePath != null }) + + val five = mutableListOf() + LibraryArtworkBatches.load(LibraryWidgetSnapshot(subscriptions = List(5) { show(it) }), load = pathFor) { five += it } + assertEquals(2, five.size) + assertEquals(4, five[0].subscriptions.count { it.artworkCachePath != null }) + assertEquals(5, five[1].subscriptions.count { it.artworkCachePath != null }) + } + + @Test + fun showsAndEpisodesShareOneCountAndEachPublishHoldsEveryCoverSoFar() = runTest { + val published = mutableListOf() + val snapshot = LibraryWidgetSnapshot(subscriptions = List(3) { show(it) }, newEpisodes = List(3) { episode(it) }) + + LibraryArtworkBatches.load(snapshot, load = pathFor) { published += it } + + assertEquals(2, published.size) + assertEquals(3 to 1, published[0].subscriptions.count { it.artworkCachePath != null } to published[0].newEpisodes.count { it.artworkCachePath != null }) + assertEquals(3 to 3, published[1].subscriptions.count { it.artworkCachePath != null } to published[1].newEpisodes.count { it.artworkCachePath != null }) + assertEquals("/cache/episode-2.jpg", published[1].newEpisodes[2].artworkCachePath) + } + + @Test + fun coversThatCannotBeLoadedAreSkippedAndRowsWithACoverOrNoAddressAreNotLoaded() = runTest { + val asked = mutableListOf() + val published = mutableListOf() + val snapshot = + LibraryWidgetSnapshot( + subscriptions = listOf(show(0), show(1, cached = "/cache/already.jpg"), show(2, url = null), show(3)), + ) + + LibraryArtworkBatches.load( + snapshot, + load = { url, _ -> + asked += url + if (url!!.endsWith("show-0.jpg")) null else "/cache/show-3.jpg" + }, + ) { published += it } + + assertEquals(listOf("https://example.com/show-0.jpg", "https://example.com/show-3.jpg"), asked) + assertEquals(1, published.size) + assertEquals(listOf(null, "/cache/already.jpg", null, "/cache/show-3.jpg"), published.single().subscriptions.map { it.artworkCachePath }) + + // Nothing loaded means nothing to publish. + val none = mutableListOf() + LibraryArtworkBatches.load(LibraryWidgetSnapshot(subscriptions = List(3) { show(it) }), load = { _, _ -> null }) { none += it } + assertTrue(none.isEmpty()) + } + + @Test + fun aCancelledLoadPublishesNothingMore() = runTest { + val published = mutableListOf() + val sixthAsked = CompletableDeferred() + var asked = 0 + val job = + launch { + LibraryArtworkBatches.load( + LibraryWidgetSnapshot(subscriptions = List(8) { show(it) }), + load = { url, fallbacks -> + if (++asked == 6) { + sixthAsked.complete(Unit) + // Never answers: the newer library cancels this load here. + CompletableDeferred().await() + } else { + pathFor(url, fallbacks) + } + }, + ) { published += it } + } + sixthAsked.await() + job.cancelAndJoin() + + // The first four went out; the fifth was loaded but never published once the load was cancelled. + assertEquals(1, published.size) + assertEquals(4, published.single().subscriptions.count { it.artworkCachePath != null }) + } +} From eca9b639c2bc4cbfe4408237cd05e609842752e9 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Wed, 11 Nov 2026 22:38:35 +0530 Subject: [PATCH 8/9] fix(settings): watch only the latest add request and gate the first picker preview publish --- app/README.md | 2 +- .../cx/aswin/boxlore/BoxLoreApplication.kt | 1 + .../NavGraphSettingsDestinations.kt | 1 + .../widgets/HomeScreenWidgetsInstaller.kt | 27 +++++++-- .../widgets/HomeScreenWidgetsInstallerTest.kt | 40 +++++++++++++ feature/settings/README.md | 2 +- .../feature/settings/SettingsScreen.kt | 1 + .../settings/pages/AppearanceSettingsPage.kt | 4 ++ .../settings/pages/AppearanceWidgetsPage.kt | 41 ++++++++----- .../settings/AppearanceActionsTrackedTest.kt | 2 + .../pages/AppearanceWidgetsPageTest.kt | 60 ++++++++++++++++++- feature/widgets/README.md | 3 +- feature/widgets/src/main/AndroidManifest.xml | 4 ++ .../feature/widgets/HomeScreenWidgets.kt | 17 +++++- .../feature/widgets/WidgetAddedReceiver.kt | 30 ++++++++++ .../feature/widgets/WidgetPickerPreviews.kt | 9 ++- .../feature/widgets/HomeScreenWidgetsTest.kt | 20 +++++++ .../widgets/WidgetPickerPreviewsTest.kt | 21 +++++++ 18 files changed, 260 insertions(+), 25 deletions(-) create mode 100644 app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstallerTest.kt create mode 100644 feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetAddedReceiver.kt diff --git a/app/README.md b/app/README.md index feebc6169..f6a2b63e3 100644 --- a/app/README.md +++ b/app/README.md @@ -20,7 +20,7 @@ - The one `PlaybackRepository` is built lazily. Cast state waits in `DeferredCastSessionState` until it exists, so widget, worker and notification starts never build playback on the main thread. - The container gives core modules ports instead of reverse dependencies: `feedTelemetry` for feed events, `rolloutSwitches`, and a `DownloadServiceLauncherHolder` that starts `MediaDownloadService`. - `onCreate` order: API sessions first, then the Coil image loader (before widgets and services), Media3 Cast from `BoxLoreCastOptionsProvider`, the shared `UserPreferencesRepository`, the container and its holders. PostHog starts later on a background thread through `PostHogStartup`, after `AnalyticsHelper.initPolicy`. -- `StartupWorkGate` holds optional work until the first screen commits: cloud sync, Smart Downloads reconciliation, feed address repair scheduling, foreground subscription sync and legacy RSS repair. Initializers run one at a time. +- `StartupWorkGate` holds optional work until the first screen commits: cloud sync, Smart Downloads reconciliation, feed address repair scheduling, foreground subscription sync, legacy RSS repair and the first publish of the widget picker previews. Initializers run one at a time. - Home releases the gate after loaded content and two frames, other start routes after two frames, and a headless worker or widget launch at the first idle main queue. An Activity stopping early also releases it; configuration recreation does not. - Missing DataStore appearance keys are filled from the theme fast cache off the main thread; existing choices are never overwritten. - `TabletAlphaStartupGate` shows a one-time notice on tablet windows of tablet-sized displays before the app root composes; update and survey prompts wait behind it. diff --git a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt index e2006abc6..1034b4eec 100644 --- a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt +++ b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt @@ -184,6 +184,7 @@ class BoxLoreApplication : subscriptionRepository = { container.subscriptionRepository }, userPreferencesRepository = userPreferencesRepository, adaptiveScorer = { container.adaptiveCandidateScorer }, + awaitStartupReady = { startupWorkGate.awaitReady() }, ) engagementPromptCoordinator = EngagementPromptCoordinator(userPreferencesRepository, cx.aswin.boxlore.core.prefs.NpsSurveyStore(this)) diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt index 6c5dbf9e4..bd34383aa 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt @@ -267,6 +267,7 @@ private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings { }, canAddWidgets = { HomeScreenWidgets.canAdd(w.context) }, onAddWidget = { key -> HomeScreenWidgets.requestAdd(w.context, key) }, + addedWidgets = HomeScreenWidgets.added, onSetExploreDefaultTab = { tab -> scope.launch { userPrefs.setExploreDefaultTab(tab) } }, diff --git a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt index 6112c72d0..e7c4419e2 100644 --- a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt +++ b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt @@ -13,6 +13,8 @@ import cx.aswin.boxlore.feature.widgets.WidgetPickerPreviews import cx.aswin.boxlore.feature.widgets.configureLibraryWidgets import cx.aswin.boxlore.feature.widgets.configureNowPlayingWidget import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch @@ -28,6 +30,7 @@ object HomeScreenWidgetsInstaller { subscriptionRepository: () -> SubscriptionRepository, userPreferencesRepository: UserPreferencesRepository, adaptiveScorer: () -> AdaptiveCandidateScorer, + awaitStartupReady: suspend () -> Unit = {}, ) { configureNowPlayingWidget( object : NowPlayingWidgetDependencies { @@ -56,8 +59,9 @@ object HomeScreenWidgetsInstaller { } }, ) - // Re-render when widget chrome source, Appearance theme, lettering, or the Your Shows artwork style changes. - scope.launch { + // Everything that changes how a widget looks: widget chrome source, Appearance theme, lettering + // and the Your Shows artwork style. + val widgetLook = combine( combine( userPreferencesRepository.widgetAppearanceStream, @@ -68,12 +72,25 @@ object HomeScreenWidgetsInstaller { ) { _, _, _, _, _ -> Unit }, userPreferencesRepository.fontRoundnessStream, userPreferencesRepository.widgetShowsArtworkStream, - ) { _, _, _ -> Unit }.collect { + ) { _, _, _ -> Unit } + scope.launch { + widgetLook.collect { NowPlayingWidgetCoordinator.requestRefresh(context) LibraryWidgetCoordinator.requestRefresh(context) - // The widget picker shows the same look the placed widgets now have. - WidgetPickerPreviews.refresh(context) } } + // The widget picker shows the same look the placed widgets have. + scope.launch { publishWhenStartupReady(widgetLook, awaitStartupReady) { WidgetPickerPreviews.refresh(context) } } + } + + /** + * Runs [publish] for the current look and again for every later one. Picker previews are optional + * work, so the first run waits for [awaitStartupReady] and never competes with the first screen; + * once the app is up, a changed look is published at once. A newer look replaces one still being + * published. + */ + internal suspend fun publishWhenStartupReady(looks: Flow, awaitStartupReady: suspend () -> Unit, publish: suspend () -> Unit) { + awaitStartupReady() + looks.collectLatest { publish() } } } diff --git a/app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstallerTest.kt b/app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstallerTest.kt new file mode 100644 index 000000000..b7f2dca6e --- /dev/null +++ b/app/src/test/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstallerTest.kt @@ -0,0 +1,40 @@ +package cx.aswin.boxlore.widgets + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeScreenWidgetsInstallerTest { + @Test + fun theFirstPickerPreviewPublishWaitsForStartupAndLaterLooksPublishAtOnce() = runTest { + val look = MutableStateFlow(0) + val startupReady = CompletableDeferred() + var published = 0 + val job = + launch { + HomeScreenWidgetsInstaller.publishWhenStartupReady(look.map { }, awaitStartupReady = { startupReady.await() }) { published++ } + } + + // The look is known from the start, and changes once more, while the first screen is still coming up. + advanceUntilIdle() + look.value = 1 + advanceUntilIdle() + assertEquals("nothing is published before the app is up", 0, published) + + startupReady.complete(Unit) + advanceUntilIdle() + assertEquals("the current look is published once", 1, published) + + look.value = 2 + advanceUntilIdle() + assertEquals("a later change is published straight away", 2, published) + job.cancel() + } +} diff --git a/feature/settings/README.md b/feature/settings/README.md index c7ee775d1..abcf17b8d 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -26,7 +26,7 @@ Owns the Settings hub and its pages, sign-in, the Smart and automatic download s - The overview shows Display mode, then one row per page with its current choice (`appearanceSummary`): **Theme & colors**, **Home**, **Player**, **Navigation**, **Text** and **Widgets**. - Theme & colors offers four looks (boxlore classic, Pure, Material 3, Custom) and **Use artwork colors** (on by default). Legacy palettes show as Custom without changing stored values (`ThemeSettingsLogic.kt`). - The custom theme editor is a full page. Drafts change only its preview; Save applies the palette in one write, and Back discards it. -- Widgets holds the home screen widgets' colours (App theme or System) and every widget (`AddableWidget`, supplied by the app) as a card: its real look drawn by the app through `AppearanceWidgetPreview`, scaled to fit and never enlarged, its name, and an Add button that asks the home screen app to add it. The two round players share a row, and the Your Shows card carries the cover choice (whole covers or shapes) under its picture. A request the phone leaves unanswered (the app is not paused within `ADD_WIDGET_ANSWER_MS`) is explained in that card, with a way to the app's permission settings. When the home screen app cannot add widgets (`AppearanceActions.canAddWidgets`), the cards stay without buttons, with the steps for adding one by hand. +- Widgets holds the home screen widgets' colours (App theme or System) and every widget (`AddableWidget`, supplied by the app) as a card: its real look drawn by the app through `AppearanceWidgetPreview`, scaled to fit and never enlarged, its name, and an Add button that asks the home screen app to add it. The two round players share a row, and the Your Shows card carries the cover choice (whole covers or shapes) under its picture. When nothing answers the latest request within `ADD_WIDGET_ANSWER_MS` (the app is not paused by a confirmation and `AppearanceActions.addedWidgets` reports nothing), that card offers help and a way to the app's permission settings; the help goes away when an answer arrives. When the home screen app cannot add widgets (`AppearanceActions.canAddWidgets`), the cards stay without buttons, with the steps for adding one by hand. - Home holds the hero card style and its colour choice, with live previews drawn by the app through `AppearanceHeroPreview`. Hero styles are described in [`feature/home/README.md`](../home/README.md). - Player holds **Player style** (Standard or bunpod) and the miniplayer's **Seek buttons** (off by default). The bunpod option credits Kamran Bekirov's BunPod design. Video always uses Standard. - Navigation holds the bar style, **Blur behind the bar** (Floating bar only) and **Start page & tabs** (**Explore opens to** hides while Picked for you is off). Text holds the letter shape with a preview. diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt index 0893ca355..4e72ae02a 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt @@ -734,6 +734,7 @@ internal fun AppearanceActions.trackedForAnalytics(currentState: () -> Appearanc onSetWidgetAppearance = tracked("widget_appearance_changed", { it.currentWidgetAppearance }, onSetWidgetAppearance), onSetWidgetShowsArtwork = tracked("widget_shows_artwork_changed", { it.currentWidgetShowsArtwork }, onSetWidgetShowsArtwork), canAddWidgets = canAddWidgets, + addedWidgets = addedWidgets, // Every request is reported: there is no current value a repeat could match. onAddWidget = tracked("widget_add_requested", { "" }, onAddWidget), onSetExploreDefaultTab = { diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt index 87dc588fe..66ab23430 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt @@ -51,6 +51,8 @@ import cx.aswin.boxlore.feature.settings.components.SettingsGroup import cx.aswin.boxlore.feature.settings.components.SettingsNavigationRow import cx.aswin.boxlore.feature.settings.components.SettingsPicture import cx.aswin.boxlore.feature.settings.components.SettingsScaffold +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow /** * A home screen widget the listener can add from Appearance › Widgets, named as the widget picker @@ -123,6 +125,8 @@ data class AppearanceActions( val canAddWidgets: () -> Boolean = { false }, /** Asks the home screen app to add the widget with this key; it confirms with the listener itself. */ val onAddWidget: (String) -> Unit = {}, + /** The key of each widget the home screen app has just added after such a request. */ + val addedWidgets: Flow = emptyFlow(), val onSetExploreDefaultTab: (String) -> Unit = {}, val onSetSubscriptionsDefaultTab: (String) -> Unit = {}, val onSetSubscriptionsTabStyle: (String) -> Unit = {}, diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt index 17c9bbd31..4dc8d0229 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -55,6 +56,7 @@ import cx.aswin.boxlore.feature.settings.components.SettingsScaffold import cx.aswin.boxlore.feature.settings.components.SettingsTileSection import cx.aswin.boxlore.feature.settings.openAppPermissionSettings import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch /** @@ -125,7 +127,7 @@ private fun WidgetGallery( canAdd: Boolean, preview: AppearanceWidgetPreview, ) { - val add = rememberAddWidgetRequest(actions.onAddWidget) + val add = rememberAddWidgetRequest(actions.onAddWidget, actions.addedWidgets) SettingsTileSection( title = if (canAdd) "Add to home screen" else "boxlore widgets", footer = if (canAdd) ADD_WIDGETS_CONFIRM else null, @@ -150,34 +152,45 @@ private fun WidgetGallery( } } -/** Asks for a widget to be added, and knows which request the phone left unanswered. */ +/** Asks for a widget to be added, and knows which request the phone seems to have left unanswered. */ private class AddWidgetRequest(val request: (String) -> Unit, val unansweredKey: String?) /** - * The home screen app answers a request by opening its confirmation over the app, which pauses it. - * Some phones turn the request down without showing anything. So when the app has not been paused - * [ADD_WIDGET_ANSWER_MS] after a request, that request counts as unanswered, until another one is - * answered. + * The home screen app answers a request by opening its confirmation over the app, which pauses it, + * or by adding the widget and saying so ([added]). Some phones turn the request down without showing + * anything. So when neither has happened [ADD_WIDGET_ANSWER_MS] after a request, its card offers + * help; that is a guess, so the help goes away as soon as an answer does arrive. Only the latest + * request is watched: an earlier one's wait ends without effect once the next has begun. */ @Composable -private fun rememberAddWidgetRequest(onAdd: (String) -> Unit): AddWidgetRequest { +private fun rememberAddWidgetRequest(onAdd: (String) -> Unit, added: Flow): AddWidgetRequest { val lifecycle = LocalLifecycleOwner.current.lifecycle val scope = rememberCoroutineScope() var unansweredKey by rememberSaveable { mutableStateOf(null) } - val pausedSinceRequest = remember { mutableStateOf(false) } + val answered = remember { mutableStateOf(false) } + // Counts requests, so a wait that ends after a newer request began knows it is no longer the latest. + val latestRequest = remember { intArrayOf(0) } + + fun onAnswer() { + answered.value = true + unansweredKey = null + } DisposableEffect(lifecycle) { - val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE) pausedSinceRequest.value = true } + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE) onAnswer() } lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) } } + LaunchedEffect(added) { added.collect { onAnswer() } } val request: (String) -> Unit = remember(onAdd) { { key -> - pausedSinceRequest.value = false + val thisRequest = ++latestRequest[0] + answered.value = false + unansweredKey = null onAdd(key) scope.launch { delay(ADD_WIDGET_ANSWER_MS) - unansweredKey = key.takeUnless { pausedSinceRequest.value } + if (thisRequest == latestRequest[0] && !answered.value) unansweredKey = key } } } @@ -188,7 +201,7 @@ private fun rememberAddWidgetRequest(onAdd: (String) -> Unit): AddWidgetRequest * One widget: its picture on a tonal stage that stands in for the wallpaper, any [options] that * change that picture, then its name, what it shows and the Add button. Wide widgets put the button * beside the text; a small widget sharing its row with another puts it underneath, where it still - * has room at large text sizes. An [unanswered] request explains itself under the button. + * has room at large text sizes. A request that seems [unanswered] offers help under the button. */ @Composable private fun WidgetCard( @@ -232,7 +245,7 @@ private fun WidgetCard( } } -/** What to do when the phone did not open its confirmation: allow it in the phone's settings, or add by hand. */ +/** Help for a request nothing answered: how to allow it in the phone's settings, or add the widget by hand. */ @Composable private fun AddWidgetUnanswered() { val context = LocalContext.current @@ -308,7 +321,7 @@ private const val SMALL_WIDGET_MAX_WIDTH_DP = 200 internal const val ADD_WIDGET_ANSWER_MS = 1_500L internal const val ADD_WIDGET_UNANSWERED = - "Your phone didn’t let boxlore add it. Turn on Home screen shortcuts for boxlore in your phone’s app settings, then try again. " + + "Nothing opened? Your phone may be blocking this. Turn on Home screen shortcuts for boxlore in your phone’s app settings, then try again. " + "Or touch and hold an empty spot on your home screen and choose Widgets." internal const val ADD_WIDGETS_CONFIRM = "Your home screen app asks you to confirm." diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt index d9640623e..b9dfecd5b 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt @@ -54,6 +54,8 @@ class AppearanceActionsTrackedTest { tracked.onAddWidget("compact") assertEquals(listOf("compact", "compact"), asked) assertEquals(true, tracked.canAddWidgets()) + // What the home screen reports back reaches the page through the same wrapper. + assertEquals(kotlinx.coroutines.flow.emptyFlow(), tracked.addedWidgets) } @Test diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt index b6ae5156f..0ba0d1556 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork +import kotlinx.coroutines.flow.MutableSharedFlow import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule @@ -31,6 +32,7 @@ class AppearanceWidgetsPageTest { @get:Rule val compose = createAndroidComposeRule() private val added = mutableListOf() private val coverChoices = mutableListOf() + private val addedByHomeScreen = MutableSharedFlow(extraBufferCapacity = 4) private val widgets = listOf( AddableWidget("full", "Now Playing · Full Controls", "The full player", 320, 170), @@ -50,7 +52,16 @@ class AppearanceWidgetsPageTest { private fun render(canAdd: Boolean, state: AppearanceUiState = this.state, preview: AppearanceWidgetPreview? = picture) { val actions = - AppearanceActions({}, {}, {}, {}, canAddWidgets = { canAdd }, onAddWidget = { added.add(it) }, onSetWidgetShowsArtwork = { coverChoices.add(it) }) + AppearanceActions( + {}, + {}, + {}, + {}, + canAddWidgets = { canAdd }, + onAddWidget = { added.add(it) }, + addedWidgets = addedByHomeScreen, + onSetWidgetShowsArtwork = { coverChoices.add(it) }, + ) compose.setContent { BoxLoreTheme { AppearanceWidgetsPage(state, actions, onBack = {}, preview = preview) } } } @@ -155,6 +166,53 @@ class AppearanceWidgetsPageTest { assertEquals(listOf("full", "full"), added) } + @Test + fun `a widget the home screen adds without asking is not reported as unanswered`() { + render(canAdd = true) + compose.mainClock.autoAdvance = false + compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").performScrollTo().performClick() + // No confirmation covered the app, but the home screen says the widget is there. + addedByHomeScreen.tryEmit("full") + compose.mainClock.advanceTimeBy(ADD_WIDGET_ANSWER_MS + 100) + compose.onNodeWithText(ADD_WIDGET_UNANSWERED).assertDoesNotExist() + } + + @Test + fun `help for an unanswered request goes away when an answer arrives late`() { + render(canAdd = true) + compose.mainClock.autoAdvance = false + compose.onNodeWithContentDescription("Add Now Playing · Full Controls to home screen").performScrollTo().performClick() + compose.mainClock.advanceTimeBy(ADD_WIDGET_ANSWER_MS + 100) + compose.onNodeWithText(ADD_WIDGET_UNANSWERED).assertExists() + + // The confirmation opens after all, a little late. + compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED) + compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + compose.mainClock.advanceTimeBy(100) + compose.onNodeWithText(ADD_WIDGET_UNANSWERED).assertDoesNotExist() + } + + @Test + fun `only the latest request is watched, so an earlier card is never blamed for it`() { + render(canAdd = true) + // The two round players sit side by side: bring both into view before the clock is held. + compose.onNodeWithContentDescription("Add Now Playing · Round with Next to home screen").performScrollTo() + compose.mainClock.autoAdvance = false + compose.onNodeWithContentDescription("Add Now Playing · Round to home screen").performClick() + compose.mainClock.advanceTimeBy(800) + compose.onNodeWithContentDescription("Add Now Playing · Round with Next to home screen").performClick() + + // The first request's wait ends here; it no longer counts once the second has begun. + compose.mainClock.advanceTimeBy(800) + compose.onNodeWithText(ADD_WIDGET_UNANSWERED).assertDoesNotExist() + + compose.mainClock.advanceTimeBy(800) + val help = compose.onNodeWithText(ADD_WIDGET_UNANSWERED).getUnclippedBoundsInRoot() + val firstCardPicture = compose.onNodeWithTag("picture-round", useUnmergedTree = true).getUnclippedBoundsInRoot() + assertTrue("the help is in the second card, the one asked last", help.left > firstCardPicture.right) + assertEquals(listOf("round", "round_next"), added) + } + @Test fun `without pictures each widget is a row that adds it and the cover choice keeps its own group`() { render(canAdd = true, preview = null) diff --git a/feature/widgets/README.md b/feature/widgets/README.md index 21c3a3fcd..2684e8b3f 100644 --- a/feature/widgets/README.md +++ b/feature/widgets/README.md @@ -25,7 +25,7 @@ Owns boxlore's home-screen widgets, built with `AppWidgetProvider` and `RemoteVi ## Public API - The six receivers above: picker-visible providers. -- `HomeScreenWidgets`: the six widgets under their picker names, each one's live preview as `RemoteViews` with the size it is drawn for (`preview`), whether the current home screen app can add a widget on request (`canAdd`), and the request itself (`requestAdd`). Settings shows and offers them through `:app`. +- `HomeScreenWidgets`: the six widgets under their picker names, each one's live preview as `RemoteViews` with the size it is drawn for (`preview`), whether the current home screen app can add a widget on request (`canAdd`), the request itself (`requestAdd`) and the keys of widgets the home screen app then added (`added`). Settings shows and offers them through `:app`. - `WidgetControlReceiver`: one explicit, non-exported action endpoint shared by the playback widgets. - `WidgetActionIntents`: builds broadcast, open-app and deep-link `PendingIntent`s with `SecurityException` guards. `cancelAll(context, appWidgetId)` and `LibraryWidgetRenderer.cancelAll` cancel a deleted widget's intents so the app never exhausts the system's `PendingIntent` quota. - `NowPlayingWidgetDependencies`, `configureNowPlayingWidget(...)` and `WidgetPlaybackSource`: the playback port. `refreshPosition()` asks the player for its position, which the app only tracks while it is on screen. @@ -94,6 +94,7 @@ src/main/res/ | Artwork cache | `{cacheDir}/widget_artwork/` | | Receiver FQCNs | the six receivers above, under `cx.aswin.boxlore.feature.widgets` | | Action receiver | `cx.aswin.boxlore.feature.widgets.actions.WidgetControlReceiver` (not exported) | +| Added-widget receiver | `cx.aswin.boxlore.feature.widgets.WidgetAddedReceiver` (not exported), action `cx.aswin.boxlore.feature.widgets.action.WIDGET_ADDED` | | RemoteViewsService | `LibraryWidgetRemoteViewsService` (not exported; `BIND_REMOTEVIEWS`) | | Provider info | every provider uses `updatePeriodMillis=0` | diff --git a/feature/widgets/src/main/AndroidManifest.xml b/feature/widgets/src/main/AndroidManifest.xml index fd64466b6..cf0301ead 100644 --- a/feature/widgets/src/main/AndroidManifest.xml +++ b/feature/widgets/src/main/AndroidManifest.xml @@ -96,6 +96,10 @@ + + diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt index 9153f876e..e244e30fb 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt @@ -6,6 +6,8 @@ import android.content.ComponentName import android.content.Context import android.widget.RemoteViews import androidx.annotation.StringRes +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow /** * The widgets a listener can see and add to the home screen from inside the app. The home screen app @@ -43,11 +45,24 @@ object HomeScreenWidgets { */ fun requestAdd(context: Context, key: String): Boolean { val entry = all.firstOrNull { it.key == key } ?: return false + val app = context.applicationContext return runCatching { - manager(context)?.requestPinAppWidget(ComponentName(context.applicationContext, entry.receiver), null, null) == true + manager(app)?.requestPinAppWidget(ComponentName(app, entry.receiver), null, WidgetAddedReceiver.callback(app, key)) == true }.getOrDefault(false) } + /** + * The key of each widget the home screen app has just added after a [requestAdd]. A request that is + * turned down, or left unanswered, sends nothing. + */ + val added: SharedFlow get() = addedKeys + + internal fun onAdded(key: String) { + addedKeys.tryEmit(key) + } + + private val addedKeys = MutableSharedFlow(extraBufferCapacity = 8) + /** * The widget with this [key] as its own renderer draws it, with sample content and in the * listener's widget colours. Lay it out at its entry's preview size, then scale that picture to diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetAddedReceiver.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetAddedReceiver.kt new file mode 100644 index 000000000..e0990f077 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetAddedReceiver.kt @@ -0,0 +1,30 @@ +package cx.aswin.boxlore.feature.widgets + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +/** + * Hears from the home screen app that a widget asked for through [HomeScreenWidgets.requestAdd] is + * now on the home screen, and passes its key on. Not exported: only the system sends it, through the + * callback made here. + */ +class WidgetAddedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION_ADDED) return + intent.getStringExtra(EXTRA_KEY)?.let(HomeScreenWidgets::onAdded) + } + + internal companion object { + const val ACTION_ADDED = "cx.aswin.boxlore.feature.widgets.action.WIDGET_ADDED" + const val EXTRA_KEY = "widget_key" + + /** The callback the home screen app sends once it has added the widget with this [key]; null if it cannot be made. */ + fun callback(context: Context, key: String): PendingIntent? = runCatching { + val intent = Intent(context, WidgetAddedReceiver::class.java).setAction(ACTION_ADDED).putExtra(EXTRA_KEY, key) + // One callback per widget, replaced by the next request for the same widget. + PendingIntent.getBroadcast(context, key.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + }.getOrNull() + } +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt index 56bcfa5c7..02cfe46b6 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt @@ -205,7 +205,14 @@ object WidgetPickerPreviews { val bitmap = createBitmap(SAMPLE_COVER_PX, SAMPLE_COVER_PX) drawable.setBounds(0, 0, SAMPLE_COVER_PX, SAMPLE_COVER_PX) drawable.draw(Canvas(bitmap)) - file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, it) } + // Several previews are built at once and share covers: write beside the file, then move it into + // place, so a reader never finds half a picture and a write cut short leaves nothing behind. + val unfinished = File(directory, "${file.name}.${System.nanoTime()}.tmp") + unfinished.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, it) } + if (!unfinished.renameTo(file)) { + unfinished.delete() + if (!file.exists()) return null + } file.absolutePath }.getOrNull() } diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt index 693d7624e..bcbc7080a 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt @@ -2,7 +2,11 @@ package cx.aswin.boxlore.feature.widgets import android.appwidget.AppWidgetManager import android.content.Context +import android.content.Intent import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -58,4 +62,20 @@ class HomeScreenWidgetsTest { // Only Your Shows follows the cover setting, so only it carries that choice in Settings. assertEquals(listOf("shows"), HomeScreenWidgets.all.filter { it.usesShowsCoverStyle }.map { it.key }) } + + @Test + fun theHomeScreenSayingAWidgetWasAddedIsPassedOnWithThatWidgetsKey() = runTest(UnconfinedTestDispatcher()) { + val added = mutableListOf() + val collecting = launch { HomeScreenWidgets.added.collect { added += it } } + + val callback = WidgetAddedReceiver.callback(context, "compact") + assertTrue("a request carries a callback", callback != null) + WidgetAddedReceiver().onReceive(context, Intent(context, WidgetAddedReceiver::class.java).setAction(WidgetAddedReceiver.ACTION_ADDED).putExtra(WidgetAddedReceiver.EXTRA_KEY, "compact")) + // Anything else sent to the receiver says nothing about a widget. + WidgetAddedReceiver().onReceive(context, Intent(context, WidgetAddedReceiver::class.java).setAction("something.else").putExtra(WidgetAddedReceiver.EXTRA_KEY, "full")) + WidgetAddedReceiver().onReceive(context, Intent(context, WidgetAddedReceiver::class.java).setAction(WidgetAddedReceiver.ACTION_ADDED)) + + assertEquals(listOf("compact"), added) + collecting.cancel() + } } diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt index decac7cc8..3564e25b7 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt @@ -1,13 +1,18 @@ package cx.aswin.boxlore.feature.widgets import android.content.Context +import android.graphics.BitmapFactory import android.widget.FrameLayout import androidx.test.core.app.ApplicationProvider import cx.aswin.boxlore.core.prefs.FontRoundnessAxis import cx.aswin.boxlore.core.prefs.WidgetAppearance import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue @@ -52,6 +57,22 @@ class WidgetPickerPreviewsTest { assertTrue(library.count(LibraryWidgetKind.SUBSCRIPTIONS) > library.subscriptions.size) } + @Test + fun sampleCoversBuiltByManyPreviewsAtOnceAreWholeFilesWithNothingLeftHalfWritten() = runTest { + val covers = File(context.cacheDir, WidgetArtworkLoader.CACHE_DIR_NAME) + covers.deleteRecursively() + + // Settings draws every preview at the same time, and they share covers. + withContext(Dispatchers.Default) { + List(12) { async { WidgetPickerPreviews.samplePlayback(context) to WidgetPickerPreviews.sampleLibrary(context) } }.awaitAll() + } + + val files = covers.listFiles().orEmpty() + assertTrue("covers were written", files.isNotEmpty()) + assertTrue("no unfinished file is left behind: ${files.map { it.name }}", files.none { it.name.endsWith(".tmp") }) + assertTrue("every cover decodes", files.all { BitmapFactory.decodeFile(it.absolutePath) != null }) + } + @Test fun thePreviewLookChangesWithTheColoursAndTheCoverStyleAndOnlyThen() { useTheme(brand = "violet") From 0de36e29b3e5bb47f2e213dcac65c511e5cfd3d3 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Wed, 11 Nov 2026 22:41:54 +0530 Subject: [PATCH 9/9] refactor(widgets): tell a timed-out cover load from a finished one without a wrapper --- .../widgets/LibraryWidgetRemoteViewsService.kt | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt index e394031ca..bdb718057 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt @@ -187,14 +187,20 @@ internal class LibraryWidgetRemoteViewsFactory( if (cx.aswin.boxlore.core.model.artworkUrls(url, *fallbackUrls.toTypedArray()).isEmpty()) return null artworkLoader.resolveCachedPath(url)?.let { return it } if (timeouts.isCoolingDown(url, fallbackUrls)) return null - // A null answer is a finished load that found nothing; no answer at all means it ran out of time. - val answer = runCatching { runBlocking { withTimeoutOrNull(ARTWORK_TIMEOUT_MS) { LoadAnswer(artworkLoader.load(url, fallbackUrls)) } } }.getOrElse { return null } - if (answer == null) timeouts.recordTimeout(url, fallbackUrls) - return answer?.path + // A load that finishes, with a cover or without, or fails, has answered. Only one cut off by the + // timeout has not, and that is the one worth not waiting for again. + var answered = false + val path = + runCatching { + runBlocking { withTimeoutOrNull(ARTWORK_TIMEOUT_MS) { artworkLoader.load(url, fallbackUrls).also { answered = true } } } + }.getOrElse { + answered = true + null + } + if (!answered) timeouts.recordTimeout(url, fallbackUrls) + return path } - private class LoadAnswer(val path: String?) - private fun px(dp: Int): Int = (dp * context.resources.displayMetrics.density).roundToInt().coerceIn(1, MAX_ART_PX) internal companion object {