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`. *