Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions AnkiDroid/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.plugin.parcelize'
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.keeper)
alias(libs.plugins.roborazzi)
id 'idea'
Expand Down Expand Up @@ -61,6 +62,7 @@ android {
aidl = true
viewBinding = true
resValues = true
compose = true
}

if (rootProject.testReleaseBuild) {
Expand Down Expand Up @@ -466,6 +468,15 @@ dependencies {
implementation libs.kotlinx.serialization.json
implementation libs.seismic

implementation platform(libs.androidx.compose.bom)
implementation libs.androidx.compose.ui
implementation libs.androidx.compose.ui.tooling.preview
implementation libs.androidx.compose.material3
implementation libs.androidx.activity.compose
implementation libs.androidx.lifecycle.viewmodel.compose
implementation libs.androidx.lifecycle.runtime.compose
debugImplementation libs.androidx.compose.ui.tooling

debugImplementation libs.androidx.fragment.testing.manifest

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

}
55 changes: 17 additions & 38 deletions AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,14 @@ import com.google.android.material.navigation.NavigationView
import com.ichi2.anki.IntentHandler.Companion.grantedStoragePermissions
import com.ichi2.anki.NoteEditorFragment.Companion.NoteEditorCaller
import com.ichi2.anki.common.utils.android.HandlerUtils
import com.ichi2.anki.dialogs.help.HelpDialog
import com.ichi2.anki.libanki.CardId
import com.ichi2.anki.navigation.AppDestination
import com.ichi2.anki.navigation.handleAppDestination
import com.ichi2.anki.navigation.populateFromAppDestinations
import com.ichi2.anki.pages.StatisticsDestination
import com.ichi2.anki.preferences.PreferencesActivity
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.anki.utils.ext.showDialogFragment
import com.ichi2.anki.workarounds.FullDraggableContainerFix
import com.ichi2.utils.IntentUtil
import timber.log.Timber

abstract class NavigationDrawerActivity(
Expand Down Expand Up @@ -171,6 +171,7 @@ abstract class NavigationDrawerActivity(
// Setup toolbar and hamburger
navigationView = drawerLayout.findViewById(R.id.navdrawer_items_container)
navigationView!!.setNavigationItemSelectedListener(this)
navigationView!!.menu.populateFromAppDestinations()
val toolbar: Toolbar? = mainView.findViewById(R.id.toolbar)
if (toolbar != null) {
setSupportActionBar(toolbar)
Expand Down Expand Up @@ -344,43 +345,21 @@ abstract class NavigationDrawerActivity(
* This runnable will be executed in onDrawerClosed(...)
* to make the animation more fluid on older devices.
*/
val dest = AppDestination.fromMenuId(item.itemId)
if (dest == null) {
Timber.w("Unknown nav menu item: %d", item.itemId)
closeDrawer()
return true
}
pendingRunnable =
Runnable {
// Take action if a different item selected
when (item.itemId) {
R.id.nav_decks -> {
Timber.i("Navigating to decks")
val deckPicker = Intent(this@NavigationDrawerActivity, DeckPicker::class.java)
// opening DeckPicker should use the instance on the back stack & clear back history
deckPicker.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(deckPicker)
}

R.id.nav_browser -> {
Timber.i("Navigating to card browser")
openCardBrowser()
}

R.id.nav_stats -> {
Timber.i("Navigating to stats")
openStatistics()
}

R.id.nav_settings -> {
Timber.i("Navigating to settings")
openSettings()
}

R.id.nav_help -> {
Timber.i("Navigating to help")
showDialogFragment(HelpDialog.newHelpInstance())
}

R.id.support_ankidroid -> {
Timber.i("Navigating to support AnkiDroid")
val canRateApp = IntentUtil.canOpenIntent(this, AnkiDroidApp.getMarketIntent(this))
showDialogFragment(HelpDialog.newSupportInstance(canRateApp))
}
Timber.i("Navigating to %s", dest)
when (dest) {
// Legacy: Card Browser is opened with the currently-viewed card id as an extra
AppDestination.Browser -> openCardBrowser()
// Legacy: Settings is launched via preferencesLauncher so we can recreate() on return
AppDestination.Settings -> openSettings()
else -> handleAppDestination(dest)
}
}
closeDrawer()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2026 Tim Rae <perceptualchaos2@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

package com.ichi2.anki.navigation

import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import androidx.annotation.StringRes
import com.ichi2.anki.R

/**
* An entry in the app-level navigation surface (drawer / rail).
*
* Carries only visual metadata and the legacy menu id used by
* [com.ichi2.anki.NavigationDrawerActivity]. For the side-effect of selecting
* an entry, see [handleAppDestination].
*/
enum class AppDestination(
@IdRes val menuItemId: Int,
val group: Group,
@StringRes val titleRes: Int,
@DrawableRes val iconRes: Int,
) {
Decks(R.id.nav_decks, Group.Primary, R.string.decks, R.drawable.ic_list_black),
Browser(R.id.nav_browser, Group.Primary, R.string.card_browser, R.drawable.ic_flashcard_black),
Stats(R.id.nav_stats, Group.Primary, R.string.statistics, R.drawable.ic_bar_chart_black),
Settings(R.id.nav_settings, Group.Utility, R.string.settings, R.drawable.ic_settings_black),
Help(R.id.nav_help, Group.Utility, R.string.help, R.drawable.ic_help_black),
Support(R.id.support_ankidroid, Group.Utility, R.string.help_title_support_ankidroid, R.drawable.ic_support_ankidroid),
;

enum class Group { Primary, Utility }

companion object {
fun fromMenuId(
@IdRes id: Int,
): AppDestination? = entries.find { it.menuItemId == id }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2026 Tim Rae <perceptualchaos2@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

package com.ichi2.anki.navigation

import android.content.Intent
import android.view.Menu
import com.ichi2.anki.AnkiActivity
import com.ichi2.anki.AnkiDroidApp
import com.ichi2.anki.CardBrowser
import com.ichi2.anki.DeckPicker
import com.ichi2.anki.dialogs.help.HelpDialog
import com.ichi2.anki.pages.StatisticsDestination
import com.ichi2.anki.preferences.PreferencesActivity
import com.ichi2.anki.utils.ext.showDialogFragment
import com.ichi2.utils.IntentUtil

/**
* Default side-effect for selecting [dest] from an app-level navigation surface.
*
* Hosts that need to customise a specific destination (e.g. legacy
* [com.ichi2.anki.NavigationDrawerActivity] passes a `currentCardId` extra to the
* Card Browser and launches Settings via an `ActivityResultLauncher`) should
* branch on that destination before delegating here.
*/
fun AnkiActivity.handleAppDestination(dest: AppDestination) {
when (dest) {
AppDestination.Decks ->
startActivity(
Intent(this, DeckPicker::class.java)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP),
)
AppDestination.Browser -> startActivity(Intent(this, CardBrowser::class.java))
AppDestination.Stats -> startActivity(StatisticsDestination().toIntent(this))
AppDestination.Settings -> startActivity(PreferencesActivity.getIntent(this))
AppDestination.Help -> showDialogFragment(HelpDialog.newHelpInstance())
AppDestination.Support -> {
val canRate = IntentUtil.canOpenIntent(this, AnkiDroidApp.getMarketIntent(this))
showDialogFragment(HelpDialog.newSupportInstance(canRate))
}
}
}

/**
* Populates a [Menu] with one item per [AppDestination], grouped by [AppDestination.Group].
* Items in the [AppDestination.Group.Primary] group are made checkable as a single-selection group.
*/
fun Menu.populateFromAppDestinations() {
AppDestination.Group.entries.forEachIndexed { groupOrder, group ->
AppDestination.entries
.filter { it.group == group }
.forEach { dest ->
add(groupOrder, dest.menuItemId, Menu.NONE, dest.titleRes)
.setIcon(dest.iconRes)
}
if (group == AppDestination.Group.Primary) {
setGroupCheckable(groupOrder, true, true)
}
}
}
79 changes: 79 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/ui/compose/AnkiTheme.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2026 Tim Rae <perceptualchaos2@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

package com.ichi2.anki.ui.compose

import androidx.annotation.AttrRes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.ColorUtils
import com.google.android.material.color.MaterialColors
import com.ichi2.themes.Themes
import androidx.appcompat.R as AppCompatR
import com.google.android.material.R as MaterialR

/**
* Bridges the host activity's AppCompat/Material-Components theme into Compose's
* [MaterialTheme] so Compose surfaces pick up the user-selected AnkiDroid theme.
*
* For each slot we read the corresponding theme attribute. Some AnkiDroid themes
* define M3 attributes with alpha (e.g. `colorSurfaceContainer = #0F03A9F4`,
* intended for compositing in the View hierarchy); those are composited against
* `?android:colorBackground` so the resulting Compose color is fully opaque.
*/
@Suppress("ktlint:standard:function-naming")
@Composable
fun AnkiTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val isDark = Themes.isNightTheme

fun themeColor(
@AttrRes attr: Int,
fallback: Color,
): Color {
val fallbackArgb = fallback.toArgb()
val resolved = MaterialColors.getColor(context, attr, fallbackArgb)
if (resolved == fallbackArgb) return fallback
val alpha = resolved ushr 24
if (alpha == 0xFF) return Color(resolved)
val background =
MaterialColors.getColor(context, android.R.attr.colorBackground, fallbackArgb) or
0xFF000000.toInt()
return Color(ColorUtils.compositeColors(resolved, background))
}

val base = if (isDark) darkColorScheme() else lightColorScheme()
val scheme =
base.copy(
primary = themeColor(AppCompatR.attr.colorPrimary, base.primary),
onPrimary = themeColor(MaterialR.attr.colorOnPrimary, base.onPrimary),
surface = themeColor(MaterialR.attr.colorSurface, base.surface),
onSurface = themeColor(MaterialR.attr.colorOnSurface, base.onSurface),
onSurfaceVariant = themeColor(MaterialR.attr.colorOnSurfaceVariant, base.onSurfaceVariant),
background = themeColor(android.R.attr.colorBackground, base.background),
onBackground = themeColor(MaterialR.attr.colorOnSurface, base.onBackground),
surfaceContainer = themeColor(MaterialR.attr.colorSurfaceContainer, base.surfaceContainer),
secondaryContainer = themeColor(MaterialR.attr.colorSecondaryContainer, base.secondaryContainer),
onSecondaryContainer = themeColor(MaterialR.attr.colorOnSecondaryContainer, base.onSecondaryContainer),
)

MaterialTheme(colorScheme = scheme, content = content)
}
Loading
Loading