Skip to content

Commit b5f9dca

Browse files
committed
feat(card-browser): enable edge to edge
A wrapper was required for the toolbar: adding padding increased the height from minHeight, so apply the padding to a parent element. Roborazzi test added to catch future regressions Issue 17334 Assisted-by: Claude Opus 4.7 - unit tests + frame fix, rest was my own
1 parent ed752d5 commit b5f9dca

8 files changed

Lines changed: 174 additions & 61 deletions

File tree

.idea/dictionaries/android.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<w>ENOENT</w>
1010
<w>FILESIZE</w>
1111
<w>ONEPLUS</w>
12+
<w>Roborazzi</w>
1213
<w>allempty</w>
1314
<w>apkgfileprovider</w>
1415
<w>asynctasks</w>

AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ package com.ichi2.anki
2020

2121
import android.content.Context
2222
import android.content.Intent
23+
import android.graphics.Color
2324
import android.os.Bundle
2425
import android.view.KeyEvent
2526
import android.view.Menu
@@ -30,6 +31,8 @@ import android.view.WindowManager
3031
import android.widget.LinearLayout
3132
import android.widget.TextView
3233
import androidx.activity.OnBackPressedCallback
34+
import androidx.activity.SystemBarStyle
35+
import androidx.activity.enableEdgeToEdge
3336
import androidx.activity.result.ActivityResult
3437
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
3538
import androidx.annotation.CheckResult
@@ -38,7 +41,10 @@ import androidx.annotation.MainThread
3841
import androidx.annotation.VisibleForTesting
3942
import androidx.core.view.MenuHost
4043
import androidx.core.view.MenuProvider
44+
import androidx.core.view.ViewCompat
45+
import androidx.core.view.WindowInsetsCompat
4146
import androidx.core.view.isVisible
47+
import androidx.core.view.updatePadding
4248
import androidx.fragment.app.commit
4349
import androidx.lifecycle.Lifecycle
4450
import androidx.lifecycle.LifecycleOwner
@@ -298,6 +304,8 @@ open class CardBrowser :
298304
return
299305
}
300306
tagsDialogFactory = TagsDialogFactory(this).attachToActivity<TagsDialogFactory>(this)
307+
// match the status bar theme of the rest of the app
308+
enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT))
301309
super.onCreate(savedInstanceState)
302310
binding = ActivityCardBrowserBinding.inflate(layoutInflater)
303311
if (!ensureStoragePermissions()) {
@@ -312,6 +320,7 @@ open class CardBrowser :
312320

313321
setViewBinding(binding)
314322
initNavigationDrawer(findViewById(android.R.id.content))
323+
applyToolbarInsets()
315324

316325
/**
317326
* Check if noteEditorFrame is not null and if its visibility is set to VISIBLE.
@@ -477,6 +486,20 @@ open class CardBrowser :
477486
super.setupBackPressedCallbacks()
478487
}
479488

489+
override fun fitsSystemWindows(): Boolean = false
490+
491+
private fun applyToolbarInsets() {
492+
val container = findViewById<View>(R.id.toolbar_container) ?: return
493+
ViewCompat.setOnApplyWindowInsetsListener(container) { view, insets ->
494+
val bars =
495+
insets.getInsets(
496+
WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.displayCutout(),
497+
)
498+
view.updatePadding(left = bars.left, top = bars.top, right = bars.right)
499+
insets
500+
}
501+
}
502+
480503
private fun showSaveChangesDialog(launcher: NoteEditorLauncher) {
481504
DiscardChangesDialog.showDialog(
482505
context = this,

AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import androidx.core.view.MenuProvider
4848
import androidx.core.view.ViewCompat
4949
import androidx.core.view.WindowInsetsCompat
5050
import androidx.core.view.isVisible
51+
import androidx.core.view.updatePadding
5152
import androidx.core.widget.doAfterTextChanged
5253
import androidx.fragment.app.Fragment
5354
import androidx.fragment.app.FragmentTransaction
@@ -252,7 +253,9 @@ class CardBrowserFragment :
252253
cardsListView =
253254
view.findViewById<RecyclerView>(R.id.card_browser_list).apply {
254255
attachFastScroller(R.id.browser_scroller)
256+
clipToPadding = false
255257
}
258+
applyContentInsets(view)
256259
DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL).apply {
257260
setDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.browser_divider)!!)
258261
cardsListView.addItemDecoration(this)
@@ -372,6 +375,19 @@ class CardBrowserFragment :
372375
setupMenu()
373376
}
374377

378+
private fun applyContentInsets(root: View) {
379+
ViewCompat.setOnApplyWindowInsetsListener(root) { v, insets ->
380+
val bars =
381+
insets.getInsets(
382+
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
383+
)
384+
v.updatePadding(left = bars.left, right = bars.right)
385+
// RecyclerView uses clipToPadding=false so list scrolls under the navigation bar
386+
cardsListView.updatePadding(bottom = bars.bottom)
387+
insets
388+
}
389+
}
390+
375391
private fun setupMenu() {
376392
val menuHost: MenuHost = requireCardBrowserActivity()
377393

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,56 @@
11
<?xml version="1.0" encoding="utf-8"?>
2-
<androidx.appcompat.widget.Toolbar xmlns:android="http://schemas.android.com/apk/res/android"
2+
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
33
xmlns:app="http://schemas.android.com/apk/res-auto"
44
xmlns:tools="http://schemas.android.com/tools"
5-
android:id="@+id/toolbar"
5+
android:id="@+id/toolbar_container"
66
android:layout_width="match_parent"
77
android:layout_height="wrap_content"
8-
android:background="?attr/appBarColor"
9-
android:minHeight="?attr/actionBarSize"
10-
android:theme="@style/ActionBarStyle"
11-
app:navigationContentDescription="@string/abc_action_bar_up_description"
12-
app:navigationIcon="?attr/homeAsUpIndicator">
8+
android:background="?attr/appBarColor">
139

14-
<com.ichi2.ui.FixedTextView
15-
android:id="@+id/toolbar_title"
16-
android:layout_width="wrap_content"
17-
android:layout_height="match_parent"
18-
android:textColor="@color/white"
19-
android:textSize="20sp"
20-
android:visibility="gone" />
21-
22-
<LinearLayout
23-
android:id="@+id/toolbar_content"
10+
<androidx.appcompat.widget.Toolbar
11+
android:id="@+id/toolbar"
2412
android:layout_width="match_parent"
2513
android:layout_height="wrap_content"
26-
android:background="?attr/selectableItemBackground"
27-
android:orientation="vertical">
14+
android:minHeight="?attr/actionBarSize"
15+
android:theme="@style/ActionBarStyle"
16+
app:navigationContentDescription="@string/abc_action_bar_up_description"
17+
app:navigationIcon="?attr/homeAsUpIndicator">
2818

2919
<com.ichi2.ui.FixedTextView
30-
android:id="@+id/deck_name"
31-
android:layout_width="match_parent"
32-
android:layout_height="wrap_content"
33-
android:gravity="center_vertical"
34-
android:drawableEnd="@drawable/id_arrow_drop_down"
35-
android:textAppearance="?attr/textAppearanceListItem"
20+
android:id="@+id/toolbar_title"
21+
android:layout_width="wrap_content"
22+
android:layout_height="match_parent"
3623
android:textColor="@color/white"
37-
android:maxLines="1"
38-
android:ellipsize="end"
39-
tools:text="Deck name here"
40-
/>
24+
android:textSize="20sp"
25+
android:visibility="gone" />
4126

42-
<TextView
43-
android:id="@+id/subtitle"
27+
<LinearLayout
28+
android:id="@+id/toolbar_content"
4429
android:layout_width="match_parent"
4530
android:layout_height="wrap_content"
46-
android:textColor="@color/white"
47-
android:textAppearance="?attr/textAppearanceListItemSecondary"
48-
tools:text="2 cards shown"/>
49-
</LinearLayout>
50-
</androidx.appcompat.widget.Toolbar>
31+
android:background="?attr/selectableItemBackground"
32+
android:orientation="vertical">
33+
34+
<com.ichi2.ui.FixedTextView
35+
android:id="@+id/deck_name"
36+
android:layout_width="match_parent"
37+
android:layout_height="wrap_content"
38+
android:gravity="center_vertical"
39+
android:drawableEnd="@drawable/id_arrow_drop_down"
40+
android:textAppearance="?attr/textAppearanceListItem"
41+
android:textColor="@color/white"
42+
android:maxLines="1"
43+
android:ellipsize="end"
44+
tools:text="Deck name here"
45+
/>
46+
47+
<TextView
48+
android:id="@+id/subtitle"
49+
android:layout_width="match_parent"
50+
android:layout_height="wrap_content"
51+
android:textColor="@color/white"
52+
android:textAppearance="?attr/textAppearanceListItemSecondary"
53+
tools:text="2 cards shown"/>
54+
</LinearLayout>
55+
</androidx.appcompat.widget.Toolbar>
56+
</FrameLayout>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright (c) 2026 David Allison <davidallisongithub@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
package com.ichi2.anki
17+
18+
import androidx.core.graphics.Insets
19+
import androidx.core.view.ViewCompat
20+
import androidx.core.view.WindowInsetsCompat
21+
import androidx.test.ext.junit.runners.AndroidJUnit4
22+
import org.junit.Test
23+
import org.junit.runner.RunWith
24+
25+
/**
26+
* Screenshot tests for [CardBrowser]
27+
*
28+
* `./gradlew :AnkiDroid:verifyRoborazziPlayDebug -Pscreenshot --tests "com.ichi2.anki.CardBrowserScreenshotTest"`
29+
*/
30+
@RunWith(AndroidJUnit4::class)
31+
class CardBrowserScreenshotTest : ScreenshotTest() {
32+
init {
33+
setPhoneQualifiers()
34+
}
35+
36+
@Test
37+
fun cardBrowserWith30Notes() =
38+
withCardBrowser(noteCount = 30) { browser ->
39+
// Robolectric reports zero system-bar insets by default. Use 24dp to expose the issue.
40+
val statusBarPx = (24 * browser.resources.displayMetrics.density).toInt()
41+
val insets =
42+
WindowInsetsCompat
43+
.Builder()
44+
.setInsets(WindowInsetsCompat.Type.statusBars(), Insets.of(0, statusBarPx, 0, 0))
45+
.build()
46+
ViewCompat.dispatchApplyWindowInsets(browser.window.decorView, insets)
47+
captureScreen("30_notes")
48+
}
49+
}

AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -974,26 +974,6 @@ class CardBrowserTest : RobolectricTest() {
974974
advanceRobolectricLooper()
975975
}
976976

977-
/** Returns an instance of [CardBrowser] containing [noteCount] notes */
978-
private fun getBrowserWithNotes(
979-
noteCount: Int,
980-
reversed: Boolean = false,
981-
): CardBrowser {
982-
ensureCollectionLoadIsSynchronous()
983-
if (reversed) {
984-
for (i in 0 until noteCount) {
985-
addBasicAndReversedNote(i.toString(), "back")
986-
}
987-
} else {
988-
for (i in 0 until noteCount) {
989-
addBasicNote(i.toString(), "back")
990-
}
991-
}
992-
return super.startRegularActivity<CardBrowser>(Intent()).also {
993-
advanceRobolectricLooper() // may be a fix for flaky tests
994-
}
995-
}
996-
997977
private val browserWithNoNewCards: CardBrowser
998978
get() = getBrowserWithNotes(0)
999979

@@ -2132,3 +2112,30 @@ suspend fun CardBrowser.selectAll() {
21322112

21332113
val CardBrowser.menu: Menu
21342114
get() = if (this.useSearchView) cardBrowserFragment.searchBar!!.menu else shadowOf(this).optionsMenu!!
2115+
2116+
/** Returns an instance of [CardBrowser] containing [noteCount] notes */
2117+
context(test: RobolectricTest)
2118+
fun getBrowserWithNotes(
2119+
noteCount: Int,
2120+
reversed: Boolean = false,
2121+
): CardBrowser {
2122+
test.ensureCollectionLoadIsSynchronous()
2123+
if (reversed) {
2124+
for (i in 0 until noteCount) {
2125+
test.addBasicAndReversedNote(i.toString(), "back")
2126+
}
2127+
} else {
2128+
for (i in 0 until noteCount) {
2129+
test.addBasicNote(i.toString(), "back")
2130+
}
2131+
}
2132+
return test.startRegularActivity<CardBrowser>(Intent()).also {
2133+
advanceRobolectricLooper() // may be a fix for flaky tests
2134+
}
2135+
}
2136+
2137+
context(test: RobolectricTest)
2138+
fun withCardBrowser(
2139+
noteCount: Int,
2140+
block: (CardBrowser) -> Unit,
2141+
) = block(getBrowserWithNotes(noteCount))

AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515
*/
1616
package com.ichi2.anki
1717

18+
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
19+
import com.github.takahirom.roborazzi.captureScreenRoboImage
1820
import org.junit.experimental.categories.Category
21+
import org.robolectric.RuntimeEnvironment
1922
import org.robolectric.annotation.GraphicsMode
2023

2124
interface ScreenshotTestCategory
@@ -25,4 +28,15 @@ interface ScreenshotTestCategory
2528
*/
2629
@Category(ScreenshotTestCategory::class)
2730
@GraphicsMode(GraphicsMode.Mode.NATIVE)
28-
abstract class ScreenshotTest : RobolectricTest()
31+
abstract class ScreenshotTest : RobolectricTest() {
32+
/** Pixel-class phone in portrait, light theme. */
33+
protected fun setPhoneQualifiers() {
34+
RuntimeEnvironment.setQualifiers("w411dp-h914dp-notnight-420dpi")
35+
}
36+
37+
/** Captures a screenshot to `build/outputs/roborazzi/<TestClass>/<name>.png`. */
38+
@OptIn(ExperimentalRoborazziApi::class)
39+
protected fun captureScreen(name: String) {
40+
captureScreenRoboImage(filePath = "build/outputs/roborazzi/${this.javaClass.simpleName}/$name.png")
41+
}
42+
}

AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/StudyScreenScreenshotTest.kt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616
package com.ichi2.anki.ui.windows.reviewer
1717

1818
import androidx.test.core.app.ActivityScenario
19-
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
20-
import com.github.takahirom.roborazzi.captureScreenRoboImage
2119
import com.ichi2.anki.ScreenshotTest
2220
import com.ichi2.anki.previewer.CardViewerActivity
2321
import com.ichi2.anki.settings.Prefs
@@ -40,15 +38,14 @@ class StudyScreenScreenshotTest(
4038
RuntimeEnvironment.setQualifiers(config.qualifier.toString())
4139
}
4240

43-
@OptIn(ExperimentalRoborazziApi::class)
4441
@Test
4542
fun captureScreenshot() {
4643
ActivityScenario
4744
.launch<CardViewerActivity>(
4845
ReviewerFragment.getIntent(targetContext),
4946
).use { scenario ->
5047
scenario.onActivity {
51-
captureScreenRoboImage(filePath = "build/outputs/roborazzi/${this.javaClass.simpleName}/$config.png")
48+
captureScreen(config.toString())
5249
}
5350
}
5451
}

0 commit comments

Comments
 (0)