From 269c5f216dc5d5f9874ef20dfebd6da9f0705515 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 4 May 2026 07:52:41 +0100 Subject: [PATCH 1/3] fix(ui): remove 'launch_screen' background For activities with 'Theme_Dark.Launcher', android:windowBackground is set, but was never removed. On views with transparent backgrounds (such as the Card Browser column headers), this leaked through when edge to edge was enabled Part of issue 17334 - Edge to Edge Assisted-by: Claude Opus 4.7 - diagnostics + fix --- .../src/main/java/com/ichi2/themes/Themes.kt | 23 +++++++++++++++++++ .../layout-sw600dp/activity_card_browser.xml | 1 - 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt b/AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt index e326a9bafaba..1662053ce029 100644 --- a/AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt +++ b/AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt @@ -18,11 +18,14 @@ package com.ichi2.themes +import android.app.Activity import android.content.Context import android.content.res.Configuration import android.graphics.Color +import android.util.TypedValue import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatDelegate +import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.withStyledAttributes import androidx.core.graphics.drawable.toDrawable import androidx.core.view.WindowInsetsControllerCompat @@ -34,6 +37,7 @@ import com.ichi2.anki.settings.enums.AppTheme import com.ichi2.anki.settings.enums.DayTheme import com.ichi2.anki.settings.enums.NightTheme import com.ichi2.anki.settings.enums.Theme +import com.ichi2.themes.Themes.currentTheme /** * Helper methods to configure things related to AnkiDroid's themes @@ -50,6 +54,25 @@ object Themes { context.setTheme(currentTheme.styleResId) } + fun setTheme(activity: Activity) { + val tv = TypedValue() + activity.theme.resolveAttribute(android.R.attr.windowBackground, tv, true) + val hadLauncherSplash = tv.resourceId == R.drawable.launch_screen + + setTheme(activity as Context) + + if (hadLauncherSplash) { + activity.theme.resolveAttribute(android.R.attr.windowBackground, tv, true) + val replacement = + if (tv.type in TypedValue.TYPE_FIRST_COLOR_INT..TypedValue.TYPE_LAST_COLOR_INT) { + tv.data.toDrawable() + } else { + AppCompatResources.getDrawable(activity, tv.resourceId) + } + activity.window.setBackgroundDrawable(replacement) + } + } + fun setLegacyActionBar(context: Context) { context.setTheme(R.style.ThemeOverlay_LegacyActionBar) } diff --git a/AnkiDroid/src/main/res/layout-sw600dp/activity_card_browser.xml b/AnkiDroid/src/main/res/layout-sw600dp/activity_card_browser.xml index d461daaa92ce..4a4525f0f83e 100644 --- a/AnkiDroid/src/main/res/layout-sw600dp/activity_card_browser.xml +++ b/AnkiDroid/src/main/res/layout-sw600dp/activity_card_browser.xml @@ -11,7 +11,6 @@ android:id="@+id/card_browser_xl_view" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="?android:attr/colorBackground" android:orientation="horizontal"> Date: Tue, 5 May 2026 21:53:03 +0100 Subject: [PATCH 2/3] test(deck-picker): screenshot tests Prep for edge-to-edge work. Scrolling tests were attempted, but Roborazzi did not scroll the RecyclerView correctly Issue 17334 Assisted-by: Claude Opus 4.7 - much of the initial implementation --- .../ichi2/anki/AllActivitiesScreenshotTest.kt | 3 +- .../ichi2/anki/DeckPickerScreenshotTest.kt | 35 ++++++++++++++---- .../anki/DeckPickerTabletScreenshotTest.kt | 37 +++++++++++++++++++ .../com/ichi2/anki/DeckPickerTestFixture.kt | 35 ++++++++++++++++++ 4 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesScreenshotTest.kt index d9587d99d33c..125b10f551ba 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesScreenshotTest.kt @@ -130,7 +130,8 @@ class AllActivitiesScreenshotTest : ScreenshotTest() { * * WARN: Does not match reality. There are issues with element placement and scrolling lists. [FAB in Deck Picker] */ -private fun Activity.simulateEdgeToEdge() { +// TODO: Move to testFixtures after #20989 +fun Activity.simulateEdgeToEdge() { val insets = WindowInsetsCompat .Builder() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerScreenshotTest.kt index bf46c6b1a934..050bfd052f2f 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerScreenshotTest.kt @@ -1,17 +1,25 @@ -// SPDX-FileCopyrightText: 2026 Brayan Oliveira // SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Brayan Oliveira + package com.ichi2.anki import androidx.core.content.edit import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.testutils.BackupManagerTestUtilities +import org.junit.After import org.junit.Before import org.junit.Test +/** + * Screenshot tests for [DeckPicker] + * + * `./gradlew :AnkiDroid:verifyRoborazziPlayDebug -Pscreenshot --tests "com.ichi2.anki.DeckPickerScreenshotTest"` + */ class DeckPickerScreenshotTest : ScreenshotTest() { @Before override fun setUp() { super.setUp() + setPhoneQualifiers() ensureCollectionLoadIsSynchronous() setIntroductionSlidesShown(true) BackupManagerTestUtilities.setupSpaceForBackup(targetContext) @@ -19,13 +27,24 @@ class DeckPickerScreenshotTest : ScreenshotTest() { targetContext.sharedPrefs().edit { putBoolean("backupPromptDisabled", true) } } + @After + fun tearDownBackup() { + BackupManagerTestUtilities.reset() + } + @Test - fun baseState_and_fabExpanded() { - val intent = DeckPicker.getIntent(targetContext) - val activity = startActivityNormallyOpenCollectionWithIntent(DeckPicker::class.java, intent) - captureScreen("baseState") + fun baseState_and_fabExpanded() = + withDeckPicker(deckCount = 0) { deckPicker -> + captureScreen("baseState") - activity.floatingActionMenu.showFloatingActionMenu() - captureScreen("fabExpanded") - } + deckPicker.floatingActionMenu.showFloatingActionMenu() + captureScreen("fabExpanded") + } + + @Test + fun edgeToEdge_30_decks() = + withDeckPicker(deckCount = 30) { deckPicker -> + deckPicker.simulateEdgeToEdge() + captureScreen("edgeToEdge_30_decks") + } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt new file mode 100644 index 000000000000..2247e83466ea --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import com.ichi2.testutils.BackupManagerTestUtilities +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * Screenshot tests for [DeckPicker] in fragmented mode. + * + * `./gradlew :AnkiDroid:verifyRoborazziPlayDebug -Pscreenshot --tests "com.ichi2.anki.DeckPickerTabletScreenshotTest"` + */ +class DeckPickerTabletScreenshotTest : ScreenshotTest() { + @Before + override fun setUp() { + super.setUp() + setTabletQualifiers() + } + + @After + fun tearDownBackup() { + BackupManagerTestUtilities.reset() + } + + @Test + fun deckPickerWith30Decks() = + withDeckPicker(deckCount = 30, withCards = true) { deckPicker -> + deckPicker.simulateEdgeToEdge() + // Allow tryShowStudyOptionsPanel()'s async fragment commit to finalize + // before capturing, otherwise the right pane is empty. + advanceRobolectricLooper() + advanceRobolectricLooper() + captureScreen("30_decks_tablet") + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt new file mode 100644 index 000000000000..00991975ea84 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import android.content.Intent +import androidx.core.content.edit +import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooper +import com.ichi2.anki.preferences.sharedPrefs +import com.ichi2.testutils.BackupManagerTestUtilities + +// TODO: move to testFixtures once RobolectricTest is moved + +context(test: RobolectricTest) +fun withDeckPicker( + deckCount: Int, + withCards: Boolean = false, + block: (DeckPicker) -> Unit, +) { + // startup code occurs here so all users of this method are correctly setup + test.ensureCollectionLoadIsSynchronous() + test.setIntroductionSlidesShown(true) + BackupManagerTestUtilities.setupSpaceForBackup(test.targetContext) + // suppress the periodic 'backup your collection' prompt so the screenshot is just the deck list + test.targetContext.sharedPrefs().edit { putBoolean("backupPromptDisabled", true) } + if (withCards) test.ensureNonEmptyCollection() + for (i in 0 until deckCount) { + // 'Deck' is before 'Default' alphabetically + test.addDeck("Test Deck $i") + } + val deckPicker = + test.startActivityNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent()).also { + advanceRobolectricLooper() // may be a fix for flaky tests + } + block(deckPicker) +} From ebe8e44b67e313e8fd83254dffcc2a035d4951b0 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 5 May 2026 21:53:03 +0100 Subject: [PATCH 3/3] feat(deck-picker): edge to edge support In order to support edge to edge, we needed to move the RecyclerView underneath the nav + "Studied X in Y" TextView. The bottom nav on many Android phones will fade the content underneath it, but different phones have different implementations, using private colors. So we define our own 'BottomFadeFrameLayout' to handle this. Roughly equivalent to the fade on an API 33 emulator ---- The screenshot test did not place the FAB in the correct place vertically. Therefore adjustmnets were made behind an isRobolectric wrapper The last row of the RecyclerView is moved by a few pixels to be directly in-line with the top of the FAB The padding cannot be lower, otherwise the FAB would obscure the counts. If the nav bar is on the side, this transparent bar should not occur, otherwise the action bar 'blue' appears in the nav, and this looks unusual. I attempted to make tests in Roborazzi which scrolled to the bottom of the list, but they were flaky (1px of padding made a huge difference in scroll position) Issue 17334 --- .idea/dictionaries/davidallison.xml | 1 + .../main/java/com/ichi2/anki/DeckPicker.kt | 81 ++++++++++++- .../ichi2/anki/android/view/ViewLocation.kt | 26 +++++ .../ichi2/anki/ui/BottomFadeFrameLayout.kt | 106 ++++++++++++++++++ .../layout-sw600dp/activity_homescreen.xml | 9 +- .../main/res/layout/activity_homescreen.xml | 9 +- .../main/res/layout/include_deck_picker.xml | 25 +++-- .../layout/include_floating_add_button.xml | 4 +- .../java/com/ichi2/anki/compat/BaseCompat.kt | 7 ++ .../main/java/com/ichi2/anki/compat/Compat.kt | 5 + .../com/ichi2/anki/compat/CompatHelper.kt | 5 + .../java/com/ichi2/anki/compat/CompatV29.kt | 6 + 12 files changed, 268 insertions(+), 16 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/android/view/ViewLocation.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ui/BottomFadeFrameLayout.kt diff --git a/.idea/dictionaries/davidallison.xml b/.idea/dictionaries/davidallison.xml index c3804ce27bfb..833a85d06aa6 100644 --- a/.idea/dictionaries/davidallison.xml +++ b/.idea/dictionaries/davidallison.xml @@ -17,6 +17,7 @@ Notetypes Pictogrammers RTRIGGER + Roborazzi SPDX Spdx SuperMemo diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 505c2d809373..a9d4e3e49c6e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -30,16 +30,20 @@ import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration import android.database.SQLException +import android.graphics.Color import android.graphics.PixelFormat +import android.os.Build import android.os.Bundle import android.text.util.Linkify import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.View -import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams import android.widget.TextView import androidx.activity.OnBackPressedCallback +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultLauncher @@ -60,8 +64,13 @@ import androidx.core.util.component1 import androidx.core.util.component2 import androidx.core.view.MenuItemCompat import androidx.core.view.OnReceiveContentListener +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat.Type.displayCutout +import androidx.core.view.WindowInsetsCompat.Type.navigationBars +import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.doOnLayout import androidx.core.view.isVisible +import androidx.core.view.updatePadding import androidx.draganddrop.DropHelper import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.commit @@ -93,9 +102,11 @@ import com.ichi2.anki.analytics.UsageAnalytics import com.ichi2.anki.android.back.exitViaDoubleTapBackCallback import com.ichi2.anki.android.input.ShortcutGroup import com.ichi2.anki.android.input.shortcut +import com.ichi2.anki.android.view.locationInWindow import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.time.TimeManager +import com.ichi2.anki.common.utils.android.isRobolectric import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.anki.contextmenu.DeckPickerMenuContentProvider @@ -161,6 +172,7 @@ import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.sync.MeteredSyncPolicy import com.ichi2.anki.sync.launchCatchingRequiringOneWaySyncDiscardUndo +import com.ichi2.anki.ui.BottomFadeFrameLayout import com.ichi2.anki.ui.ResizablePaneManager import com.ichi2.anki.ui.animations.fadeIn import com.ichi2.anki.ui.animations.fadeOut @@ -253,7 +265,9 @@ open class DeckPicker : @VisibleForTesting internal val deckPickerBinding: IncludeDeckPickerBinding get() = binding.deckPickerPane - private val floatingActionButtonBinding: IncludeFloatingAddButtonBinding + + @VisibleForTesting + val floatingActionButtonBinding: IncludeFloatingAddButtonBinding get() = deckPickerBinding.floatingActionButton override var fragmented: Boolean @@ -464,6 +478,11 @@ open class DeckPicker : return } + // match the status bar theme of the rest of the app + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + navigationBarStyle = BottomFadeFrameLayout.navigationBarStyle(), + ) // Then set theme and content view super.onCreate(savedInstanceState) @@ -497,6 +516,7 @@ open class DeckPicker : // create inherited navigation drawer layout here so that it can be used by parent class initNavigationDrawer() + applyEdgeToEdgeInsets() title = resources.getString(R.string.app_name) deckPickerBinding.deckPickerContent.visibility = View.GONE @@ -599,6 +619,61 @@ open class DeckPicker : super.setupBackPressedCallbacks() } + override fun fitsSystemWindows(): Boolean = false + + private fun applyEdgeToEdgeInsets() { + fun setRecyclerViewBottomPaddingAbove(target: View) { + val recyclerView = deckPickerBinding.decks + if (recyclerView.height == 0 || target.height == 0) return + val bottom = recyclerView.locationInWindow().y + recyclerView.height + val topOfTarget = (target.layoutParams as? MarginLayoutParams)?.topMargin ?: 0 + recyclerView.updatePadding( + bottom = (bottom - target.locationInWindow().y - topOfTarget).coerceAtLeast(0), + ) + } + + deckPickerBinding.decksFadeWrapper.setup(window) + deckPickerBinding.decksFadeWrapper.anchorView = deckPickerBinding.reviewSummaryTextView + ViewCompat.setOnApplyWindowInsetsListener(binding.toolbarContainer) { toolbar, insets -> + val bars = insets.getInsets(systemBars() or displayCutout()) + toolbar.updatePadding(left = bars.left, top = bars.top, right = bars.right) + insets + } + // Bottom padding is used. wrap_content meant margin wasn't viable + ViewCompat.setOnApplyWindowInsetsListener(deckPickerBinding.root) { deckPickerInclude, insets -> + val bars = insets.getInsets(systemBars() or displayCutout()) + // BottomFadeFrameLayout handles contrast for bottom nav; let the system draw + // its own scrim when the nav bar is on the side (no manual fade applies there) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val nav = insets.getInsets(navigationBars()) + window.isNavigationBarContrastEnforced = nav.left > 0 || nav.right > 0 + } + deckPickerInclude.updatePadding( + left = bars.left, + right = if (fragmented) 0 else bars.right, + ) + deckPickerBinding.reviewSummaryTextView.updatePadding(bottom = bars.bottom) + + // hack for Roborazzi screenshot tests + val fabBottomOffset = if (isRobolectric) 12.dp.toPx(this) else -12.dp.toPx(this) + floatingActionButtonBinding.root.updatePadding(bottom = bars.bottom + fabBottomOffset) + + setRecyclerViewBottomPaddingAbove(floatingActionButtonBinding.fabMain) + insets + } + floatingActionButtonBinding.fabMain.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + setRecyclerViewBottomPaddingAbove(v) + } + if (fragmented) { + val studyoptionsView = binding.studyoptionsFragment ?: return + ViewCompat.setOnApplyWindowInsetsListener(studyoptionsView) { studyOptions, insets -> + val bars = insets.getInsets(systemBars() or displayCutout()) + studyOptions.updatePadding(right = bars.right, bottom = bars.bottom) + insets + } + } + } + @Suppress("UNUSED_PARAMETER") private fun setupFlows() { fun onDeckDeleted(result: DeckDeletionResult) { @@ -648,7 +723,7 @@ open class DeckPicker : deckPickerBinding.reviewSummaryTextView.text = studiedToday // Adjust bottom margin of fabLinearLayout based on reviewSummaryTextView height deckPickerBinding.reviewSummaryTextView.doOnLayout { view -> - val layoutParams = floatingActionButtonBinding.fabLinearLayout.layoutParams as ViewGroup.MarginLayoutParams + val layoutParams = floatingActionButtonBinding.fabLinearLayout.layoutParams as MarginLayoutParams layoutParams.setMargins(0, 0, 0, view.height / 2) floatingActionButtonBinding.fabLinearLayout.layoutParams = layoutParams } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/android/view/ViewLocation.kt b/AnkiDroid/src/main/java/com/ichi2/anki/android/view/ViewLocation.kt new file mode 100644 index 000000000000..04ea982350bf --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/android/view/ViewLocation.kt @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.android.view + +import android.view.View + +/** Wrapper around the `IntArray` returned by [View.getLocationOnScreen]. */ +@JvmInline +value class ViewLocation( + val data: IntArray, +) { + val x: Int get() = data[0] + val y: Int get() = data[1] +} + +/** The receiver's position on the device screen. */ +fun View.locationOnScreen(scratch: IntArray = IntArray(2)): ViewLocation { + getLocationOnScreen(scratch) + return ViewLocation(scratch) +} + +/** The receiver's position relative to its window. */ +fun View.locationInWindow(scratch: IntArray = IntArray(2)): ViewLocation { + getLocationInWindow(scratch) + return ViewLocation(scratch) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/BottomFadeFrameLayout.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/BottomFadeFrameLayout.kt new file mode 100644 index 000000000000..217f1a6d512c --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/BottomFadeFrameLayout.kt @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.ui + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.os.Build +import android.util.AttributeSet +import android.view.View +import android.view.Window +import android.widget.FrameLayout +import androidx.activity.SystemBarStyle +import com.ichi2.anki.android.view.locationOnScreen +import com.ichi2.anki.compat.setDstOutBlendCompat +import com.ichi2.themes.Themes + +/** + * A container which emulates the 'fade' effect which is applied to content when underneath the + * button navigation. + * + * Some Android versions have a transparent nav, which applied a transparency filter to elements + * underneath it. + * Different Android versions implement this differently, and the colors were non-public, this + * class applies this effect in a standardized manner. + * + * @see anchorView + * + * BUG: This applies transparency twice on API <= 25 - not a huge deal. + */ +class BottomFadeFrameLayout + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + ) : FrameLayout(context, attrs, defStyleAttr) { + /** buffer used by [View.screenY] */ + private val locBuf = IntArray(2) + + /** The fade applies to this view, and all views below it vertically */ + var anchorView: View? = null + set(value) { + if (field !== value) { + field = value + invalidate() + } + } + + private val paint = + Paint().apply { + color = FADE_ALPHA shl 24 + setDstOutBlendCompat() + } + + override fun dispatchDraw(canvas: Canvas) { + val target = anchorView + if (target == null || target.height == 0 || width == 0 || height == 0) { + super.dispatchDraw(canvas) + return + } + val targetTopInSelf = target.screenY - screenY + val bandTop = (targetTopInSelf + target.paddingTop).toFloat() + val bandBottom = height.toFloat() + if (bandBottom <= bandTop) { + super.dispatchDraw(canvas) + return + } + // Draw children into a scratch buffer so the fade only affects them + val saveCount = canvas.saveLayer(0f, 0f, width.toFloat(), height.toFloat(), null) + super.dispatchDraw(canvas) + canvas.drawRect(0f, bandTop, width.toFloat(), bandBottom, paint) + canvas.restoreToCount(saveCount) + } + + private inline val View.screenY: Int + get() = locationOnScreen(locBuf).y + + /** Configures [window] for use with this view */ + fun setup(window: Window) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + } + + companion object { + /** Alpha removed from each child pixel inside the fade. */ + // 90% - standard Android, so "Studied in" isn't obscured when overlapping a deck name + const val FADE_ALPHA: Int = 0xE6 + + /** `navigationBarStyle` to pass to [androidx.activity.enableEdgeToEdge] */ + fun navigationBarStyle(): SystemBarStyle = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + SystemBarStyle.auto( + lightScrim = Color.TRANSPARENT, + darkScrim = Color.TRANSPARENT, + ) { Themes.isNightTheme } + } else { + // Dark nav bar icons are not supported by the platform + // Maintain a dark nav, rather than just using fade + // androidx.activity.EdgeToEdge.DefaultDarkScrim + SystemBarStyle.dark(Color.argb(0x80, 0x1b, 0x1b, 0x1b)) + } + } + } diff --git a/AnkiDroid/src/main/res/layout-sw600dp/activity_homescreen.xml b/AnkiDroid/src/main/res/layout-sw600dp/activity_homescreen.xml index ca0b2804cb6c..7e9aea8235ae 100644 --- a/AnkiDroid/src/main/res/layout-sw600dp/activity_homescreen.xml +++ b/AnkiDroid/src/main/res/layout-sw600dp/activity_homescreen.xml @@ -8,7 +8,14 @@ android:layout_height="match_parent" android:orientation="vertical"> - + + + + - + + + + - + android:layout_height="match_parent"> + + + diff --git a/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt b/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt index 8262169b7b34..cf00c5842080 100644 --- a/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt +++ b/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt @@ -22,6 +22,9 @@ import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.graphics.Bitmap +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode import android.media.MediaRecorder import android.media.ThumbnailUtils import android.net.Uri @@ -292,6 +295,10 @@ open class BaseCompat : Compat { context: Context, defaultValue: Boolean, ): Boolean = false + + override fun setDstOutBlend(paint: Paint) { + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT) + } } typealias CompatV24 = BaseCompat diff --git a/compat/src/main/java/com/ichi2/anki/compat/Compat.kt b/compat/src/main/java/com/ichi2/anki/compat/Compat.kt index 6923c71632b2..c027b24511c5 100644 --- a/compat/src/main/java/com/ichi2/anki/compat/Compat.kt +++ b/compat/src/main/java/com/ichi2/anki/compat/Compat.kt @@ -25,6 +25,8 @@ import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.ResolveInfo import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat +import android.graphics.BlendMode +import android.graphics.Paint import android.media.MediaRecorder import android.net.Uri import android.os.Bundle @@ -288,4 +290,7 @@ interface Compat { context: Context, defaultValue: Boolean = false, ): Boolean + + /** Configures [paint] to use [BlendMode.DST_OUT]. */ + fun setDstOutBlend(paint: Paint) } diff --git a/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt b/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt index 6ae88076b6ea..a4132d8937ea 100644 --- a/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt +++ b/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt @@ -24,6 +24,8 @@ import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.ResolveInfo +import android.graphics.BlendMode +import android.graphics.Paint import android.os.Build import android.os.Bundle import android.view.KeyCharacterMap.deviceHasKey @@ -222,3 +224,6 @@ inline fun Bundle.requireSerializableCompat(key: Stri requireNotNull(compat.getSerializable(this, key, T::class.java)) { "key: '$key' not found or null" } + +/** Configures this [Paint] to use [BlendMode.DST_OUT]. */ +fun Paint.setDstOutBlendCompat() = compat.setDstOutBlend(this) diff --git a/compat/src/main/java/com/ichi2/anki/compat/CompatV29.kt b/compat/src/main/java/com/ichi2/anki/compat/CompatV29.kt index b678e8b0c85f..96922c22ac16 100644 --- a/compat/src/main/java/com/ichi2/anki/compat/CompatV29.kt +++ b/compat/src/main/java/com/ichi2/anki/compat/CompatV29.kt @@ -18,6 +18,8 @@ package com.ichi2.anki.compat import android.content.ContentValues import android.content.Context import android.graphics.Bitmap +import android.graphics.BlendMode +import android.graphics.Paint import android.media.ThumbnailUtils import android.net.Uri import android.os.Environment @@ -93,6 +95,10 @@ open class CompatV29 : CompatV26() { return Settings.Secure.getInt(context.contentResolver, "navigation_mode", defaultMode) == 2 } + override fun setDstOutBlend(paint: Paint) { + paint.blendMode = BlendMode.DST_OUT + } + companion object { // obtained from AOSP source private val THUMBNAIL_MINI_KIND = Size(512, 384)