diff --git a/.idea/dictionaries/android.xml b/.idea/dictionaries/android.xml
index 3e99df561dfe..e92a82e184a2 100644
--- a/.idea/dictionaries/android.xml
+++ b/.idea/dictionaries/android.xml
@@ -9,6 +9,7 @@
ENOENT
FILESIZE
ONEPLUS
+ Roborazzi
allempty
apkgfileprovider
asynctasks
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
index 7a8267a8225a..8b5f0c24841f 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
@@ -20,6 +20,7 @@ package com.ichi2.anki
import android.content.Context
import android.content.Intent
+import android.graphics.Color
import android.os.Bundle
import android.view.KeyEvent
import android.view.Menu
@@ -30,6 +31,8 @@ import android.view.WindowManager
import android.widget.LinearLayout
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.contract.ActivityResultContracts.StartActivityForResult
import androidx.annotation.CheckResult
@@ -38,7 +41,10 @@ import androidx.annotation.MainThread
import androidx.annotation.VisibleForTesting
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
+import androidx.core.view.updatePadding
import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
@@ -298,6 +304,8 @@ open class CardBrowser :
return
}
tagsDialogFactory = TagsDialogFactory(this).attachToActivity(this)
+ // match the status bar theme of the rest of the app
+ enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT))
super.onCreate(savedInstanceState)
binding = ActivityCardBrowserBinding.inflate(layoutInflater)
if (!ensureStoragePermissions()) {
@@ -312,6 +320,7 @@ open class CardBrowser :
setViewBinding(binding)
initNavigationDrawer(findViewById(android.R.id.content))
+ applyToolbarInsets()
/**
* Check if noteEditorFrame is not null and if its visibility is set to VISIBLE.
@@ -477,6 +486,20 @@ open class CardBrowser :
super.setupBackPressedCallbacks()
}
+ override fun fitsSystemWindows(): Boolean = false
+
+ private fun applyToolbarInsets() {
+ val container = findViewById(R.id.toolbar_container) ?: return
+ ViewCompat.setOnApplyWindowInsetsListener(container) { view, insets ->
+ val bars =
+ insets.getInsets(
+ WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.displayCutout(),
+ )
+ view.updatePadding(left = bars.left, top = bars.top, right = bars.right)
+ insets
+ }
+ }
+
private fun showSaveChangesDialog(launcher: NoteEditorLauncher) {
DiscardChangesDialog.showDialog(
context = this,
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt
index 1fe5741aa6f4..3bfd48c3acb8 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt
@@ -48,6 +48,7 @@ import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
+import androidx.core.view.updatePadding
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
@@ -252,7 +253,9 @@ class CardBrowserFragment :
cardsListView =
view.findViewById(R.id.card_browser_list).apply {
attachFastScroller(R.id.browser_scroller)
+ clipToPadding = false
}
+ applyContentInsets(view)
DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL).apply {
setDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.browser_divider)!!)
cardsListView.addItemDecoration(this)
@@ -372,6 +375,19 @@ class CardBrowserFragment :
setupMenu()
}
+ private fun applyContentInsets(root: View) {
+ ViewCompat.setOnApplyWindowInsetsListener(root) { v, insets ->
+ val bars =
+ insets.getInsets(
+ WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
+ )
+ v.updatePadding(left = bars.left, right = bars.right)
+ // RecyclerView uses clipToPadding=false so list scrolls under the navigation bar
+ cardsListView.updatePadding(bottom = bars.bottom)
+ insets
+ }
+ }
+
private fun setupMenu() {
val menuHost: MenuHost = requireCardBrowserActivity()
diff --git a/AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt b/AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt
index e326a9bafaba..d49dd3c974d6 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,8 +54,23 @@ object Themes {
context.setTheme(currentTheme.styleResId)
}
- fun setLegacyActionBar(context: Context) {
- context.setTheme(R.style.ThemeOverlay_LegacyActionBar)
+ 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)
+ }
}
/**
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">
-
+ android:background="?attr/appBarColor">
-
-
-
+ android:minHeight="?attr/actionBarSize"
+ android:theme="@style/ActionBarStyle"
+ app:navigationContentDescription="@string/abc_action_bar_up_description"
+ app:navigationIcon="?attr/homeAsUpIndicator">
+ android:textSize="20sp"
+ android:visibility="gone" />
-
-
-
+ android:background="?attr/selectableItemBackground"
+ android:orientation="vertical">
+
+
+
+
+
+
+
diff --git a/AnkiDroid/src/main/res/layout/item_card_browser.xml b/AnkiDroid/src/main/res/layout/item_card_browser.xml
index 1d70b58080fb..9c003506e304 100644
--- a/AnkiDroid/src/main/res/layout/item_card_browser.xml
+++ b/AnkiDroid/src/main/res/layout/item_card_browser.xml
@@ -3,7 +3,6 @@
android:id="@+id/card_item_browser"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:background="?attr/selectableItemBackground"
xmlns:tools="http://schemas.android.com/tools">
-
-
-
\ No newline at end of file
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserScreenshotTest.kt
new file mode 100644
index 000000000000..c4e207f8c38b
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserScreenshotTest.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2026 David Allison
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki
+
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.core.graphics.Insets
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Screenshot tests for [CardBrowser]
+ *
+ * `./gradlew :AnkiDroid:verifyRoborazziPlayDebug -Pscreenshot --tests "com.ichi2.anki.CardBrowserScreenshotTest"`
+ */
+@RunWith(AndroidJUnit4::class)
+class CardBrowserScreenshotTest : ScreenshotTest() {
+ init {
+ setPhoneQualifiers()
+ }
+
+ @Test
+ fun cardBrowserWith30Notes() =
+ withCardBrowser(noteCount = 50) { browser ->
+ // Robolectric reports zero system-bar insets by default. Inject realistic ones
+ // so the app's edge-to-edge layout responds as it would on a real device.
+ val density = browser.resources.displayMetrics.density
+ val statusBarPx = (24 * density).toInt()
+ val navBarPx = (48 * density).toInt()
+ val insets =
+ WindowInsetsCompat
+ .Builder()
+ .setInsets(WindowInsetsCompat.Type.statusBars(), Insets.of(0, statusBarPx, 0, 0))
+ .setInsets(WindowInsetsCompat.Type.navigationBars(), Insets.of(0, 0, 0, navBarPx))
+ .build()
+ ViewCompat.dispatchApplyWindowInsets(browser.window.decorView, insets)
+
+ // overlay a translucent band where the nav bar would sit
+ // to see if content is drawn underneath it
+ val decor = browser.window.decorView as ViewGroup
+ val navBarOverlay =
+ View(browser).apply {
+ setBackgroundColor(0x80000000.toInt())
+ }
+ decor.addView(
+ navBarOverlay,
+ FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ navBarPx,
+ Gravity.BOTTOM,
+ ),
+ )
+
+ captureScreen("30_notes")
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt
index 3ef7c9be6ef8..1c059ec88c03 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt
@@ -974,26 +974,6 @@ class CardBrowserTest : RobolectricTest() {
advanceRobolectricLooper()
}
- /** Returns an instance of [CardBrowser] containing [noteCount] notes */
- private fun getBrowserWithNotes(
- noteCount: Int,
- reversed: Boolean = false,
- ): CardBrowser {
- ensureCollectionLoadIsSynchronous()
- if (reversed) {
- for (i in 0 until noteCount) {
- addBasicAndReversedNote(i.toString(), "back")
- }
- } else {
- for (i in 0 until noteCount) {
- addBasicNote(i.toString(), "back")
- }
- }
- return super.startRegularActivity(Intent()).also {
- advanceRobolectricLooper() // may be a fix for flaky tests
- }
- }
-
private val browserWithNoNewCards: CardBrowser
get() = getBrowserWithNotes(0)
@@ -2132,3 +2112,30 @@ suspend fun CardBrowser.selectAll() {
val CardBrowser.menu: Menu
get() = if (this.useSearchView) cardBrowserFragment.searchBar!!.menu else shadowOf(this).optionsMenu!!
+
+/** Returns an instance of [CardBrowser] containing [noteCount] notes */
+context(test: RobolectricTest)
+fun getBrowserWithNotes(
+ noteCount: Int,
+ reversed: Boolean = false,
+): CardBrowser {
+ test.ensureCollectionLoadIsSynchronous()
+ if (reversed) {
+ for (i in 0 until noteCount) {
+ test.addBasicAndReversedNote(i.toString(), "back")
+ }
+ } else {
+ for (i in 0 until noteCount) {
+ test.addBasicNote(i.toString(), "back")
+ }
+ }
+ return test.startRegularActivity(Intent()).also {
+ advanceRobolectricLooper() // may be a fix for flaky tests
+ }
+}
+
+context(test: RobolectricTest)
+fun withCardBrowser(
+ noteCount: Int,
+ block: (CardBrowser) -> Unit,
+) = block(getBrowserWithNotes(noteCount))
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt
index f9879e791edf..253136d4202b 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt
@@ -21,6 +21,7 @@ import com.github.takahirom.roborazzi.captureScreenRoboImage
import com.github.takahirom.roborazzi.provideRoborazziContext
import org.junit.experimental.categories.Category
import org.robolectric.annotation.GraphicsMode
+import org.robolectric.RuntimeEnvironment
import java.io.File
interface ScreenshotTestCategory
@@ -31,6 +32,11 @@ interface ScreenshotTestCategory
@Category(ScreenshotTestCategory::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
abstract class ScreenshotTest : RobolectricTest() {
+ /** Pixel-class phone in portrait, light theme. */
+ protected fun setPhoneQualifiers() {
+ RuntimeEnvironment.setQualifiers("w411dp-h914dp-notnight-420dpi")
+ }
+
/**
* Captures a screenshot to `build/outputs/roborazzi//.png`.
*