Skip to content

Commit 626a601

Browse files
committed
feat(compose): render the reviewer's nav drawer with Compose
Swap the ClosableDrawerLayout-based drawer for a Compose ModalNavigationDrawer hosted in a ComposeView overlay. This is the first Compose surface in the app. Brings in: - Compose BOM 2026.05.01 + Material3 + kotlin.plugin.compose, plus activity-compose / lifecycle-viewmodel-compose / lifecycle-runtime-compose. - AnkiTheme — a minimal MaterialTheme bridge that reads the AppCompat / Material Components theme attributes so Compose surfaces honour the user's selected colour theme. Translucent values are composited over colorBackground so Compose colours are fully opaque. - AppNavigationDrawer — the M3 ModalNavigationDrawer rendering the AppDestination entries. Owns its own open/closed state; the host emits on a one-shot Flow<Unit> of "open" requests and the drawer closes itself on scrim tap / predictive back / item click. Returns an empty composition while fully closed so touches fall through to the WebView. In the reviewer: - fragment_reviewer.xml: drop the ClosableDrawerLayout wrap and the <include> of include_navigation_drawer.xml. Add a ComposeView overlay for the drawer. Move android:fitsSystemWindows="true" off the root CoordinatorLayout onto the inner LinearLayout so the ComposeView spans the full window and the drawer paints up to the top edge — parity with the legacy DrawerLayout behaviour. - ReviewerFragment: drop the Views-based setupNavigationDrawer in favour of one that wires the ComposeView to AppNavigationDrawer. - ReviewerViewModel: add openNavigationDrawerFlow / onHamburgerClicked so view-model state survives configuration changes; the drawer's own internal state is what handles the open/close UX.
1 parent d2c9599 commit 626a601

7 files changed

Lines changed: 288 additions & 65 deletions

File tree

AnkiDroid/build.gradle

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ plugins {
55
id 'com.android.application'
66
id 'org.jetbrains.kotlin.plugin.parcelize'
77
alias(libs.plugins.kotlin.serialization)
8+
alias(libs.plugins.kotlin.compose)
89
alias(libs.plugins.keeper)
910
alias(libs.plugins.roborazzi)
1011
id 'idea'
@@ -61,6 +62,7 @@ android {
6162
aidl = true
6263
viewBinding = true
6364
resValues = true
65+
compose = true
6466
}
6567

6668
if (rootProject.testReleaseBuild) {
@@ -466,6 +468,15 @@ dependencies {
466468
implementation libs.kotlinx.serialization.json
467469
implementation libs.seismic
468470

471+
implementation platform(libs.androidx.compose.bom)
472+
implementation libs.androidx.compose.ui
473+
implementation libs.androidx.compose.ui.tooling.preview
474+
implementation libs.androidx.compose.material3
475+
implementation libs.androidx.activity.compose
476+
implementation libs.androidx.lifecycle.viewmodel.compose
477+
implementation libs.androidx.lifecycle.runtime.compose
478+
debugImplementation libs.androidx.compose.ui.tooling
479+
469480
debugImplementation libs.androidx.fragment.testing.manifest
470481

471482
// Backend libraries
@@ -572,5 +583,10 @@ dependencies {
572583
// Required so the ExperimentalCoroutinesApi opt-in (applied globally) doesn't cause
573584
// an "unresolved" warning, which is treated as an error due to allWarningsAsErrors
574585
testFixturesImplementation libs.kotlinx.coroutines.core
586+
// The Compose Compiler plugin is applied module-wide and runs its classpath check
587+
// on every Kotlin compilation, including testFixtures — even though no fixtures call
588+
// Compose. The runtime jar must be present for that check to pass.
589+
testFixturesImplementation platform(libs.androidx.compose.bom)
590+
testFixturesImplementation libs.androidx.compose.runtime
575591

576592
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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.ui.compose
18+
19+
import androidx.annotation.AttrRes
20+
import androidx.compose.material3.MaterialTheme
21+
import androidx.compose.material3.darkColorScheme
22+
import androidx.compose.material3.lightColorScheme
23+
import androidx.compose.runtime.Composable
24+
import androidx.compose.ui.graphics.Color
25+
import androidx.compose.ui.graphics.toArgb
26+
import androidx.compose.ui.platform.LocalContext
27+
import androidx.core.graphics.ColorUtils
28+
import com.google.android.material.color.MaterialColors
29+
import com.ichi2.themes.Themes
30+
import androidx.appcompat.R as AppCompatR
31+
import com.google.android.material.R as MaterialR
32+
33+
/**
34+
* Bridges the host activity's AppCompat/Material-Components theme into Compose's
35+
* [MaterialTheme] so Compose surfaces pick up the user-selected AnkiDroid theme.
36+
*
37+
* For each slot we read the corresponding theme attribute. Some AnkiDroid themes
38+
* define M3 attributes with alpha (e.g. `colorSurfaceContainer = #0F03A9F4`,
39+
* intended for compositing in the View hierarchy); those are composited against
40+
* `?android:colorBackground` so the resulting Compose color is fully opaque.
41+
*/
42+
@Suppress("ktlint:standard:function-naming")
43+
@Composable
44+
fun AnkiTheme(content: @Composable () -> Unit) {
45+
val context = LocalContext.current
46+
val isDark = Themes.isNightTheme
47+
48+
fun themeColor(
49+
@AttrRes attr: Int,
50+
fallback: Color,
51+
): Color {
52+
val fallbackArgb = fallback.toArgb()
53+
val resolved = MaterialColors.getColor(context, attr, fallbackArgb)
54+
if (resolved == fallbackArgb) return fallback
55+
val alpha = resolved ushr 24
56+
if (alpha == 0xFF) return Color(resolved)
57+
val background =
58+
MaterialColors.getColor(context, android.R.attr.colorBackground, fallbackArgb) or
59+
0xFF000000.toInt()
60+
return Color(ColorUtils.compositeColors(resolved, background))
61+
}
62+
63+
val base = if (isDark) darkColorScheme() else lightColorScheme()
64+
val scheme =
65+
base.copy(
66+
primary = themeColor(AppCompatR.attr.colorPrimary, base.primary),
67+
onPrimary = themeColor(MaterialR.attr.colorOnPrimary, base.onPrimary),
68+
surface = themeColor(MaterialR.attr.colorSurface, base.surface),
69+
onSurface = themeColor(MaterialR.attr.colorOnSurface, base.onSurface),
70+
onSurfaceVariant = themeColor(MaterialR.attr.colorOnSurfaceVariant, base.onSurfaceVariant),
71+
background = themeColor(android.R.attr.colorBackground, base.background),
72+
onBackground = themeColor(MaterialR.attr.colorOnSurface, base.onBackground),
73+
surfaceContainer = themeColor(MaterialR.attr.colorSurfaceContainer, base.surfaceContainer),
74+
secondaryContainer = themeColor(MaterialR.attr.colorSecondaryContainer, base.secondaryContainer),
75+
onSecondaryContainer = themeColor(MaterialR.attr.colorOnSecondaryContainer, base.onSecondaryContainer),
76+
)
77+
78+
MaterialTheme(colorScheme = scheme, content = content)
79+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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.ui.compose
18+
19+
import android.util.TypedValue
20+
import androidx.compose.foundation.Image
21+
import androidx.compose.foundation.layout.Column
22+
import androidx.compose.foundation.layout.WindowInsets
23+
import androidx.compose.foundation.layout.fillMaxWidth
24+
import androidx.compose.foundation.layout.height
25+
import androidx.compose.foundation.layout.padding
26+
import androidx.compose.foundation.layout.width
27+
import androidx.compose.foundation.rememberScrollState
28+
import androidx.compose.foundation.shape.RoundedCornerShape
29+
import androidx.compose.foundation.verticalScroll
30+
import androidx.compose.material3.DrawerValue
31+
import androidx.compose.material3.HorizontalDivider
32+
import androidx.compose.material3.Icon
33+
import androidx.compose.material3.ModalDrawerSheet
34+
import androidx.compose.material3.ModalNavigationDrawer
35+
import androidx.compose.material3.NavigationDrawerItem
36+
import androidx.compose.material3.Text
37+
import androidx.compose.material3.rememberDrawerState
38+
import androidx.compose.runtime.Composable
39+
import androidx.compose.runtime.LaunchedEffect
40+
import androidx.compose.runtime.rememberCoroutineScope
41+
import androidx.compose.ui.Modifier
42+
import androidx.compose.ui.layout.ContentScale
43+
import androidx.compose.ui.platform.LocalContext
44+
import androidx.compose.ui.res.painterResource
45+
import androidx.compose.ui.res.stringResource
46+
import androidx.compose.ui.unit.dp
47+
import com.ichi2.anki.R
48+
import com.ichi2.anki.navigation.AppDestination
49+
import kotlinx.coroutines.flow.Flow
50+
import kotlinx.coroutines.launch
51+
52+
/**
53+
* App-level modal navigation drawer used by the new study screen.
54+
*
55+
* Items come from [AppDestination] grouped by [AppDestination.Group]. The drawer
56+
* owns its open/closed state — the host opens it by emitting on [openRequests];
57+
* dismissal (scrim tap, predictive back, item click) is handled internally.
58+
*/
59+
@Suppress("ktlint:standard:function-naming")
60+
@Composable
61+
fun AppNavigationDrawer(
62+
openRequests: Flow<Unit>,
63+
selected: AppDestination?,
64+
onDestinationClick: (AppDestination) -> Unit,
65+
) {
66+
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
67+
val scope = rememberCoroutineScope()
68+
val context = LocalContext.current
69+
70+
val headerDrawableId =
71+
TypedValue().let { value ->
72+
context.theme.resolveAttribute(R.attr.navDrawerImage, value, true)
73+
value.resourceId
74+
}
75+
76+
LaunchedEffect(openRequests) {
77+
openRequests.collect { drawerState.open() }
78+
}
79+
80+
// Skip emitting any UI while fully closed so touch events fall through to
81+
// the underlying View hierarchy.
82+
if (drawerState.currentValue == DrawerValue.Closed &&
83+
drawerState.targetValue == DrawerValue.Closed
84+
) {
85+
return
86+
}
87+
88+
ModalNavigationDrawer(
89+
drawerState = drawerState,
90+
drawerContent = {
91+
ModalDrawerSheet(
92+
modifier = Modifier.width(280.dp),
93+
drawerShape = RoundedCornerShape(topEnd = 16.dp, bottomEnd = 16.dp),
94+
windowInsets = WindowInsets(0, 0, 0, 0),
95+
) {
96+
if (headerDrawableId != 0) {
97+
Image(
98+
painter = painterResource(headerDrawableId),
99+
contentDescription = null,
100+
contentScale = ContentScale.Crop,
101+
modifier =
102+
Modifier
103+
.fillMaxWidth()
104+
.height(150.dp),
105+
)
106+
}
107+
108+
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
109+
AppDestination.Group.entries.forEachIndexed { groupIndex, group ->
110+
if (groupIndex > 0) {
111+
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
112+
}
113+
AppDestination.entries
114+
.filter { it.group == group }
115+
.forEach { dest ->
116+
NavigationDrawerItem(
117+
selected = dest == selected,
118+
onClick = {
119+
scope.launch { drawerState.close() }
120+
onDestinationClick(dest)
121+
},
122+
icon = {
123+
Icon(
124+
painter = painterResource(dest.iconRes),
125+
contentDescription = null,
126+
)
127+
},
128+
label = { Text(stringResource(dest.titleRes)) },
129+
modifier = Modifier.padding(horizontal = 12.dp),
130+
)
131+
}
132+
}
133+
}
134+
}
135+
},
136+
content = {},
137+
)
138+
}

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

Lines changed: 18 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,18 @@ 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
3231
import androidx.appcompat.app.AlertDialog
3332
import androidx.appcompat.widget.ActionMenuView
33+
import androidx.compose.ui.platform.ViewCompositionStrategy
3434
import androidx.constraintlayout.widget.ConstraintLayout
3535
import androidx.core.content.ContextCompat
3636
import androidx.core.content.getSystemService
37-
import androidx.core.view.GravityCompat
3837
import androidx.core.view.ViewCompat
3938
import androidx.core.view.WindowInsetsCompat
4039
import androidx.core.view.WindowInsetsControllerCompat
4140
import androidx.core.view.isVisible
4241
import androidx.core.view.updateLayoutParams
4342
import androidx.core.view.updatePadding
44-
import androidx.drawerlayout.widget.DrawerLayout
4543
import androidx.fragment.app.Fragment
4644
import androidx.fragment.app.FragmentManager
4745
import androidx.fragment.app.commit
@@ -51,7 +49,6 @@ import androidx.lifecycle.flowWithLifecycle
5149
import androidx.lifecycle.lifecycleScope
5250
import androidx.lifecycle.repeatOnLifecycle
5351
import anki.scheduler.CardAnswer.Rating
54-
import com.google.android.material.navigation.NavigationView
5552
import com.ichi2.anki.AnkiActivity
5653
import com.ichi2.anki.CollectionManager
5754
import com.ichi2.anki.DispatchKeyEventListener
@@ -68,9 +65,7 @@ import com.ichi2.anki.dialogs.tags.TagsDialog
6865
import com.ichi2.anki.dialogs.tags.TagsDialogFactory
6966
import com.ichi2.anki.dialogs.tags.TagsDialogListener
7067
import com.ichi2.anki.model.CardStateFilter
71-
import com.ichi2.anki.navigation.AppDestination
7268
import com.ichi2.anki.navigation.handleAppDestination
73-
import com.ichi2.anki.navigation.populateFromAppDestinations
7469
import com.ichi2.anki.pages.DeckOptionsDestination
7570
import com.ichi2.anki.preferences.reviewer.ViewerAction
7671
import com.ichi2.anki.previewer.CardViewerActivity
@@ -89,6 +84,8 @@ import com.ichi2.anki.settings.enums.ToolbarPosition
8984
import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider
9085
import com.ichi2.anki.snackbar.SnackbarBuilder
9186
import com.ichi2.anki.snackbar.showSnackbar
87+
import com.ichi2.anki.ui.compose.AnkiTheme
88+
import com.ichi2.anki.ui.compose.AppNavigationDrawer
9289
import com.ichi2.anki.ui.windows.reviewer.audiorecord.CheckPronunciationFragment
9390
import com.ichi2.anki.ui.windows.reviewer.whiteboard.WhiteboardFragment
9491
import com.ichi2.anki.utils.CollectionPreferences
@@ -174,7 +171,6 @@ class ReviewerFragment :
174171
) {
175172
super.onViewCreated(view, savedInstanceState)
176173

177-
setupNavigationDrawer()
178174
setupBindings()
179175
setupImmersiveMode()
180176
setupTypeAnswer()
@@ -189,6 +185,7 @@ class ReviewerFragment :
189185
setupActions()
190186
setupWhiteboard()
191187
setupTimebox()
188+
setupNavigationDrawer()
192189

193190
viewModel.finishResultFlow.collectIn(lifecycleScope) { result ->
194191
requireActivity().run {
@@ -574,48 +571,22 @@ class ReviewerFragment :
574571
}
575572

576573
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-
}
574+
binding.hamburgerButton.setOnClickListener { viewModel.onHamburgerClicked() }
613575

614-
override fun handleOnBackPressed() {
615-
drawerLayout.closeDrawer(GravityCompat.START)
616-
}
617-
},
576+
binding.navigationDrawer.setViewCompositionStrategy(
577+
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed,
618578
)
579+
binding.navigationDrawer.setContent {
580+
AnkiTheme {
581+
AppNavigationDrawer(
582+
openRequests = viewModel.openNavigationDrawerFlow,
583+
selected = null,
584+
onDestinationClick = { dest ->
585+
(requireActivity() as? AnkiActivity)?.handleAppDestination(dest)
586+
},
587+
)
588+
}
589+
}
619590
}
620591

621592
private fun setupTimebox() {

0 commit comments

Comments
 (0)