Skip to content

Commit d2c9599

Browse files
committed
feat(reviewer): add navigation drawer to the new study screen
Adds an app-level navigation drawer to the new study screen, mirroring the destinations in the legacy nav drawer. This allows users to navigate to the Browser/Stats/Settings/Help pages directly from the reviewer without unnecessary button presses, or leaving the reviewer context. Extracts AppDestination as a single source of truth for the app-level navigation surface; both the new ReviewerFragment drawer and the legacy NavigationDrawerActivity build their NavigationView menus from it via a shared Menu.populateFromAppDestinations() helper. res/menu/navigation_drawer.xml is deleted; the orphaned menu-item ids move into ids.xml so existing references (CardBrowser, DeckPicker, BottomNavActivity, espresso tests) keep resolving. ReviewerFragment hosts the drawer locally — its layout root becomes a ClosableDrawerLayout wrapping the existing CoordinatorLayout, with an <include> of the existing include_navigation_drawer.xml as the drawer panel. The hamburger button replaces the back button in the tools row; exiting the reviewer is the system back gesture (which closes the drawer first when it's open via an OnBackPressedCallback). No Compose; no toolchain changes. The legacy and new drawers use the same Views widgets so they stay in lockstep.
1 parent 862f73c commit d2c9599

8 files changed

Lines changed: 226 additions & 91 deletions

File tree

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

Lines changed: 17 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,14 @@ import com.google.android.material.navigation.NavigationView
4848
import com.ichi2.anki.IntentHandler.Companion.grantedStoragePermissions
4949
import com.ichi2.anki.NoteEditorFragment.Companion.NoteEditorCaller
5050
import com.ichi2.anki.common.utils.android.HandlerUtils
51-
import com.ichi2.anki.dialogs.help.HelpDialog
5251
import com.ichi2.anki.libanki.CardId
52+
import com.ichi2.anki.navigation.AppDestination
53+
import com.ichi2.anki.navigation.handleAppDestination
54+
import com.ichi2.anki.navigation.populateFromAppDestinations
5355
import com.ichi2.anki.pages.StatisticsDestination
5456
import com.ichi2.anki.preferences.PreferencesActivity
5557
import com.ichi2.anki.preferences.sharedPrefs
56-
import com.ichi2.anki.utils.ext.showDialogFragment
5758
import com.ichi2.anki.workarounds.FullDraggableContainerFix
58-
import com.ichi2.utils.IntentUtil
5959
import timber.log.Timber
6060

6161
abstract class NavigationDrawerActivity(
@@ -171,6 +171,7 @@ abstract class NavigationDrawerActivity(
171171
// Setup toolbar and hamburger
172172
navigationView = drawerLayout.findViewById(R.id.navdrawer_items_container)
173173
navigationView!!.setNavigationItemSelectedListener(this)
174+
navigationView!!.menu.populateFromAppDestinations()
174175
val toolbar: Toolbar? = mainView.findViewById(R.id.toolbar)
175176
if (toolbar != null) {
176177
setSupportActionBar(toolbar)
@@ -344,43 +345,21 @@ abstract class NavigationDrawerActivity(
344345
* This runnable will be executed in onDrawerClosed(...)
345346
* to make the animation more fluid on older devices.
346347
*/
348+
val dest = AppDestination.fromMenuId(item.itemId)
349+
if (dest == null) {
350+
Timber.w("Unknown nav menu item: %d", item.itemId)
351+
closeDrawer()
352+
return true
353+
}
347354
pendingRunnable =
348355
Runnable {
349-
// Take action if a different item selected
350-
when (item.itemId) {
351-
R.id.nav_decks -> {
352-
Timber.i("Navigating to decks")
353-
val deckPicker = Intent(this@NavigationDrawerActivity, DeckPicker::class.java)
354-
// opening DeckPicker should use the instance on the back stack & clear back history
355-
deckPicker.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
356-
startActivity(deckPicker)
357-
}
358-
359-
R.id.nav_browser -> {
360-
Timber.i("Navigating to card browser")
361-
openCardBrowser()
362-
}
363-
364-
R.id.nav_stats -> {
365-
Timber.i("Navigating to stats")
366-
openStatistics()
367-
}
368-
369-
R.id.nav_settings -> {
370-
Timber.i("Navigating to settings")
371-
openSettings()
372-
}
373-
374-
R.id.nav_help -> {
375-
Timber.i("Navigating to help")
376-
showDialogFragment(HelpDialog.newHelpInstance())
377-
}
378-
379-
R.id.support_ankidroid -> {
380-
Timber.i("Navigating to support AnkiDroid")
381-
val canRateApp = IntentUtil.canOpenIntent(this, AnkiDroidApp.getMarketIntent(this))
382-
showDialogFragment(HelpDialog.newSupportInstance(canRateApp))
383-
}
356+
Timber.i("Navigating to %s", dest)
357+
when (dest) {
358+
// Legacy: Card Browser is opened with the currently-viewed card id as an extra
359+
AppDestination.Browser -> openCardBrowser()
360+
// Legacy: Settings is launched via preferencesLauncher so we can recreate() on return
361+
AppDestination.Settings -> openSettings()
362+
else -> handleAppDestination(dest)
384363
}
385364
}
386365
closeDrawer()
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright (c) 2026 Tim Rae <perceptualchaos2@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+
17+
package com.ichi2.anki.navigation
18+
19+
import androidx.annotation.DrawableRes
20+
import androidx.annotation.IdRes
21+
import androidx.annotation.StringRes
22+
import com.ichi2.anki.R
23+
24+
/**
25+
* An entry in the app-level navigation surface (drawer / rail).
26+
*
27+
* Carries only visual metadata and the legacy menu id used by
28+
* [com.ichi2.anki.NavigationDrawerActivity]. For the side-effect of selecting
29+
* an entry, see [handleAppDestination].
30+
*/
31+
enum class AppDestination(
32+
@IdRes val menuItemId: Int,
33+
val group: Group,
34+
@StringRes val titleRes: Int,
35+
@DrawableRes val iconRes: Int,
36+
) {
37+
Decks(R.id.nav_decks, Group.Primary, R.string.decks, R.drawable.ic_list_black),
38+
Browser(R.id.nav_browser, Group.Primary, R.string.card_browser, R.drawable.ic_flashcard_black),
39+
Stats(R.id.nav_stats, Group.Primary, R.string.statistics, R.drawable.ic_bar_chart_black),
40+
Settings(R.id.nav_settings, Group.Utility, R.string.settings, R.drawable.ic_settings_black),
41+
Help(R.id.nav_help, Group.Utility, R.string.help, R.drawable.ic_help_black),
42+
Support(R.id.support_ankidroid, Group.Utility, R.string.help_title_support_ankidroid, R.drawable.ic_support_ankidroid),
43+
;
44+
45+
enum class Group { Primary, Utility }
46+
47+
companion object {
48+
fun fromMenuId(
49+
@IdRes id: Int,
50+
): AppDestination? = entries.find { it.menuItemId == id }
51+
}
52+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright (c) 2026 Tim Rae <perceptualchaos2@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+
17+
package com.ichi2.anki.navigation
18+
19+
import android.content.Intent
20+
import android.view.Menu
21+
import com.ichi2.anki.AnkiActivity
22+
import com.ichi2.anki.AnkiDroidApp
23+
import com.ichi2.anki.CardBrowser
24+
import com.ichi2.anki.DeckPicker
25+
import com.ichi2.anki.dialogs.help.HelpDialog
26+
import com.ichi2.anki.pages.StatisticsDestination
27+
import com.ichi2.anki.preferences.PreferencesActivity
28+
import com.ichi2.anki.utils.ext.showDialogFragment
29+
import com.ichi2.utils.IntentUtil
30+
31+
/**
32+
* Default side-effect for selecting [dest] from an app-level navigation surface.
33+
*
34+
* Hosts that need to customise a specific destination (e.g. legacy
35+
* [com.ichi2.anki.NavigationDrawerActivity] passes a `currentCardId` extra to the
36+
* Card Browser and launches Settings via an `ActivityResultLauncher`) should
37+
* branch on that destination before delegating here.
38+
*/
39+
fun AnkiActivity.handleAppDestination(dest: AppDestination) {
40+
when (dest) {
41+
AppDestination.Decks ->
42+
startActivity(
43+
Intent(this, DeckPicker::class.java)
44+
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP),
45+
)
46+
AppDestination.Browser -> startActivity(Intent(this, CardBrowser::class.java))
47+
AppDestination.Stats -> startActivity(StatisticsDestination().toIntent(this))
48+
AppDestination.Settings -> startActivity(PreferencesActivity.getIntent(this))
49+
AppDestination.Help -> showDialogFragment(HelpDialog.newHelpInstance())
50+
AppDestination.Support -> {
51+
val canRate = IntentUtil.canOpenIntent(this, AnkiDroidApp.getMarketIntent(this))
52+
showDialogFragment(HelpDialog.newSupportInstance(canRate))
53+
}
54+
}
55+
}
56+
57+
/**
58+
* Populates a [Menu] with one item per [AppDestination], grouped by [AppDestination.Group].
59+
* Items in the [AppDestination.Group.Primary] group are made checkable as a single-selection group.
60+
*/
61+
fun Menu.populateFromAppDestinations() {
62+
AppDestination.Group.entries.forEachIndexed { groupOrder, group ->
63+
AppDestination.entries
64+
.filter { it.group == group }
65+
.forEach { dest ->
66+
add(groupOrder, dest.menuItemId, Menu.NONE, dest.titleRes)
67+
.setIcon(dest.iconRes)
68+
}
69+
if (group == AppDestination.Group.Primary) {
70+
setGroupCheckable(groupOrder, true, true)
71+
}
72+
}
73+
}

AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,20 @@ import android.view.inputmethod.InputMethodManager
2828
import android.webkit.WebView
2929
import android.widget.FrameLayout
3030
import android.widget.LinearLayout
31+
import androidx.activity.OnBackPressedCallback
3132
import androidx.appcompat.app.AlertDialog
3233
import androidx.appcompat.widget.ActionMenuView
3334
import androidx.constraintlayout.widget.ConstraintLayout
3435
import androidx.core.content.ContextCompat
3536
import androidx.core.content.getSystemService
37+
import androidx.core.view.GravityCompat
3638
import androidx.core.view.ViewCompat
3739
import androidx.core.view.WindowInsetsCompat
3840
import androidx.core.view.WindowInsetsControllerCompat
3941
import androidx.core.view.isVisible
4042
import androidx.core.view.updateLayoutParams
4143
import androidx.core.view.updatePadding
44+
import androidx.drawerlayout.widget.DrawerLayout
4245
import androidx.fragment.app.Fragment
4346
import androidx.fragment.app.FragmentManager
4447
import androidx.fragment.app.commit
@@ -48,6 +51,8 @@ import androidx.lifecycle.flowWithLifecycle
4851
import androidx.lifecycle.lifecycleScope
4952
import androidx.lifecycle.repeatOnLifecycle
5053
import anki.scheduler.CardAnswer.Rating
54+
import com.google.android.material.navigation.NavigationView
55+
import com.ichi2.anki.AnkiActivity
5156
import com.ichi2.anki.CollectionManager
5257
import com.ichi2.anki.DispatchKeyEventListener
5358
import com.ichi2.anki.Flag
@@ -63,6 +68,9 @@ import com.ichi2.anki.dialogs.tags.TagsDialog
6368
import com.ichi2.anki.dialogs.tags.TagsDialogFactory
6469
import com.ichi2.anki.dialogs.tags.TagsDialogListener
6570
import com.ichi2.anki.model.CardStateFilter
71+
import com.ichi2.anki.navigation.AppDestination
72+
import com.ichi2.anki.navigation.handleAppDestination
73+
import com.ichi2.anki.navigation.populateFromAppDestinations
6674
import com.ichi2.anki.pages.DeckOptionsDestination
6775
import com.ichi2.anki.preferences.reviewer.ViewerAction
6876
import com.ichi2.anki.previewer.CardViewerActivity
@@ -166,10 +174,7 @@ class ReviewerFragment :
166174
) {
167175
super.onViewCreated(view, savedInstanceState)
168176

169-
binding.backButton.setOnClickListener {
170-
requireActivity().finish()
171-
}
172-
177+
setupNavigationDrawer()
173178
setupBindings()
174179
setupImmersiveMode()
175180
setupTypeAnswer()
@@ -568,6 +573,51 @@ class ReviewerFragment :
568573
}
569574
}
570575

576+
private fun setupNavigationDrawer() {
577+
val drawerLayout = binding.drawerLayout
578+
val navigationView = drawerLayout.findViewById<NavigationView>(R.id.navdrawer_items_container)
579+
580+
navigationView.menu.populateFromAppDestinations()
581+
582+
binding.hamburgerButton.setOnClickListener {
583+
drawerLayout.openDrawer(GravityCompat.START)
584+
}
585+
586+
navigationView.setNavigationItemSelectedListener { item ->
587+
val dest = AppDestination.fromMenuId(item.itemId)
588+
if (dest != null) {
589+
drawerLayout.closeDrawer(GravityCompat.START)
590+
(requireActivity() as? AnkiActivity)?.handleAppDestination(dest)
591+
} else {
592+
Timber.w("Unknown nav menu item: %d", item.itemId)
593+
}
594+
true
595+
}
596+
597+
requireActivity().onBackPressedDispatcher.addCallback(
598+
viewLifecycleOwner,
599+
object : OnBackPressedCallback(false) {
600+
init {
601+
drawerLayout.addDrawerListener(
602+
object : DrawerLayout.SimpleDrawerListener() {
603+
override fun onDrawerOpened(drawerView: View) {
604+
isEnabled = true
605+
}
606+
607+
override fun onDrawerClosed(drawerView: View) {
608+
isEnabled = false
609+
}
610+
},
611+
)
612+
}
613+
614+
override fun handleOnBackPressed() {
615+
drawerLayout.closeDrawer(GravityCompat.START)
616+
}
617+
},
618+
)
619+
}
620+
571621
private fun setupTimebox() {
572622
viewModel.timeBoxReachedFlow.flowWithLifecycle(lifecycle).collectIn(lifecycleScope) { timebox ->
573623
Timber.i("ReviewerFragment: Timebox reached (reps %d - secs %d)", timebox.reps, timebox.secs)

AnkiDroid/src/main/res/layout/fragment_reviewer.xml

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
<?xml version="1.0" encoding="utf-8"?>
22

3-
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
3+
<androidx.drawerlayout.widget.ClosableDrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
44
xmlns:app="http://schemas.android.com/apk/res-auto"
55
xmlns:tools="http://schemas.android.com/tools"
6-
android:id="@+id/root_layout"
6+
android:id="@+id/drawer_layout"
77
android:layout_width="match_parent"
88
android:layout_height="match_parent"
9-
android:background="?attr/alternativeBackgroundColor"
109
android:fitsSystemWindows="true"
11-
android:focusableInTouchMode="true"
1210
tools:context=".ui.windows.reviewer.ReviewerFragment">
1311

14-
<LinearLayout
15-
android:id="@+id/main_layout"
12+
<androidx.coordinatorlayout.widget.CoordinatorLayout
13+
android:id="@+id/root_layout"
1614
android:layout_width="match_parent"
1715
android:layout_height="match_parent"
18-
android:orientation="vertical"
19-
android:clipChildren="false">
16+
android:background="?attr/alternativeBackgroundColor"
17+
android:focusableInTouchMode="true">
18+
19+
<LinearLayout
20+
android:id="@+id/main_layout"
21+
android:layout_width="match_parent"
22+
android:layout_height="match_parent"
23+
android:orientation="vertical"
24+
android:clipChildren="false">
2025

2126
<androidx.constraintlayout.widget.ConstraintLayout
2227
android:id="@+id/tools_layout"
@@ -28,13 +33,13 @@
2833
>
2934

3035
<androidx.appcompat.widget.AppCompatImageButton
31-
android:id="@+id/back_button"
36+
android:id="@+id/hamburger_button"
3237
style="?actionButtonStyle"
3338
android:layout_width="?minTouchTargetSize"
3439
android:layout_height="?actionBarSize"
3540
android:layout_marginStart="4dp"
36-
android:contentDescription="@string/abc_action_bar_up_description"
37-
android:src="?attr/homeAsUpIndicator"
41+
android:contentDescription="@string/drawer_open"
42+
android:src="@drawable/ic_menu_24"
3843
app:layout_constraintStart_toStartOf="parent"
3944
app:layout_constraintBottom_toBottomOf="parent"
4045
app:layout_constraintTop_toTopOf="parent" />
@@ -44,7 +49,7 @@
4449
android:layout_width="wrap_content"
4550
android:layout_height="wrap_content"
4651
app:layout_constraintTop_toTopOf="parent"
47-
app:layout_constraintStart_toEndOf="@id/back_button"
52+
app:layout_constraintStart_toEndOf="@id/hamburger_button"
4853
app:layout_constraintBottom_toTopOf="@id/timer"
4954
/>
5055

@@ -55,7 +60,7 @@
5560
android:id="@+id/timer"
5661
android:layout_width="wrap_content"
5762
android:layout_height="wrap_content"
58-
app:layout_constraintStart_toEndOf="@id/back_button"
63+
app:layout_constraintStart_toEndOf="@id/hamburger_button"
5964
app:layout_constraintEnd_toStartOf="@id/counts_barrier"
6065
app:layout_constraintTop_toBottomOf="@id/study_counts"
6166
app:layout_constraintBottom_toBottomOf="parent"
@@ -194,5 +199,9 @@
194199
android:layout_height="wrap_content"/>
195200

196201
</LinearLayout>
197-
</LinearLayout>
198-
</androidx.coordinatorlayout.widget.CoordinatorLayout>
202+
</LinearLayout>
203+
</androidx.coordinatorlayout.widget.CoordinatorLayout>
204+
205+
<include layout="@layout/include_navigation_drawer" />
206+
207+
</androidx.drawerlayout.widget.ClosableDrawerLayout>

0 commit comments

Comments
 (0)