diff --git a/app/README.md b/app/README.md index 6b354c1bd..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. @@ -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/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 7a23d4d1a..bd34383aa 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,12 @@ 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) }, + addedWidgets = HomeScreenWidgets.added, onSetExploreDefaultTab = { tab -> scope.launch { userPrefs.setExploreDefaultTab(tab) } }, @@ -246,6 +289,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..e7c4419e2 100644 --- a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt +++ b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt @@ -9,9 +9,12 @@ 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 +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch @@ -27,6 +30,7 @@ object HomeScreenWidgetsInstaller { subscriptionRepository: () -> SubscriptionRepository, userPreferencesRepository: UserPreferencesRepository, adaptiveScorer: () -> AdaptiveCandidateScorer, + awaitStartupReady: suspend () -> Unit = {}, ) { configureNowPlayingWidget( object : NowPlayingWidgetDependencies { @@ -55,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, @@ -67,10 +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 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/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/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 2739592f0..abcf17b8d 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. 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. @@ -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..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 @@ -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,12 @@ 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, + addedWidgets = addedWidgets, + // 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 +845,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 +859,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..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 @@ -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,12 +44,36 @@ 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 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 + * 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( @@ -61,6 +86,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 +120,13 @@ 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 = {}, + /** 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 = {}, @@ -116,7 +151,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 +180,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 +211,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..4dc8d0229 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPage.kt @@ -0,0 +1,330 @@ +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.LaunchedEffect +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.flow.Flow +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, actions.addedWidgets) + 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 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, + * 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, added: Flow): AddWidgetRequest { + val lifecycle = LocalLifecycleOwner.current.lifecycle + val scope = rememberCoroutineScope() + var unansweredKey by rememberSaveable { mutableStateOf(null) } + 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) onAnswer() } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + LaunchedEffect(added) { added.collect { onAnswer() } } + val request: (String) -> Unit = + remember(onAdd) { + { key -> + val thisRequest = ++latestRequest[0] + answered.value = false + unansweredKey = null + onAdd(key) + scope.launch { + delay(ADD_WIDGET_ANSWER_MS) + if (thisRequest == latestRequest[0] && !answered.value) unansweredKey = key + } + } + } + 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. A request that seems [unanswered] offers help 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() + } + } +} + +/** 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 + 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 partner = widgets.getOrNull(index + 1)?.takeIf { widget.isSmall && it.isSmall } + rows += listOfNotNull(widget, partner) + index += if (partner != null) 2 else 1 + } + return rows +} + +private val AddableWidget.isSmall: Boolean get() = previewWidthDp <= SMALL_WIDGET_MAX_WIDTH_DP + +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 = + "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." + +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..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 @@ -28,6 +28,36 @@ 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()) + // What the home screen reports back reaches the page through the same wrapper. + assertEquals(kotlinx.coroutines.flow.emptyFlow(), tracked.addedWidgets) + } + @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..0ba0d1556 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AppearanceWidgetsPageTest.kt @@ -0,0 +1,239 @@ +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 kotlinx.coroutines.flow.MutableSharedFlow +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 addedByHomeScreen = MutableSharedFlow(extraBufferCapacity = 4) + 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) }, + addedWidgets = addedByHomeScreen, + 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 `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) + 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 bba713471..2684e8b3f 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`), 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. @@ -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 (`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. +- 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, LibraryArtworkBatches, @@ -88,11 +89,12 @@ 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` | | 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` | @@ -108,7 +110,7 @@ Hermetic JVM tests live in `feature/widgets/src/test`, using Robolectric and fak | Library widgets | `LibraryWidgetRemoteViewsTest`, `LibraryWidgetLogicTest`, `LibraryArtworkBatchesTest`, `ArtworkTimeoutCooldownTest` | | 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/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 new file mode 100644 index 000000000..e244e30fb --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgets.kt @@ -0,0 +1,89 @@ +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 +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 + * 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 + val app = context.applicationContext + return runCatching { + 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 + * 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 bdb718057..e4738e635 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 @@ -46,9 +46,14 @@ 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, + /** 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 +64,7 @@ internal class LibraryWidgetRemoteViewsFactory( override fun onCreate() = Unit override fun onDataSetChanged() { - val snapshot = LibraryWidgetSnapshotStore(context).read() ?: LibraryWidgetSnapshot() + val snapshot = preset ?: savedSnapshot(context) shows = snapshot.subscriptions episodes = snapshot.newEpisodes chrome = WidgetChrome.resolve(context) @@ -184,7 +189,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 } if (timeouts.isCoolingDown(url, fallbackUrls)) return null // A load that finishes, with a cover or without, or fails, has answered. Only one cut off by the 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/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 new file mode 100644 index 000000000..02cfe46b6 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviews.kt @@ -0,0 +1,257 @@ +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)) + // 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() + } + + 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..bcbc7080a --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/HomeScreenWidgetsTest.kt @@ -0,0 +1,81 @@ +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 +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 }) + } + + @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 new file mode 100644 index 000000000..3564e25b7 --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetPickerPreviewsTest.kt @@ -0,0 +1,125 @@ +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 +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 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") + 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